diff --git a/migrations/models/7_20260121021919_update.py b/migrations/models/7_20260121021919_update.py new file mode 100644 index 0000000..17fd304 --- /dev/null +++ b/migrations/models/7_20260121021919_update.py @@ -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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" +) diff --git a/migrations/models/8_20260121022045_add_channel_constraint.py b/migrations/models/8_20260121022045_add_channel_constraint.py new file mode 100644 index 0000000..c7acd67 --- /dev/null +++ b/migrations/models/8_20260121022045_add_channel_constraint.py @@ -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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' +) diff --git a/src/adapter/parser.py b/src/adapter/parser.py index fa2dde2..2ab8199 100644 --- a/src/adapter/parser.py +++ b/src/adapter/parser.py @@ -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 diff --git a/src/controller/http_v1/channels.py b/src/controller/http_v1/channels.py index 36a3f2b..c353305 100644 --- a/src/controller/http_v1/channels.py +++ b/src/controller/http_v1/channels.py @@ -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)] diff --git a/src/domain/channel.py b/src/domain/channel.py index e8e4455..81b6e04 100644 --- a/src/domain/channel.py +++ b/src/domain/channel.py @@ -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' diff --git a/src/dto/__init__.py b/src/dto/__init__.py index 1904b61..6ef4eaa 100644 --- a/src/dto/__init__.py +++ b/src/dto/__init__.py @@ -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 ( diff --git a/src/dto/channel.py b/src/dto/channel.py index 867cccc..b19de98 100644 --- a/src/dto/channel.py +++ b/src/dto/channel.py @@ -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] diff --git a/src/dto/purchase.py b/src/dto/purchase.py index b77b4f6..466340c 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -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 diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index c73ca9d..3965f83 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -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 diff --git a/src/usecase/channel/create_channels.py b/src/usecase/channel/create_channels.py new file mode 100644 index 0000000..9acef2f --- /dev/null +++ b/src/usecase/channel/create_channels.py @@ -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) diff --git a/src/usecase/channel/get_channels.py b/src/usecase/channel/get_channels.py index 485fdc3..ba97f67 100644 --- a/src/usecase/channel/get_channels.py +++ b/src/usecase/channel/get_channels.py @@ -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 [ diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py index 4a3385f..c591112 100644 --- a/src/usecase/project/move_project_to_workspace.py +++ b/src/usecase/project/move_project_to_workspace.py @@ -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): diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py index 115044e..bd38ad2 100644 --- a/src/usecase/project/tg_add_project.py +++ b/src/usecase/project/tg_add_project.py @@ -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) diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index cd1690a..4cb3ba7 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -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 diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 5b17ba7..62a3603 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -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, diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index 629f187..f1bbb1f 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -521,7 +521,7 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM case "cost_value": text += "Ввод стоимости\n\nНапример: 15000" if s.CurrentChannel != "" { - text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel) + text += fmt.Sprintf("\n\nКанал: %s", 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 += "Ввод стоимости до торга\n\nНапример: 20000" if s.CurrentChannel != "" { - text += fmt.Sprintf("\n\nКанал: @%s", s.CurrentChannel) + text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel)) } case "format_select": text = "Страница ввода формата размещения\n\n" if s.CurrentChannel != "" { - text += fmt.Sprintf("Канал: @%s\n\n", s.CurrentChannel) + text += fmt.Sprintf("Канал: %s\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 } } diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go index 1cce617..7ada8ee 100644 --- a/tg_bot/screens/select_channels_for_purchase.go +++ b/tg_bot/screens/select_channels_for_purchase.go @@ -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 канала (без @) -Например: channel_name +Отправьте username (без @) или invite link приватного канала +Например: channel_name или https://t.me/+abcdef После добавления всех каналов нажмите Далее` } else { text += fmt.Sprintf("Добавлено каналов: %d\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 +} diff --git a/tg_parser/internal/adapter/database/get_channels.go b/tg_parser/internal/adapter/database/get_channels.go index c98056b..9d43207 100644 --- a/tg_parser/internal/adapter/database/get_channels.go +++ b/tg_parser/internal/adapter/database/get_channels.go @@ -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, } } diff --git a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go index 52065d1..6e53509 100644 --- a/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go +++ b/tg_parser/internal/adapter/database/get_channels_with_tracked_posts.go @@ -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, } } diff --git a/tg_parser/internal/adapter/telegram/pts.go b/tg_parser/internal/adapter/telegram/pts.go new file mode 100644 index 0000000..cb9d91c --- /dev/null +++ b/tg_parser/internal/adapter/telegram/pts.go @@ -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 +} diff --git a/tg_parser/internal/adapter/telegram/resolve_invite.go b/tg_parser/internal/adapter/telegram/resolve_invite.go new file mode 100644 index 0000000..c81e376 --- /dev/null +++ b/tg_parser/internal/adapter/telegram/resolve_invite.go @@ -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 +} diff --git a/tg_parser/internal/controller/httpserver/server.go b/tg_parser/internal/controller/httpserver/server.go index bbbeb11..61e73dc 100644 --- a/tg_parser/internal/controller/httpserver/server.go +++ b/tg_parser/internal/controller/httpserver/server.go @@ -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") } diff --git a/tg_parser/internal/domain/channel.go b/tg_parser/internal/domain/channel.go index 3d4abcb..6dc22d8 100644 --- a/tg_parser/internal/domain/channel.go +++ b/tg_parser/internal/domain/channel.go @@ -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 != "", ) } diff --git a/tg_parser/internal/domain/post.go b/tg_parser/internal/domain/post.go index 64b8632..e7eea11 100644 --- a/tg_parser/internal/domain/post.go +++ b/tg_parser/internal/domain/post.go @@ -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, } } diff --git a/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go new file mode 100644 index 0000000..a358003 --- /dev/null +++ b/tg_parser/internal/usecase/fetch_channel_meta_by_invite.go @@ -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) +} diff --git a/tg_parser/internal/usecase/fetch_channels.go b/tg_parser/internal/usecase/fetch_channels.go index 10fa36b..fc3d341 100644 --- a/tg_parser/internal/usecase/fetch_channels.go +++ b/tg_parser/internal/usecase/fetch_channels.go @@ -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. diff --git a/tg_parser/internal/usecase/usecase.go b/tg_parser/internal/usecase/usecase.go index a98b498..7516578 100644 --- a/tg_parser/internal/usecase/usecase.go +++ b/tg_parser/internal/usecase/usecase.go @@ -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