правки
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 = (
|
||||
"eJztXWtz2roW/SsMn3pmejoJkJBm7twZHm7LKQGGR9pzmo7HGIX4xtgc2zTNdPrfr+QHlu"
|
||||
"UHfgE27C80lbRlaW299tKW9Ku6UhdI1t91ngRFQXL1tvKrqggrhP9go95WqsJ67UaQAEOY"
|
||||
"y2ZakUo01w1NEA0c/CjIOsJBC6SLmrQ2JFXBocpGlkmgKuKEkrJ0gzaK9O8G8Ya6RMYT0n"
|
||||
"DEt+84WFIW6CfSnf+un/lHCckLT2GlBfm2Gc4br2szbDbrdT+YKcnn5ryoypuV4qZevxpP"
|
||||
"qrJNvtlIi3dEhsQtkYI0wUALqhqklHaFnSCrxDjA0DZoW9SFG7BAj8JGJmBU//O4UUSCQc"
|
||||
"X8Evlp/LeaAB5RVQi0kmIQLH79tmrl1tkMrZJPdT61xm/q13+YtVR1Y6mZkSYi1d+moGAI"
|
||||
"lqiJqwukqCFSbV4w/IB2cYwhrVAwqF5JBtyFLfrO+SMNyE6Ai7LbwhyYHfjSYVrFdVgMFf"
|
||||
"nV1mAExtPeHTeZtu5GpCYrXf9XNiFqTTkSUzNDX5nQN5ZKVNw/rH6zzaTypTf9VCH/rfwz"
|
||||
"HHCs4rbppv9USZmEjaHyivrCCwuqsTmhDjA4pavYzXqRUrFeSVDsURVrF97VKx6LUTq9ei"
|
||||
"Vz0Ktd2gOqtSRqdKod2UENrI6lJqz4oLmsLS17ihGsR0aQUSSGaj8TWsYOuSRf+fN9rVav"
|
||||
"N2sX9eubq0azeXVzcYPTmkXyRzUj1N3ufewNpl7tkYDfXowlQ0Z+dPEaRwvB1hFgUMVVKe"
|
||||
"hAtxJ+8jJSlsYT/m/t6ioCs/vW2Fwp4FRMux/YUTUrzgviRkea+XcCHGmZVFCyY8ve2+f+"
|
||||
"cRREEek6/yToT8k6PCOYqsMffqw+To9fG7of3FBk7dTlQrR22Wg2burXjS2Q25Ao/PxYSc"
|
||||
"oPyUC8LCnPSfo2I5ZL9z70QHl5UWvE6OEkWWgXtyIZSHXe6q3SPGjiaauqjAQlBFdWlkF2"
|
||||
"joX3NQltR9O8l2Xt4bDvWZG1e0wfHszu2hyG2sQZJ8Jty22uhAB4fKYsVxIwF8TnF0Fb8J"
|
||||
"4YagyQBRGtkBI0FLRt2Q+fx0gWzIr6kbapkJGTTzGn/t9OA3JCqwHmAlm1ZoUBZ1FmBDT1"
|
||||
"f0jMDIKVS8lwIL1Fralh/ccftaqt2BBBEZZmqcm3yZccopDwP9IPVA0iEZ24t5EsIp0KaE"
|
||||
"SgEYFtAhoRFAs0ItCI8WnEpMxMJlbmNAkuA/0M6AVTHBpGwP4MavuFhTCquXNfp56W7gD1"
|
||||
"5q719Q9Pa+8PBx+d5BSwnf6wzeA53xgGLoEf0r8mw0EwpJQIg+pMwbX9tpBE421FlnTj+7"
|
||||
"4wplZa840kG5KivyMf3NNii2ARjTwLMjN4kAxY5HVDMDYBwJPhgFM2KxP8Hi6ioIjIpwRX"
|
||||
"+nAtu4rX/LYJ4MW42upMe/fcbcVK8KAQdHBAF4do4hMOslpRwgHkJsbwcRM6eNz4hg5hmR"
|
||||
"ZtW/SAUBtINwhSfqyneBrqDT7iudhK8qCMxsPuDGtgOLitYAt6sREd+zg51RaLaIug2VjQ"
|
||||
"HZNh/sqTfYbADbRwKy5YOoNVt3v9kvNeRRYTzseLJETPK5WnLVxg2HwspA9FP4QfVA1JS+"
|
||||
"UzevUNAwdgmPYDnY9gwsGa8LIlVpjWgetoGQsmxK1Jp9XlqlE9OQcUZ3Y2Be2zOxEMHp08"
|
||||
"SE64aWUw6/erv+OQ4Su0kAReMtAqIwPqcHp3JMNCL3R38MFH2B04mom7AwqkrSRdx1nzuq"
|
||||
"iSATETIl9U7VlfY1RIJxxt856QrMsF0iF4c6sfRZDn2462m0HnV9u0ufLo39wv2BO+qktm"
|
||||
"Rt+BYt/L+gwo9hNnYoFiP1HFAsV+qhS7tYQ28fcpMpxo90qVhSv2cjf1Wgzupl4L5W5I1O"
|
||||
"8ALB8lGQUSD7vgpATLiejVZRxIcapQTM24IFD1Ov+MXpNj6sqV0pVvL4hu17k+NMN9SSmR"
|
||||
"fHzyy+dRylgLiShZysg4e1aR9ozKSIjRrlhFBS8eK+Y2kGBiMZwN26c531eXkjJVn5FSDb"
|
||||
"Dlqdi3UYa8TNJh09tJCN5wYKqDRQemOigWTHUw1RMcqnVm0NgHPh2BfMzJEzimiH6uJayR"
|
||||
"FH3BK1nOMe6UOsNGTzdV6TCeFUWFK6Tr2O4ItKdDmQiv0Jkebk3hGZaPO9iJUBCH9ccpLv"
|
||||
"UQ5oVzZNrB9T4JYB08rinhpMPak2yflMM32k/OdjP+DjwE8BBgrgIPcb6KBR7iVNftJTyI"
|
||||
"87C5aFxekt/6lfnbrJB/GmZQo2YGXbh/N+pubP3CP/VVB0MeozidTW4rmbJ+UL6Me1POzm"
|
||||
"XhJq4/mr83nsRuJiIWbPXIsRZ+zE1Gw8HEyePaTNsgv1eWnGhG1BFVnppb3G0JlSk3vpvw"
|
||||
"rdFoPLxv9a3sapeubN0seX1OFYaOpWpaX7iFr19VfNWe+8SspO9xIYb8qPW3XRdEIUF/lp"
|
||||
"Jx8Ri1et0dck1KSwu7DEqnNehwfc4ja39B9KW/IIeHeh2OHwyn/PCzR29WRe3PIlcNjSs2"
|
||||
"xCm6hcx10FdI/mNcrPvWYOr9CgWzwDYrO3xuY+KpJ26yTFPxFDuseTSpfO0GU403kXnZvD"
|
||||
"gHpWrhB6VqvoNS2wV+igmFlYUp5chTylp4TatKjyQo8siKFHF+Ed6Eu5cHngyO67NV/dD7"
|
||||
"SuaFRwmL4YlidHdbEderNMNfzidzTZB+CPImyIlGVoUQ7tQrxoD7SOTK1j+6w1m7z1VGY6"
|
||||
"7Tm/TsQ+fbDmFGmqzM9ua1MdfqB4E5R4+qhvi5oC0FScnegkPygwYd0aC9mCVv2X55aOLs"
|
||||
"WilLw/bncuTmPOH6H3hsrtwNbys6kh/5taau1AcFT2KDbmuMWzqpyULQUt2mcHkd52w/O9"
|
||||
"lSZ/uv2VaOm+cqaIUTfhuLK1ES9+VDX8ciqiuH5I+LKSUCoAaCesZ35+7F4Z4Chk+/bRGe"
|
||||
"C1g9R7Z6aNVkmWGD8jnyUaDqCK8wep3bynozlyXxQXEJQmGN51tsThRg/UgDl/TSuCDZUo"
|
||||
"5d+R9os5/CSnrixSN1Jt4mhTopBLc2nXpzg1ubnGawj1ubDn46rdhXNu06nBYwZeQBn5tT"
|
||||
"aVufdypM6mMXRODk8eaBk1nJHz/QN/Nt5hkhmVBZlRgRuPMqBKSDeKua3SnKY9XpbzG8Vs"
|
||||
"1+bqaF07LgpQrOjAflfMBL9WwUC16qp8rJgpdqjl6qE27Q5UfDyTTAS9J0eGxcUH6CAf6Z"
|
||||
"Pt/I7ZcVku2+3E4Dv2jXyXGgnXQ+cd1Zn/M4j15TWTWiP0FjQXu2Xrs+mlhAcT7jOJrSjp"
|
||||
"U+V1X6Oza0QdW1sDMZ+smnbc4Bda64wg3LO/SGagvzB6UzvBv1uSnX5buc+a+tCFqtTdYp"
|
||||
"tvHe57oahoynfVyysbTfah1V/gzVHeVL7FGLKWn7Oy/o2hA/2uLXqE674sao3ieu89mpFs"
|
||||
"+1xv2/adUHNJgrKtzbLzNiXfFBsbAyphsZPdTQZROcrmhViOgqsC3nWSEP1JE9wizTtiUd"
|
||||
"sfWk2V+7jvMC7HX4+6/XvtdfXdM0IT3PyJ0JQc++U5oUNVfkPPaBojY06KO8Wbc08n329l"
|
||||
"ibGkyf2k3LO3RSVvjiMcaF3c6gulXiZyf2yiGGUYe7GcO98ITfmJ0L6tYNuBwfOESgmoBD"
|
||||
"BMUCh3jqHOKxbqg6Rm/M+YoqeHc2X598Z2R41NQV7yzO0g8wAdnAaHPsQ9DEw1p/SjVxsL"
|
||||
"KgzGMfhAbX6Wp26ge8CU/Om7BAXmF0f/0hoRedf5J0Q9WkrI5zBIp7kuEnM7/XQi9hDuwt"
|
||||
"x0ITwnqx8EUzYDytvtd98GE0h48M0Z5pgQwDMgw4EyDDQLFAhp26RWNNsaK6CdqIDWXDGK"
|
||||
"lzpcOoNUPCXuCVPMzodjjTsHS94Gi+HIVSWwZnjoM6IhTXlg/zRDj21fv2kd8gq8w9DRxh"
|
||||
"jFGJcrbAXpzTaDZsFBsCZhiYYbBaBzMMFAtm2KkvQEt4rgkvAuxrMJgDSq3OtHfP3VasBA"
|
||||
"9Kb+CESIoTRrbOcVAXp9LEJxyU6jLHnO+bWm9wWQQdL0Wsy6NIhrxT45Sq2ZXnIRVG3+x1"
|
||||
"iOvALuNcD38Zfj38pe96eNh/TXVUgV1kxwWOlTsT6GDr2mkIOWxdB7bDHPD7QudVWgTZHp"
|
||||
"Z++9+50ynjJneC662KtLkdeKYtLx+IMmMBdwcd3hvCc+9UAOfG3ksVTrzpbMq8/R+YI2cG"
|
||||
"HnuWmrDinTdEgYMDDg6oGuDgQLHAwZ06B3fEBxIOb6Ac4IGEU+Y0Z4PJrD3pjHttwmFuFH"
|
||||
"uhNk/HY8ZCPwJ8P/Z0idLMOH5xGJ6O7aMCV7Wk5T99Zk0C+IJkzwTCKIcfuL0l8+0tnpaV"
|
||||
"A45TO7+ZnV1poQzqckVyp/IAHcDvsIoI53d8TQAuhgbyJp/FFtj4QN6AYoG8ORPraDuTBs"
|
||||
"1lbWkZepSFEcznKMvuCS2Xgyzva7V6vVm7qF/fXDWazaubi+2JFn9U1NGWdu8jOd3i0Z7/"
|
||||
"uAtZpCR9h4+WKeX7e7WrqxjkDE4Vys6YcczBIUnTjcRvGnqlAE0HTVlIAaZH6Lyx9Nn5cf"
|
||||
"xN4J2mxG4E3pE0HLOhgqYq/tmNXExTv2B+FwlM7TATe7dpDRZ19AIELGowvMCiBsWCRZ3/"
|
||||
"tFd0NSazqAu4abZv+zrHPbPYvtu45R/eh7tAK0OPRacuJVwt9RlltS36JKcpyaiYE0EsOH"
|
||||
"RzW9H0M8rLgbtn5lZiTKiTEwBMMDBk8M3T4b9koPht89h7744FfqCd9wNNZ/vaeM+0me6e"
|
||||
"4wow8z2HvMJtfc+5MjD4weDPx34AuxAMflAsGPxnYvAn3UHLtHl2jC64/51I4Qee1jRer/"
|
||||
"PP6DUJlj7BUu5I5naMJBWLYt9Sl/XUt3vXXfGacCwDEOxhsIcPfeSdbS5RtpzbomJYdDa7"
|
||||
"k79h5796Es68g80HpgHYfKBYsPnOxeYr4RntNVIWRD0+BVZH3KDbG3y8rdhJHpRWp8ONpu"
|
||||
"Y1k6KI1rgkD8qYux9+JkEa+qE+F+LiSWuNs+Dnrwn32X2CZ3IylfVKTwjb+R3lhdsgU0MX"
|
||||
"cQoa7jN8m+o+w+CxLwcQS3/02Teg7wYwp9PjpYeuoIfFvYxRFC+y26fdS2UBJwKcCHAihb"
|
||||
"K5gBM5G8UCJwKcSGE4kQRvcdz3TD7EXmg+KO3+sGPSIXNZFVPSIVFHyx06pBlKhzR9l9eB"
|
||||
"RQ8WPVj0xTCrklv0YJBmNkiDLvDfz9X9xVwlxnLsKNbbBkVC6WB8BtWOdjEb3iYXl+Pg11"
|
||||
"6xvdEd9ALC/STQHkB7gHUMtAcoFmiPU6c9vDNtGurDm8ORjwVUW9273oD/MOuTVywXK0nh"
|
||||
"H3G6B2U0Hv7FdaYTfsy1urcVx2ucJz2Uiv0y7k05KvpFkwz0oHSwFKFUHPHtkX1b3o23M3"
|
||||
"AT2DmM+q0Od8cN3BJs3ytzyuCmcErhJrFzaQ1a/b+nvY6TCV6uya+GJDp5uPGk5QxnU77T"
|
||||
"73U+T+iULxLW0MbgRVkSn3VaZvhlwG9rQouoL4r3loLExy4acU5dNMIPXTRYtihwDZecAT"
|
||||
"k/BikODZLX3eR78Js/Oh9Shg1n1o6Nb6Vt7d4Uppplj+dvsIE1todhAKyxk1+0gzV2oooF"
|
||||
"awysMbDGwBorjjXmFCehHcaIZViM7R52CmOAFex1t7IiZ40JSXHzSJ0fasCaAGtSTNYkoG"
|
||||
"/nAGX8az2O1aF3vzDoGbJ2g+fMqTmgl+uNskeCj1lixGh8R3rjsrAIxnvi8jisZwtpkvhU"
|
||||
"DeA37Zi3UUym4KYpDGEZ+mJV3IeqbBVmIyozEi3WO1W1y0azcVO/bmyfp9qGRLkOOy9Qhf"
|
||||
"OTP5AWbiIHo0eJwMVl7sVluGskANFOXk4ALy8uYgCIU4W/t07iGLtXVYzAqeKvyXAQYvO6"
|
||||
"IgyQMwVX8NtCEo23FVnSje/FhDUCRVJrD9XmgPfmrvWVxbXTH7bZ1TbJoJ3sJrj8p5ff/w"
|
||||
"cQREBB"
|
||||
)
|
||||
@@ -33,8 +33,12 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
)
|
||||
return message.message_id
|
||||
|
||||
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str:
|
||||
invite_link = await self.bot.create_chat_invite_link(chat_id=chat_id, creates_join_request=requires_approval)
|
||||
async def create_chat_invite_link(
|
||||
self, chat_id: int, requires_approval: bool = False, name: str | None = None
|
||||
) -> str:
|
||||
invite_link = await self.bot.create_chat_invite_link(
|
||||
chat_id=chat_id, creates_join_request=requires_approval, name=name
|
||||
)
|
||||
return invite_link.invite_link
|
||||
|
||||
async def send_message_with_inline_keyboard(
|
||||
@@ -122,13 +126,9 @@ class TelegramBot(TelegramBase, TelegramBotWriter):
|
||||
for index, item in enumerate(media_items):
|
||||
item_caption = caption if index == 0 else None
|
||||
if item.media_type == 'photo':
|
||||
group.append(
|
||||
InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
|
||||
)
|
||||
group.append(InputMediaPhoto(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode))
|
||||
elif item.media_type == 'video':
|
||||
group.append(
|
||||
InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode)
|
||||
)
|
||||
group.append(InputMediaVideo(media=item.media_file_id, caption=item_caption, parse_mode=parse_mode))
|
||||
else:
|
||||
raise ValueError(f'Unsupported media type for group: {item.media_type}')
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from .error import (
|
||||
CreativeMediaGroupUnsupported,
|
||||
CreativeMediaTooLarge,
|
||||
CreativeMediaTooMany,
|
||||
CreativeMultipleInviteLinks,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -89,44 +88,43 @@ def _strip_html(text: str) -> str:
|
||||
|
||||
|
||||
def replace_invite_link_with_tag(text: str) -> str:
|
||||
"""Replace Telegram invite link (t.me/+xxx) with tracking tag."""
|
||||
# Проверяем, есть ли уже замененная ссылка
|
||||
replaced_count = len(_INVITE_LINK_REPLACED.findall(text))
|
||||
html_count = len(_INVITE_LINK_HTML.findall(text))
|
||||
"""Replace ALL Telegram invite links (t.me/+xxx) with tracking tags."""
|
||||
# Check for any invite links first (excluding tg-link which are already replaced)
|
||||
replaced = _INVITE_LINK_REPLACED.findall(text)
|
||||
html_links = _INVITE_LINK_HTML.findall(text)
|
||||
|
||||
# Сначала удаляем HTML-ссылки и tg-link теги, чтобы не считать их дважды
|
||||
# Remove HTML links and tg-link tags to find plain text links
|
||||
text_without_links = _INVITE_LINK_HTML.sub('', text)
|
||||
text_without_links = _INVITE_LINK_REPLACED.sub('', text_without_links)
|
||||
plain_count = len(_INVITE_LINK_PLAIN.findall(text_without_links))
|
||||
plain_links = _INVITE_LINK_PLAIN.findall(text_without_links)
|
||||
|
||||
total = replaced_count + html_count + plain_count
|
||||
total = len(replaced) + len(html_links) + len(plain_links)
|
||||
|
||||
if total == 0:
|
||||
raise CreativeInviteLinkNotFound()
|
||||
if total > 1:
|
||||
raise CreativeMultipleInviteLinks()
|
||||
|
||||
# Если ссылка уже заменена, нормализуем текст если внутри был URL или теги
|
||||
if replaced_count == 1:
|
||||
def _normalize_replaced(match: re.Match[str]) -> str:
|
||||
inner = _strip_html(match.group(1))
|
||||
if inner == "" or _INVITE_LINK_PLAIN.search(inner):
|
||||
return '<a class="tg-link"></a>'
|
||||
return f'<a class="tg-link">{inner}</a>'
|
||||
# Normalize already replaced links (preserve anchor text)
|
||||
def _normalize_replaced(match: re.Match[str]) -> str:
|
||||
inner = _strip_html(match.group(1))
|
||||
if inner == "" or _INVITE_LINK_PLAIN.search(inner):
|
||||
return '<a class="tg-link"></a>'
|
||||
return f'<a class="tg-link">{inner}</a>'
|
||||
|
||||
return _INVITE_LINK_REPLACED.sub(_normalize_replaced, text)
|
||||
text = _INVITE_LINK_REPLACED.sub(_normalize_replaced, text)
|
||||
|
||||
if html_count == 1:
|
||||
# Replace HTML links with tracking tags (preserve anchor text)
|
||||
def _replace_html(match: re.Match[str]) -> str:
|
||||
inner = _strip_html(match.group(1))
|
||||
if _INVITE_LINK_PLAIN.fullmatch(inner):
|
||||
return '<a class="tg-link"></a>'
|
||||
return f'<a class="tg-link">{inner}</a>'
|
||||
|
||||
def _replace_html(match: re.Match[str]) -> str:
|
||||
inner = _strip_html(match.group(1))
|
||||
if _INVITE_LINK_PLAIN.fullmatch(inner):
|
||||
return '<a class="tg-link"></a>'
|
||||
return f'<a class="tg-link">{inner}</a>'
|
||||
text = _INVITE_LINK_HTML.sub(_replace_html, text)
|
||||
|
||||
return _INVITE_LINK_HTML.sub(_replace_html, text)
|
||||
# Replace plain text invite links
|
||||
text = _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
|
||||
|
||||
return _INVITE_LINK_PLAIN.sub('<a class="tg-link"></a>', text)
|
||||
return text
|
||||
|
||||
|
||||
def validate_media_size(media_data: bytes | None) -> None:
|
||||
|
||||
@@ -99,6 +99,8 @@ class Placement(TimestampedModel):
|
||||
invite_link_created_at = fields.DatetimeField(null=True)
|
||||
invite_link_type = fields.CharEnumField(InviteLinkType)
|
||||
|
||||
invite_link_name = fields.CharField(max_length=32, null=True)
|
||||
|
||||
project: fields.ForeignKeyRelation['Project'] = fields.ForeignKeyField(
|
||||
'models.Project', related_name='placements', on_delete=fields.CASCADE, index=True
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import pydantic
|
||||
|
||||
from src import domain
|
||||
from src.domain.project import ProjectStatus
|
||||
from .channel import ChannelOutput
|
||||
|
||||
|
||||
class ChannelBotPermissions(pydantic.BaseModel):
|
||||
@@ -33,6 +34,7 @@ class ProjectOutput(pydantic.BaseModel):
|
||||
username: str | None
|
||||
status: ProjectStatus
|
||||
purchase_invite_type_default: domain.InviteLinkType
|
||||
channel: ChannelOutput
|
||||
|
||||
|
||||
class UpdateProjectInviteLinkTypeInput(pydantic.BaseModel):
|
||||
|
||||
@@ -7,12 +7,7 @@ from src.domain.placement import CostType, InviteLinkType, PlacementStatus, Plac
|
||||
from src.domain.placement_post import PlacementPostStatus
|
||||
|
||||
from .channel import ChannelOutput
|
||||
|
||||
|
||||
class ProjectOutput(pydantic.BaseModel):
|
||||
id: uuid.UUID
|
||||
status: str
|
||||
channel: ChannelOutput
|
||||
from .project import ProjectOutput
|
||||
|
||||
|
||||
class CostInfo(pydantic.BaseModel):
|
||||
@@ -43,7 +38,7 @@ class PlacementOutput(pydantic.BaseModel):
|
||||
invite_link_type: InviteLinkType
|
||||
channel: ChannelOutput
|
||||
project: ProjectOutput | None = None
|
||||
placement_number: int
|
||||
short_id: str
|
||||
details: PlacementDetails | None = None
|
||||
|
||||
|
||||
|
||||
@@ -114,7 +114,9 @@ class TelegramBotWriter(typing.Protocol):
|
||||
|
||||
async def edit_message_reply_markup(self, chat_id: int, message_id: int) -> None: ...
|
||||
|
||||
async def create_chat_invite_link(self, chat_id: int, requires_approval: bool = False) -> str: ...
|
||||
async def create_chat_invite_link(
|
||||
self, chat_id: int, requires_approval: bool = False, name: str | None = None
|
||||
) -> str: ...
|
||||
|
||||
|
||||
class JWTEncoder(typing.Protocol):
|
||||
|
||||
@@ -46,4 +46,10 @@ async def attach_channel_to_workspace(self: 'Usecase', input: dto.AttachChannelT
|
||||
username=channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -27,6 +27,12 @@ async def archive_project(self: 'Usecase', input: dto.ArchiveProjectInput) -> dt
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -51,4 +57,10 @@ async def unarchive_project(self: 'Usecase', input: dto.ArchiveProjectInput) ->
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,4 +21,10 @@ async def get_project(self: 'Usecase', input: dto.GetProjectInput) -> dto.Projec
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -25,6 +25,12 @@ async def get_workspace_projects(self: 'Usecase', input: dto.GetWorkspaceProject
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
for project in projects
|
||||
]
|
||||
|
||||
@@ -67,4 +67,10 @@ async def move_project_to_workspace(
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -200,6 +200,12 @@ async def tg_add_project(self: 'Usecase', input: dto.ConnectProjectInput) -> dto
|
||||
username=channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=channel.id,
|
||||
telegram_id=channel.telegram_id,
|
||||
title=channel.title,
|
||||
username=channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
# Если >1 workspace - создаем/обновляем канал и отправляем уведомление
|
||||
|
||||
@@ -39,4 +39,10 @@ async def update_project_invite_link_type(
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,8 @@ from tortoise import timezone
|
||||
|
||||
from src import domain, dto
|
||||
|
||||
from .create_placements import generate_invite_link_name, uuid_to_short_id
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .. import Usecase
|
||||
|
||||
@@ -152,8 +154,15 @@ async def build_placement_creative(
|
||||
if project.channel.telegram_id is None:
|
||||
raise domain.ChannelNotFound(project.channel.id)
|
||||
|
||||
# Generate invite_link_name if not set
|
||||
if not placement.invite_link_name:
|
||||
short_id = uuid_to_short_id(placement.id)
|
||||
placement.invite_link_name = generate_invite_link_name(short_id, project.channel.title)
|
||||
|
||||
requires_approval = placement.invite_link_type == domain.InviteLinkType.APPROVAL
|
||||
invite_link = await self.telegram_bot.create_chat_invite_link(project.channel.telegram_id, requires_approval)
|
||||
invite_link = await self.telegram_bot.create_chat_invite_link(
|
||||
project.channel.telegram_id, requires_approval, name=placement.invite_link_name
|
||||
)
|
||||
placement.invite_link = invite_link
|
||||
placement.invite_link_created_at = timezone.now()
|
||||
await self.database.update_placement(placement)
|
||||
|
||||
@@ -9,6 +9,76 @@ if TYPE_CHECKING:
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Base62 alphabet for encoding
|
||||
BASE62_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
|
||||
|
||||
def uuid_to_short_id(placement_uuid: uuid.UUID) -> str:
|
||||
"""
|
||||
Конвертирует UUID в короткий ID (8 символов base62).
|
||||
|
||||
Берёт первые 8 байт UUID (64 бита) и кодирует в base62.
|
||||
"""
|
||||
# Берём первые 8 байт UUID
|
||||
uuid_bytes = placement_uuid.bytes[:8]
|
||||
|
||||
# Конвертируем байты в integer
|
||||
num = int.from_bytes(uuid_bytes, byteorder='big')
|
||||
|
||||
# Кодируем в base62
|
||||
if num == 0:
|
||||
return BASE62_ALPHABET[0]
|
||||
|
||||
result = []
|
||||
base = len(BASE62_ALPHABET)
|
||||
while num > 0:
|
||||
num, remainder = divmod(num, base)
|
||||
result.append(BASE62_ALPHABET[remainder])
|
||||
|
||||
# Разворачиваем и ограничиваем до 8 символов
|
||||
short_id = ''.join(reversed(result))[:8]
|
||||
return short_id.lower()
|
||||
|
||||
|
||||
def generate_invite_link_name(short_id: str, project_channel_title: str | None) -> str:
|
||||
"""
|
||||
Генерирует название для invite link с умным сокращением.
|
||||
|
||||
Формат: "{short_id} {channel_name}"
|
||||
Приоритет: short_id всегда полный, channel_name сокращается.
|
||||
|
||||
Args:
|
||||
short_id: Короткий ID размещения
|
||||
project_channel_title: Название канала проекта
|
||||
|
||||
Returns:
|
||||
Строка до 32 символов
|
||||
"""
|
||||
channel_name = project_channel_title or 'Unknown'
|
||||
|
||||
# Базовый формат: "{short_id} {name}"
|
||||
base_name = f'{short_id} {channel_name}'
|
||||
|
||||
# Если помещается, возвращаем как есть
|
||||
if len(base_name) <= 32:
|
||||
return base_name
|
||||
|
||||
# Считаем доступное место для канала
|
||||
# Формат: "{short_id} " занимает len(short_id) + 1 символов
|
||||
prefix_len = len(short_id) + 1 # например: "a3b9x2m " = 8 символов
|
||||
max_channel_len = 32 - prefix_len
|
||||
|
||||
# Если места слишком мало, возвращаем только short_id
|
||||
if max_channel_len < 3:
|
||||
return short_id
|
||||
|
||||
# Сокращаем название канала (с многоточием если нужно)
|
||||
truncated_channel = channel_name[: max_channel_len - 3]
|
||||
if len(truncated_channel) < len(channel_name):
|
||||
truncated_channel = truncated_channel + '...'
|
||||
|
||||
return f'{short_id} {truncated_channel}'
|
||||
|
||||
|
||||
def _build_cost_info(cost_type: domain.CostType | None, cost_value: float | None) -> dto.CostInfo | None:
|
||||
if cost_type is None or cost_value is None:
|
||||
@@ -34,7 +104,6 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail
|
||||
def _build_placement_output(
|
||||
placement: domain.Placement,
|
||||
project: domain.Project | None = None,
|
||||
placement_number: int = 0,
|
||||
creative_name: str | None = None,
|
||||
) -> dto.PlacementOutput:
|
||||
channel = placement.channel
|
||||
@@ -42,24 +111,31 @@ def _build_placement_output(
|
||||
log.error('Placement %s has no channel prefetched', placement.id)
|
||||
raise ValueError(f'Placement {placement.id} has no channel')
|
||||
|
||||
# Generate short_id from UUID
|
||||
short_id = uuid_to_short_id(placement.id)
|
||||
|
||||
# Build project output if project is provided
|
||||
project_output: dto.ProjectOutput | None = None
|
||||
if project is not None:
|
||||
project_channel = project.channel
|
||||
if project_channel is None:
|
||||
log.error('Project %s has no channel', project.id)
|
||||
raise ValueError(f'Project {project.id} has no channel')
|
||||
|
||||
project_output = dto.ProjectOutput(
|
||||
id=project.id,
|
||||
status=project.status.value,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project_channel.id,
|
||||
telegram_id=project_channel.telegram_id,
|
||||
title=project_channel.title,
|
||||
username=project_channel.username,
|
||||
),
|
||||
)
|
||||
log.warning('Project %s has no channel prefetched, skipping project output', project.id)
|
||||
else:
|
||||
project_output = dto.ProjectOutput(
|
||||
id=project.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
status=project.status,
|
||||
purchase_invite_type_default=project.purchase_invite_type_default,
|
||||
channel=dto.ChannelOutput(
|
||||
id=project.channel.id,
|
||||
telegram_id=project.channel.telegram_id,
|
||||
title=project.channel.title,
|
||||
username=project.channel.username,
|
||||
),
|
||||
)
|
||||
log.debug('Built project output for project %s', project.id)
|
||||
|
||||
return dto.PlacementOutput(
|
||||
id=placement.id,
|
||||
@@ -76,7 +152,7 @@ def _build_placement_output(
|
||||
username=channel.username,
|
||||
),
|
||||
project=project_output,
|
||||
placement_number=placement_number,
|
||||
short_id=short_id,
|
||||
details=_build_placement_details(placement),
|
||||
)
|
||||
|
||||
@@ -132,7 +208,9 @@ async def create_placements(
|
||||
creative_id=creative.id if creative else None,
|
||||
channel_id=channel.id,
|
||||
invite_link=None,
|
||||
invite_link_type=(channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None)
|
||||
invite_link_type=(
|
||||
channel_details.invite_link_type if channel_details and channel_details.invite_link_type else None
|
||||
)
|
||||
or (details.invite_link_type if details and details.invite_link_type else None)
|
||||
or project.purchase_invite_type_default,
|
||||
status=channel_input.status or domain.PlacementStatus.NO_STATUS,
|
||||
@@ -145,9 +223,17 @@ async def create_placements(
|
||||
or (details.cost.type if details and details.cost else None),
|
||||
cost_value=(channel_details.cost.value if channel_details and channel_details.cost else None)
|
||||
or (details.cost.value if details and details.cost else None),
|
||||
cost_before_bargain_type=(channel_details.cost_before_bargain.type if channel_details and channel_details.cost_before_bargain else None)
|
||||
cost_before_bargain_type=(
|
||||
channel_details.cost_before_bargain.type
|
||||
if channel_details and channel_details.cost_before_bargain
|
||||
else None
|
||||
)
|
||||
or (details.cost_before_bargain.type if details and details.cost_before_bargain else None),
|
||||
cost_before_bargain=(channel_details.cost_before_bargain.value if channel_details and channel_details.cost_before_bargain else None)
|
||||
cost_before_bargain=(
|
||||
channel_details.cost_before_bargain.value
|
||||
if channel_details and channel_details.cost_before_bargain
|
||||
else None
|
||||
)
|
||||
or (details.cost_before_bargain.value if details and details.cost_before_bargain else None),
|
||||
placement_type=details.placement_type if details else None,
|
||||
format=(channel_details.format if channel_details else None) or (details.format if details else None),
|
||||
@@ -155,6 +241,12 @@ async def create_placements(
|
||||
|
||||
await self.database.create_placement(placement)
|
||||
placement.channel = channel
|
||||
|
||||
# Generate invite_link_name after placement has UUID
|
||||
short_id = uuid_to_short_id(placement.id)
|
||||
placement.invite_link_name = generate_invite_link_name(short_id, project.channel.title)
|
||||
await self.database.update_placement(placement)
|
||||
|
||||
placements.append(placement)
|
||||
|
||||
log.info(
|
||||
|
||||
@@ -89,18 +89,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
|
||||
if creative:
|
||||
creative_name = creative.name
|
||||
|
||||
# Calculate placement number (ordinal number of this placement)
|
||||
placement_number = await self.database.count_placements_by_project_and_channel(
|
||||
project.id, placement.channel_id
|
||||
)
|
||||
# Count placements created before this one
|
||||
placements_before = await domain.Placement.filter(
|
||||
project_id=project.id,
|
||||
channel_id=placement.channel_id,
|
||||
created_at__lt=placement.created_at,
|
||||
).count()
|
||||
placement_number = placements_before + 1
|
||||
|
||||
placement_posts = await self.database.get_workspace_placement_posts(
|
||||
input.workspace_id,
|
||||
placement_id=placement.id,
|
||||
@@ -139,7 +127,6 @@ async def get_placement_user(self: 'Usecase', input: dto.GetPlacementInput) -> d
|
||||
placement_output = _build_placement_output(
|
||||
placement,
|
||||
project=project,
|
||||
placement_number=placement_number,
|
||||
creative_name=creative_name,
|
||||
)
|
||||
return dto.PlacementWithPostsOutput(
|
||||
|
||||
@@ -27,6 +27,11 @@ async def get_placements(self: 'Usecase', input: dto.GetPlacementsInput) -> dto.
|
||||
|
||||
context.ensure_project_permission(domain.PermissionKey.PLACEMENTS_READ, project.id)
|
||||
|
||||
# Prefetch project channel for output building
|
||||
if project.channel is None:
|
||||
project_channel = await self.database.get_channel(channel_id=project.channel_id)
|
||||
project.channel = project_channel
|
||||
|
||||
placements = await self.database.get_project_placements(input.workspace_id, project.id)
|
||||
log.debug('Fetched %s placements for project %s', len(placements), project.id)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -55,7 +55,7 @@ const msgDownloadMediaError = `
|
||||
const msgInviteLinkRequired = `
|
||||
<b>❌ Ошибка валидации</b>
|
||||
|
||||
Текст должен содержать <b>ОДНУ</b> инвайт-ссылку вашего канала!
|
||||
Текст должен содержать <b>хотя бы одну</b> инвайт-ссылку вашего канала!
|
||||
|
||||
<b>Формат ссылки:</b>
|
||||
<code>https://t.me/+xxx</code>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
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,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:
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -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")),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user