diff --git a/migrations/models/16_20260202115014_add_invite_link_name.py b/migrations/models/16_20260202115014_add_invite_link_name.py new file mode 100644 index 0000000..93e8efd --- /dev/null +++ b/migrations/models/16_20260202115014_add_invite_link_name.py @@ -0,0 +1,105 @@ +# ruff: noqa +# mypy: ignore-errors +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "invite_link_name" VARCHAR(32);""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "invite_link_name";""" + + +MODELS_STATE = ( + "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" + "cQREBB" +) diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index d54a222..6f9ca93 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -33,8 +33,12 @@ class TelegramBot(TelegramBase, TelegramBotWriter): ) return message.message_id - async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: - invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval) + async def create_chat_invite_link( + self, chat_id: int, requires_approval: bool = False, name: str | None = None + ) -> str: + invite_link = await self.bot.create_chat_invite_link( + chat_id=chat_id, creates_join_request=requires_approval, name=name + ) return invite_link.invite_link async def send_message_with_inline_keyboard( @@ -122,13 +126,9 @@ class TelegramBot(TelegramBase, TelegramBotWriter): for index, item in enumerate(media_items): item_caption = caption if index == 0 else None if item.media_type == 'photo': - group.append( - InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode) - ) + group.append(InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)) elif item.media_type == 'video': - group.append( - InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode) - ) + group.append(InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)) else: raise ValueError(f'Unsupported media type for group: {item.media_type}') diff --git a/src/domain/creative.py b/src/domain/creative.py index 6c7e1da..677935b 100644 --- a/src/domain/creative.py +++ b/src/domain/creative.py @@ -11,7 +11,6 @@ from .error import ( CreativeMediaGroupUnsupported, CreativeMediaTooLarge, CreativeMediaTooMany, - CreativeMultipleInviteLinks, ) if TYPE_CHECKING: @@ -89,44 +88,43 @@ def _strip_html(text: str) -> str: def replace_invite_link_with_tag(text: str) -> str: - """Replace Telegram invite link (t.me/+xxx) with tracking tag.""" - # Проверяем, есть ли уже замененная ссылка - replaced_count = len(_INVITE_LINK_REPLACED.findall(text)) - html_count = len(_INVITE_LINK_HTML.findall(text)) + """Replace ALL Telegram invite links (t.me/+xxx) with tracking tags.""" + # Check for any invite links first (excluding tg-link which are already replaced) + replaced = _INVITE_LINK_REPLACED.findall(text) + html_links = _INVITE_LINK_HTML.findall(text) - # Сначала удаляем HTML-ссылки и tg-link теги, чтобы не считать их дважды + # Remove HTML links and tg-link tags to find plain text links text_without_links = _INVITE_LINK_HTML.sub('', text) text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links) - plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_links)) + plain_links = _INVITE_LINK_PLAIN.findall(text_without_links) - total = replaced_count + html_count + plain_count + total = len(replaced) + len(html_links) + len(plain_links) if total == 0: raise CreativeInviteLinkNotFound() - if total > 1: - raise CreativeMultipleInviteLinks() - # Если ссылка уже заменена, нормализуем текст если внутри был URL или теги - if replaced_count == 1: - def _normalize_replaced(match: re.Match[str]) -> str: - inner = _strip_html(match.group(1)) - if inner == "" or _INVITE_LINK_PLAIN.search(inner): - return '' - return f'{inner}' + # Normalize already replaced links (preserve anchor text) + def _normalize_replaced(match: re.Match[str]) -> str: + inner = _strip_html(match.group(1)) + if inner == "" or _INVITE_LINK_PLAIN.search(inner): + return '' + return f'{inner}' - return _INVITE_LINK_REPLACED.sub(_normalize_replaced, text) + text = _INVITE_LINK_REPLACED.sub(_normalize_replaced, text) - if html_count == 1: + # Replace HTML links with tracking tags (preserve anchor text) + def _replace_html(match: re.Match[str]) -> str: + inner = _strip_html(match.group(1)) + if _INVITE_LINK_PLAIN.fullmatch(inner): + return '' + return f'{inner}' - def _replace_html(match: re.Match[str]) -> str: - inner = _strip_html(match.group(1)) - if _INVITE_LINK_PLAIN.fullmatch(inner): - return '' - return f'{inner}' + text = _INVITE_LINK_HTML.sub(_replace_html, text) - return _INVITE_LINK_HTML.sub(_replace_html, text) + # Replace plain text invite links + text = _INVITE_LINK_PLAIN.sub('', text) - return _INVITE_LINK_PLAIN.sub('', text) + return text def validate_media_size(media_data: bytes | None) -> None: diff --git a/src/domain/placement.py b/src/domain/placement.py index ffe4363..becb114 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -99,6 +99,8 @@ class Placement(TimestampedModel): invite_link_created_at = fields.DatetimeField(null=True) invite_link_type = fields.CharEnumField(InviteLinkType) + invite_link_name = fields.CharField(max_length=32, null=True) + project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField( 'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True ) diff --git a/src/dto/project.py b/src/dto/project.py index 708cecc..94c6a1f 100644 --- a/src/dto/project.py +++ b/src/dto/project.py @@ -4,6 +4,7 @@ import pydantic from src import domain from src.domain.project import ProjectStatus +from .channel import ChannelOutput class ChannelBotPermissions(pydantic.BaseModel): @@ -33,6 +34,7 @@ class ProjectOutput(pydantic.BaseModel): username: str | None status: ProjectStatus purchase_invite_type_default: domain.InviteLinkType + channel: ChannelOutput class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel): diff --git a/src/dto/purchase.py b/src/dto/purchase.py index 8d525b0..420ceba 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -7,12 +7,7 @@ from src.domain.placement import CostType, InviteLinkType, PlacementStatus, Plac from src.domain.placement_post import PlacementPostStatus from .channel import ChannelOutput - - -class ProjectOutput(pydantic.BaseModel): - id: uuid.UUID - status: str - channel: ChannelOutput +from .project import ProjectOutput class CostInfo(pydantic.BaseModel): @@ -43,7 +38,7 @@ class PlacementOutput(pydantic.BaseModel): invite_link_type: InviteLinkType channel: ChannelOutput project: ProjectOutput | None = None - placement_number: int + short_id: str details: PlacementDetails | None = None diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index d663458..86d9495 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -114,7 +114,9 @@ class TelegramBotWriter(typing.Protocol): async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ... - async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ... + async def create_chat_invite_link( + self, chat_id: int, requires_approval: bool = False, name: str | None = None + ) -> str: ... class JWTEncoder(typing.Protocol): diff --git a/src/usecase/channel/attach_channel_to_workspace.py b/src/usecase/channel/attach_channel_to_workspace.py index 5abbe4c..ab5b377 100644 --- a/src/usecase/channel/attach_channel_to_workspace.py +++ b/src/usecase/channel/attach_channel_to_workspace.py @@ -46,4 +46,10 @@ async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelT username=channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), ) diff --git a/src/usecase/project/archive_project.py b/src/usecase/project/archive_project.py index dd4fd87..e7304b7 100644 --- a/src/usecase/project/archive_project.py +++ b/src/usecase/project/archive_project.py @@ -27,6 +27,12 @@ async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dt username=project.channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), ) @@ -51,4 +57,10 @@ async def unarchive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> username=project.channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), ) diff --git a/src/usecase/project/get_project.py b/src/usecase/project/get_project.py index 8e5eed6..5c3824c 100644 --- a/src/usecase/project/get_project.py +++ b/src/usecase/project/get_project.py @@ -21,4 +21,10 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec username=project.channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), ) diff --git a/src/usecase/project/get_workspace_projects.py b/src/usecase/project/get_workspace_projects.py index ef38b15..f6c2c42 100644 --- a/src/usecase/project/get_workspace_projects.py +++ b/src/usecase/project/get_workspace_projects.py @@ -25,6 +25,12 @@ async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProject username=project.channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), ) for project in projects ] diff --git a/src/usecase/project/move_project_to_workspace.py b/src/usecase/project/move_project_to_workspace.py index c591112..f90d2cf 100644 --- a/src/usecase/project/move_project_to_workspace.py +++ b/src/usecase/project/move_project_to_workspace.py @@ -67,4 +67,10 @@ async def move_project_to_workspace( username=project.channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), ) diff --git a/src/usecase/project/tg_add_project.py b/src/usecase/project/tg_add_project.py index 44d3e0d..9304423 100644 --- a/src/usecase/project/tg_add_project.py +++ b/src/usecase/project/tg_add_project.py @@ -200,6 +200,12 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto username=channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=channel.id, + telegram_id=channel.telegram_id, + title=channel.title, + username=channel.username, + ), ) # Если >1 workspace - создаем/обновляем канал и отправляем уведомление diff --git a/src/usecase/project/update_project_invite_link_type.py b/src/usecase/project/update_project_invite_link_type.py index e97b3b8..01dd1ab 100644 --- a/src/usecase/project/update_project_invite_link_type.py +++ b/src/usecase/project/update_project_invite_link_type.py @@ -39,4 +39,10 @@ async def update_project_invite_link_type( username=project.channel.username, status=project.status, purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), ) diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index 131fa5d..29c128a 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -8,6 +8,8 @@ from tortoise import timezone from src import domain, dto +from .create_placements import generate_invite_link_name, uuid_to_short_id + if TYPE_CHECKING: from .. import Usecase @@ -152,8 +154,15 @@ async def build_placement_creative( if project.channel.telegram_id is None: raise domain.ChannelNotFound(project.channel.id) + # Generate invite_link_name if not set + if not placement.invite_link_name: + short_id = uuid_to_short_id(placement.id) + placement.invite_link_name = generate_invite_link_name(short_id, project.channel.title) + requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL - invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval) + invite_link = await self.telegram_bot.create_chat_invite_link( + project.channel.telegram_id, requires_approval, name=placement.invite_link_name + ) placement.invite_link = invite_link placement.invite_link_created_at = timezone.now() await self.database.update_placement(placement) diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index 7a9c774..78fccce 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -9,6 +9,76 @@ if TYPE_CHECKING: log = logging.getLogger(__name__) +# Base62 alphabet for encoding +BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' + + +def uuid_to_short_id(placement_uuid: uuid.UUID) -> str: + """ + Конвертирует UUID в короткий ID (8 символов base62). + + Берёт первые 8 байт UUID (64 бита) и кодирует в base62. + """ + # Берём первые 8 байт UUID + uuid_bytes = placement_uuid.bytes[:8] + + # Конвертируем байты в integer + num = int.from_bytes(uuid_bytes, byteorder='big') + + # Кодируем в base62 + if num == 0: + return BASE62_ALPHABET[0] + + result = [] + base = len(BASE62_ALPHABET) + while num > 0: + num, remainder = divmod(num, base) + result.append(BASE62_ALPHABET[remainder]) + + # Разворачиваем и ограничиваем до 8 символов + short_id = ''.join(reversed(result))[:8] + return short_id.lower() + + +def generate_invite_link_name(short_id: str, project_channel_title: str | None) -> str: + """ + Генерирует название для invite link с умным сокращением. + + Формат: "{short_id} {channel_name}" + Приоритет: short_id всегда полный, channel_name сокращается. + + Args: + short_id: Короткий ID размещения + project_channel_title: Название канала проекта + + Returns: + Строка до 32 символов + """ + channel_name = project_channel_title or 'Unknown' + + # Базовый формат: "{short_id} {name}" + base_name = f'{short_id} {channel_name}' + + # Если помещается, возвращаем как есть + if len(base_name) <= 32: + return base_name + + # Считаем доступное место для канала + # Формат: "{short_id} " занимает len(short_id) + 1 символов + prefix_len = len(short_id) + 1 # например: "a3b9x2m " = 8 символов + max_channel_len = 32 - prefix_len + + # Если места слишком мало, возвращаем только short_id + if max_channel_len < 3: + return short_id + + # Сокращаем название канала (с многоточием если нужно) + truncated_channel = channel_name[: max_channel_len - 3] + if len(truncated_channel) < len(channel_name): + truncated_channel = truncated_channel + '...' + + return f'{short_id} {truncated_channel}' + def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None: if cost_type is None or cost_value is None: @@ -34,7 +104,6 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail def _build_placement_output( placement: domain.Placement, project: domain.Project | None = None, - placement_number: int = 0, creative_name: str | None = None, ) -> dto.PlacementOutput: channel = placement.channel @@ -42,24 +111,31 @@ def _build_placement_output( log.error('Placement %s has no channel prefetched', placement.id) raise ValueError(f'Placement {placement.id} has no channel') + # Generate short_id from UUID + short_id = uuid_to_short_id(placement.id) + # Build project output if project is provided project_output: dto.ProjectOutput | None = None if project is not None: project_channel = project.channel if project_channel is None: - log.error('Project %s has no channel', project.id) - raise ValueError(f'Project {project.id} has no channel') - - project_output = dto.ProjectOutput( - id=project.id, - status=project.status.value, - channel=dto.ChannelOutput( - id=project_channel.id, - telegram_id=project_channel.telegram_id, - title=project_channel.title, - username=project_channel.username, - ), - ) + log.warning('Project %s has no channel prefetched, skipping project output', project.id) + else: + project_output = dto.ProjectOutput( + id=project.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + status=project.status, + purchase_invite_type_default=project.purchase_invite_type_default, + channel=dto.ChannelOutput( + id=project.channel.id, + telegram_id=project.channel.telegram_id, + title=project.channel.title, + username=project.channel.username, + ), + ) + log.debug('Built project output for project %s', project.id) return dto.PlacementOutput( id=placement.id, @@ -76,7 +152,7 @@ def _build_placement_output( username=channel.username, ), project=project_output, - placement_number=placement_number, + short_id=short_id, details=_build_placement_details(placement), ) @@ -132,7 +208,9 @@ async def create_placements( creative_id=creative.id if creative else None, channel_id=channel.id, invite_link=None, - invite_link_type=(channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None) + invite_link_type=( + channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None + ) or (details.invite_link_type if details and details.invite_link_type else None) or project.purchase_invite_type_default, status=channel_input.status or domain.PlacementStatus.NO_STATUS, @@ -145,9 +223,17 @@ async def create_placements( or (details.cost.type if details and details.cost else None), cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None) or (details.cost.value if details and details.cost else None), - cost_before_bargain_type=(channel_details.cost_before_bargain.type if channel_details and channel_details.cost_before_bargain else None) + cost_before_bargain_type=( + channel_details.cost_before_bargain.type + if channel_details and channel_details.cost_before_bargain + else None + ) or (details.cost_before_bargain.type if details and details.cost_before_bargain else None), - cost_before_bargain=(channel_details.cost_before_bargain.value if channel_details and channel_details.cost_before_bargain else None) + cost_before_bargain=( + channel_details.cost_before_bargain.value + if channel_details and channel_details.cost_before_bargain + else None + ) or (details.cost_before_bargain.value if details and details.cost_before_bargain else None), placement_type=details.placement_type if details else None, format=(channel_details.format if channel_details else None) or (details.format if details else None), @@ -155,6 +241,12 @@ async def create_placements( await self.database.create_placement(placement) placement.channel = channel + + # Generate invite_link_name after placement has UUID + short_id = uuid_to_short_id(placement.id) + placement.invite_link_name = generate_invite_link_name(short_id, project.channel.title) + await self.database.update_placement(placement) + placements.append(placement) log.info( diff --git a/src/usecase/purchase/get_placement.py b/src/usecase/purchase/get_placement.py index 6c09dc3..460f3cc 100644 --- a/src/usecase/purchase/get_placement.py +++ b/src/usecase/purchase/get_placement.py @@ -89,18 +89,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d if creative: creative_name = creative.name - # Calculate placement number (ordinal number of this placement) - placement_number = await self.database.count_placements_by_project_and_channel( - project.id, placement.channel_id - ) - # Count placements created before this one - placements_before = await domain.Placement.filter( - project_id=project.id, - channel_id=placement.channel_id, - created_at__lt=placement.created_at, - ).count() - placement_number = placements_before + 1 - placement_posts = await self.database.get_workspace_placement_posts( input.workspace_id, placement_id=placement.id, @@ -139,7 +127,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d placement_output = _build_placement_output( placement, project=project, - placement_number=placement_number, creative_name=creative_name, ) return dto.PlacementWithPostsOutput( diff --git a/src/usecase/purchase/get_placements.py b/src/usecase/purchase/get_placements.py index a9770db..a0efd09 100644 --- a/src/usecase/purchase/get_placements.py +++ b/src/usecase/purchase/get_placements.py @@ -27,6 +27,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto. context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id) + # Prefetch project channel for output building + if project.channel is None: + project_channel = await self.database.get_channel(channel_id=project.channel_id) + project.channel = project_channel + placements = await self.database.get_project_placements(input.workspace_id, project.id) log.debug('Fetched %s placements for project %s', len(placements), project.id) diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index 058274c..73978ce 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -570,9 +570,13 @@ type CreateChannelResult struct { } type ProjectOutput struct { - ID string `json:"id"` - Status string `json:"status"` - Channel Channel `json:"channel"` + ID string `json:"id"` + TelegramID int64 `json:"telegram_id"` + Title string `json:"title"` + Username *string `json:"username"` + Status string `json:"status"` + PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"` + Channel Channel `json:"channel"` } type CreateChannelsOutput struct { @@ -598,18 +602,18 @@ type PlacementDetails struct { } type PlacementOutput struct { - ID string `json:"id"` - Status string `json:"status"` - CreativeID *string `json:"creative_id,omitempty"` - CreativeName *string `json:"creative_name,omitempty"` - Comment *string `json:"comment,omitempty"` - InviteLink *string `json:"invite_link,omitempty"` - InviteLinkType string `json:"invite_link_type"` - Channel Channel `json:"channel"` - Project *ProjectOutput `json:"project"` - PlacementNumber int `json:"placement_number"` - Details *PlacementDetails `json:"details,omitempty"` - PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` + ID string `json:"id"` + Status string `json:"status"` + CreativeID *string `json:"creative_id,omitempty"` + CreativeName *string `json:"creative_name,omitempty"` + Comment *string `json:"comment,omitempty"` + InviteLink *string `json:"invite_link,omitempty"` + InviteLinkType string `json:"invite_link_type"` + Channel Channel `json:"channel"` + Project *ProjectOutput `json:"project"` + ShortID string `json:"short_id"` + Details *PlacementDetails `json:"details,omitempty"` + PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"` } type PlacementPostOutput struct { diff --git a/tg_bot/screens/add_creative.go b/tg_bot/screens/add_creative.go index 83f8e80..802337a 100644 --- a/tg_bot/screens/add_creative.go +++ b/tg_bot/screens/add_creative.go @@ -55,7 +55,7 @@ const msgDownloadMediaError = ` const msgInviteLinkRequired = ` ❌ Ошибка валидации -Текст должен содержать ОДНУ инвайт-ссылку вашего канала! +Текст должен содержать хотя бы одну инвайт-ссылку вашего канала! Формат ссылки: https://t.me/+xxx diff --git a/tg_bot/screens/creative_details.go b/tg_bot/screens/creative_details.go index b3d150a..3e7ef21 100644 --- a/tg_bot/screens/creative_details.go +++ b/tg_bot/screens/creative_details.go @@ -16,9 +16,15 @@ import ( type CreativeDetails struct { CreativeEditorFields // Встраивание общих полей - CreativeID string - ProjectID string - BackState bot.State + CreativeID string + ProjectID string + ProjectTitle string // Название проекта для сообщений + + BackState bot.State + + // View mode: открыты из режима просмотра + ViewMode bool // Открыты из CreativeView + ViewBackTo bot.State // Состояние для возврата (CreativeView) // Флаг подтверждения удаления confirmingDelete bool @@ -89,7 +95,10 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { switch { case data == "cancel", data == "back": // Отмена - возвращаемся назад - if s.BackState != nil { + if s.ViewMode && s.ViewBackTo != nil { + // Если открыты из view mode, возвращаемся к просмотру + b.SetState(s.ViewBackTo, bot.EditMessage) + } else if s.BackState != nil { b.SetState(s.BackState, bot.EditMessage) } @@ -355,7 +364,12 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) { log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully") // Возвращаемся назад - if s.BackState != nil { + if s.ViewMode && s.ViewBackTo != nil { + // Если открыты из view mode, возвращаемся к просмотру + b.SendNew("✅ Креатив успешно обновлен!", Keyboard()) + b.SetState(s.ViewBackTo, bot.EditMessage) + } else if s.BackState != nil { + // Иначе возвращаемся к BackState b.SendNew("✅ Креатив успешно обновлен!", Keyboard()) b.SetState(s.BackState, bot.EditMessage) } diff --git a/tg_bot/screens/creative_view.go b/tg_bot/screens/creative_view.go new file mode 100644 index 0000000..01e4ffc --- /dev/null +++ b/tg_bot/screens/creative_view.go @@ -0,0 +1,218 @@ +package screens + +import ( + "context" + "fmt" + + "github.com/NicoNex/echotron/v3" + "github.com/TelegramExchange/tgex-backend/tg_bot/backend" + "github.com/TelegramExchange/tgex-backend/tg_bot/bot" + "github.com/rs/zerolog/log" +) + +// CreativeView - экран просмотра креатива (read-only mode) +// Показывает превью креатива и кнопки действий: Редактировать, Тег, Закупить +type CreativeView struct { + CreativeEditorFields // Встраивание общих полей для отображения превью + + CreativeID string + ProjectID string + + // Данные проекта для передачи в AddPurchase + ProjectTitle string + ProjectTelegramID int64 + ProjectUsername string + ProjectStatus string + + BackState bot.State +} + +const msgCreativeView = ` +📋 Просмотр креатива + +Выберите действие: +` + +func (s *CreativeView) Enter(b *bot.Bot, mode bot.RenderMode) { + // Загружаем креатив + creative, err := b.Backend.GetCreative(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.CreativeID) + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to get creative") + b.SendNew("❌ Не удалось загрузить креатив", Keyboard( + Row(Button("← Назад", "back")), + )) + return + } + + // Загружаем проект для получения данных + project, err := b.Backend.GetProject(context.Background(), b.Session.JWT, b.Session.WorkspaceID, s.ProjectID) + if err != nil { + log.Error().Err(err).Str("project_id", s.ProjectID).Msg("Failed to get project") + } else { + s.ProjectTitle = project.Title + s.ProjectTelegramID = project.TelegramID + s.ProjectStatus = project.Status + if project.Username != nil { + s.ProjectUsername = *project.Username + } + } + + // Заполняем поля креатива для отображения превью + s.Name = &creative.Name + if creative.Text != "" { + s.Text = &creative.Text + } + s.MediaItems = nil + if len(creative.MediaItems) > 0 { + for _, item := range creative.MediaItems { + s.MediaItems = append(s.MediaItems, MediaItem{ + MediaType: item.MediaType, + MediaFileID: item.MediaFileID, + }) + } + } + s.Buttons = nil + for _, btn := range creative.Buttons { + s.Buttons = append(s.Buttons, InlineButton{Text: btn.Text, URL: btn.URL}) + } + if creative.Tag != "" { + s.Tag = &creative.Tag + } + + // Отправляем превью креатива + s.SendCreativePreview(b) + + // Отправляем панель управления с кнопками действий + s.showActionPanel(b) +} + +func (s *CreativeView) HandleCallback(b *bot.Bot, u *echotron.Update) { + if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { + return + } + + data := u.CallbackQuery.Data + + switch { + case data == "back": + if s.BackState != nil { + b.SetState(s.BackState, bot.EditMessage) + } + + case data == "edit": + // Переход к редактированию креатива + b.SetState(&CreativeDetails{ + CreativeID: s.CreativeID, + ProjectID: s.ProjectID, + ViewMode: true, // Флаг, что мы открылись из view mode + ViewBackTo: s, // Возврат к этому экрану после сохранения + ProjectTitle: s.ProjectTitle, + BackState: s.BackState, + }, bot.NewMessage) + + case data == "toggle_tag": + // Переключение тега (testing <-> production) + s.toggleTag(b) + + case data == "purchase_creative": + // Переход к созданию закупа с предвыбором проекта и креатива + creativeTitle := "" + if s.Name != nil { + creativeTitle = *s.Name + } + + b.SetState(&AddPurchase{ + ProjectID: s.ProjectID, + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + CreativeID: s.CreativeID, + CreativeTitle: creativeTitle, + BackState: s, + }, bot.NewMessage) + + default: + log.Warn().Str("callback", data).Msg("Unknown callback in CreativeView") + } +} + +func (s *CreativeView) HandleMessage(_ *bot.Bot, _ *echotron.Update) {} + +func (s *CreativeView) Handle(_ *bot.Bot, _ *echotron.Update) { return } + +func (s *CreativeView) Exit() {} + +// showActionPanel показывает панель действий с кнопками +func (s *CreativeView) showActionPanel(b *bot.Bot) { + tagLabel := s.GetTagLabel() + purchaseLabel := "🛒 Закупить на креатив" + + // Если креатив архивирован (в будущем), можно изменить кнопки + var buttons [][]echotron.InlineKeyboardButton + + // Кнопка редактирования + buttons = append(buttons, Row( + Button("✏️ Редактировать", "edit"), + )) + + // Кнопка тега + buttons = append(buttons, Row( + Button(tagLabel, "toggle_tag"), + )) + + // Кнопка закупа + buttons = append(buttons, Row( + Button(purchaseLabel, "purchase_creative"), + )) + + // Кнопка назад + buttons = append(buttons, Row( + Button("← Назад", "back"), + )) + + s.ShowControlPanel(b, msgCreativeView, buttons) +} + +// toggleTag переключает тег креатива (testing <-> production) +func (s *CreativeView) toggleTag(b *bot.Bot) { + newTag := "testing" + if s.Tag != nil && *s.Tag == "testing" { + newTag = "production" + } + + // Обновляем тег через backend + input := backend.UpdateCreativeInput{ + Tag: &newTag, + } + + _, err := b.Backend.UpdateCreative( + context.Background(), + b.Session.JWT, + b.Session.WorkspaceID, + s.CreativeID, + input, + ) + + if err != nil { + log.Error().Err(err).Str("creative_id", s.CreativeID).Msg("Failed to update creative tag") + s.ShowControlPanel(b, "❌ Не удалось изменить тег\n\nПопробуйте еще раз.", [][]echotron.InlineKeyboardButton{ + Row(Button("← Назад", "back")), + Row(Button("↻ Попробовать снова", "toggle_tag")), + }) + return + } + + // Обновляем локальное значение + s.Tag = &newTag + + // Показываем успех и обновляем панель + tagText := "🧪 Тестовый" + if newTag == "production" { + tagText = "🚀 Рабочий" + } + b.SendNew(fmt.Sprintf("✅ Тег изменен на «%s»", tagText), Keyboard()) + + // Обновляем панель + s.showActionPanel(b) +} diff --git a/tg_bot/screens/creatives.go b/tg_bot/screens/creatives.go index 099d3c2..c49d93d 100644 --- a/tg_bot/screens/creatives.go +++ b/tg_bot/screens/creatives.go @@ -120,10 +120,16 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) { return } - b.SetState(&CreativeDetails{ + // Открываем CreativeView вместо CreativeDetails + b.SetState(&CreativeView{ CreativeID: creativeID, ProjectID: s.ProjectID, - BackState: s, + // Данные проекта для передачи в AddPurchase + ProjectTitle: s.ProjectTitle, + ProjectTelegramID: s.ProjectTelegramID, + ProjectUsername: s.ProjectUsername, + ProjectStatus: s.ProjectStatus, + BackState: s, }, bot.NewMessage) default: diff --git a/tg_bot/screens/help.go b/tg_bot/screens/help.go index 32fe913..cd38e90 100644 --- a/tg_bot/screens/help.go +++ b/tg_bot/screens/help.go @@ -29,7 +29,7 @@ type Help struct{} func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) { keyboard := Keyboard( - Row(URLButton("Поддержка", "https://example.com")), + Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")), Row(Button("↩ В главное меню", "main_menu")), ) diff --git a/tg_bot/screens/main_menu.go b/tg_bot/screens/main_menu.go index a410de5..f8d00b9 100644 --- a/tg_bot/screens/main_menu.go +++ b/tg_bot/screens/main_menu.go @@ -14,14 +14,22 @@ type MainMenu struct{} const msgAbout = ` Главное меню -Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц. +
+За что отвечает каждая кнопка в этом окне:
 
