правки

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

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

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)

View File

@@ -570,9 +570,13 @@ type CreateChannelResult struct {
}
type ProjectOutput struct {
ID string `json:"id"`
Status string `json:"status"`
Channel Channel `json:"channel"`
ID string `json:"id"`
TelegramID int64 `json:"telegram_id"`
Title string `json:"title"`
Username *string `json:"username"`
Status string `json:"status"`
PurchaseInviteTypeDefault string `json:"purchase_invite_type_default"`
Channel Channel `json:"channel"`
}
type CreateChannelsOutput struct {
@@ -598,18 +602,18 @@ type PlacementDetails struct {
}
type PlacementOutput struct {
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
PlacementNumber int `json:"placement_number"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
ID string `json:"id"`
Status string `json:"status"`
CreativeID *string `json:"creative_id,omitempty"`
CreativeName *string `json:"creative_name,omitempty"`
Comment *string `json:"comment,omitempty"`
InviteLink *string `json:"invite_link,omitempty"`
InviteLinkType string `json:"invite_link_type"`
Channel Channel `json:"channel"`
Project *ProjectOutput `json:"project"`
ShortID string `json:"short_id"`
Details *PlacementDetails `json:"details,omitempty"`
PlacementPost *PlacementPostOutput `json:"placement_post,omitempty"`
}
type PlacementPostOutput struct {

View File

@@ -55,7 +55,7 @@ const msgDownloadMediaError = `
const msgInviteLinkRequired = `
<b>❌ Ошибка валидации</b>
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
Текст должен содержать <b>хотя бы одну</b> инвайт-ссылку вашего канала!
<b>Формат ссылки:</b>
<code>https://t.me/+xxx</code>

View File

@@ -16,9 +16,15 @@ import (
type CreativeDetails struct {
CreativeEditorFields // Встраивание общих полей
CreativeID string
ProjectID string
BackState bot.State
CreativeID string
ProjectID string
ProjectTitle string // Название проекта для сообщений
BackState bot.State
// View mode: открыты из режима просмотра
ViewMode bool // Открыты из CreativeView
ViewBackTo bot.State // Состояние для возврата (CreativeView)
// Флаг подтверждения удаления
confirmingDelete bool
@@ -89,7 +95,10 @@ func (s *CreativeDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
switch {
case data == "cancel", data == "back":
// Отмена - возвращаемся назад
if s.BackState != nil {
if s.ViewMode && s.ViewBackTo != nil {
// Если открыты из view mode, возвращаемся к просмотру
b.SetState(s.ViewBackTo, bot.EditMessage)
} else if s.BackState != nil {
b.SetState(s.BackState, bot.EditMessage)
}
@@ -355,7 +364,12 @@ func (s *CreativeDetails) updateCreative(b *bot.Bot) {
log.Info().Str("creative_id", s.CreativeID).Msg("Creative updated successfully")
// Возвращаемся назад
if s.BackState != nil {
if s.ViewMode && s.ViewBackTo != nil {
// Если открыты из view mode, возвращаемся к просмотру
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
b.SetState(s.ViewBackTo, bot.EditMessage)
} else if s.BackState != nil {
// Иначе возвращаемся к BackState
b.SendNew("✅ Креатив успешно обновлен!", Keyboard())
b.SetState(s.BackState, bot.EditMessage)
}

View 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)
}

View File

@@ -120,10 +120,16 @@ func (s *Creatives) HandleCallback(b *bot.Bot, u *echotron.Update) {
return
}
b.SetState(&CreativeDetails{
// Открываем CreativeView вместо CreativeDetails
b.SetState(&CreativeView{
CreativeID: creativeID,
ProjectID: s.ProjectID,
BackState: s,
// Данные проекта для передачи в AddPurchase
ProjectTitle: s.ProjectTitle,
ProjectTelegramID: s.ProjectTelegramID,
ProjectUsername: s.ProjectUsername,
ProjectStatus: s.ProjectStatus,
BackState: s,
}, bot.NewMessage)
default:

View File

@@ -29,7 +29,7 @@ type Help struct{}
func (s *Help) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(URLButton("Поддержка", "https://example.com")),
Row(URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("↩ В главное меню", "main_menu")),
)

View File

@@ -14,14 +14,22 @@ type MainMenu struct{}
const msgAbout = `
<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) {
@@ -65,8 +73,8 @@ func (s *MainMenu) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Рабочее пространство: %s", workspaceName), "workspace_menu")),
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://example.com")),
Row(Button("Размещения", "placements"), URLButton("Дашборд", "https://example.com")),
Row(Button("Мои проекты", "my_projects"), URLButton("Поддержка", "https://t.me/SmartpostSupport")),
Row(Button("Размещения", "placements"), URLButton("Дашборд", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
)
b.Render(msg, keyboard, mode)

View File

@@ -57,9 +57,9 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) {
// Тип ссылки
text += fmt.Sprintf("🔓 <b>Тип ссылки:</b> %s\n", formatInviteLinkType(placement.InviteLinkType))
// Порядковый номер размещения
if placement.PlacementNumber > 0 {
text += fmt.Sprintf("🔢 <b>Размещение №%d</b> в этом канале\n", placement.PlacementNumber)
// Short ID
if placement.ShortID != "" {
text += fmt.Sprintf("🔢 <b>ID:</b> %s\n", placement.ShortID)
}
// Креатив
@@ -242,15 +242,63 @@ func formatDateTime(value string) string {
if value == "" {
return value
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed.Format("02.01.2006 15:04")
var parsed time.Time
var err error
// Try parsing with timezone
if parsed, err = time.Parse(time.RFC3339, value); err == nil {
return formatCompactDateTime(parsed)
}
if parsed, err := time.Parse(time.RFC3339Nano, value); err == nil {
return parsed.Format("02.01.2006 15:04")
if parsed, err = time.Parse(time.RFC3339Nano, value); err == nil {
return formatCompactDateTime(parsed)
}
if parsed, err = time.Parse("2006-01-02T15:04:05", value); err == nil {
return formatCompactDateTime(parsed)
}
if parsed, err = time.Parse("2006-01-02", value); err == nil {
return formatCompactDateTime(parsed)
}
return value
}
func WeekdayName(day time.Weekday) string {
switch day {
case time.Monday:
return "Пн"
case time.Tuesday:
return "Вт"
case time.Wednesday:
return "Ср"
case time.Thursday:
return "Чт"
case time.Friday:
return "Пт"
case time.Saturday:
return "Сб"
case time.Sunday:
return "Вс"
default:
return ""
}
}
func MonthShort(month time.Month) string {
months := []string{
"янв", "фев", "мар", "апр", "май", "июн",
"июл", "авг", "сен", "окт", "ноя", "дек",
}
if int(month) < 1 || int(month) > len(months) {
return ""
}
return months[int(month)-1]
}
func formatCompactDateTime(value time.Time) string {
weekday := WeekdayName(value.Weekday())
month := MonthShort(value.Month())
return fmt.Sprintf("%s %02d %s %s", weekday, value.Day(), month, value.Format("15:04"))
}
func (s *PlacementDetails) HandleCallback(b *bot.Bot, u *echotron.Update) {
if u.CallbackQuery == nil || u.CallbackQuery.Data == "" {
return

View File

@@ -30,7 +30,7 @@ func (s *Placements) Enter(b *bot.Bot, mode bot.RenderMode) {
rows = append(rows, Row(Button(" Создать размещение", "add_purchase")))
rows = append(rows, Row(Button("Размещения", "placements_list")))
rows = append(rows, Row(URLButton("План закупов", "https://example.com")))
rows = append(rows, Row(URLButton("План закупов", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s/purchase-plans/%s", b.Session.WorkspaceID, s.ProjectID))))
rows = append(rows, Row(Button("← Назад", "back")))
keyboard := Keyboard(rows...)

View File

@@ -1,6 +1,8 @@
package screens
import (
"fmt"
"github.com/NicoNex/echotron/v3"
"github.com/TelegramExchange/tgex-backend/tg_bot/bot"
)
@@ -17,7 +19,7 @@ type PlatformLink struct{}
func (s *PlatformLink) Enter(b *bot.Bot, mode bot.RenderMode) {
keyboard := Keyboard(
Row(URLButton("Открыть платформу", "https://example.com")),
Row(URLButton("Открыть платформу", fmt.Sprintf("https://app.smart-post.ru/dashboard/%s", b.Session.WorkspaceID))),
Row(Button("↩ В главное меню", "main_menu")),
)

View File

@@ -111,12 +111,6 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) {
s.inviteLinkTypeButton(),
))
if row := s.costBeforeDeleteRow(); row != nil {
rows = append(rows, row)
}
if row := s.commentDeleteRow(); row != nil {
rows = append(rows, row)
}
rows = append(rows, Row(Button("Назад", "back"), Button("Далее", "next")))
keyboard := Keyboard(rows...)
@@ -683,10 +677,21 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
}
typeLabel := s.costBeforeTypeLabelForCurrent()
keyboard := Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
var keyboard echotron.InlineKeyboardMarkup
// Показываем кнопку удаления только если есть значение и это не режим per-channel
if s.CurrentChannel == "" && s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil {
keyboard = Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("⌫ Удалить до торга", "delete_cost_before")),
Row(Button("← Назад", s.backAction())),
)
} else {
keyboard = Keyboard(
Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")),
Row(Button("← Назад", s.backAction())),
)
}
if mode == bot.EditMessage {
b.Edit(text, keyboard)
@@ -750,6 +755,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM
return
case "comment":
text += "<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:
s.InputMode = ""
s.Enter(b, mode)

View File

@@ -6,6 +6,7 @@ import (
"strings"
"github.com/jackc/pgx/v5/pgtype"
"github.com/rs/zerolog/log"
"github.com/TelegramExchange/pkg/transaction"
"github.com/TelegramExchange/tgex-backend/tg_parser/internal/domain"
@@ -19,6 +20,7 @@ SET telegram_id = $2,
access_hash = $5,
pts = $6,
is_accessible = $7,
invite_link = $8,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1;`
@@ -32,6 +34,23 @@ WHERE id = $1;`
usernameDTO = pgtype.Text{Valid: false}
}
inviteLink := strings.TrimSpace(channel.InviteLink)
var inviteLinkDTO pgtype.Text
if inviteLink != "" {
inviteLinkDTO = pgtype.Text{String: inviteLink, Valid: true}
} else {
inviteLinkDTO = pgtype.Text{Valid: false}
}
// Log warning if both identity fields are empty (will violate CHECK constraint)
if !usernameDTO.Valid && !inviteLinkDTO.Valid {
log.Warn().
Str("channel_id", channel.ID.String()).
Int64("telegram_id", channel.TelegramID).
Str("title", channel.Title).
Msg("UpdateChannel: both username and invite_link are empty, this may violate CHECK constraint")
}
dto := updateChannelDTO{
ID: pgtype.UUID{Bytes: channel.ID, Valid: true},
TelegramID: pgtype.Int8{Int64: channel.TelegramID, Valid: true},
@@ -40,9 +59,10 @@ WHERE id = $1;`
AccessHash: pgtype.Int8{Int64: channel.AccessHash, Valid: true},
Pts: pgtype.Int4{Int32: int32(channel.Pts), Valid: true},
IsAccessible: pgtype.Bool{Bool: channel.IsAccessible, Valid: true},
InviteLink: inviteLinkDTO,
}
_, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible)
_, err := txOrPool.Exec(ctx, query, dto.ID, dto.TelegramID, dto.Username, dto.Title, dto.AccessHash, dto.Pts, dto.IsAccessible, dto.InviteLink)
if err != nil {
return fmt.Errorf("txOrPool.Exec: %w", err)
}
@@ -58,4 +78,5 @@ type updateChannelDTO struct {
AccessHash pgtype.Int8
Pts pgtype.Int4
IsAccessible pgtype.Bool
InviteLink pgtype.Text
}