правки
This commit is contained in:
@@ -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}')
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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 - создаем/обновляем канал и отправляем уведомление
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user