-• Внутренний счёт и безопасные сделки
-• Фиксация цен, дат и форматов
-• Автоматические отчёты
-• Повторные закупки в пару кликов
+• Рабочее пространство — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.
 
-Комиссия — 7%. Без скрытых платежей.
+• Мои проекты — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы.
+
+• Размещения — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку).
+
+• Веб-платформа — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов.
+
+• Помощь — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы.
+
+ +Чтобы делать закупы, добавьте канал и креатив: +/addchannel — новый канал ` func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { @@ -65,8 +73,8 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) { keyboard := Keyboard( Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")), - Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://example.com")), - Row(Button("Размещения", "placements"), URLButton("Дашборд", "https://example.com")), + Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")), + Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))), ) b.Render(msg, keyboard, mode) diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index 8b3e35e..bda0877 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -57,9 +57,9 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { // Тип ссылки text += fmt.Sprintf("🔓 Тип ссылки: %s\n", formatInviteLinkType(placement.InviteLinkType)) - // Порядковый номер размещения - if placement.PlacementNumber > 0 { - text += fmt.Sprintf("🔢 Размещение №%d в этом канале\n", placement.PlacementNumber) + // Short ID + if placement.ShortID != "" { + text += fmt.Sprintf("🔢 ID: %s\n", placement.ShortID) } // Креатив @@ -242,15 +242,63 @@ func formatDateTime(value string) string { if value == "" { return value } - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - return parsed.Format("02.01.2006 15:04") + var parsed time.Time + var err error + + // Try parsing with timezone + if parsed, err = time.Parse(time.RFC3339, value); err == nil { + return formatCompactDateTime(parsed) } - if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil { - return parsed.Format("02.01.2006 15:04") + if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil { + return formatCompactDateTime(parsed) + } + if parsed, err = time.Parse("2006-01-02T15:04:05", value); err == nil { + return formatCompactDateTime(parsed) + } + if parsed, err = time.Parse("2006-01-02", value); err == nil { + return formatCompactDateTime(parsed) } return value } +func WeekdayName(day time.Weekday) string { + switch day { + case time.Monday: + return "Пн" + case time.Tuesday: + return "Вт" + case time.Wednesday: + return "Ср" + case time.Thursday: + return "Чт" + case time.Friday: + return "Пт" + case time.Saturday: + return "Сб" + case time.Sunday: + return "Вс" + default: + return "" + } +} + +func MonthShort(month time.Month) string { + months := []string{ + "янв", "фев", "мар", "апр", "май", "июн", + "июл", "авг", "сен", "окт", "ноя", "дек", + } + if int(month) < 1 || int(month) > len(months) { + return "" + } + return months[int(month)-1] +} + +func formatCompactDateTime(value time.Time) string { + weekday := WeekdayName(value.Weekday()) + month := MonthShort(value.Month()) + return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04")) +} + func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) { if u.CallbackQuery == nil || u.CallbackQuery.Data == "" { return diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go index a67c6e2..b3b1a94 100644 --- a/tg_bot/screens/placements.go +++ b/tg_bot/screens/placements.go @@ -30,7 +30,7 @@ func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) { rows = append(rows, Row(Button("+ Создать размещение", "add_purchase"))) rows = append(rows, Row(Button("Размещения", "placements_list"))) - rows = append(rows, Row(URLButton("План закупов", "https://example.com"))) + rows = append(rows, Row(URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID)))) rows = append(rows, Row(Button("← Назад", "back"))) keyboard := Keyboard(rows...) diff --git a/tg_bot/screens/platform_link.go b/tg_bot/screens/platform_link.go index 13edc38..8534293 100644 --- a/tg_bot/screens/platform_link.go +++ b/tg_bot/screens/platform_link.go @@ -1,6 +1,8 @@ package screens import ( + "fmt" + "github.com/NicoNex/echotron/v3" "github.com/TelegramExchange/tgex-backend/tg_bot/bot" ) @@ -17,7 +19,7 @@ type PlatformLink struct{} func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) { keyboard := Keyboard( - Row(URLButton("Открыть платформу", "https://example.com")), + Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))), Row(Button("↩ В главное меню", "main_menu")), ) diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index a8cd945..3d0d010 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -111,12 +111,6 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) { s.inviteLinkTypeButton(), )) - if row := s.costBeforeDeleteRow(); row != nil { - rows = append(rows, row) - } - if row := s.commentDeleteRow(); row != nil { - rows = append(rows, row) - } rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next"))) keyboard := Keyboard(rows...) @@ -683,10 +677,21 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM } typeLabel := s.costBeforeTypeLabelForCurrent() - keyboard := Keyboard( - Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), - Row(Button("← Назад", s.backAction())), - ) + var keyboard echotron.InlineKeyboardMarkup + + // Показываем кнопку удаления только если есть значение и это не режим per-channel + if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil { + keyboard = Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("⌫ Удалить до торга", "delete_cost_before")), + Row(Button("← Назад", s.backAction())), + ) + } else { + keyboard = Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("← Назад", s.backAction())), + ) + } if mode == bot.EditMessage { b.Edit(text, keyboard) @@ -750,6 +755,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM return case "comment": text += "Введите комментарий" + keyboard := Keyboard(Row(Button("← Назад", s.backAction()))) + if s.Comment != "" { + keyboard = Keyboard( + Row(Button("⌫ Удалить комментарий", "delete_comment")), + Row(Button("← Назад", s.backAction())), + ) + } + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return default: s.InputMode = "" s.Enter(b, mode) diff --git a/tg_parser/internal/adapter/database/update_channel.go b/tg_parser/internal/adapter/database/update_channel.go index 08d2387..624dc1b 100644 --- a/tg_parser/internal/adapter/database/update_channel.go +++ b/tg_parser/internal/adapter/database/update_channel.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/jackc/pgx/v5/pgtype" + "github.com/rs/zerolog/log" "github.com/TelegramExchange/pkg/transaction" "github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain" @@ -19,6 +20,7 @@ SET telegram_id = $2, access_hash = $5, pts = $6, is_accessible = $7, + invite_link = $8, updated_at = CURRENT_TIMESTAMP WHERE id = $1;` @@ -32,6 +34,23 @@ WHERE id = $1;` usernameDTO = pgtype.Text{Valid: false} } + inviteLink := strings.TrimSpace(channel.InviteLink) + var inviteLinkDTO pgtype.Text + if inviteLink != "" { + inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true} + } else { + inviteLinkDTO = pgtype.Text{Valid: false} + } + + // Log warning if both identity fields are empty (will violate CHECK constraint) + if !usernameDTO.Valid && !inviteLinkDTO.Valid { + log.Warn(). + Str("channel_id", channel.ID.String()). + Int64("telegram_id", channel.TelegramID). + Str("title", channel.Title). + Msg("UpdateChannel: both username and invite_link are empty, this may violate CHECK constraint") + } + dto := updateChannelDTO{ ID: pgtype.UUID{Bytes: channel.ID, Valid: true}, TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true}, @@ -40,9 +59,10 @@ WHERE id = $1;` AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true}, Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true}, IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, Valid: true}, + InviteLink: inviteLinkDTO, } - _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible) + _, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible, dto.InviteLink) if err != nil { return fmt.Errorf("txOrPool.Exec: %w", err) } @@ -58,4 +78,5 @@ type updateChannelDTO struct { AccessHash pgtype.Int8 Pts pgtype.Int4 IsAccessible pgtype.Bool + InviteLink pgtype.Text }