приватные каналы

This commit is contained in:
Artem Tsyrulnikov
2026-01-21 02:41:20 +03:00
parent 233afb5451
commit b5695360c2
27 changed files with 1026 additions and 217 deletions

View File

@@ -0,0 +1,94 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "channel" ADD "invite_link" VARCHAR(1024);
ALTER TABLE "channel" ALTER COLUMN "username" DROP NOT NULL;"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "channel" DROP COLUMN "invite_link";
ALTER TABLE "channel" ALTER COLUMN "username" SET NOT NULL;"""
MODELS_STATE = (
"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"
)

View File

@@ -0,0 +1,96 @@
from tortoise import BaseDBAsyncClient
RUN_IN_TRANSACTION = True
async def upgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "channel"
ADD CONSTRAINT "channel_identity_required"
CHECK (username IS NOT NULL OR invite_link IS NOT NULL); \
"""
async def downgrade(db: BaseDBAsyncClient) -> str:
return """
ALTER TABLE "channel" DROP CONSTRAINT IF EXISTS "channel_identity_required"; \
"""
MODELS_STATE = (
'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'
)

View File

@@ -12,8 +12,8 @@ log = logging.getLogger(__name__)
class FetchChannelResponse(BaseModel):
id: str
telegram_id: int
username: str
title: str
username: str | None
title: str | None
access_hash: int
pts: int
@@ -52,3 +52,33 @@ class ParserClient(Parser):
except Exception as e:
log.error('Parser unexpected error for @%s: %s', username, e)
raise
async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None:
invite_link = invite_link.strip()
url = f'{self.base_url}/resolve-channel-by-invite'
payload = {'invite_link': invite_link}
async with httpx.AsyncClient(timeout=self.timeout) as client:
try:
response = await client.post(url, json=payload)
if response.status_code == 404:
log.info('Channel not found by invite link')
return None
response.raise_for_status()
data: dict[str, Any] = response.json()
return FetchChannelResponse.model_validate(data)
except httpx.HTTPStatusError as e:
if e.response.status_code == 404:
return None
log.error('Parser HTTP error for invite link: %s', e)
raise
except httpx.TimeoutException:
log.error('Parser timeout for invite link')
raise
except Exception as e:
log.error('Parser unexpected error for invite link: %s', e)
raise

View File

