правки
This commit is contained in:
105
migrations/models/16_20260202115014_add_invite_link_name.py
Normal file
105
migrations/models/16_20260202115014_add_invite_link_name.py
Normal file
@@ -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"
|
||||||
|
)
|
||||||
@@ -33,8 +33,12 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
)
|
)
|
||||||
return message.message_id
|
return message.message_id
|
||||||
|
|
||||||
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
|
async def create_chat_invite_link(
|
||||||
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
|
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
|
return invite_link.invite_link
|
||||||
|
|
||||||
async def send_message_with_inline_keyboard(
|
async def send_message_with_inline_keyboard(
|
||||||
@@ -122,13 +126,9 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
|||||||
for index, item in enumerate(media_items):
|
for index, item in enumerate(media_items):
|
||||||
item_caption = caption if index == 0 else None
|
item_caption = caption if index == 0 else None
|
||||||
if item.media_type == 'photo':
|
if item.media_type == 'photo':
|
||||||
group.append(
|
group.append(InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode))
|
||||||
InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
|
|
||||||
)
|
|
||||||
elif item.media_type == 'video':
|
elif item.media_type == 'video':
|
||||||
group.append(
|
group.append(InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode))
|
||||||
InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from .error import (
|
|||||||
CreativeMediaGroupUnsupported,
|
CreativeMediaGroupUnsupported,
|
||||||
CreativeMediaTooLarge,
|
CreativeMediaTooLarge,
|
||||||
CreativeMediaTooMany,
|
CreativeMediaTooMany,
|
||||||
CreativeMultipleInviteLinks,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -89,44 +88,43 @@ def _strip_html(text: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def replace_invite_link_with_tag(text: str) -> str:
|
def replace_invite_link_with_tag(text: str) -> str:
|
||||||
"""Replace Telegram invite link (t.me/+xxx) with tracking tag."""
|
"""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_count = len(_INVITE_LINK_REPLACED.findall(text))
|
replaced = _INVITE_LINK_REPLACED.findall(text)
|
||||||
html_count = len(_INVITE_LINK_HTML.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_HTML.sub('', text)
|
||||||
text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links)
|
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:
|
if total == 0:
|
||||||
raise CreativeInviteLinkNotFound()
|
raise CreativeInviteLinkNotFound()
|
||||||
if total > 1:
|
|
||||||
raise CreativeMultipleInviteLinks()
|
|
||||||
|
|
||||||
# Если ссылка уже заменена, нормализуем текст если внутри был URL или теги
|
# Normalize already replaced links (preserve anchor text)
|
||||||
if replaced_count == 1:
|
|
||||||
def _normalize_replaced(match: re.Match[str]) -> str:
|
def _normalize_replaced(match: re.Match[str]) -> str:
|
||||||
inner = _strip_html(match.group(1))
|
inner = _strip_html(match.group(1))
|
||||||
if inner == "" or _INVITE_LINK_PLAIN.search(inner):
|
if inner == "" or _INVITE_LINK_PLAIN.search(inner):
|
||||||
return '<a class="tg-link"></a>'
|
return '<a class="tg-link"></a>'
|
||||||
return f'<a class="tg-link">{inner}</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:
|
def _replace_html(match: re.Match[str]) -> str:
|
||||||
inner = _strip_html(match.group(1))
|
inner = _strip_html(match.group(1))
|
||||||
if _INVITE_LINK_PLAIN.fullmatch(inner):
|
if _INVITE_LINK_PLAIN.fullmatch(inner):
|
||||||
return '<a class="tg-link"></a>'
|
return '<a class="tg-link"></a>'
|
||||||
return f'<a class="tg-link">{inner}</a>'
|
return f'<a class="tg-link">{inner}</a>'
|
||||||
|
|
||||||
return _INVITE_LINK_HTML.sub(_replace_html, text)
|
text = _INVITE_LINK_HTML.sub(_replace_html, text)
|
||||||
|
|
||||||
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
|
# Replace plain text invite links
|
||||||
|
text = _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def validate_media_size(media_data: bytes | None) -> None:
|
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_created_at = fields.DatetimeField(null=True)
|
||||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||||
|
|
||||||
|
invite_link_name = fields.CharField(max_length=32, null=True)
|
||||||
|
|
||||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import pydantic
|
|||||||
|
|
||||||
from src import domain
|
from src import domain
|
||||||
from src.domain.project import ProjectStatus
|
from src.domain.project import ProjectStatus
|
||||||
|
from .channel import ChannelOutput
|
||||||
|
|
||||||
|
|
||||||
class ChannelBotPermissions(pydantic.BaseModel):
|
class ChannelBotPermissions(pydantic.BaseModel):
|
||||||
@@ -33,6 +34,7 @@ class ProjectOutput(pydantic.BaseModel):
|
|||||||
username: str | None
|
username: str | None
|
||||||
status: ProjectStatus
|
status: ProjectStatus
|
||||||
purchase_invite_type_default: domain.InviteLinkType
|
purchase_invite_type_default: domain.InviteLinkType
|
||||||
|
channel: ChannelOutput
|
||||||
|
|
||||||
|
|
||||||
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
|
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 src.domain.placement_post import PlacementPostStatus
|
||||||
|
|
||||||
from .channel import ChannelOutput
|
from .channel import ChannelOutput
|
||||||
|
from .project import ProjectOutput
|
||||||
|
|
||||||
class ProjectOutput(pydantic.BaseModel):
|
|
||||||
id: uuid.UUID
|
|
||||||
status: str
|
|
||||||
channel: ChannelOutput
|
|
||||||
|
|
||||||
|
|
||||||
class CostInfo(pydantic.BaseModel):
|
class CostInfo(pydantic.BaseModel):
|
||||||
@@ -43,7 +38,7 @@ class PlacementOutput(pydantic.BaseModel):
|
|||||||
invite_link_type: InviteLinkType
|
invite_link_type: InviteLinkType
|
||||||
channel: ChannelOutput
|
channel: ChannelOutput
|
||||||
project: ProjectOutput | None = None
|
project: ProjectOutput | None = None
|
||||||
placement_number: int
|
short_id: str
|
||||||
details: PlacementDetails | None = None
|
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 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):
|
class JWTEncoder(typing.Protocol):
|
||||||
|
|||||||
@@ -46,4 +46,10 @@ async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelT
|
|||||||
username=channel.username,
|
username=channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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
|
for project in projects
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -67,4 +67,10 @@ async def move_project_to_workspace(
|
|||||||
username=project.channel.username,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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,
|
username=channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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 - создаем/обновляем канал и отправляем уведомление
|
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
||||||
|
|||||||
@@ -39,4 +39,10 @@ async def update_project_invite_link_type(
|
|||||||
username=project.channel.username,
|
username=project.channel.username,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
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 src import domain, dto
|
||||||
|
|
||||||
|
from .create_placements import generate_invite_link_name, uuid_to_short_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .. import Usecase
|
from .. import Usecase
|
||||||
|
|
||||||
@@ -152,8 +154,15 @@ async def build_placement_creative(
|
|||||||
if project.channel.telegram_id is None:
|
if project.channel.telegram_id is None:
|
||||||
raise domain.ChannelNotFound(project.channel.id)
|
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
|
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 = invite_link
|
||||||
placement.invite_link_created_at = timezone.now()
|
placement.invite_link_created_at = timezone.now()
|
||||||
await self.database.update_placement(placement)
|
await self.database.update_placement(placement)
|
||||||
|
|||||||
@@ -9,6 +9,76 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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:
|
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:
|
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(
|
def _build_placement_output(
|
||||||
placement: domain.Placement,
|
placement: domain.Placement,
|
||||||
project: domain.Project | None = None,
|
project: domain.Project | None = None,
|
||||||
placement_number: int = 0,
|
|
||||||
creative_name: str | None = None,
|
creative_name: str | None = None,
|
||||||
) -> dto.PlacementOutput:
|
) -> dto.PlacementOutput:
|
||||||
channel = placement.channel
|
channel = placement.channel
|
||||||
@@ -42,24 +111,31 @@ def _build_placement_output(
|
|||||||
log.error('Placement %s has no channel prefetched', placement.id)
|
log.error('Placement %s has no channel prefetched', placement.id)
|
||||||
raise ValueError(f'Placement {placement.id} has no channel')
|
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
|
# Build project output if project is provided
|
||||||
project_output: dto.ProjectOutput | None = None
|
project_output: dto.ProjectOutput | None = None
|
||||||
if project is not None:
|
if project is not None:
|
||||||
project_channel = project.channel
|
project_channel = project.channel
|
||||||
if project_channel is None:
|
if project_channel is None:
|
||||||
log.error('Project %s has no channel', project.id)
|
log.warning('Project %s has no channel prefetched, skipping project output', project.id)
|
||||||
raise ValueError(f'Project {project.id} has no channel')
|
else:
|
||||||
|
|
||||||
project_output = dto.ProjectOutput(
|
project_output = dto.ProjectOutput(
|
||||||
id=project.id,
|
id=project.id,
|
||||||
status=project.status.value,
|
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(
|
channel=dto.ChannelOutput(
|
||||||
id=project_channel.id,
|
id=project.channel.id,
|
||||||
telegram_id=project_channel.telegram_id,
|
telegram_id=project.channel.telegram_id,
|
||||||
title=project_channel.title,
|
title=project.channel.title,
|
||||||
username=project_channel.username,
|
username=project.channel.username,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
log.debug('Built project output for project %s', project.id)
|
||||||
|
|
||||||
return dto.PlacementOutput(
|
return dto.PlacementOutput(
|
||||||
id=placement.id,
|
id=placement.id,
|
||||||
@@ -76,7 +152,7 @@ def _build_placement_output(
|
|||||||
username=channel.username,
|
username=channel.username,
|
||||||
),
|
),
|
||||||
project=project_output,
|
project=project_output,
|
||||||
placement_number=placement_number,
|
short_id=short_id,
|
||||||
details=_build_placement_details(placement),
|
details=_build_placement_details(placement),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -132,7 +208,9 @@ async def create_placements(
|
|||||||
creative_id=creative.id if creative else None,
|
creative_id=creative.id if creative else None,
|
||||||
channel_id=channel.id,
|
channel_id=channel.id,
|
||||||
invite_link=None,
|
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 (details.invite_link_type if details and details.invite_link_type else None)
|
||||||
or project.purchase_invite_type_default,
|
or project.purchase_invite_type_default,
|
||||||
status=channel_input.status or domain.PlacementStatus.NO_STATUS,
|
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),
|
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)
|
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),
|
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),
|
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),
|
or (details.cost_before_bargain.value if details and details.cost_before_bargain else None),
|
||||||
placement_type=details.placement_type if details 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),
|
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)
|
await self.database.create_placement(placement)
|
||||||
placement.channel = channel
|
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)
|
placements.append(placement)
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
|
|||||||
@@ -89,18 +89,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
|
|||||||
if creative:
|
if creative:
|
||||||
creative_name = creative.name
|
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(
|
placement_posts = await self.database.get_workspace_placement_posts(
|
||||||
input.workspace_id,
|
input.workspace_id,
|
||||||
placement_id=placement.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_output = _build_placement_output(
|
||||||
placement,
|
placement,
|
||||||
project=project,
|
project=project,
|
||||||
placement_number=placement_number,
|
|
||||||
creative_name=creative_name,
|
creative_name=creative_name,
|
||||||
)
|
)
|
||||||
return dto.PlacementWithPostsOutput(
|
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)
|
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)
|
placements = await self.database.get_project_placements(input.workspace_id, project.id)
|
||||||
log.debug('Fetched %s placements for project %s', len(placements), project.id)
|
log.debug('Fetched %s placements for project %s', len(placements), project.id)
|
||||||
|
|
||||||
|
|||||||
@@ -571,7 +571,11 @@ type CreateChannelResult struct {
|
|||||||
|
|
||||||
type ProjectOutput struct {
|
type ProjectOutput struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
TelegramID int64 `json:"telegram_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Username *string `json:"username"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
|
||||||
Channel Channel `json:"channel"`
|
Channel Channel `json:"channel"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -607,7 +611,7 @@ type PlacementOutput struct {
|
|||||||
InviteLinkType string `json:"invite_link_type"`
|
InviteLinkType string `json:"invite_link_type"`
|
||||||
Channel Channel `json:"channel"`
|
Channel Channel `json:"channel"`
|
||||||
Project *ProjectOutput `json:"project"`
|
Project *ProjectOutput `json:"project"`
|
||||||
PlacementNumber int `json:"placement_number"`
|
ShortID string `json:"short_id"`
|
||||||
Details *PlacementDetails `json:"details,omitempty"`
|
Details *PlacementDetails `json:"details,omitempty"`
|
||||||
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
|
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const msgDownloadMediaError = `
|
|||||||
const msgInviteLinkRequired = `
|
const msgInviteLinkRequired = `
|
||||||
<b>❌ Ошибка валидации</b>
|
<b>❌ Ошибка валидации</b>
|
||||||
|
|
||||||
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
|
Текст должен содержать <b>хотя бы одну</b> инвайт-ссылку вашего канала!
|
||||||
|
|
||||||
<b>Формат ссылки:</b>
|
<b>Формат ссылки:</b>
|
||||||
<code>https://t.me/+xxx</code>
|
<code>https://t.me/+xxx</code>
|
||||||
|
|||||||
@@ -18,8 +18,14 @@ type CreativeDetails struct {
|
|||||||
|
|
||||||
CreativeID string
|
CreativeID string
|
||||||
ProjectID string
|
ProjectID string
|
||||||
|
ProjectTitle string // Название проекта для сообщений
|
||||||
|
|
||||||
BackState bot.State
|
BackState bot.State
|
||||||
|
|
||||||
|
// View mode: открыты из режима просмотра
|
||||||
|
ViewMode bool // Открыты из CreativeView
|
||||||
|
ViewBackTo bot.State // Состояние для возврата (CreativeView)
|
||||||
|
|
||||||
// Флаг подтверждения удаления
|
// Флаг подтверждения удаления
|
||||||
confirmingDelete bool
|
confirmingDelete bool
|
||||||
}
|
}
|
||||||
@@ -89,7 +95,10 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
switch {
|
switch {
|
||||||
case data == "cancel", data == "back":
|
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)
|
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")
|
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.SendNew("✅ Креатив успешно обновлен!", Keyboard())
|
||||||
b.SetState(s.BackState, bot.EditMessage)
|
b.SetState(s.BackState, bot.EditMessage)
|
||||||
}
|
}
|
||||||
|
|||||||
218
tg_bot/screens/creative_view.go
Normal file
218
tg_bot/screens/creative_view.go
Normal file
@@ -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 = `
|
||||||
|
<b>📋 Просмотр креатива</b>
|
||||||
|
|
||||||
|
Выберите действие:
|
||||||
|
`
|
||||||
|
|
||||||
|
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("<b>❌ Не удалось загрузить креатив</b>", 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, "<b>❌ Не удалось изменить тег</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)
|
||||||
|
}
|
||||||
@@ -120,9 +120,15 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
b.SetState(&CreativeDetails{
|
// Открываем CreativeView вместо CreativeDetails
|
||||||
|
b.SetState(&CreativeView{
|
||||||
CreativeID: creativeID,
|
CreativeID: creativeID,
|
||||||
ProjectID: s.ProjectID,
|
ProjectID: s.ProjectID,
|
||||||
|
// Данные проекта для передачи в AddPurchase
|
||||||
|
ProjectTitle: s.ProjectTitle,
|
||||||
|
ProjectTelegramID: s.ProjectTelegramID,
|
||||||
|
ProjectUsername: s.ProjectUsername,
|
||||||
|
ProjectStatus: s.ProjectStatus,
|
||||||
BackState: s,
|
BackState: s,
|
||||||
}, bot.NewMessage)
|
}, bot.NewMessage)
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type Help struct{}
|
|||||||
|
|
||||||
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
|
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
keyboard := Keyboard(
|
keyboard := Keyboard(
|
||||||
Row(URLButton("Поддержка", "https://example.com")),
|
Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
|
||||||
Row(Button("↩ В главное меню", "main_menu")),
|
Row(Button("↩ В главное меню", "main_menu")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,22 @@ type MainMenu struct{}
|
|||||||
const msgAbout = `
|
const msgAbout = `
|
||||||
<i>Главное меню</i>
|
<i>Главное меню</i>
|
||||||
|
|
||||||
Smartpost — платформа для покупки и продажи рекламы без ЛС и ручных таблиц.
|
<pre>
|
||||||
|
За что отвечает каждая кнопка в этом окне:
|
||||||
|
|
||||||
• Внутренний счёт и безопасные сделки
|
<b>• Рабочее пространство</b> — Одно можно использовать для своих проектов, а другое, например, для проектов, где вы являетесь закупщиком.
|
||||||
• Фиксация цен, дат и форматов
|
|
||||||
• Автоматические отчёты
|
|
||||||
• Повторные закупки в пару кликов
|
|
||||||
|
|
||||||
Комиссия — 7%. Без скрытых платежей.
|
<b>• Мои проекты</b> — Управляйте вашими телеграм-каналами: добавляйте новые, привязывайте к ним креативы, создавайте закупы.
|
||||||
|
|
||||||
|
<b>• Размещения</b> — Просматривайте статистику по всем сделанным размещениям или создавайте новые (бот автоматически сформирует для вас креатив и заменит в нём пригласительную ссылку).
|
||||||
|
|
||||||
|
<b>• Веб-платформа</b> — Используйте функционал нашего сервиса на все 100%: планируйте размещения, просматривайте аналитику, следите за показателями проектов.
|
||||||
|
|
||||||
|
<b>• Помощь</b> — Изучите инструкции, как пользоваться нашим сервисом или напишите в поддержку, если возникли какие-то вопросы.
|
||||||
|
</pre>
|
||||||
|
|
||||||
|
Чтобы делать закупы, добавьте канал и креатив:
|
||||||
|
<b>/addchannel</b> — новый канал
|
||||||
`
|
`
|
||||||
|
|
||||||
func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
|
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(
|
keyboard := Keyboard(
|
||||||
Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
|
Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
|
||||||
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://example.com")),
|
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")),
|
||||||
Row(Button("Размещения", "placements"), URLButton("Дашборд", "https://example.com")),
|
Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
|
||||||
)
|
)
|
||||||
|
|
||||||
b.Render(msg, keyboard, mode)
|
b.Render(msg, keyboard, mode)
|
||||||
|
|||||||
@@ -57,9 +57,9 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
// Тип ссылки
|
// Тип ссылки
|
||||||
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
|
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
|
||||||
|
|
||||||
// Порядковый номер размещения
|
// Short ID
|
||||||
if placement.PlacementNumber > 0 {
|
if placement.ShortID != "" {
|
||||||
text += fmt.Sprintf("🔢 <b>Размещение №%d</b> в этом канале\n", placement.PlacementNumber)
|
text += fmt.Sprintf("🔢 <b>ID:</b> %s\n", placement.ShortID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Креатив
|
// Креатив
|
||||||
@@ -242,15 +242,63 @@ func formatDateTime(value string) string {
|
|||||||
if value == "" {
|
if value == "" {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
|
var parsed time.Time
|
||||||
return parsed.Format("02.01.2006 15:04")
|
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 {
|
if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil {
|
||||||
return parsed.Format("02.01.2006 15:04")
|
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
|
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) {
|
func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
|
||||||
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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("+ Создать размещение", "add_purchase")))
|
||||||
rows = append(rows, Row(Button("Размещения", "placements_list")))
|
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")))
|
rows = append(rows, Row(Button("← Назад", "back")))
|
||||||
|
|
||||||
keyboard := Keyboard(rows...)
|
keyboard := Keyboard(rows...)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package screens
|
package screens
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/NicoNex/echotron/v3"
|
"github.com/NicoNex/echotron/v3"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
|
"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) {
|
func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) {
|
||||||
keyboard := Keyboard(
|
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")),
|
Row(Button("↩ В главное меню", "main_menu")),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -111,12 +111,6 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
|
|||||||
s.inviteLinkTypeButton(),
|
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")))
|
rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next")))
|
||||||
|
|
||||||
keyboard := Keyboard(rows...)
|
keyboard := Keyboard(rows...)
|
||||||
@@ -683,10 +677,21 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
}
|
}
|
||||||
|
|
||||||
typeLabel := s.costBeforeTypeLabelForCurrent()
|
typeLabel := s.costBeforeTypeLabelForCurrent()
|
||||||
keyboard := Keyboard(
|
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(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
|
||||||
Row(Button("← Назад", s.backAction())),
|
Row(Button("← Назад", s.backAction())),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if mode == bot.EditMessage {
|
if mode == bot.EditMessage {
|
||||||
b.Edit(text, keyboard)
|
b.Edit(text, keyboard)
|
||||||
@@ -750,6 +755,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
|
|||||||
return
|
return
|
||||||
case "comment":
|
case "comment":
|
||||||
text += "<b>Введите комментарий</b>"
|
text += "<b>Введите комментарий</b>"
|
||||||
|
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:
|
default:
|
||||||
s.InputMode = ""
|
s.InputMode = ""
|
||||||
s.Enter(b, mode)
|
s.Enter(b, mode)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/TelegramExchange/pkg/transaction"
|
"github.com/TelegramExchange/pkg/transaction"
|
||||||
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
|
||||||
@@ -19,6 +20,7 @@ SET telegram_id = $2,
|
|||||||
access_hash = $5,
|
access_hash = $5,
|
||||||
pts = $6,
|
pts = $6,
|
||||||
is_accessible = $7,
|
is_accessible = $7,
|
||||||
|
invite_link = $8,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $1;`
|
WHERE id = $1;`
|
||||||
|
|
||||||
@@ -32,6 +34,23 @@ WHERE id = $1;`
|
|||||||
usernameDTO = pgtype.Text{Valid: false}
|
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{
|
dto := updateChannelDTO{
|
||||||
ID: pgtype.UUID{Bytes: channel.ID, Valid: true},
|
ID: pgtype.UUID{Bytes: channel.ID, Valid: true},
|
||||||
TelegramID: pgtype.Int8{Int64: channel.TelegramID, 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},
|
AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true},
|
||||||
Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true},
|
Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true},
|
||||||
IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, 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 {
|
if err != nil {
|
||||||
return fmt.Errorf("txOrPool.Exec: %w", err)
|
return fmt.Errorf("txOrPool.Exec: %w", err)
|
||||||
}
|
}
|
||||||
@@ -58,4 +78,5 @@ type updateChannelDTO struct {
|
|||||||
AccessHash pgtype.Int8
|
AccessHash pgtype.Int8
|
||||||
Pts pgtype.Int4
|
Pts pgtype.Int4
|
||||||
IsAccessible pgtype.Bool
|
IsAccessible pgtype.Bool
|
||||||
|
InviteLink pgtype.Text
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user