правки

This commit is contained in:
Artem Tsyrulnikov
2026-02-02 11:59:37 +03:00
parent 52cd2ec1a0
commit 31b63e5a39
30 changed files with 710 additions and 126 deletions

View File

@@ -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}')

View File

@@ -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 '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>'
# 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 '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>'
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 '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>'
def _replace_html(match: re.Match[str]) -> str:
inner = _strip_html(match.group(1))
if _INVITE_LINK_PLAIN.fullmatch(inner):
return '<a class="tg-link"></a>'
return f'<a class="tg-link">{inner}</a>'
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('<a class="tg-link"></a>', text)
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
return text
def validate_media_size(media_data: bytes | None) -> None:

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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,
),
)

View File

@@ -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,
),
)

View File

@@ -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,
),
)

View File

@@ -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
]

View File

@@ -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,
),
)

View File

@@ -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 - создаем/обновляем канал и отправляем уведомление

View File

@@ -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,
),
)

View File

@@ -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)

View File

@@ -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(

View File

@@ -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(

View File

@@ -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)