@@ -20,6 +20,13 @@ async def get_channels(
return paginate(result) # type: ignore[no-any-return]
@channels_router.post('')
async def create_channels(
request: dto.CreateChannelsInput, _: Annotated[JWTPayload, Depends(deps.get_current_user)]
) -> dto.CreateChannelsOutput:
return await deps.get_usecase().create_channels(input=request)
@channels_router.get('/{channel_id}')
async def get_channel(
channel_id: uuid.UUID, _: Annotated[JWTPayload, Depends(deps.get_current_user)]

View File

@@ -6,10 +6,11 @@ from .base import TimestampedModel
class Channel(TimestampedModel):
telegram_id = fields.BigIntField(unique=True, index=True)
title = fields.CharField(max_length=255)
username = fields.CharField(max_length=255, unique=True, index=True)
username = fields.CharField(max_length=255, unique=True, index=True, null=True)
access_hash = fields.BigIntField(null=True)
pts = fields.IntField(null=True)
invite_link = fields.CharField(max_length=1024, null=True)
class Meta:
table = 'channel'

View File

@@ -15,6 +15,10 @@ __all__ = (
'ChannelOutput',
'GetChannelInput',
'GetChannelsInput',
'CreateChannelInput',
'CreateChannelsInput',
'CreateChannelResult',
'CreateChannelsOutput',
'GetChannelsOutput',
'AttachChannelToWorkspaceInput',
'PlacementOutput',
@@ -121,6 +125,10 @@ from .channel import (
ChannelOutput,
GetChannelInput,
GetChannelsInput,
CreateChannelInput,
CreateChannelsInput,
CreateChannelResult,
CreateChannelsOutput,
GetChannelsOutput,
)
from .creative import (

View File

@@ -1,4 +1,5 @@
import uuid
from typing import Literal
import pydantic
@@ -26,3 +27,54 @@ class AttachChannelToWorkspaceInput(pydantic.BaseModel):
channel_id: uuid.UUID
workspace_id: uuid.UUID
user_telegram_id: int
class CreateChannelInput(pydantic.BaseModel):
username: str | None = None
invite_link: str | None = None
@pydantic.field_validator('username')
@classmethod
def normalize_username(cls, value: str | None) -> str | None:
if value is None:
return None
username = value.strip()
if username.startswith('@'):
username = username[1:]
username = username.strip()
if not username:
return None
return username
@pydantic.field_validator('invite_link')
@classmethod
def normalize_invite_link(cls, value: str | None) -> str | None:
if value is None:
return None
link = value.strip()
if not link:
return None
return link
@pydantic.model_validator(mode='after')
def validate_input(self) -> 'CreateChannelInput':
has_username = bool(self.username)
has_invite = bool(self.invite_link)
if has_username == has_invite:
raise ValueError('Specify exactly one of username or invite_link')
return self
class CreateChannelsInput(pydantic.BaseModel):
channels: list[CreateChannelInput]
class CreateChannelResult(pydantic.BaseModel):
index: int
status: Literal['created', 'updated', 'failed']
channel: ChannelOutput | None = None
error: str | None = None
class CreateChannelsOutput(pydantic.BaseModel):
results: list[CreateChannelResult]

View File

@@ -59,7 +59,7 @@ class PlacementWithPostsOutput(PlacementOutput):
class CreatePlacementChannelInput(pydantic.BaseModel):
"""Input для создания одного placement (channel + детали)"""
username: str = pydantic.Field(pattern=r'^[^@]')
channel_id: uuid.UUID
status: PlacementStatus | None = None
comment: str | None = None
details: PlacementDetails | None = None

View File

@@ -21,6 +21,7 @@ from .auth.get_jwt_by_telegram_id import get_jwt_by_telegram_id
from .auth.get_me import get_me
from .auth.validate_login_token import validate_login_token
from .channel.attach_channel_to_workspace import attach_channel_to_workspace
from .channel.create_channels import create_channels
from .channel.get_channel import get_channel
from .channel.get_channels import get_channels
from .creative.create_creative import create_creative
@@ -98,12 +99,15 @@ class JWTEncoder(typing.Protocol):
class FetchChannelResponse(typing.Protocol):
telegram_id: int
username: str
title: str
username: str | None
title: str | None
access_hash: int | None
pts: int | None
class Parser(typing.Protocol):
async def fetch_telegram_channel(self, username: str) -> FetchChannelResponse | None: ...
async def resolve_telegram_channel_by_invite(self, invite_link: str) -> FetchChannelResponse | None: ...
class S3Storage(typing.Protocol):
@@ -185,6 +189,7 @@ class Usecase:
update_project_permissions = update_project_permissions
update_project_invite_link_type = update_project_invite_link_type
get_channels = get_channels
create_channels = create_channels
get_channel = get_channel
attach_channel_to_workspace = attach_channel_to_workspace
# Placement (user-managed) use cases

View File

@@ -0,0 +1,93 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
if TYPE_CHECKING:
from .. import Usecase
log = logging.getLogger(__name__)
async def create_channels(self: 'Usecase', input: dto.CreateChannelsInput) -> dto.CreateChannelsOutput:
results: list[dto.CreateChannelResult] = []
for index, channel_input in enumerate(input.channels):
try:
if channel_input.username:
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
if not parser_response:
raise domain.TelegramChannelNotFound(channel_input.username)
else:
parser_response = await self.parser.resolve_telegram_channel_by_invite(channel_input.invite_link or '')
if not parser_response:
raise ValueError('Telegram channel not found by invite link')
parsed_username = parser_response.username or None
is_private = channel_input.invite_link != ""
channel = await self.database.get_channel(telegram_id=parser_response.telegram_id)
if not channel and parsed_username:
channel = await self.database.get_channel(username=parsed_username)
status = 'created'
if channel:
status = 'updated'
updated = False
if parsed_username is not None and channel.username != parsed_username:
channel.username = parsed_username
updated = True
if parser_response.title is not None and channel.title != parser_response.title:
channel.title = parser_response.title
updated = True
if parser_response.telegram_id is not None and channel.telegram_id != parser_response.telegram_id:
channel.telegram_id = parser_response.telegram_id
updated = True
if parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash:
channel.access_hash = parser_response.access_hash
updated = True
if parser_response.pts is not None and channel.pts != parser_response.pts:
channel.pts = parser_response.pts
updated = True
if is_private and channel.pts != 0:
channel.pts = 0
updated = True
if channel_input.invite_link and channel.invite_link != channel_input.invite_link:
channel.invite_link = channel_input.invite_link
updated = True
if updated:
await self.database.update_channel(channel)
else:
channel = domain.Channel(
username=parsed_username,
telegram_id=parser_response.telegram_id,
title=parser_response.title,
access_hash=parser_response.access_hash,
pts=0 if is_private else parser_response.pts,
invite_link=channel_input.invite_link,
)
await self.database.create_channel(channel)
results.append(
dto.CreateChannelResult(
index=index,
status=status,
channel=dto.ChannelOutput(
id=channel.id,
telegram_id=channel.telegram_id,
title=channel.title,
username=channel.username,
),
)
)
except Exception as exc:
log.warning('Failed to create channel at index %s: %s', index, exc)
results.append(
dto.CreateChannelResult(
index=index,
status='failed',
error=str(exc),
)
)
return dto.CreateChannelsOutput(results=results)

View File

@@ -1,7 +1,7 @@
import logging
from typing import TYPE_CHECKING
from src import domain, dto
from src import dto
if TYPE_CHECKING:
from .. import Usecase
@@ -12,44 +12,6 @@ log = logging.getLogger(__name__)
async def get_channels(self: 'Usecase', input: dto.GetChannelsInput) -> list[dto.ChannelOutput]:
channels = await self.database.search_channels(username_query=input.username)
if not channels and input.username:
try:
parser_response = await self.parser.fetch_telegram_channel(input.username)
except Exception as exc:
log.warning('Parser fetch failed for username query %s: %s', input.username, exc)
parser_response = None
if parser_response:
channel = await self.database.get_channel(telegram_id=parser_response.telegram_id)
if not channel:
channel = await self.database.get_channel(username=parser_response.username)
if channel:
updated = False
if channel.username != parser_response.username:
channel.username = parser_response.username
updated = True
if channel.title != parser_response.title:
channel.title = parser_response.title
updated = True
if channel.telegram_id != parser_response.telegram_id:
channel.telegram_id = parser_response.telegram_id
updated = True
if updated:
await self.database.update_channel(channel)
log.info('Updated channel @%s from parser response', channel.username)
else:
channel = domain.Channel(
username=parser_response.username,
telegram_id=parser_response.telegram_id,
title=parser_response.title,
)
await self.database.create_channel(channel)
log.info('Created channel @%s from parser response', channel.username)
channels = [channel]
log.debug('Found %s channels for username query: %s', len(channels), input.username)
return [

View File

@@ -29,13 +29,11 @@ async def move_project_to_workspace(
if not target_workspace:
raise domain.WorkspaceNotFound(target_workspace_id)
# Проверяем permissions в source workspace (scope на конкретный project)
await self.ensure_workspace_permission(
source_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE, for_project_id=project_id
)
# Проверяем права владельца в source workspace
await self.ensure_workspace_permission(source_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
# Проверяем permissions в target workspace
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.PROJECTS_WRITE)
# Проверяем права владельца в target workspace
await self.ensure_workspace_permission(target_workspace_id, user_id, domain.PermissionKey.ADMIN_FULL)
# Проверяем что канал не существует в target workspace (UNIQUE constraint)
if await self.database.check_channel_exists_in_workspace(project.channel.id, target_workspace_id):

View File

@@ -79,28 +79,90 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
workspace = await self.get_or_create_personal_workspace(user)
workspaces = [workspace]
invite_link = ""
parser_response = None
is_private = input.username is None
if is_private:
try:
invite_link = await self.telegram_bot.create_chat_invite_link(input.telegram_id)
parser_response = await self.parser.resolve_telegram_channel_by_invite(invite_link)
except Exception as exc:
log.warning('Failed to resolve private channel %s: %s', input.telegram_id, exc)
await self.telegram_bot.send_message(
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
'Не удалось создать или проверить инвайт-ссылку.',
input.user_telegram_id,
)
return None
def build_channel_title() -> str:
if parser_response and parser_response.title:
return parser_response.title
return input.title
def build_channel_username() -> str | None:
if parser_response and parser_response.username:
return parser_response.username
return input.username
def build_channel_telegram_id() -> int:
if parser_response and parser_response.telegram_id:
return parser_response.telegram_id
return input.telegram_id
def update_channel_meta(channel: domain.Channel) -> bool:
updated = False
title = build_channel_title()
username = build_channel_username()
telegram_id = build_channel_telegram_id()
if channel.title != title:
channel.title = title
updated = True
if username is not None and channel.username != username:
channel.username = username
updated = True
if channel.telegram_id != telegram_id:
channel.telegram_id = telegram_id
updated = True
if parser_response and parser_response.access_hash is not None and channel.access_hash != parser_response.access_hash:
channel.access_hash = parser_response.access_hash
updated = True
if parser_response and parser_response.pts is not None and channel.pts != parser_response.pts:
channel.pts = parser_response.pts
updated = True
if is_private and channel.pts != 0:
channel.pts = 0
updated = True
if invite_link and channel.invite_link != invite_link:
channel.invite_link = invite_link
updated = True
return updated
if len(workspaces) == 1:
workspace = workspaces[0]
channel = await self.database.get_channel(telegram_id=input.telegram_id)
if channel:
channel.title = input.title
if input.username is not None:
channel.username = input.username
await self.database.update_channel(channel)
if update_channel_meta(channel):
await self.database.update_channel(channel)
else:
if input.username is None:
log.warning('Cannot create channel %s without username', input.telegram_id)
username = build_channel_username()
if username is None and invite_link == "":
log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
await self.telegram_bot.send_message(
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
'У канала отсутствует публичный username и не удалось получить invite link.',
input.user_telegram_id,
)
return None
channel = domain.Channel(
telegram_id=input.telegram_id,
title=input.title,
username=input.username,
telegram_id=build_channel_telegram_id(),
title=build_channel_title(),
username=username,
access_hash=(parser_response.access_hash if parser_response else None),
pts=(0 if is_private else (parser_response.pts if parser_response else None)),
invite_link=invite_link or None,
)
await self.database.create_channel(channel)
@@ -139,23 +201,26 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
channel = await self.database.get_channel(telegram_id=input.telegram_id)
if channel:
channel.title = input.title
if input.username is not None:
channel.username = input.username
await self.database.update_channel(channel)
if update_channel_meta(channel):
await self.database.update_channel(channel)
else:
if input.username is None:
log.warning('Cannot create channel %s without username', input.telegram_id)
username = build_channel_username()
if username is None and invite_link == "":
log.warning('Cannot create channel %s without username or invite link', input.telegram_id)
await self.telegram_bot.send_message(
f'⚠️ Канал "{input.title}" не может быть подключен.\n\nУ канала отсутствует публичный username.',
f'⚠️ Канал "{input.title}" не может быть подключен.\n\n'
'У канала отсутствует публичный username и не удалось получить invite link.',
input.user_telegram_id,
)
return None
channel = domain.Channel(
telegram_id=input.telegram_id,
title=input.title,
username=input.username,
telegram_id=build_channel_telegram_id(),
title=build_channel_title(),
username=username,
access_hash=(parser_response.access_hash if parser_response else None),
pts=(0 if is_private else (parser_response.pts if parser_response else None)),
invite_link=invite_link or None,
)
await self.database.create_channel(channel)

View File

@@ -79,20 +79,9 @@ async def create_placements(
creatives_by_id: dict[uuid.UUID, domain.Creative] = {}
for channel_input in input.channels:
channel = await self.database.get_channel(username=channel_input.username)
channel = await self.database.get_channel(channel_id=channel_input.channel_id)
if not channel:
parser_response = await self.parser.fetch_telegram_channel(channel_input.username)
if not parser_response:
raise domain.TelegramChannelNotFound(channel_input.username)
channel = domain.Channel(
username=parser_response.username,
telegram_id=parser_response.telegram_id,
title=parser_response.title,
)
await self.database.create_channel(channel)
log.info('Created channel @%s with telegram_id=%s', channel.username, channel.telegram_id)
raise domain.ChannelNotFound(channel_input.channel_id)
# Объединяем общие детали с деталями конкретного канала
channel_details = channel_input.details

View File

@@ -542,6 +542,26 @@ type Channel struct {
Username *string `json:"username"`
}
type CreateChannelInput struct {
Username *string `json:"username,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
}
type CreateChannelsInput struct {
Channels []CreateChannelInput `json:"channels"`
}
type CreateChannelResult struct {
Index int `json:"index"`
Status string `json:"status"`
Channel *Channel `json:"channel,omitempty"`
Error *string `json:"error,omitempty"`
}
type CreateChannelsOutput struct {
Results []CreateChannelResult `json:"results"`
}
type CostInfo struct {
Type string `json:"type"`
Value float64 `json:"value"`
@@ -588,10 +608,10 @@ type PostOutput struct {
}
type CreatePlacementChannelInput struct {
Username string `json:"username"`
Status *string `json:"status,omitempty"`
Comment *string `json:"comment,omitempty"`
Details *PlacementDetails `json:"details,omitempty"`
ChannelID string `json:"channel_id"`
Status *string `json:"status,omitempty"`
Comment *string `json:"comment,omitempty"`
Details *PlacementDetails `json:"details,omitempty"`
}
type CreatePlacementsInput struct {
@@ -635,6 +655,24 @@ func (c *Client) CreatePlacements(
return &placements, err
}
func (c *Client) CreateChannels(
ctx context.Context,
jwt string,
input CreateChannelsInput,
) (*CreateChannelsOutput, error) {
var response CreateChannelsOutput
err := c.do(
ctx,
http.MethodPost,
"api/v1/channels",
input,
&response,
withBearer(jwt),
)
return &response, err
}
func (c *Client) GetPlacements(
ctx context.Context,
jwt string,

View File

@@ -521,7 +521,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
case "cost_value":
text += "<b>Ввод стоимости</b>\n\nНапример: <code>15000</code>"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: <b>@%s</b>", s.CurrentChannel)
text += fmt.Sprintf("\n\nКанал: <b>%s</b>", channelLabelByKey(s.Channels, s.CurrentChannel))
}
typeLabel := s.costTypeLabelForCurrent()
@@ -539,12 +539,12 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
case "cost_before_value":
text += "<b>Ввод стоимости до торга</b>\n\nНапример: <code>20000</code>"
if s.CurrentChannel != "" {
text += fmt.Sprintf("\n\nКанал: <b>@%s</b>", s.CurrentChannel)
text += fmt.Sprintf("\n\nКанал: <b>%s</b>", channelLabelByKey(s.Channels, s.CurrentChannel))
}
case "format_select":
text = "<i>Страница ввода формата размещения</i>\n\n"
if s.CurrentChannel != "" {
text += fmt.Sprintf("Канал: <b>@%s</b>\n\n", s.CurrentChannel)
text += fmt.Sprintf("Канал: <b>%s</b>\n\n", channelLabelByKey(s.Channels, s.CurrentChannel))
}
text += "Выберите формат\n\n"
keyboard := Keyboard(
@@ -707,7 +707,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
// Список каналов с кнопками в зависимости от режима
for i := start; i < end; i++ {
label := fmt.Sprintf("%d", i+1)
channelUsername := s.Channels[i].Username
channelKey := channelKey(s.Channels[i])
if s.ChannelEditMode == "copy" {
// Режим копирования
@@ -723,7 +723,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
}
// Кнопка удаления - только если есть данные у канала
if s.hasChannelValue(param, channelUsername) {
if s.hasChannelValue(param, channelKey) {
channelRow = append(channelRow, Button("⌫", fmt.Sprintf("param_clear:%s:%d", param, i)))
} else {
channelRow = append(channelRow, Button(" ", "noop"))
@@ -736,7 +736,7 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
channelRow = append(channelRow, Button(label, fmt.Sprintf("param_edit:%s:%d", param, i)))
// Кнопка "Добавить" или "Изменить" в зависимости от наличия данных
hasValue := s.hasChannelValue(param, channelUsername)
hasValue := s.hasChannelValue(param, channelKey)
if hasValue {
channelRow = append(channelRow, Button("Изменить", fmt.Sprintf("param_edit:%s:%d", param, i)))
} else {
@@ -772,8 +772,9 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende
func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string {
var lines []string
for i, ch := range s.Channels {
value := s.formatParamValue(param, ch.Username)
lines = append(lines, fmt.Sprintf("%d. @%s — %s", i+1, ch.Username, value))
channelKey := channelKey(ch)
value := s.formatParamValue(param, channelKey)
lines = append(lines, fmt.Sprintf("%d. %s — %s", i+1, channelLabel(ch), value))
}
return strings.Join(lines, "\n")
}
@@ -788,7 +789,7 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
}
if index >= 0 && index < len(s.Channels) {
s.CurrentParam = param
s.CurrentChannel = s.Channels[index].Username
s.CurrentChannel = channelKey(s.Channels[index])
s.openChannelEditor(b, param, s.CurrentChannel)
return true
}
@@ -801,8 +802,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
return true
}
if index >= 0 && index < len(s.Channels) {
username := s.Channels[index].Username
s.copyParamValue(param, username)
channelKey := channelKey(s.Channels[index])
s.copyParamValue(param, channelKey)
s.Enter(b, bot.EditMessage)
return true
}
@@ -815,8 +816,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
return true
}
if index >= 0 && index < len(s.Channels) {
username := s.Channels[index].Username
s.pasteParamValue(param, username)
channelKey := channelKey(s.Channels[index])
s.pasteParamValue(param, channelKey)
s.Enter(b, bot.EditMessage)
return true
}
@@ -829,8 +830,8 @@ func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) b
return true
}
if index >= 0 && index < len(s.Channels) {
username := s.Channels[index].Username
s.clearParamValue(param, username)
channelKey := channelKey(s.Channels[index])
s.clearParamValue(param, channelKey)
s.Enter(b, bot.EditMessage)
return true
}
@@ -902,31 +903,31 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) {
}
}
func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username string) {
func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKey string) {
switch param {
case "placement":
s.InputMode = "placement_channels"
b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{
Title: "Дата и время размещения",
Key: "placement_datetime:" + username,
Key: "placement_datetime:" + channelKey,
IncludeTime: true,
Selected: s.PlacementByChannel[username],
Selected: s.PlacementByChannel[channelKey],
BackState: s,
}), bot.EditMessage)
case "cost":
s.InputMode = "cost_value"
s.ReturnMode = "cost_channels"
s.CurrentChannel = username
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "cost_before":
s.InputMode = "cost_before_value"
s.ReturnMode = "cost_before_channels"
s.CurrentChannel = username
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
case "format":
s.InputMode = "format_select"
s.ReturnMode = "format_channels"
s.CurrentChannel = username
s.CurrentChannel = channelKey
s.Enter(b, bot.EditMessage)
default:
s.InputMode = ""
@@ -934,12 +935,12 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, username
}
}
func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) {
switch param {
case "placement":
s.PlacementCopy = s.PlacementByChannel[username]
s.PlacementCopy = s.PlacementByChannel[channelKey]
case "cost":
entry := s.CostByChannel[username]
entry := s.CostByChannel[channelKey]
copied := entry
if entry.Value == nil {
copied.Value = nil
@@ -949,9 +950,9 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
}
s.CostCopy = &copied
case "cost_before":
s.CostBeforeCopy = s.CostBeforeByChannel[username]
s.CostBeforeCopy = s.CostBeforeByChannel[channelKey]
case "format":
if value, ok := s.FormatByChannel[username]; ok {
if value, ok := s.FormatByChannel[channelKey]; ok {
copied := value
s.FormatCopy = &copied
} else {
@@ -960,18 +961,18 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, username string) {
}
}
func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) {
switch param {
case "placement":
if s.PlacementCopy == nil {
s.PlacementByChannel[username] = nil
s.PlacementByChannel[channelKey] = nil
return
}
value := s.PlacementCopy.In(ui2.MskLocation)
s.PlacementByChannel[username] = &value
s.PlacementByChannel[channelKey] = &value
case "cost":
if s.CostCopy == nil {
delete(s.CostByChannel, username)
delete(s.CostByChannel, channelKey)
return
}
copied := *s.CostCopy
@@ -979,20 +980,20 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, username string) {
value := *copied.Value
copied.Value = &value
}
s.CostByChannel[username] = copied
s.CostByChannel[channelKey] = copied
case "cost_before":
if s.CostBeforeCopy == nil {
s.CostBeforeByChannel[username] = nil
s.CostBeforeByChannel[channelKey] = nil
return
}
value := *s.CostBeforeCopy
s.CostBeforeByChannel[username] = &value
s.CostBeforeByChannel[channelKey] = &value
case "format":
if s.FormatCopy == nil {
delete(s.FormatByChannel, username)
delete(s.FormatByChannel, channelKey)
return
}
s.FormatByChannel[username] = *s.FormatCopy
s.FormatByChannel[channelKey] = *s.FormatCopy
}
}
@@ -1000,33 +1001,33 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) {
switch param {
case "placement":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
s.pasteParamValue(param, channelKey(ch))
}
case "cost":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
s.pasteParamValue(param, channelKey(ch))
}
case "cost_before":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
s.pasteParamValue(param, channelKey(ch))
}
case "format":
for _, ch := range s.Channels {
s.pasteParamValue(param, ch.Username)
s.pasteParamValue(param, channelKey(ch))
}
}
}
func (s *PurchaseOptionalDetails) clearParamValue(param, username string) {
func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) {
switch param {
case "placement":
s.PlacementByChannel[username] = nil
s.PlacementByChannel[channelKey] = nil
case "cost":
delete(s.CostByChannel, username)
delete(s.CostByChannel, channelKey)
case "cost_before":
s.CostBeforeByChannel[username] = nil
s.CostBeforeByChannel[channelKey] = nil
case "format":
delete(s.FormatByChannel, username)
delete(s.FormatByChannel, channelKey)
}
}
@@ -1116,7 +1117,10 @@ func (s *PurchaseOptionalDetails) ensureDefaults() {
func (s *PurchaseOptionalDetails) syncChannelMaps() {
valid := make(map[string]struct{}, len(s.Channels))
for _, ch := range s.Channels {
valid[ch.Username] = struct{}{}
key := channelKey(ch)
if key != "" {
valid[key] = struct{}{}
}
}
for key := range s.PlacementByChannel {
if _, ok := valid[key]; !ok {
@@ -1196,17 +1200,17 @@ func (s *PurchaseOptionalDetails) formatFormatDetails() string {
return "\n" + s.renderParamChannelSummary("format")
}
func (s *PurchaseOptionalDetails) formatParamValue(param, username string) string {
func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) string {
switch param {
case "placement":
value := s.PlacementByChannel[username]
value := s.PlacementByChannel[channelKey]
return s.formatDateTime(value)
case "cost":
return s.formatCostForChannel(username)
return s.formatCostForChannel(channelKey)
case "cost_before":
return s.formatCostBeforeForChannel(username)
return s.formatCostBeforeForChannel(channelKey)
case "format":
if value, ok := s.FormatByChannel[username]; ok && value != "" {
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
return value
}
return "—"
@@ -1215,8 +1219,8 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, username string) strin
}
}
func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
entry, ok := s.CostByChannel[username]
func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string {
entry, ok := s.CostByChannel[channelKey]
if !ok || entry.Value == nil {
return "—"
}
@@ -1224,8 +1228,8 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(username string) string {
return fmt.Sprintf("%s %.0f₽", label, *entry.Value)
}
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(username string) string {
value, ok := s.CostBeforeByChannel[username]
func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string {
value, ok := s.CostBeforeByChannel[channelKey]
if !ok || value == nil {
return "—"
}
@@ -1247,19 +1251,19 @@ func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool {
}
}
func (s *PurchaseOptionalDetails) hasChannelValue(param, username string) bool {
func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool {
switch param {
case "placement":
value, ok := s.PlacementByChannel[username]
value, ok := s.PlacementByChannel[channelKey]
return ok && value != nil
case "cost":
entry, ok := s.CostByChannel[username]
entry, ok := s.CostByChannel[channelKey]
return ok && entry.Value != nil
case "cost_before":
value, ok := s.CostBeforeByChannel[username]
value, ok := s.CostBeforeByChannel[channelKey]
return ok && value != nil
case "format":
value, ok := s.FormatByChannel[username]
value, ok := s.FormatByChannel[channelKey]
return ok && value != ""
default:
return false
@@ -1412,14 +1416,14 @@ func (s *PurchaseOptionalDetails) createPurchase(b *bot.Bot, jwt string) {
apiChannels := make([]backend.CreatePlacementChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {
channelDetails := s.buildChannelDetails(ch.Username)
channelDetails := s.buildChannelDetails(channelKey(ch))
if channelDetails != nil && s.isChannelDetailsEmpty(channelDetails) {
channelDetails = nil
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
Username: ch.Username,
Comment: ch.Comment,
Details: channelDetails,
ChannelID: ch.ChannelID,
Comment: ch.Comment,
Details: channelDetails,
})
}
@@ -1495,29 +1499,29 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) *
return details
}
func (s *PurchaseOptionalDetails) buildChannelDetails(username string) *backend.PlacementDetails {
func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backend.PlacementDetails {
if len(s.Channels) <= 1 {
return nil
}
details := &backend.PlacementDetails{}
if s.PlacementMode == "per_channel" {
if value, ok := s.PlacementByChannel[username]; ok && value != nil {
if value, ok := s.PlacementByChannel[channelKey]; ok && value != nil {
formatted := value.Format(time.RFC3339)
details.PlacementAt = &formatted
}
}
if s.CostMode == "per_channel" {
entry := s.CostByChannel[username]
entry := s.CostByChannel[channelKey]
details.Cost = s.buildCostInfo(entry.Type, entry.Value)
}
if s.CostBeforeMode == "per_channel" {
if value, ok := s.CostBeforeByChannel[username]; ok && value != nil {
if value, ok := s.CostBeforeByChannel[channelKey]; ok && value != nil {
details.CostBeforeBargain = value
}
}
if s.FormatMode == "per_channel" {
if value, ok := s.FormatByChannel[username]; ok && value != "" {
if value, ok := s.FormatByChannel[channelKey]; ok && value != "" {
details.Format = &value
}
}

View File

@@ -14,7 +14,10 @@ import (
)
type PurchaseChannelInput struct {
ChannelID string
Username string
Title string
InviteLink string
PlannedCost *float64
Comment *string
}
@@ -48,15 +51,15 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
if len(s.Channels) == 0 {
text += `Добавьте каналы для размещения рекламы
Отправьте username канала (без @)
Например: <code>channel_name</code>
Отправьте username (без @) или invite link приватного канала
Например: <code>channel_name</code> или <code>https://t.me/+abcdef</code>
После добавления всех каналов нажмите <b>Далее</b>`
} else {
text += fmt.Sprintf("Добавлено каналов: <b>%d</b>\n\n", len(s.Channels))
for i, ch := range s.Channels {
channelText := fmt.Sprintf(" %d. @%s", i+1, ch.Username)
channelText := fmt.Sprintf(" %d. %s", i+1, channelLabel(ch))
if ch.PlannedCost != nil {
channelText += fmt.Sprintf(" — %.0f₽", *ch.PlannedCost)
}
@@ -73,9 +76,9 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot.
var slots []echotron.InlineKeyboardButton
for i := start; i < end; i++ {
ch := s.Channels[i]
buttonText := fmt.Sprintf("@%s", ch.Username)
buttonText := channelLabel(ch)
if ch.PlannedCost != nil {
buttonText = fmt.Sprintf("@%s — %.0f₽", ch.Username, *ch.PlannedCost)
buttonText = fmt.Sprintf("%s — %.0f₽", channelLabel(ch), *ch.PlannedCost)
}
slots = append(slots, Button(buttonText, fmt.Sprintf("remove_channel:%d", i)))
}
@@ -161,7 +164,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
// Проверяем существование каналов через API
// Создаем/обновляем каналы в БД через API
jwt := b.Session.JWT
if jwt == "" {
log.Error().Msg("JWT is empty in session")
@@ -169,11 +172,11 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat
return
}
notFoundChannels := s.validateChannels(b, jwt)
if len(notFoundChannels) > 0 {
text := "❌ Следующие каналы не найдены:\n\n"
for _, username := range notFoundChannels {
text += fmt.Sprintf("• @%s\n", username)
failed := s.resolveChannels(b, jwt)
if len(failed) > 0 {
text := "❌ Не удалось добавить каналы:\n\n"
for _, label := range failed {
text += fmt.Sprintf("• %s\n", label)
}
text += "\nУдалите их из списка и попробуйте снова"
@@ -242,9 +245,9 @@ func (s *SelectChannelsForPurchase) createPurchase(b *bot.Bot, jwt string) {
}
}
apiChannels = append(apiChannels, backend.CreatePlacementChannelInput{
Username: ch.Username,
Comment: ch.Comment,
Details: details,
ChannelID: ch.ChannelID,
Comment: ch.Comment,
Details: details,
})
}
@@ -294,22 +297,32 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
added := 0
var invalid []string
for _, token := range tokens {
username := strings.TrimSpace(token)
username = strings.TrimPrefix(username, "@")
username = strings.Trim(username, "@")
username = strings.Trim(username, ",;")
if username == "" {
entry := strings.TrimSpace(token)
entry = strings.Trim(entry, ",;")
if entry == "" {
continue
}
if !channelUsernameRe.MatchString(username) {
invalid = append(invalid, username)
continue
var username string
var inviteLink string
if isInviteLink(entry) {
inviteLink = entry
} else {
username = strings.TrimPrefix(entry, "@")
username = strings.Trim(username, "@")
if !channelUsernameRe.MatchString(username) {
invalid = append(invalid, entry)
continue
}
}
exists := false
for _, ch := range s.Channels {
if ch.Username == username {
if inviteLink != "" && ch.InviteLink == inviteLink {
exists = true
break
}
if username != "" && ch.Username == username {
exists = true
break
}
@@ -319,7 +332,8 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
}
s.Channels = append(s.Channels, PurchaseChannelInput{
Username: username,
Username: username,
InviteLink: inviteLink,
})
added++
}
@@ -336,37 +350,48 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update
s.Enter(b, bot.NewMessage)
}
func (s *SelectChannelsForPurchase) validateChannels(b *bot.Bot, jwt string) []string {
var notFound []string
func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string {
inputs := make([]backend.CreateChannelInput, 0, len(s.Channels))
for _, ch := range s.Channels {
channels, err := b.Backend.SearchChannels(
context.Background(),
jwt,
ch.Username,
)
if err != nil {
log.Error().Err(err).Str("username", ch.Username).Msg("Failed to search channel")
notFound = append(notFound, ch.Username)
continue
}
// Проверяем что нашли хотя бы один канал с точным совпадением username
found := false
for _, channel := range channels {
if channel.Username != nil && *channel.Username == ch.Username {
found = true
break
}
}
if !found {
notFound = append(notFound, ch.Username)
if ch.InviteLink != "" {
link := ch.InviteLink
inputs = append(inputs, backend.CreateChannelInput{InviteLink: &link})
} else {
username := ch.Username
inputs = append(inputs, backend.CreateChannelInput{Username: &username})
}
}
return notFound
resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{
Channels: inputs,
})
if err != nil {
log.Error().Err(err).Msg("Failed to create channels")
return []string{"ошибка сервера"}
}
var failed []string
for _, result := range resp.Results {
if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" {
if result.Index >= 0 && result.Index < len(s.Channels) {
failed = append(failed, channelLabel(s.Channels[result.Index]))
}
continue
}
if result.Index < 0 || result.Index >= len(s.Channels) {
continue
}
ch := &s.Channels[result.Index]
ch.ChannelID = result.Channel.ID
if result.Channel.Username != nil {
ch.Username = *result.Channel.Username
}
if result.Channel.Title != nil {
ch.Title = *result.Channel.Title
}
}
return failed
}
func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
@@ -380,3 +405,52 @@ func (s *SelectChannelsForPurchase) Handle(b *bot.Bot, u *echotron.Update) {
}
func (s *SelectChannelsForPurchase) Exit() {}
func channelLabel(ch PurchaseChannelInput) string {
if ch.Username != "" {
return "@" + ch.Username
}
if ch.Title != "" {
return ch.Title
}
if ch.InviteLink != "" {
return "invite link"
}
if ch.ChannelID != "" {
return ch.ChannelID
}
return "канал"
}
func channelKey(ch PurchaseChannelInput) string {
if ch.ChannelID != "" {
return ch.ChannelID
}
if ch.Username != "" {
return ch.Username
}
if ch.InviteLink != "" {
return ch.InviteLink
}
return ""
}
func channelLabelByKey(channels []PurchaseChannelInput, key string) string {
for _, ch := range channels {
if channelKey(ch) == key {
return channelLabel(ch)
}
}
return key
}
func isInviteLink(value string) bool {
value = strings.TrimSpace(value)
if strings.Contains(value, "t.me/") || strings.Contains(value, "telegram.me/") {
return true
}
if strings.HasPrefix(value, "tg://") || strings.HasPrefix(value, "tg:") {
return true
}
return false
}

View File

@@ -17,7 +17,8 @@ func (d *Database) GetChannels(ctx context.Context) []domain.Channel {
username,
title,
access_hash,
pts
pts,
invite_link
FROM channel
WHERE deleted_at IS NULL;`
@@ -54,6 +55,7 @@ type getChannelsDTO struct {
Title pgtype.Text
AccessHash pgtype.Int8
Pts pgtype.Int4
InviteLink pgtype.Text
}
func (dto *getChannelsDTO) destination() []any {
@@ -64,6 +66,7 @@ func (dto *getChannelsDTO) destination() []any {
&dto.Title,
&dto.AccessHash,
&dto.Pts,
&dto.InviteLink,
}
}
@@ -75,5 +78,6 @@ func (dto *getChannelsDTO) toDomain() domain.Channel {
Title: dto.Title.String,
AccessHash: dto.AccessHash.Int64,
Pts: int(dto.Pts.Int32),
InviteLink: dto.InviteLink.String,
}
}

View File

@@ -17,7 +17,8 @@ func (d *Database) GetChannelsWithTrackedPosts(ctx context.Context) ([]domain.Ch
c.username,
c.title,
c.access_hash,
c.pts
c.pts,
c.invite_link
FROM channel c
INNER JOIN placement p ON p.channel_id = c.id
INNER JOIN placement_post pp ON pp.placement_id = p.id
@@ -55,6 +56,7 @@ type getChannelsWithTrackedPostsDTO struct {
Title pgtype.Text
AccessHash pgtype.Int8
Pts pgtype.Int4
InviteLink pgtype.Text
}
func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
@@ -65,6 +67,7 @@ func (dto *getChannelsWithTrackedPostsDTO) destination() []any {
&dto.Title,
&dto.AccessHash,
&dto.Pts,
&dto.InviteLink,
}
}
@@ -76,5 +79,6 @@ func (dto *getChannelsWithTrackedPostsDTO) toDomain() domain.Channel {
Title: dto.Title.String,
AccessHash: dto.AccessHash.Int64,
Pts: int(dto.Pts.Int32),
InviteLink: dto.InviteLink.String,
}
}

View File

@@ -0,0 +1,33 @@
package telegram
import (
"context"
"fmt"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/gotd/td/tg"
)
func (t *Telegram) GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error) {
req := []tg.InputDialogPeerClass{
&tg.InputDialogPeer{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ChannelID(),
AccessHash: channel.AccessHash,
},
},
}
dialogs, err := t.API().MessagesGetPeerDialogs(ctx, req)
if err != nil {
return 0, fmt.Errorf("peer dialogs: %w", err)
}
if len(dialogs.Dialogs) > 0 {
if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
return d.Pts, nil
}
}
return 0, nil
}

View File

@@ -0,0 +1,130 @@
package telegram
import (
"context"
"fmt"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
"github.com/google/uuid"
"github.com/gotd/td/telegram/deeplink"
"github.com/gotd/td/tg"
)
func (t *Telegram) ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
link, err := deeplink.Parse(inviteLink)
if err != nil {
return domain.Channel{}, fmt.Errorf("parse invite link: %w", err)
}
if link.Type != deeplink.Join {
return domain.Channel{}, fmt.Errorf("invite link is not a join link")
}
hash := link.Args.Get("invite")
if hash == "" {
return domain.Channel{}, fmt.Errorf("invite link missing hash")
}
info, err := t.API().MessagesCheckChatInvite(ctx, hash)
if err != nil {
return domain.Channel{}, fmt.Errorf("check invite: %w", err)
}
var channel *tg.Channel
switch v := info.(type) {
case *tg.ChatInviteAlready:
channel = extractChannelFromChat(v.Chat)
case *tg.ChatInvite:
updates, err := t.API().MessagesImportChatInvite(ctx, hash)
if err != nil {
return domain.Channel{}, fmt.Errorf("import invite: %w", err)
}
channel = extractChannelFromUpdates(updates)
default:
return domain.Channel{}, fmt.Errorf("unexpected invite response: %T", v)
}
if channel == nil {
return domain.Channel{}, fmt.Errorf("no channel in invite response")
}
if channel.AccessHash == 0 {
return domain.Channel{}, fmt.Errorf("channel access hash missing")
}
pts, err := getChannelPTS(ctx, t.API(), channel)
if err != nil {
return domain.Channel{}, fmt.Errorf("get peer dialogs: %w", err)
}
username := ""
if usernameVal, ok := channel.GetUsername(); ok {
username = usernameVal
}
return domain.Channel{
ID: uuid.New(),
TelegramID: domain.ChatIDFromChannelID(channel.ID),
Username: username,
Title: channel.Title,
AccessHash: channel.AccessHash,
Pts: pts,
}, nil
}
func extractChannelFromChat(chat tg.ChatClass) *tg.Channel {
switch v := chat.(type) {
case *tg.Channel:
return v
case *tg.ChannelForbidden:
return &tg.Channel{
ID: v.ID,
AccessHash: v.AccessHash,
Title: v.Title,
}
default:
return nil
}
}
func extractChannelFromUpdates(updates tg.UpdatesClass) *tg.Channel {
var chats []tg.ChatClass
switch v := updates.(type) {
case *tg.Updates:
chats = v.Chats
case *tg.UpdatesCombined:
chats = v.Chats
default:
return nil
}
for _, chat := range chats {
if channel := extractChannelFromChat(chat); channel != nil {
return channel
}
}
return nil
}
func getChannelPTS(ctx context.Context, api *tg.Client, channel *tg.Channel) (int, error) {
req := []tg.InputDialogPeerClass{
&tg.InputDialogPeer{
Peer: &tg.InputPeerChannel{
ChannelID: channel.ID,
AccessHash: channel.AccessHash,
},
},
}
dialogs, err := api.MessagesGetPeerDialogs(ctx, req)
if err != nil {
return 0, err
}
if len(dialogs.Dialogs) > 0 {
if d, ok := dialogs.Dialogs[0].(*tg.Dialog); ok {
return d.Pts, nil
}
}
return 0, nil
}

View File

@@ -62,6 +62,58 @@ func New(uc *usecase.UseCase, addr string) *Server {
log.Error().Err(err).Msg("encode channel response")
}
})
mux.HandleFunc("/resolve-channel-by-invite", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var payload struct {
InviteLink string `json:"invite_link"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, "invalid payload", http.StatusBadRequest)
return
}
inviteLink := strings.TrimSpace(payload.InviteLink)
if inviteLink == "" {
http.Error(w, "missing invite_link", http.StatusBadRequest)
return
}
channel, err := uc.FetchChannelMetaByInvite(r.Context(), inviteLink)
if err != nil {
if isNotFoundError(err) {
http.Error(w, "channel not found", http.StatusNotFound)
return
}
log.Error().Err(err).Msg("resolve channel by invite failed")
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
resp := struct {
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Username string `json:"username"`
Title string `json:"title"`
AccessHash int64 `json:"access_hash"`
Pts int `json:"pts"`
}{
ID: channel.ID.String(),
TelegramID: channel.TelegramID,
Username: channel.Username,
Title: channel.Title,
AccessHash: channel.AccessHash,
Pts: channel.Pts,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
log.Error().Err(err).Msg("encode channel response")
}
})
return &Server{
httpServer: &http.Server{
@@ -80,11 +132,21 @@ func (s *Server) Shutdown(ctx context.Context) error {
}
func isNotFoundError(err error) bool {
if tgerr.Is(err, "USERNAME_NOT_OCCUPIED", "USERNAME_INVALID", "CHANNEL_INVALID", "CHANNEL_PRIVATE") {
if tgerr.Is(
err,
"USERNAME_NOT_OCCUPIED",
"USERNAME_INVALID",
"CHANNEL_INVALID",
"CHANNEL_PRIVATE",
"INVITE_HASH_INVALID",
"INVITE_HASH_EXPIRED",
"INVITE_HASH_EMPTY",
) {
return true
}
msg := err.Error()
return strings.Contains(msg, "no chats in resolve result") ||
strings.Contains(msg, "not a channel")
strings.Contains(msg, "not a channel") ||
strings.Contains(msg, "invite link")
}

View File

@@ -13,6 +13,7 @@ type Channel struct {
Title string
AccessHash int64
Pts int
InviteLink string
}
const channelIDOffset int64 = 1000000000000
@@ -47,13 +48,14 @@ func NormalizeChatID(id int64) int64 {
func (c Channel) String() string {
return fmt.Sprintf(
"Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d}",
"Channel{id=%s telegram_id=%d username=%q title=%q access_hash=%d pts=%d invite_link=%t}",
c.ID,
c.TelegramID,
c.Username,
c.Title,
c.AccessHash,
c.Pts,
c.InviteLink != "",
)
}

View File

@@ -16,12 +16,16 @@ type Post struct {
}
func NewPost(channel Channel, messageID int, text string, views int) Post {
link := ""
if channel.Username != "" {
link = fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID)
}
return Post{
ID: uuid.New(),
ChannelID: channel.ID,
MessageID: messageID,
Text: text,
Link: fmt.Sprintf("https://t.me/%s/%d", channel.Username, messageID),
Link: link,
Views: views,
}
}

View File

@@ -0,0 +1,11 @@
package usecase
import (
"context"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
)
func (uc *UseCase) FetchChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error) {
return uc.telegram.ParseChannelMetaByInvite(ctx, inviteLink)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"github.com/TelegramExchange/pkg/transaction"
"github.com/gotd/td/tgerr"
"github.com/rs/zerolog/log"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
@@ -23,6 +24,11 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
c = updatedChannel
}
if c.Pts == 0 {
log.Warn().Int64("telegram_id", c.TelegramID).Msg("Channel pts still empty, skipping diff")
continue
}
err := uc.processChannel(ctx, c)
if err != nil {
return fmt.Errorf("uc.processChannel: %w", err)
@@ -35,6 +41,25 @@ func (uc *UseCase) FetchChannels(ctx context.Context) error {
func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) error {
diff, err := uc.telegram.GetChannelDiff(ctx, channel, 20)
if err != nil {
if isPrivateChannelError(err) {
log.Warn().Err(err).Int64("telegram_id", channel.TelegramID).Msg("Private channel, attempting rejoin")
if channel.InviteLink == "" {
log.Warn().Int64("telegram_id", channel.TelegramID).Msg("No invite link stored, skipping channel")
return nil
}
updatedChannel, joinErr := uc.telegram.ParseChannelMetaByInvite(ctx, channel.InviteLink)
if joinErr != nil {
log.Warn().Err(joinErr).Int64("telegram_id", channel.TelegramID).Msg("Invite rejoin failed")
return nil
}
updatedChannel.ID = channel.ID
updatedChannel.InviteLink = channel.InviteLink
updatedChannel.Pts = 0
if err := uc.database.UpdateChannel(ctx, updatedChannel); err != nil {
return fmt.Errorf("database.UpdateChannel: %w", err)
}
return nil
}
return fmt.Errorf("telegram.GetChannelDiff: %w", err)
}
@@ -74,10 +99,26 @@ func (uc *UseCase) processChannel(ctx context.Context, channel domain.Channel) e
return nil
}
func isPrivateChannelError(err error) bool {
return tgerr.Is(err, "CHANNEL_PRIVATE", "CHANNEL_INVALID", "CHANNEL_FORBIDDEN")
}
func (uc *UseCase) initializeChannel(ctx context.Context, channel domain.Channel) (domain.Channel, error) {
c, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
if err != nil {
return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
c := channel
if channel.Username != "" {
updated, err := uc.telegram.ParseChannelMeta(ctx, channel.Username)
if err != nil {
return domain.Channel{}, fmt.Errorf("telegram.ParseChannelMeta: %w", err)
}
c = updated
} else if channel.AccessHash == 0 {
return domain.Channel{}, fmt.Errorf("channel %s missing access hash", channel.ID)
} else {
pts, err := uc.telegram.GetChannelPTS(ctx, channel)
if err != nil {
return domain.Channel{}, fmt.Errorf("telegram.GetChannelPTS: %w", err)
}
c.Pts = pts
}
// Preserve persistent channel ID from the database so foreign keys remain valid.

View File

@@ -8,6 +8,8 @@ import (
type Telegram interface {
ParseChannelMeta(ctx context.Context, username string) (domain.Channel, error)
ParseChannelMetaByInvite(ctx context.Context, inviteLink string) (domain.Channel, error)
GetChannelPTS(ctx context.Context, channel domain.Channel) (int, error)
GetChannelHistory(ctx context.Context, channel domain.Channel, limit int) ([]domain.Post, error)
GetChannelDiff(ctx context.Context, channel domain.Channel, limit int) (domain.ChannelDiff, error)
UpdatePostsViews(ctx context.Context, channel domain.Channel, posts []domain.Post) error