From 4e35c73a66014d4765ba4beb652e50b9a3fae42b Mon Sep 17 00:00:00 2001 From: Artem Tsyrulnikov Date: Sun, 1 Feb 2026 17:57:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...0201174836_add_cost_before_bargain_type.py | 103 ++ src/adapter/telegram_bot.py | 58 +- src/domain/placement.py | 1 + src/dto/purchase.py | 5 +- src/usecase/__init__.py | 18 +- .../purchase/build_placement_creative.py | 82 +- src/usecase/purchase/create_placements.py | 8 +- src/usecase/purchase/update_placement.py | 7 +- tg_bot/backend/backend_client.go | 2 +- tg_bot/screens/add_purchase.go | 2 + tg_bot/screens/channel_parser.go | 380 ++++++++ tg_bot/screens/placement_details.go | 5 +- tg_bot/screens/placements.go | 6 +- tg_bot/screens/purchase_optional_details.go | 905 +++++++++++++++--- .../screens/select_channels_for_purchase.go | 222 ++++- 15 files changed, 1573 insertions(+), 231 deletions(-) create mode 100644 migrations/models/15_20260201174836_add_cost_before_bargain_type.py create mode 100644 tg_bot/screens/channel_parser.go diff --git a/migrations/models/15_20260201174836_add_cost_before_bargain_type.py b/migrations/models/15_20260201174836_add_cost_before_bargain_type.py new file mode 100644 index 0000000..08f7d9b --- /dev/null +++ b/migrations/models/15_20260201174836_add_cost_before_bargain_type.py @@ -0,0 +1,103 @@ +from tortoise import BaseDBAsyncClient + +RUN_IN_TRANSACTION = True + + +async def upgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" ADD "cost_before_bargain_type" VARCHAR(8); + COMMENT ON COLUMN "placement"."cost_before_bargain_type" IS 'FIXED: fixed\nCPM: cpm';""" + + +async def downgrade(db: BaseDBAsyncClient) -> str: + return """ + ALTER TABLE "placement" DROP COLUMN "cost_before_bargain_type";""" + + +MODELS_STATE = ( + "eJztXWtz2roW/SsMn3pmejoJkJBm7twZHm7LKQGGR9pzmo7HGIX4xtgc2zTNdPrfr+QHlu" + "UHfgE27C80lbVlaW299tLe8q/qSl0gWX/XeRIUBcnV28qvqiKsEP6DffS2UhXWa/cBSTCE" + "uWzmFalMc93QBNHAyY+CrCOctEC6qElrQ1IVnKpsZJkkqiLOKClLN2mjSP9uEG+oS2Q8IQ" + "0/+PYdJ0vKAv1EuvPf9TP/KCF54amstCDvNtN543Vtps1mve4HMyd53ZwXVXmzUtzc61fj" + "SVW22TcbafGOyJBnS6QgTTDQgmoGqaXdYCfJqjFOMLQN2lZ14SYs0KOwkQkY1f88bhSRYF" + "Ax30R+Gv+tJoBHVBUCraQYBItfv61WuW02U6vkVZ1PrfGb+vUfZitV3Vhq5kMTkepvU1Aw" + "BEvUxNUFUtQQaTYvGH5Au/iJIa1QMKheSQbchS36zvkjDchOgouy28McmB340mFaxW1YDB" + "X51dZgBMbT3h03mbbuRqQlK13/VzYhak058qRmpr4yqW8slah4fFjjZltI5Utv+qlC/lv5" + "ZzjgWMVt803/qZI6CRtD5RX1hRcWVGdzUh1gcE5XsZv1IqVivZKg2KMq1q68q1c8F6N0ev" + "VK5qBXu7YHVGtJ1Og0O3KAGlgdS01Y8UFrWVta9hQjWI+MIKNIDNV+FrSMA3JJ3vLn+1qt" + "Xm/WLurXN1eNZvPq5uIG5zWr5H/UjFB3u/exN5h6tUcSfnsxlgwZ+dHFexwtBFtHgEEVN6" + "WgE91K+MnLSFkaT/i/taurCMzuW2Nzp4BzMf1+YD+qWc+8IG50pJl/J8CRlkkFJTu37L1/" + "7h9HQRSRrvNPgv6UbMAzgqkG/OHn6uOM+LWh+8ENRdbOXS5Ea5eNZuOmft3YArlNicLPj5" + "Wk/JAMxMuS8pxkbDNiuQzvQ0+Ulxe1RowRTrKFDnHrIQOpzlujVZoHLTxtVZWRoITgysoy" + "yM6x8L4Woe1smve2rD0c9j07snaPGcOD2V2bw1CbOONMuG+53ZUQAI/PlOVKEuaC+PwiaA" + "ve84SaA2RBRCukBE0FbVv2w+cxkgWzoX6kbSpk5JRTzKX/t9OBnNRqgLlAdq1ZYcBFlBkB" + "Tf0fEjODYJVSMhzIaFFratj48T9a1VZsiqAIS7PW5N3kTQ5RSPgf6QeqBpGIzrO3kSwinQ" + "toRKARgW0CGhEUCzQi0IjxacSkzEwmVuY0CS4D/QwYBVOcGkbA/gzq+4WFMKq7c1+nnp7u" + "APXmrvX1D09v7w8HH53sFLCd/rDN4DnfGAaugR/SvybDQTCklAiD6kzBrf22kETjbUWWdO" + "P7vjCmdlrzjSQbkqK/Iy/c02aLYBGNPAsyM3mQAljkdUMwNgHAk+mAUzYrE/werqKgiMin" + "BFf6cD27ivf8tgngxbja6kx799xtxcrwoBB0cEIXp2jiE06yelHCCeQmxvRxEzp53PimDm" + "GZFm1b9IBQG0g3CFJ+rKd4GeoNPuK12MryoIzGw+4Ma2A4uK1gC3qxER37ODnVFotoi6DZ" + "WNAdk2H+ypNzhsADtHArLlg6g1W3e/+S81lFFhPOx4skRM8rlactXGDYfCykD0U/hB9UDU" + "lL5TN69U0DB2CY9gOdj2DCyZrwsiVWmN6B22gZCybErUmn1eWqUSM5BxRndjEFHbM7EQye" + "nTxITrhpZTDr96u/45DhK7SQBF4y0CojA+pwenekwEJvdHfwwUc4HTiaibsDCqStJF3HRf" + "O6qJIJMRMiX1TtWV9jVMggHG3LnpCiywXSIXhzaxxFkOfbgbabQedX27y58ujf3DfYC76q" + "S2ZB34Fi38v+DCj2E2digWI/UcUCxX6qFLu1hTbx9ykynGj3SpWFK/ZyN/VaDO6mXgvlbs" + "ij3wFYPkoyCiQedsFJCZYT0avLOJDiXKGYms+CQNXr/DN6TY6pK1dKV769ILrd5/rQDPcl" + "pUTy8ckvn0cpYy0komQpI+PsWUXaMyojIUa7YhUVvHismNtBgonFcDZsn+Z8X11KylR9Rk" + "o1wJannr6NMuRlkg+b3k5G8IYDUx0sOjDVQbFgqoOpniCo1llBYwd8OgL5mJMnEKaIfq4l" + "rJEUY8ErWc457pQGw0ZPt1TpMJ8VRYUrpOvY7gi0p0OZCK/QmQa3pvAMy8cd7EQoiMP64x" + "SXegjzwjky7eB6nwSwDh7XlHDSYe3Jtk/K4RvtJ2e7GX8HHgJ4CDBXgYc4X8UCD3Gq+/YS" + "BuI8bC4al5fkt35l/jYr5J+GmdSomUkX7t+Nuvu0fuFf+qqDIY9RnM4mt5VMRT8oX8a9KW" + "eXsnAz1x/N3xtPZrcQEQu2eiSshR9zk9FwMHHKuDbzNsjvlSUnmg/qiKpPza3utobKlBvf" + "TfjWaDQe3rf6VnG1S1e2bta8PqcqQz+lWlpfuJWvX1V8zZ77xKys73Elhvyo9bfdFkQhQb" + "+WknHxGLV63R1yTUpLC7sOSqc16HB9ziNrv0H05b8gwUO9DscPhlN++NmjN6uh9muRq4bG" + "FZviVN1C5jroLaT8Ma7WfWsw9b6Fgllgu5WdPrcx8bQTd1mmq3iqHdY9mlS5doepxlvIvG" + "xenECpWnigVM0XKLXd4KdYUFhZWFKOvKSshde0qvRIgiKPrEgRlxfhTbh7e+Ap4Lg+W9UP" + "va9kXXiUsBheKEZ3txVxvUoz/eUcmWuC9EOQN0FONLIqhHCnXjEG3EciV7bx0R3O2n2uMh" + "pznd6kZwedbweE+dBkZbY3r425Vj8IzDl6VDXEzwVtKUhK9h4cUh506IgO7cUsec/2y0MX" + "Z/dKWTq2v5Qjd+cJ1//AY3Plbnhb0ZH8yK81daU+KHgRG3RbY9zTSUsWgpbqNoXL6zix/e" + "xiS8X2X7O9HHfPVdAOJ/w2FleiJO7Lh76ORVRXDskfF1NKBEANBPWM787di8M9BQyf/tgi" + "vBSweo5s9dCqybLCBpVz5FCg6gjvMHqd28p6M5cl8UFxCUJhjddbbE4UYf9ofbMpaWiGR+" + "pM3CIKFdIC1wudeneD64WcbrCP64UOHkZV7LuFdkVRBSwZecDnllTa3uddCpM6gwUxDXlc" + "zu8UVvJb+vXNfFt4RkgmVFElRgQuZwoB6SBuleZwinKtdMZbDPdKc5ybeSGsE9wpwevuoO" + "QEuFOejWLBnfJUyUNwp8zRnXLCDbr8aDiZBrjzmZ55jQvKoS3AkdDnxLd9s0KK3Zd/ZOAb" + "7TY5np6TzieuO+tzHi/Ha6qoRvQraCxoF8xr15kQCyjOaxyPSNoD0OdTSb/HhjaouRZ2Jp" + "U8+bQtOaDNFVe4Ybkx3lB9Yf6gdIZ3oz435bp8lzP/tRVBq7XJem823vt8LMOQ8fSPS/Yp" + "7WBZR5U/Q3VHOb161GJK2o65C7o1xOGz+C2q0z6jMZr3iet8dprFc61x/29a9QEd5opK94" + "7LjFhXfFAsrILpTkZPNXTdBGcoWg0iugrsy3k2yAN15Igw67TtSUfsPWkOgq7jfKr0OvxD" + "pde+z5S6pmlCep6ROxOCnv2gZlLUXJHzOAeKOtCgY06zHmnk+33WYx1qMGNqNy3v0ElZ4Y" + "vHGBf2OIMaVom/j7BXDjGMOtzNGO6FJ/zGnFxQ10PALe7AIQLVBBwiKBY4xFPnEI91ldIx" + "RmPOdynBB1LzdR53ZoZHTV3xzuYs/QQTUAzMNseO1iWuwPpTqoWDlQVlHjtiF1ynq9mpH/" + "AmPDlvwgJ5hdHj9YeEXnT+SdINVZOyOs4RKO5JgZ/M8l4LvYU5sLccC00I68XCF82A8bT6" + "XvfBh9EcPjJEe6UFMgzIMOBMgAwDxQIZduoWjbXEiuom6CA2lA1jpM6VDqP2DAlHgVfyML" + "Pb4UzD0o2Co/lyFEptGZw5DuqIUFxbPswT4dh3xNshv0FWmRsNHGGMUZlytsBenGg0GzaK" + "DQEzDMww2K2DGQaKBTPs1DegJYxrwpsA+xoMJkCp1Zn27rnbipXhQekNnBRJcdLI0TlO6u" + "JcmviEk1LdOpjzxUjrDa6LoOOtiHW1FCmQd1qcUjW7yjykwugrqA5xb9VlnHvML8PvMb/0" + "3WMO56+pQhXYTXZc4Fi5M4EOjq6djpDD0XVgP8wBvy90WaVFkB1h6Y//nTudMh5yJ7jeqk" + "iH24ExbXn5QJQZC7g76PDeEJ57pwI4N/ZeqnDiTWdz5u3/wIScGXjuWWrCinc+dgkcHHBw" + "QNUABweKBQ7u1Dm4I97kf3gD5QA3+Z8ypzkbTGbtSWfcaxMOc6PYG7V5Oh4zFvoR4Puxp2" + "uUZsXxi8P0dGwfFbiqJS3/6TNrEsAXJHsmEEY5/MDtLZlvb/H0rBxwnNrlzeziSgtl0JAr" + "kjuVB+gAfodVRDi/4+sCcDE0kDf5bLbAxgfyBhQL5M2ZWEfblTRoLWtLy9BQFkYwn1CW3Q" + "taLoEs72u1er1Zu6hf31w1ms2rm4ttRIv/UVRoS7v3kUS3eLTnD3chmxTzbx/A4ewYLVPK" + "j1zWrq5ikDM4Vyg7Yz5jAockTTf4pFh6pQBNB01ZSAGmR+i8sfTZ+XH8TeA7TYndCLwzaT" + "hmQwVNVfyzG7mYpn7B/C4SmNphJvZu0xos6ugNCFjUYHiBRQ2KBYs6/2Wv6GpMZlEX8NBs" + "3/Z1jmdmsX23cc8/vA93gXaGHotOXUq4Weozympb9ElJU1JQMReCWHDo5rGi6WeUlwN3zy" + "ytxJhQkRMATDAwZPLN0+G/ZKD4bfPYZ++OBX6gk/cDLWf7OnjPdJjuxnEFmPmeIK9wW98T" + "VwYGPxj8+dgPYBeCwQ+KBYP/TAz+pCdomQ7PjjEE938SKfzAy5rG63X+Gb0mwdInWMoTyd" + "zCSFKxKPYtdVmjvt277orXhWMZgGAPgz186JB3trtE2XJuj4ph0dnsTv6Gnf/qSYh5B5sP" + "TAOw+UCxYPOdi81XwhjtNVIWRD0+BVZH3KDbG3y8rdhZHpRWp8ONpuY1k6KI1rgmD8qYux" + "9+Jkka+qE+F+LiSWuPs+DnrwnP2X2CZxKZynqlJ4Tt/EJ54TbI1NBFREHDfYZvU91nGDz3" + "5QBi6UOffRP6bgBzih4vPXQFDRb3MkZRvMhun3YvlQWcCHAiwIkUyuYCTuRsFAucCHAihe" + "FEEnyL475n8iH2RvNBafeHHZMOmcuqmJIOiQotd+iQZigd0vRdXgcWPVj0YNEXw6xKbtGD" + "QZrZIA26wH8/V/cXc5cYy7GjWN82KBJKB+MzqH60i9nwdrm4HAe/9ortje6gNxDuK4H2AN" + "oDrGOgPUCxQHucOu3hXWnTUB/eEo4cFlBtde96A/7DrE++YrlYSQr/iPM9KKPx8C+uM53w" + "Y67Vva04XuM8GaHU0y/j3pSjHr9okoEelA6WIpSKI74N2bfl3ed2AW4Gu4RRv9Xh7riBW4" + "Pt98qcOrg5nFq4WexSWoNW/+9pr+MUgrdr8qshiU4Z7nPSc4azKd/p9zqfJ3TOFwlraGPw" + "oiyJzzotM/wy4LctoUXUF8V7S0HisItGnKiLRnjQRYNliwL3cMkZkPNjkOLQIHndTb4Hv/" + "mj8yFlOHBm7dj4VtrW7k1hqln2eP4GG1hje5gGwBo7+U07WGMnqliwxsAaA2sMrLHiWGNO" + "dRLaYYxYhs3Y7mmnMAZYwb7uVlbkrDkhKW4eqfNDDVgTYE2KyZoEjO0coIx/rcexBvTuLw" + "x6pqzd4Dlrag7o5Xqj7JHgY7YYMTrfkb5xWVgE433i8jisZwtpkvhUDeA37Sdvo5hMwc1T" + "GMIy9ItVcT9UZaswG1GZkWixvlNVu2w0Gzf168b281TblCjXYecLVOH85A+khZvIwehRIn" + "BxmXtxGR4aCUC0s5cTwMuLixgA4lzh31snzxi7V1WMwKXir8lwEGLzuiIMkDMFN/DbQhKN" + "txVZ0o3vxYQ1AkXSag/V5oD35q71lcW10x+22d02KaCd7Ca4/JeX3/8Ha9nRLA==" +) diff --git a/src/adapter/telegram_bot.py b/src/adapter/telegram_bot.py index 1c5498a..d54a222 100644 --- a/src/adapter/telegram_bot.py +++ b/src/adapter/telegram_bot.py @@ -17,11 +17,21 @@ log = logging.getLogger(__name__) class TelegramBot(TelegramBase, TelegramBotWriter): async def send_message( - self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False - ) -> None: - await self.bot.send_message( - chat_id=chat_id, text=text, parse_mode=parse_mode, disable_web_page_preview=disable_preview + self, + text: str, + chat_id: int, + parse_mode: str | None = None, + disable_preview: bool = False, + reply_to_message_id: int | None = None, + ) -> int: + message = await self.bot.send_message( + chat_id=chat_id, + text=text, + parse_mode=parse_mode, + disable_web_page_preview=disable_preview, + reply_to_message_id=reply_to_message_id, ) + 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) @@ -34,15 +44,18 @@ class TelegramBot(TelegramBase, TelegramBotWriter): buttons: list[list[InlineKeyboardButton]], parse_mode: str | None = None, disable_preview: bool = False, - ) -> None: + reply_to_message_id: int | None = None, + ) -> int: keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) - await self.bot.send_message( + message = await self.bot.send_message( chat_id=chat_id, text=text, reply_markup=keyboard, parse_mode=parse_mode, disable_web_page_preview=disable_preview, + reply_to_message_id=reply_to_message_id, ) + return message.message_id async def send_media_with_inline_keyboard( self, @@ -52,41 +65,47 @@ class TelegramBot(TelegramBase, TelegramBotWriter): media_file_id: str, buttons: list[list[InlineKeyboardButton]], parse_mode: str | None = None, - ) -> None: + reply_to_message_id: int | None = None, + ) -> int: keyboard = InlineKeyboardMarkup(inline_keyboard=buttons) if buttons else None if media_type == 'photo': - await self.bot.send_photo( + message = await self.bot.send_photo( chat_id=chat_id, photo=media_file_id, caption=text, parse_mode=parse_mode, reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, ) - return + return message.message_id if media_type == 'video': - await self.bot.send_video( + message = await self.bot.send_video( chat_id=chat_id, video=media_file_id, caption=text, parse_mode=parse_mode, reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, ) - return + return message.message_id if media_type == 'animation': - await self.bot.send_animation( + message = await self.bot.send_animation( chat_id=chat_id, animation=media_file_id, caption=text, parse_mode=parse_mode, reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, ) - return - await self.bot.send_message( + return message.message_id + message = await self.bot.send_message( chat_id=chat_id, text=text, parse_mode=parse_mode, reply_markup=keyboard, + reply_to_message_id=reply_to_message_id, ) + return message.message_id async def send_media_group( self, @@ -94,9 +113,10 @@ class TelegramBot(TelegramBase, TelegramBotWriter): media_items: Sequence[Any], caption: str | None = None, parse_mode: str | None = None, - ) -> None: + reply_to_message_id: int | None = None, + ) -> int: if not media_items: - return + raise ValueError('No media items to send') group: list[InputMediaPhoto | InputMediaVideo] = [] for index, item in enumerate(media_items): @@ -113,7 +133,11 @@ class TelegramBot(TelegramBase, TelegramBotWriter): raise ValueError(f'Unsupported media type for group: {item.media_type}') # Type ignore: aiogram expects Union type but we only use Photo/Video - await self.bot.send_media_group(chat_id=chat_id, media=group) # type: ignore[arg-type] + messages = await self.bot.send_media_group( + chat_id=chat_id, media=group, reply_to_message_id=reply_to_message_id + ) # type: ignore[arg-type] + # Return message_id of first message (with caption) + return messages[0].message_id async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: await self.bot.edit_message_text(chat_id=chat_id, message_id=message_id, text=text, reply_markup=None) diff --git a/src/domain/placement.py b/src/domain/placement.py index 0ec49f4..ffe4363 100644 --- a/src/domain/placement.py +++ b/src/domain/placement.py @@ -89,6 +89,7 @@ class Placement(TimestampedModel): payment_at = fields.DatetimeField(null=True) cost_type = fields.CharEnumField(CostType, null=True, max_length=8) cost_value = fields.FloatField(null=True) + cost_before_bargain_type = fields.CharEnumField(CostType, null=True, max_length=8) cost_before_bargain = fields.FloatField(null=True) placement_type = fields.CharEnumField(PlacementType, null=True, max_length=16) format = fields.TextField(null=True) diff --git a/src/dto/purchase.py b/src/dto/purchase.py index f260816..86f0671 100644 --- a/src/dto/purchase.py +++ b/src/dto/purchase.py @@ -18,11 +18,12 @@ class PlacementDetails(pydantic.BaseModel): placement_at: datetime.datetime | None = None payment_at: datetime.datetime | None = None cost: CostInfo | None = None - cost_before_bargain: float | None = None + cost_before_bargain: CostInfo | None = None placement_type: PlacementType | None = None format: str | None = None comment: str | None = None creative_id: uuid.UUID | None = None + invite_link_type: InviteLinkType | None = None class PlacementOutput(pydantic.BaseModel): @@ -102,7 +103,7 @@ class UpdatePlacementInput(pydantic.BaseModel): placement_at: datetime.datetime | None = None payment_at: datetime.datetime | None = None cost: CostInfo | None = None - cost_before_bargain: float | None = None + cost_before_bargain: CostInfo | None = None placement_type: PlacementType | None = None format: str | None = None diff --git a/src/usecase/__init__.py b/src/usecase/__init__.py index eea8fb3..d663458 100644 --- a/src/usecase/__init__.py +++ b/src/usecase/__init__.py @@ -72,8 +72,13 @@ class MediaItem(typing.Protocol): class TelegramBotWriter(typing.Protocol): async def send_message( - self, text: str, chat_id: int, parse_mode: str | None = None, disable_preview: bool = False - ) -> None: ... + self, + text: str, + chat_id: int, + parse_mode: str | None = None, + disable_preview: bool = False, + reply_to_message_id: int | None = None, + ) -> int: ... async def send_message_with_inline_keyboard( self, @@ -82,7 +87,8 @@ class TelegramBotWriter(typing.Protocol): buttons: list[list['InlineKeyboardButton']], parse_mode: str | None = None, disable_preview: bool = False, - ) -> None: ... + reply_to_message_id: int | None = None, + ) -> int: ... async def send_media_with_inline_keyboard( self, @@ -92,7 +98,8 @@ class TelegramBotWriter(typing.Protocol): media_file_id: str, buttons: list[list['InlineKeyboardButton']], parse_mode: str | None = None, - ) -> None: ... + reply_to_message_id: int | None = None, + ) -> int: ... async def send_media_group( self, @@ -100,7 +107,8 @@ class TelegramBotWriter(typing.Protocol): media_items: Sequence[MediaItem], caption: str | None = None, parse_mode: str | None = None, - ) -> None: ... + reply_to_message_id: int | None = None, + ) -> int: ... async def edit_message_text(self, text: str, chat_id: int, message_id: int) -> None: ... diff --git a/src/usecase/purchase/build_placement_creative.py b/src/usecase/purchase/build_placement_creative.py index 8d771ac..131fa5d 100644 --- a/src/usecase/purchase/build_placement_creative.py +++ b/src/usecase/purchase/build_placement_creative.py @@ -51,6 +51,73 @@ def _format_channel_name(channel: domain.Channel) -> str: return 'Без названия' +def _format_channel_link(channel: domain.Channel) -> str: + """Format channel as HTML link if possible, otherwise return plain text.""" + name = _format_channel_name(channel) + + # Try invite_link first + if channel.invite_link: + return f'{name}' + + # Try username + if channel.username: + return f'{name}' + + # No link available, return plain name + return name + + +def _build_info_message( + placement: domain.Placement, + project: domain.Project, +) -> str: + """Build informational message about placement.""" + lines = [] + + # Header + lines.append('Ссылка зашита с помощью @smartpost_tg_bot') + lines.append('') + + # Placement channel (always present) + placement_channel_link = _format_channel_link(placement.channel) + lines.append(f'Размещение в канале: {placement_channel_link}') + + # Project channel (always present) + project_channel_link = _format_channel_link(project.channel) + lines.append(f'Рекламируемый проект: {project_channel_link}') + + # Invite link type (always present) + link_type_text = 'Открытая' if placement.invite_link_type == domain.InviteLinkType.PUBLIC else 'С заявками' + lines.append(f'Тип ссылки: {link_type_text}') + + # Cost / CPM (optional) + if placement.cost_value is not None and placement.cost_value > 0: + if placement.cost_type == domain.CostType.CPM: + lines.append(f'Ставка CPM: {placement.cost_value:.0f} ₽') + else: + lines.append(f'Стоимость: {placement.cost_value:.0f} ₽') + + # Date and time (optional) + if placement.placement_at: + # Check if time is 00:00 (midnight) - then show only date + if placement.placement_at.hour == 0 and placement.placement_at.minute == 0: + date_str = placement.placement_at.strftime('%d.%m.%Y') + lines.append(f'Дата: {date_str}') + else: + datetime_str = placement.placement_at.strftime('%d.%m.%Y %H:%M') + lines.append(f'Дата и время: {datetime_str}') + + # Format (optional) + if placement.format: + lines.append(f'Формат: {placement.format}') + + # Add empty line before footer + lines.append('') + lines.append('Пост ниже') + + return '\n'.join(lines) + + def _build_keyboard_buttons(buttons: list[dto.CreativeButton]) -> list[list[InlineKeyboardButton]]: return [[InlineKeyboardButton(text=btn.text, url=btn.url)] for btn in buttons] @@ -120,10 +187,12 @@ async def build_placement_creative( raise domain.UserNotFound(user_id) chat_id = user.telegram_user.telegram_id - channel_name = _format_channel_name(placement.channel) - header = f'Канал: {channel_name}' - await self.telegram_bot.send_message(header, chat_id, parse_mode='HTML') + # Build and send informational message + info_message = _build_info_message(placement, project) + info_message_id = await self.telegram_bot.send_message(info_message, chat_id, parse_mode='HTML') + + # Send creative as reply to informational message keyboard_buttons = _build_keyboard_buttons(preview.buttons) if preview.media_items: if len(preview.media_items) == 1: @@ -135,6 +204,7 @@ async def build_placement_creative( media_file_id=media_item.media_file_id, buttons=keyboard_buttons, parse_mode='HTML', + reply_to_message_id=info_message_id, ) else: await self.telegram_bot.send_media_group( @@ -142,6 +212,7 @@ async def build_placement_creative( media_items=preview.media_items, caption=preview.text, parse_mode='HTML', + reply_to_message_id=info_message_id, ) elif keyboard_buttons: await self.telegram_bot.send_message_with_inline_keyboard( @@ -150,8 +221,11 @@ async def build_placement_creative( keyboard_buttons, parse_mode='HTML', disable_preview=True, + reply_to_message_id=info_message_id, ) else: - await self.telegram_bot.send_message(preview.text, chat_id, parse_mode='HTML', disable_preview=True) + await self.telegram_bot.send_message( + preview.text, chat_id, parse_mode='HTML', disable_preview=True, reply_to_message_id=info_message_id + ) return preview diff --git a/src/usecase/purchase/create_placements.py b/src/usecase/purchase/create_placements.py index 706f73b..052cadd 100644 --- a/src/usecase/purchase/create_placements.py +++ b/src/usecase/purchase/create_placements.py @@ -21,7 +21,7 @@ def _build_placement_details(placement: domain.Placement) -> dto.PlacementDetail placement_at=placement.placement_at, payment_at=placement.payment_at, cost=_build_cost_info(placement.cost_type, placement.cost_value), - cost_before_bargain=placement.cost_before_bargain, + cost_before_bargain=_build_cost_info(placement.cost_before_bargain_type, placement.cost_before_bargain), placement_type=placement.placement_type, format=placement.format, comment=placement.comment, @@ -117,8 +117,10 @@ 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=(channel_details.cost_before_bargain if channel_details else None) - or (details.cost_before_bargain if details 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) + 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), ) diff --git a/src/usecase/purchase/update_placement.py b/src/usecase/purchase/update_placement.py index 77a9953..08d7c05 100644 --- a/src/usecase/purchase/update_placement.py +++ b/src/usecase/purchase/update_placement.py @@ -61,7 +61,12 @@ async def update_placement( placement.cost_type = input.cost.type placement.cost_value = input.cost.value if 'cost_before_bargain' in fields_set: - placement.cost_before_bargain = input.cost_before_bargain + if input.cost_before_bargain is None: + placement.cost_before_bargain_type = None + placement.cost_before_bargain = None + else: + placement.cost_before_bargain_type = input.cost_before_bargain.type + placement.cost_before_bargain = input.cost_before_bargain.value if 'placement_type' in fields_set: placement.placement_type = input.placement_type if 'format' in fields_set: diff --git a/tg_bot/backend/backend_client.go b/tg_bot/backend/backend_client.go index dfae601..6ce249b 100644 --- a/tg_bot/backend/backend_client.go +++ b/tg_bot/backend/backend_client.go @@ -581,7 +581,7 @@ type PlacementDetails struct { PlacementAt *string `json:"placement_at,omitempty"` PaymentAt *string `json:"payment_at,omitempty"` Cost *CostInfo `json:"cost,omitempty"` - CostBeforeBargain *float64 `json:"cost_before_bargain,omitempty"` + CostBeforeBargain *CostInfo `json:"cost_before_bargain,omitempty"` PlacementType *string `json:"placement_type,omitempty"` Format *string `json:"format,omitempty"` Comment *string `json:"comment,omitempty"` diff --git a/tg_bot/screens/add_purchase.go b/tg_bot/screens/add_purchase.go index f361ed1..d146e59 100644 --- a/tg_bot/screens/add_purchase.go +++ b/tg_bot/screens/add_purchase.go @@ -271,6 +271,8 @@ func (s *AddPurchase) HandleCallback(b *bot.Bot, u *echotron.Update) { CreativeID: s.CreativeID, CreativeTitle: s.CreativeTitle, Channels: []PurchaseChannelInput{}, + Duplicates: []string{}, + ParsingErrors: []ParseError{}, BackState: s.BackState, }, bot.EditMessage) } diff --git a/tg_bot/screens/channel_parser.go b/tg_bot/screens/channel_parser.go new file mode 100644 index 0000000..b7ccca3 --- /dev/null +++ b/tg_bot/screens/channel_parser.go @@ -0,0 +1,380 @@ +package screens + +import ( + "net/url" + "regexp" + "strings" +) + +// ChannelInput представляет результат парсинга ввода пользователя +type ChannelInput struct { + Input string // оригинальный ввод пользователя + Username string // извлеченный username (если применимо) + InviteLink string // извлеченный invite link (если применимо) + Source string // источник: telegram, tgstat, telemetr + Type string // тип: username, invite, deeplink, stat + Valid bool // валидный ли формат +} + +// Парсеры для разных форматов +var ( + // Username без @: 5-32 символа, начинается с буквы + channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`) + + // Invite link: t.me/+xxxxx или t.me/joinchat/xxxxx + inviteLinkRe = regexp.MustCompile(`^t\.me/\+[a-zA-Z0-9_-]+$`) + inviteLinkJoinRe = regexp.MustCompile(`^t\.me/joinchat/[a-zA-Z0-9_-]+$`) + + // TGStat: tgstat.ru/channel_name или tgstat.ru/channel/@username + tgstatRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?tgstat\.ru/(?:channel/)?@?([A-Za-z][A-Za-z0-9_.]{4,31})`) + + // Telemetr: telemetr.io/channel/username или telemetr.io/channel/@username + telemetrRe = regexp.MustCompile(`^(?:https?://)?(?:www\.)?telemetr\.io/(?:channel|channels)/@?([A-Za-z][A-Za-z0-9_.]{4,31})`) +) + +// ParseChannelInput парсит ввод пользователя и определяет тип канала +func ParseChannelInput(input string) ChannelInput { + input = strings.TrimSpace(input) + if input == "" { + return ChannelInput{Valid: false} + } + + result := ChannelInput{Input: input} + + // 1. Проверяем Telegram deeplink (tg://...) + if strings.HasPrefix(input, "tg://") { + return parseTelegramDeeplink(input) + } + + // 2. Проверяем invite ссылки (t.me/+xxx или t.me/joinchat/xxx) + if inviteLinkRe.MatchString(input) || inviteLinkJoinRe.MatchString(input) { + result.Type = "invite" + result.Source = "telegram" + result.InviteLink = normalizeInviteLink(input) + result.Valid = true + return result + } + + // 3. Проверяем URL форматы (http://, https://, t.me/, telegram.me/) + if isURLFormat(input) { + return parseURLFormat(input) + } + + // 4. Проверяем @username + if strings.HasPrefix(input, "@") { + username := strings.TrimPrefix(input, "@") + username = strings.TrimSpace(username) + if channelUsernameRe.MatchString(username) { + result.Username = username + result.Type = "username" + result.Source = "telegram" + result.Valid = true + return result + } + result.Valid = false + return result + } + + // 5. Проверяем username (без @) + if channelUsernameRe.MatchString(input) { + result.Username = input + result.Type = "username" + result.Source = "telegram" + result.Valid = true + return result + } + + // 6. Ничего не подошло + result.Valid = false + return result +} + +// parseTelegramDeeplink парсит tg:// ссылки +func parseTelegramDeeplink(input string) ChannelInput { + result := ChannelInput{Input: input, Source: "telegram", Type: "deeplink"} + + // tg://resolve?domain=username&post=123 + if strings.HasPrefix(input, "tg://resolve?domain=") { + parsed, err := url.Parse(input) + if err != nil { + result.Valid = false + return result + } + query := parsed.Query() + domain := query.Get("domain") + if domain != "" && channelUsernameRe.MatchString(domain) { + result.Username = domain + result.Type = "username" + result.Valid = true + return result + } + } + + // tg://join?invite=abcdef + if strings.HasPrefix(input, "tg://join?invite=") { + parsed, err := url.Parse(input) + if err != nil { + result.Valid = false + return result + } + query := parsed.Query() + invite := query.Get("invite") + if invite != "" { + result.InviteLink = "t.me/+" + invite + result.Type = "invite" + result.Valid = true + return result + } + } + + result.Valid = false + return result +} + +// isURLFormat проверяет, является ли ввод URL или ссылкой +func isURLFormat(input string) bool { + return strings.HasPrefix(input, "http://") || + strings.HasPrefix(input, "https://") || + strings.HasPrefix(input, "t.me/") || + strings.HasPrefix(input, "telegram.me/") || + strings.Contains(input, "t.me/") || + strings.Contains(input, "telegram.me/") +} + +// parseURLFormat парсит URL-форматы ссылок +func parseURLFormat(input string) ChannelInput { + result := ChannelInput{Input: input} + + // Нормализуем: убираем протокол + normalized := input + normalized = strings.TrimPrefix(normalized, "https://") + normalized = strings.TrimPrefix(normalized, "http://") + normalized = strings.TrimPrefix(normalized, "www.") + + // Проверяем TGStat + if matches := tgstatRe.FindStringSubmatch(input); len(matches) > 1 { + username := strings.TrimSuffix(matches[1], "_") + result.Username = username + result.Type = "stat" + result.Source = "tgstat" + result.Valid = true + return result + } + + // Проверяем Telemetr + if matches := telemetrRe.FindStringSubmatch(input); len(matches) > 1 { + username := strings.TrimSuffix(matches[1], "_") + result.Username = username + result.Type = "stat" + result.Source = "telemetr" + result.Valid = true + return result + } + + // Проверяем telegram.me или t.me + if strings.HasPrefix(normalized, "t.me/") || strings.HasPrefix(normalized, "telegram.me/") { + return parseTelegramLink(normalized) + } + + result.Valid = false + return result +} + +// parseTelegramLink парсит ссылки t.me/... и telegram.me/... +func parseTelegramLink(link string) ChannelInput { + result := ChannelInput{Source: "telegram"} + + // Убираем t.me/ или telegram.me/ + path := link + if strings.HasPrefix(path, "telegram.me/") { + path = strings.TrimPrefix(path, "telegram.me/") + } else { + path = strings.TrimPrefix(path, "t.me/") + } + + // Проверяем invite link: +xxxxx или joinchat/xxxxx + if strings.HasPrefix(path, "+") { + inviteHash := strings.TrimPrefix(path, "+") + inviteHash = extractBeforeChar(inviteHash, '?', '/') + if inviteHash != "" { + result.InviteLink = "t.me/+" + inviteHash + result.Type = "invite" + result.Valid = true + return result + } + } + + if strings.HasPrefix(path, "joinchat/") { + inviteHash := strings.TrimPrefix(path, "joinchat/") + inviteHash = extractBeforeChar(inviteHash, '?', '/') + if inviteHash != "" { + result.InviteLink = "t.me/joinchat/" + inviteHash + result.Type = "invite" + result.Valid = true + return result + } + } + + // Извлекаем username из t.me/username или t.me/username/12345 + parts := strings.SplitN(path, "/", 2) + if len(parts) > 0 { + username := parts[0] + // Убираем @ если есть + username = strings.TrimPrefix(username, "@") + + // Валидируем username + if channelUsernameRe.MatchString(username) { + result.Username = username + result.Type = "username" + result.Valid = true + return result + } + } + + result.Valid = false + return result +} + +// normalizeInviteLink нормализует invite ссылку +func normalizeInviteLink(link string) string { + link = strings.TrimSpace(link) + + // Добавляем протокол если нужно + if !strings.HasPrefix(link, "http://") && !strings.HasPrefix(link, "https://") && !strings.HasPrefix(link, "t.me/") { + if strings.HasPrefix(link, "t.me/") { + // Уже в нужном формате + return link + } + } + + // Нормализуем t.me/+xxxxx + if strings.HasPrefix(link, "t.me/+") { + return link + } + + // Нормализуем t.me/joinchat/xxxxx + if strings.HasPrefix(link, "t.me/joinchat/") { + return link + } + + return link +} + +// extractBeforeChar извлекает часть строки до первого встреченного символа +func extractBeforeChar(s string, chars ...rune) string { + for _, c := range chars { + if idx := strings.IndexRune(s, c); idx != -1 { + return s[:idx] + } + } + return s +} + +// SuggestFix предлагает исправление для некорректного ввода +func SuggestFix(input string) (string, bool) { + input = strings.TrimSpace(input) + if input == "" { + return "", false + } + + // 1. Исправляем опечатки в протоколе + if strings.HasPrefix(input, "htp://") { + fixed := strings.Replace(input, "htp://", "https://", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + if strings.HasPrefix(input, "ttps://") { + fixed := strings.Replace(input, "ttps://", "https://", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + if strings.HasPrefix(input, "ttp://") { + fixed := strings.Replace(input, "ttp://", "http://", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 2. Исправляем tme/ → t.me/ + if strings.HasPrefix(input, "tme/") { + fixed := strings.Replace(input, "tme/", "t.me/", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + if strings.Contains(input, "tme/") { + fixed := strings.Replace(input, "tme/", "t.me/", 1) + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 3. Исправляем @@ или @@ + if strings.HasPrefix(input, "@@") { + fixed := strings.TrimPrefix(input, "@") + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 4. Пробуем добавить @ в начало username + if !strings.HasPrefix(input, "@") && !isURLFormat(input) { + fixed := "@" + input + if parsed := ParseChannelInput(fixed); parsed.Valid { + return fixed, true + } + } + + // 5. Пробуем убрать лишние символы в конце + cleaned := input + cleaned = strings.TrimSuffix(cleaned, ".") + cleaned = strings.TrimSuffix(cleaned, ",") + cleaned = strings.TrimSuffix(cleaned, ";") + cleaned = strings.TrimSpace(cleaned) + if cleaned != input { + if parsed := ParseChannelInput(cleaned); parsed.Valid { + return cleaned, true + } + } + + // 6. Пробуем извлечь username из сложной ссылки + if strings.Contains(input, "/") { + parts := strings.Split(input, "/") + for i := len(parts) - 1; i >= 0; i-- { + part := strings.TrimSpace(parts[i]) + part = strings.TrimPrefix(part, "@") + if channelUsernameRe.MatchString(part) { + return "@" + part, true + } + } + } + + return "", false +} + +// FormatChannelLabel форматирует метку канала для отображения +func FormatChannelLabel(input ChannelInput) string { + if input.Username != "" { + return "@" + input.Username + } + if input.InviteLink != "" { + return formatInviteLabel(input.InviteLink) + } + return input.Input +} + +// IsDuplicate проверяет, является ли канал дубликатом (case-insensitive для username) +func IsDuplicate(input ChannelInput, channels []PurchaseChannelInput) bool { + for _, ch := range channels { + // Case-insensitive сравнение username (@MyChannel == @mychannel) + if input.Username != "" && ch.Username != "" && strings.EqualFold(ch.Username, input.Username) { + return true + } + if input.InviteLink != "" && ch.InviteLink == input.InviteLink { + return true + } + } + return false +} diff --git a/tg_bot/screens/placement_details.go b/tg_bot/screens/placement_details.go index 126635f..06fd172 100644 --- a/tg_bot/screens/placement_details.go +++ b/tg_bot/screens/placement_details.go @@ -54,7 +54,10 @@ func (s *PlacementDetails) renderDetails(b *bot.Bot, mode bot.RenderMode) { ) } if placement.Details.CostBeforeBargain != nil { - text += fmt.Sprintf("• До торга: %.0f₽\n", *placement.Details.CostBeforeBargain) + text += fmt.Sprintf("• До торга: %s %.0f₽\n", + formatCostType(placement.Details.CostBeforeBargain.Type), + placement.Details.CostBeforeBargain.Value, + ) } if placement.Details.Format != nil && *placement.Details.Format != "" { text += fmt.Sprintf("• Формат: %s\n", *placement.Details.Format) diff --git a/tg_bot/screens/placements.go b/tg_bot/screens/placements.go index d014fad..a67c6e2 100644 --- a/tg_bot/screens/placements.go +++ b/tg_bot/screens/placements.go @@ -86,7 +86,7 @@ func (s *Placements) Exit() {} // PlacementsList - экран со списком размещений const placementsPerPage = 6 -const placementsPerRow = 2 +const placementsPerRow = 1 type PlacementsList struct { ProjectID string @@ -152,9 +152,9 @@ func (s *PlacementsList) render(b *bot.Bot, mode bot.RenderMode) { } // Текст кнопки: #N • Канал (с пробелом и разделителем) - buttonText := fmt.Sprintf("#%d • %s", globalNum, channelName) + buttonText := fmt.Sprintf("%d • %s", globalNum, channelName) if dateStr != "" { - buttonText += fmt.Sprintf("\n%s", dateStr) + buttonText += fmt.Sprintf(" %s", dateStr) } buttons = append(buttons, echotron.InlineKeyboardButton{ diff --git a/tg_bot/screens/purchase_optional_details.go b/tg_bot/screens/purchase_optional_details.go index f1bbb1f..f432a22 100644 --- a/tg_bot/screens/purchase_optional_details.go +++ b/tg_bot/screens/purchase_optional_details.go @@ -15,38 +15,48 @@ import ( ) type PurchaseOptionalDetails struct { - ProjectID string - ProjectTitle string - CreativeID string - CreativeTitle string - Channels []PurchaseChannelInput - PlacementDateTime *time.Time - PaymentDate *time.Time - CostType string - CostValue *float64 - CostBeforeBargain *float64 - PurchaseType string - Format string - Comment string - InputMode string - CurrentParam string - CurrentChannel string - ParamPage int - ReturnMode string - PlacementMode string - CostMode string - CostBeforeMode string - FormatMode string - PlacementByChannel map[string]*time.Time - CostByChannel map[string]CostEntry - CostBeforeByChannel map[string]*float64 - FormatByChannel map[string]string - PlacementCopy *time.Time - CostCopy *CostEntry - CostBeforeCopy *float64 - FormatCopy *string - ChannelEditMode string // "edit" или "copy" - BackState bot.State + ProjectID string + ProjectTitle string + CreativeID string + CreativeTitle string + Channels []PurchaseChannelInput + PlacementDateTime *time.Time + PaymentDate *time.Time + CostType string + CostValue *float64 + CostBeforeType string + CostBeforeBargain *CostEntry + PurchaseType string + Format string + Comment string + InputMode string + CurrentParam string + CurrentChannel string + ParamPage int + ReturnMode string + PlacementMode string + PaymentDateMode string + CostMode string + CostBeforeMode string + PurchaseTypeMode string + CommentMode string + FormatMode string + PlacementByChannel map[string]*time.Time + PaymentDateByChannel map[string]*time.Time + CostByChannel map[string]CostEntry + CostBeforeByChannel map[string]CostEntry + PurchaseTypeByChannel map[string]string + CommentByChannel map[string]string + FormatByChannel map[string]string + PlacementCopy *time.Time + PaymentDateCopy *time.Time + CostCopy *CostEntry + CostBeforeCopy *CostEntry + PurchaseTypeCopy *string + CommentCopy *string + FormatCopy *string + ChannelEditMode string // "edit" или "copy" + BackState bot.State } type CostEntry struct { @@ -74,27 +84,28 @@ func (s *PurchaseOptionalDetails) Enter(b *bot.Bot, mode bot.RenderMode) { text += s.formatOptionalSummary() var rows [][]echotron.InlineKeyboardButton - rows = append(rows, Row( - s.placementButton(), - s.costButton(), - )) - rows = append(rows, Row( - s.paymentButton(), - s.typeButton(), - )) - rows = append(rows, Row( - s.costBeforeButton(), - s.formatButton(), - )) - rows = append(rows, Row( - s.commentButton(), - )) - // Кнопка переключения режима (только если несколько каналов) + // Кнопка переключения режима (только если несколько каналов) - на первой строке if len(s.Channels) > 1 { rows = append(rows, Row(s.globalModeButton())) } + rows = append(rows, Row( + s.placementButton(), + s.paymentButton(), + )) + rows = append(rows, Row( + s.typeButton(), + s.commentButton(), + )) + rows = append(rows, Row( + s.costButton(), + s.costBeforeButton(), + )) + rows = append(rows, Row( + s.formatButton(), + )) + if row := s.costBeforeDeleteRow(); row != nil { rows = append(rows, row) } @@ -126,13 +137,20 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) } case "opt_payment_date": - b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ - Title: "Дата оплаты", - Key: "payment_date", - IncludeTime: true, - Selected: s.PaymentDate, - BackState: s, - }), bot.EditMessage) + // Открываем редактор в текущем режиме + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "payment_date_channels" + s.Enter(b, bot.EditMessage) + } else { + s.setParamMode("payment_date", "common") + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата оплаты", + Key: "payment_date", + IncludeTime: false, + Selected: s.PaymentDate, + BackState: s, + }), bot.EditMessage) + } case "opt_cost": // Открываем редактор в текущем режиме @@ -145,8 +163,14 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) } case "opt_type": - s.InputMode = "type" - s.Enter(b, bot.EditMessage) + // Открываем редактор в текущем режиме + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "purchase_type_channels" + s.Enter(b, bot.EditMessage) + } else { + s.InputMode = "type" + s.Enter(b, bot.EditMessage) + } case "opt_format": // Открываем редактор в текущем режиме @@ -159,8 +183,14 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) } case "opt_comment": - s.InputMode = "comment" - s.Enter(b, bot.EditMessage) + // Открываем редактор в текущем режиме + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + s.InputMode = "comment_channels" + s.Enter(b, bot.EditMessage) + } else { + s.InputMode = "comment" + s.Enter(b, bot.EditMessage) + } case "delete_comment": s.Comment = "" @@ -174,6 +204,10 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) s.toggleCostType() s.Enter(b, bot.EditMessage) + case "cost_before_type_toggle": + s.toggleCostBeforeType() + s.Enter(b, bot.EditMessage) + case "cost_value": s.InputMode = "cost_value" s.Enter(b, bot.EditMessage) @@ -215,6 +249,8 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) CreativeID: s.CreativeID, CreativeTitle: s.CreativeTitle, Channels: s.Channels, + Duplicates: []string{}, + ParsingErrors: []ParseError{}, BackState: s.BackState, }, bot.EditMessage) case "next": @@ -256,6 +292,8 @@ func (s *PurchaseOptionalDetails) HandleCallback(b *bot.Bot, u *echotron.Update) currentMode := s.paramMode(param) if currentMode == "common" { s.setParamMode(param, "per_channel") + // Копируем значение из общего во все каналы + s.copyCommonValueToChannels(param) } else { s.setParamMode(param, "common") } @@ -311,11 +349,19 @@ func (s *PurchaseOptionalDetails) HandleMessage(b *bot.Bot, u *echotron.Update) switch s.InputMode { case "type_custom": - s.PurchaseType = text + if s.CurrentChannel != "" { + s.PurchaseTypeByChannel[s.CurrentChannel] = text + } else { + s.PurchaseType = text + } case "format_custom": s.setFormatValue(text) case "comment": - s.Comment = text + if s.CurrentChannel != "" { + s.CommentByChannel[s.CurrentChannel] = text + } else { + s.Comment = text + } case "cost_value": value, err := strconv.ParseFloat(strings.ReplaceAll(text, ",", "."), 64) if err != nil { @@ -358,20 +404,44 @@ func (s *PurchaseOptionalDetails) SetDateTimeSelection(key string, value time.Ti s.ensureDefaults() s.PlacementByChannel[username] = &value } + if strings.HasPrefix(key, "payment_date:") { + username := strings.TrimPrefix(key, "payment_date:") + s.ensureDefaults() + s.PaymentDateByChannel[username] = &value + } } } func (s *PurchaseOptionalDetails) formatOptionalSummary() string { - lines := []string{ - fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails()), - fmt.Sprintf("Дата оплаты: %s", s.formatDateTime(s.PaymentDate)), - fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails()), - fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails()), - fmt.Sprintf("Тип закупа: %s", s.formatText(s.PurchaseType)), - fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails()), - fmt.Sprintf("Комментарий: %s", s.formatText(s.Comment)), + var lines []string + + if s.hasPlacementValue() { + lines = append(lines, fmt.Sprintf("Дата размещения: %s%s", s.formatPlacementSummary(), s.formatPlacementDetails())) } - return strings.Join(lines, "\n") + if s.hasPaymentDateValue() { + lines = append(lines, fmt.Sprintf("Дата оплаты: %s%s", s.formatPaymentDateSummary(), s.formatPaymentDateDetails())) + } + if s.hasCostValue() { + lines = append(lines, fmt.Sprintf("Стоимость: %s%s", s.formatCostSummary(), s.formatCostDetails())) + } + if s.hasCostBeforeValue() { + lines = append(lines, fmt.Sprintf("Стоимость до торга: %s%s", s.formatCostBeforeSummary(), s.formatCostBeforeDetails())) + } + if s.hasPurchaseTypeValue() { + lines = append(lines, fmt.Sprintf("Тип закупа: %s%s", s.formatPurchaseTypeSummary(), s.formatPurchaseTypeDetails())) + } + if s.hasFormatValue() { + lines = append(lines, fmt.Sprintf("Формат: %s%s", s.formatFormatSummary(), s.formatFormatDetails())) + } + if s.hasCommentValue() { + lines = append(lines, fmt.Sprintf("Комментарий: %s%s", s.formatCommentSummary(), s.formatCommentDetails())) + } + + if len(lines) == 0 { + return "Добавьте параметры ниже" + } + + return strings.Join(lines, "\n\n") } func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string { @@ -379,28 +449,41 @@ func (s *PurchaseOptionalDetails) formatDateTime(value *time.Time) string { return "—" } local := value.In(ui2.MskLocation) - return fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04")) + text := fmt.Sprintf("%s %02d %s %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month()), local.Format("15:04")) + return fmt.Sprintf("%s", text) +} + +func (s *PurchaseOptionalDetails) formatDate(value *time.Time) string { + if value == nil { + return "—" + } + local := value.In(ui2.MskLocation) + text := fmt.Sprintf("%s %02d %s", ui2.WeekdayName(local.Weekday()), local.Day(), ui2.MonthShort(local.Month())) + return fmt.Sprintf("%s", text) } func (s *PurchaseOptionalDetails) formatText(value string) string { if value == "" { return "—" } - return value + return fmt.Sprintf("%s", value) } func (s *PurchaseOptionalDetails) formatCostValue() string { if s.CostValue == nil { return "—" } - return fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue) + text := fmt.Sprintf("%s %.0f₽", s.costTypeLabel(), *s.CostValue) + return fmt.Sprintf("%s", text) } func (s *PurchaseOptionalDetails) formatCostBefore() string { - if s.CostBeforeBargain == nil { + if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil { return "—" } - return fmt.Sprintf("%.0f₽", *s.CostBeforeBargain) + label := s.costBeforeTypeLabelForEntry(*s.CostBeforeBargain) + text := fmt.Sprintf("%s %.0f₽", label, *s.CostBeforeBargain.Value) + return fmt.Sprintf("%s", text) } func (s *PurchaseOptionalDetails) costTypeLabel() string { @@ -410,6 +493,13 @@ func (s *PurchaseOptionalDetails) costTypeLabel() string { return "Фикс" } +func (s *PurchaseOptionalDetails) costBeforeTypeLabel() string { + if s.CostBeforeType == "cpm" { + return "СРМ" + } + return "Фикс" +} + func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButton { icon := "+" if s.hasPlacementValue() { @@ -423,10 +513,15 @@ func (s *PurchaseOptionalDetails) placementButton() echotron.InlineKeyboardButto } func (s *PurchaseOptionalDetails) paymentButton() echotron.InlineKeyboardButton { - if s.PaymentDate == nil { - return Button("+ Дата оплаты", "opt_payment_date") + icon := "+" + if s.hasPaymentDateValue() { + icon = "✎" } - return Button("✎ Дата оплаты", "opt_payment_date") + modeIcon := "" + if len(s.Channels) > 1 && s.PaymentDateMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Дата оплаты%s", icon, modeIcon), "opt_payment_date") } func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton { @@ -434,11 +529,7 @@ func (s *PurchaseOptionalDetails) costButton() echotron.InlineKeyboardButton { if s.hasCostValue() { icon = "✎" } - modeIcon := "" - if len(s.Channels) > 1 && s.CostMode == "per_channel" { - modeIcon = " 👥" - } - return Button(fmt.Sprintf("%s Стоимость%s", icon, modeIcon), "opt_cost") + return Button(fmt.Sprintf("%s Стоимость", icon), "opt_cost") } func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButton { @@ -446,18 +537,19 @@ func (s *PurchaseOptionalDetails) costBeforeButton() echotron.InlineKeyboardButt if s.hasCostBeforeValue() { icon = "✎" } - modeIcon := "" - if len(s.Channels) > 1 && s.CostBeforeMode == "per_channel" { - modeIcon = " 👥" - } - return Button(fmt.Sprintf("%s До торга%s", icon, modeIcon), "cost_before") + return Button(fmt.Sprintf("%s До торга", icon), "cost_before") } func (s *PurchaseOptionalDetails) typeButton() echotron.InlineKeyboardButton { - if s.PurchaseType == "" { - return Button("+ Тип", "opt_type") + icon := "+" + if s.PurchaseType != "" { + icon = "✎" } - return Button("✎ Тип", "opt_type") + modeIcon := "" + if len(s.Channels) > 1 && s.PurchaseTypeMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Тип%s", icon, modeIcon), "opt_type") } func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton { @@ -473,14 +565,19 @@ func (s *PurchaseOptionalDetails) formatButton() echotron.InlineKeyboardButton { } func (s *PurchaseOptionalDetails) commentButton() echotron.InlineKeyboardButton { - if s.Comment == "" { - return Button("+ Комментарий", "opt_comment") + icon := "+" + if s.Comment != "" { + icon = "✎" } - return Button("✎ Комментарий", "opt_comment") + modeIcon := "" + if len(s.Channels) > 1 && s.CommentMode == "per_channel" { + modeIcon = " 👥" + } + return Button(fmt.Sprintf("%s Комментарий%s", icon, modeIcon), "opt_comment") } func (s *PurchaseOptionalDetails) globalModeButton() echotron.InlineKeyboardButton { - return Button("⚙ Общие / По каналам", "toggle_global_mode") + return Button("⚙️ Режимы параметров", "toggle_global_mode") } func (s *PurchaseOptionalDetails) commentDeleteRow() []echotron.InlineKeyboardButton { @@ -541,6 +638,19 @@ func (s *PurchaseOptionalDetails) renderInputPrompt(b *bot.Bot, mode bot.RenderM if s.CurrentChannel != "" { text += fmt.Sprintf("\n\nКанал: %s", channelLabelByKey(s.Channels, s.CurrentChannel)) } + + typeLabel := s.costBeforeTypeLabelForCurrent() + keyboard := Keyboard( + Row(Button(fmt.Sprintf("Тип: %s", typeLabel), "cost_before_type_toggle")), + Row(Button("← Назад", s.backAction())), + ) + + if mode == bot.EditMessage { + b.Edit(text, keyboard) + } else { + b.SendNew(text, keyboard) + } + return case "format_select": text = "Страница ввода формата размещения\n\n" if s.CurrentChannel != "" { @@ -609,34 +719,43 @@ func (s *PurchaseOptionalDetails) renderModeSelect(b *bot.Bot, mode bot.RenderMo // Дата размещения placementLabel := "Дата размещения: " if s.PlacementMode == "per_channel" { - placementLabel += "для каждого" + placementLabel += "👥" } else { placementLabel += "общий" } rows = append(rows, Row(Button(placementLabel, "toggle_param_mode:placement"))) - // Стоимость - costLabel := "Стоимость: " - if s.CostMode == "per_channel" { - costLabel += "для каждого" + // Дата оплаты + paymentDateLabel := "Дата оплаты: " + if s.PaymentDateMode == "per_channel" { + paymentDateLabel += "👥" } else { - costLabel += "общий" + paymentDateLabel += "общий" } - rows = append(rows, Row(Button(costLabel, "toggle_param_mode:cost"))) + rows = append(rows, Row(Button(paymentDateLabel, "toggle_param_mode:payment_date"))) - // До торга - costBeforeLabel := "До торга: " - if s.CostBeforeMode == "per_channel" { - costBeforeLabel += "для каждого" + // Тип закупа + purchaseTypeLabel := "Тип закупа: " + if s.PurchaseTypeMode == "per_channel" { + purchaseTypeLabel += "👥" } else { - costBeforeLabel += "общий" + purchaseTypeLabel += "общий" } - rows = append(rows, Row(Button(costBeforeLabel, "toggle_param_mode:cost_before"))) + rows = append(rows, Row(Button(purchaseTypeLabel, "toggle_param_mode:purchase_type"))) + + // Комментарий + commentLabel := "Комментарий: " + if s.CommentMode == "per_channel" { + commentLabel += "👥" + } else { + commentLabel += "общий" + } + rows = append(rows, Row(Button(commentLabel, "toggle_param_mode:comment"))) // Формат formatLabel := "Формат: " if s.FormatMode == "per_channel" { - formatLabel += "для каждого" + formatLabel += "👥" } else { formatLabel += "общий" } @@ -652,12 +771,21 @@ func (s *PurchaseOptionalDetails) renderParamScreens(b *bot.Bot, mode bot.Render case "placement_channels": s.renderParamChannels(b, mode, "placement") return true + case "payment_date_channels": + s.renderParamChannels(b, mode, "payment_date") + return true case "cost_channels": s.renderParamChannels(b, mode, "cost") return true case "cost_before_channels": s.renderParamChannels(b, mode, "cost_before") return true + case "purchase_type_channels": + s.renderParamChannels(b, mode, "purchase_type") + return true + case "comment_channels": + s.renderParamChannels(b, mode, "comment") + return true case "format_channels": s.renderParamChannels(b, mode, "format") return true @@ -770,13 +898,68 @@ func (s *PurchaseOptionalDetails) renderParamChannels(b *bot.Bot, mode bot.Rende } func (s *PurchaseOptionalDetails) renderParamChannelSummary(param string) string { - var lines []string - for i, ch := range s.Channels { + // Собираем строки и вычисляем максимальную длину названия канала + type channelLine struct { + label string + value string + } + var lines []channelLine + maxLabelLen := 0 + + for _, ch := range s.Channels { + label := channelLabel(ch) + labelLen := len([]rune(label)) // Считаем руны для Unicode + if labelLen > maxLabelLen { + maxLabelLen = labelLen + } channelKey := channelKey(ch) value := s.formatParamValue(param, channelKey) - lines = append(lines, fmt.Sprintf("%d. %s — %s", i+1, channelLabel(ch), value)) + lines = append(lines, channelLine{label: label, value: value}) } - return strings.Join(lines, "\n") + + // Логирование для отладки (INFO уровень) + log.Info(). + Str("param", param). + Int("maxLabelLen", maxLabelLen). + Int("channelsCount", len(lines)). + Msg("🔍 renderParamChannelSummary") + + // Форматируем: маркер + название + паддинг + значение + // Паддинг вычисляем так, чтобы все значения начинались с одной позиции + const ( + marker = "· " // Маркер пункта + valueIndent = 2 // Отступ после самого длинного названия + ) + + var result []string + for i, line := range lines { + labelLen := len([]rune(line.label)) + // Паддинг = (макс.длина - тек.длина) + отступ после названия + paddingLen := maxLabelLen - labelLen + valueIndent + padding := strings.Repeat(" ", paddingLen) + + formattedLine := fmt.Sprintf("%s%s%s%s", marker, line.label, padding, line.value) + result = append(result, formattedLine) + + // Логируем каждую строку (INFO уровень) + log.Info(). + Int("index", i). + Str("label", line.label). + Int("labelLen", labelLen). + Int("paddingLen", paddingLen). + Str("formattedLine", formattedLine). + Msg("📝 Channel line") + } + + finalResult := strings.Join(result, "\n") + + // Логируем итоговый результат + log.Info(). + Str("param", param). + Str("result", finalResult). + Msg("✅ Final summary") + + return finalResult } func (s *PurchaseOptionalDetails) handleParamCallback(b *bot.Bot, data string) bool { @@ -882,6 +1065,26 @@ func (s *PurchaseOptionalDetails) openCommonEditor(b *bot.Bot, param string) { Selected: s.PlacementDateTime, BackState: s, }), bot.EditMessage) + case "payment_date": + s.InputMode = "" + s.ReturnMode = "" + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата оплаты", + Key: "payment_date", + IncludeTime: false, + Selected: s.PaymentDate, + BackState: s, + }), bot.EditMessage) + case "purchase_type": + s.InputMode = "type" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) + case "comment": + s.InputMode = "comment" + s.ReturnMode = "" + s.CurrentChannel = "" + s.Enter(b, bot.EditMessage) case "cost": s.InputMode = "cost_value" s.ReturnMode = "" @@ -914,6 +1117,25 @@ func (s *PurchaseOptionalDetails) openChannelEditor(b *bot.Bot, param, channelKe Selected: s.PlacementByChannel[channelKey], BackState: s, }), bot.EditMessage) + case "payment_date": + s.InputMode = "payment_date_channels" + b.SetState(ui2.NewDateTimePicker(ui2.DateTimePickerConfig{ + Title: "Дата оплаты", + Key: "payment_date:" + channelKey, + IncludeTime: false, + Selected: s.PaymentDateByChannel[channelKey], + BackState: s, + }), bot.EditMessage) + case "purchase_type": + s.InputMode = "type" + s.ReturnMode = "purchase_type_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) + case "comment": + s.InputMode = "comment" + s.ReturnMode = "comment_channels" + s.CurrentChannel = channelKey + s.Enter(b, bot.EditMessage) case "cost": s.InputMode = "cost_value" s.ReturnMode = "cost_channels" @@ -939,6 +1161,8 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) { switch param { case "placement": s.PlacementCopy = s.PlacementByChannel[channelKey] + case "payment_date": + s.PaymentDateCopy = s.PaymentDateByChannel[channelKey] case "cost": entry := s.CostByChannel[channelKey] copied := entry @@ -950,7 +1174,29 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) { } s.CostCopy = &copied case "cost_before": - s.CostBeforeCopy = s.CostBeforeByChannel[channelKey] + entry := s.CostBeforeByChannel[channelKey] + copied := entry + if entry.Value == nil { + copied.Value = nil + } else { + value := *entry.Value + copied.Value = &value + } + s.CostBeforeCopy = &copied + case "purchase_type": + if value, ok := s.PurchaseTypeByChannel[channelKey]; ok { + copied := value + s.PurchaseTypeCopy = &copied + } else { + s.PurchaseTypeCopy = nil + } + case "comment": + if value, ok := s.CommentByChannel[channelKey]; ok { + copied := value + s.CommentCopy = &copied + } else { + s.CommentCopy = nil + } case "format": if value, ok := s.FormatByChannel[channelKey]; ok { copied := value @@ -961,6 +1207,77 @@ func (s *PurchaseOptionalDetails) copyParamValue(param, channelKey string) { } } +func (s *PurchaseOptionalDetails) copyCommonValueToChannels(param string) { + switch param { + case "placement": + if s.PlacementDateTime == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + value := s.PlacementDateTime + s.PlacementByChannel[key] = value + } + case "payment_date": + if s.PaymentDate == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + value := s.PaymentDate + s.PaymentDateByChannel[key] = value + } + case "cost": + if s.CostValue == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + entry := CostEntry{ + Type: s.CostType, + Value: s.CostValue, + } + s.CostByChannel[key] = entry + } + case "cost_before": + if s.CostBeforeBargain == nil || s.CostBeforeBargain.Value == nil { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + entry := CostEntry{ + Type: s.CostBeforeBargain.Type, + Value: s.CostBeforeBargain.Value, + } + s.CostBeforeByChannel[key] = entry + } + case "purchase_type": + if s.PurchaseType == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.PurchaseTypeByChannel[key] = s.PurchaseType + } + case "comment": + if s.Comment == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.CommentByChannel[key] = s.Comment + } + case "format": + if s.Format == "" { + return + } + for _, ch := range s.Channels { + key := channelKey(ch) + s.FormatByChannel[key] = s.Format + } + } +} + func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) { switch param { case "placement": @@ -970,6 +1287,13 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) { } value := s.PlacementCopy.In(ui2.MskLocation) s.PlacementByChannel[channelKey] = &value + case "payment_date": + if s.PaymentDateCopy == nil { + s.PaymentDateByChannel[channelKey] = nil + return + } + value := s.PaymentDateCopy.In(ui2.MskLocation) + s.PaymentDateByChannel[channelKey] = &value case "cost": if s.CostCopy == nil { delete(s.CostByChannel, channelKey) @@ -983,11 +1307,27 @@ func (s *PurchaseOptionalDetails) pasteParamValue(param, channelKey string) { s.CostByChannel[channelKey] = copied case "cost_before": if s.CostBeforeCopy == nil { - s.CostBeforeByChannel[channelKey] = nil + delete(s.CostBeforeByChannel, channelKey) return } - value := *s.CostBeforeCopy - s.CostBeforeByChannel[channelKey] = &value + copied := *s.CostBeforeCopy + if copied.Value != nil { + value := *copied.Value + copied.Value = &value + } + s.CostBeforeByChannel[channelKey] = copied + case "purchase_type": + if s.PurchaseTypeCopy == nil { + delete(s.PurchaseTypeByChannel, channelKey) + return + } + s.PurchaseTypeByChannel[channelKey] = *s.PurchaseTypeCopy + case "comment": + if s.CommentCopy == nil { + delete(s.CommentByChannel, channelKey) + return + } + s.CommentByChannel[channelKey] = *s.CommentCopy case "format": if s.FormatCopy == nil { delete(s.FormatByChannel, channelKey) @@ -1003,6 +1343,10 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) { for _, ch := range s.Channels { s.pasteParamValue(param, channelKey(ch)) } + case "payment_date": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } case "cost": for _, ch := range s.Channels { s.pasteParamValue(param, channelKey(ch)) @@ -1011,6 +1355,14 @@ func (s *PurchaseOptionalDetails) applyParamToAll(param string) { for _, ch := range s.Channels { s.pasteParamValue(param, channelKey(ch)) } + case "purchase_type": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } + case "comment": + for _, ch := range s.Channels { + s.pasteParamValue(param, channelKey(ch)) + } case "format": for _, ch := range s.Channels { s.pasteParamValue(param, channelKey(ch)) @@ -1022,10 +1374,16 @@ func (s *PurchaseOptionalDetails) clearParamValue(param, channelKey string) { switch param { case "placement": s.PlacementByChannel[channelKey] = nil + case "payment_date": + s.PaymentDateByChannel[channelKey] = nil case "cost": delete(s.CostByChannel, channelKey) case "cost_before": - s.CostBeforeByChannel[channelKey] = nil + delete(s.CostBeforeByChannel, channelKey) + case "purchase_type": + delete(s.PurchaseTypeByChannel, channelKey) + case "comment": + delete(s.CommentByChannel, channelKey) case "format": delete(s.FormatByChannel, channelKey) } @@ -1035,10 +1393,16 @@ func (s *PurchaseOptionalDetails) paramTitle(param string) string { switch param { case "placement": return "Дата и время размещения" + case "payment_date": + return "Дата оплаты" case "cost": return "Стоимость" case "cost_before": return "Стоимость до торга" + case "purchase_type": + return "Тип закупа" + case "comment": + return "Комментарий" case "format": return "Формат" default: @@ -1053,16 +1417,31 @@ func (s *PurchaseOptionalDetails) paramMode(param string) string { return "common" } return s.PlacementMode + case "payment_date": + if s.PaymentDateMode == "" { + return "common" + } + return s.PaymentDateMode case "cost": if s.CostMode == "" { - return "common" + return "per_channel" } return s.CostMode case "cost_before": if s.CostBeforeMode == "" { - return "common" + return "per_channel" } return s.CostBeforeMode + case "purchase_type": + if s.PurchaseTypeMode == "" { + return "common" + } + return s.PurchaseTypeMode + case "comment": + if s.CommentMode == "" { + return "common" + } + return s.CommentMode case "format": if s.FormatMode == "" { return "common" @@ -1077,10 +1456,16 @@ func (s *PurchaseOptionalDetails) setParamMode(param, mode string) { switch param { case "placement": s.PlacementMode = mode + case "payment_date": + s.PaymentDateMode = mode case "cost": s.CostMode = mode case "cost_before": s.CostBeforeMode = mode + case "purchase_type": + s.PurchaseTypeMode = mode + case "comment": + s.CommentMode = mode case "format": s.FormatMode = mode } @@ -1090,11 +1475,20 @@ func (s *PurchaseOptionalDetails) ensureDefaults() { if s.PlacementMode == "" { s.PlacementMode = "common" } + if s.PaymentDateMode == "" { + s.PaymentDateMode = "common" + } if s.CostMode == "" { - s.CostMode = "common" + s.CostMode = "per_channel" } if s.CostBeforeMode == "" { - s.CostBeforeMode = "common" + s.CostBeforeMode = "per_channel" + } + if s.PurchaseTypeMode == "" { + s.PurchaseTypeMode = "common" + } + if s.CommentMode == "" { + s.CommentMode = "common" } if s.FormatMode == "" { s.FormatMode = "common" @@ -1102,11 +1496,20 @@ func (s *PurchaseOptionalDetails) ensureDefaults() { if s.PlacementByChannel == nil { s.PlacementByChannel = make(map[string]*time.Time) } + if s.PaymentDateByChannel == nil { + s.PaymentDateByChannel = make(map[string]*time.Time) + } if s.CostByChannel == nil { s.CostByChannel = make(map[string]CostEntry) } if s.CostBeforeByChannel == nil { - s.CostBeforeByChannel = make(map[string]*float64) + s.CostBeforeByChannel = make(map[string]CostEntry) + } + if s.PurchaseTypeByChannel == nil { + s.PurchaseTypeByChannel = make(map[string]string) + } + if s.CommentByChannel == nil { + s.CommentByChannel = make(map[string]string) } if s.FormatByChannel == nil { s.FormatByChannel = make(map[string]string) @@ -1127,6 +1530,11 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() { delete(s.PlacementByChannel, key) } } + for key := range s.PaymentDateByChannel { + if _, ok := valid[key]; !ok { + delete(s.PaymentDateByChannel, key) + } + } for key := range s.CostByChannel { if _, ok := valid[key]; !ok { delete(s.CostByChannel, key) @@ -1137,6 +1545,16 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() { delete(s.CostBeforeByChannel, key) } } + for key := range s.PurchaseTypeByChannel { + if _, ok := valid[key]; !ok { + delete(s.PurchaseTypeByChannel, key) + } + } + for key := range s.CommentByChannel { + if _, ok := valid[key]; !ok { + delete(s.CommentByChannel, key) + } + } for key := range s.FormatByChannel { if _, ok := valid[key]; !ok { delete(s.FormatByChannel, key) @@ -1146,7 +1564,7 @@ func (s *PurchaseOptionalDetails) syncChannelMaps() { func (s *PurchaseOptionalDetails) formatPlacementSummary() string { if s.PlacementMode == "per_channel" && len(s.Channels) > 1 { - return "для каждого" + return "👥" } return s.formatDateTime(s.PlacementDateTime) } @@ -1158,9 +1576,23 @@ func (s *PurchaseOptionalDetails) formatPlacementDetails() string { return "\n" + s.renderParamChannelSummary("placement") } +func (s *PurchaseOptionalDetails) formatPaymentDateSummary() string { + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatDate(s.PaymentDate) +} + +func (s *PurchaseOptionalDetails) formatPaymentDateDetails() string { + if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("payment_date") +} + func (s *PurchaseOptionalDetails) formatCostSummary() string { if s.CostMode == "per_channel" && len(s.Channels) > 1 { - return "для каждого" + return "👥" } return s.formatCostValue() } @@ -1174,7 +1606,7 @@ func (s *PurchaseOptionalDetails) formatCostDetails() string { func (s *PurchaseOptionalDetails) formatCostBeforeSummary() string { if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { - return "для каждого" + return "👥" } return s.formatCostBefore() } @@ -1186,9 +1618,37 @@ func (s *PurchaseOptionalDetails) formatCostBeforeDetails() string { return "\n" + s.renderParamChannelSummary("cost_before") } +func (s *PurchaseOptionalDetails) formatPurchaseTypeSummary() string { + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatText(s.PurchaseType) +} + +func (s *PurchaseOptionalDetails) formatPurchaseTypeDetails() string { + if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("purchase_type") +} + +func (s *PurchaseOptionalDetails) formatCommentSummary() string { + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + return "👥" + } + return s.formatText(s.Comment) +} + +func (s *PurchaseOptionalDetails) formatCommentDetails() string { + if s.CommentMode != "per_channel" || len(s.Channels) <= 1 { + return "" + } + return "\n" + s.renderParamChannelSummary("comment") +} + func (s *PurchaseOptionalDetails) formatFormatSummary() string { if s.FormatMode == "per_channel" && len(s.Channels) > 1 { - return "для каждого" + return "👥" } return s.formatText(s.Format) } @@ -1205,13 +1665,26 @@ func (s *PurchaseOptionalDetails) formatParamValue(param, channelKey string) str case "placement": value := s.PlacementByChannel[channelKey] return s.formatDateTime(value) + case "payment_date": + value := s.PaymentDateByChannel[channelKey] + return s.formatDate(value) case "cost": return s.formatCostForChannel(channelKey) case "cost_before": return s.formatCostBeforeForChannel(channelKey) + case "purchase_type": + if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" { + return fmt.Sprintf("%s", value) + } + return "—" + case "comment": + if value, ok := s.CommentByChannel[channelKey]; ok && value != "" { + return fmt.Sprintf("%s", value) + } + return "—" case "format": if value, ok := s.FormatByChannel[channelKey]; ok && value != "" { - return value + return fmt.Sprintf("%s", value) } return "—" default: @@ -1225,25 +1698,34 @@ func (s *PurchaseOptionalDetails) formatCostForChannel(channelKey string) string return "—" } label := s.costTypeLabelForEntry(entry) - return fmt.Sprintf("%s %.0f₽", label, *entry.Value) + text := fmt.Sprintf("%s %.0f₽", label, *entry.Value) + return fmt.Sprintf("%s", text) } func (s *PurchaseOptionalDetails) formatCostBeforeForChannel(channelKey string) string { - value, ok := s.CostBeforeByChannel[channelKey] - if !ok || value == nil { + entry, ok := s.CostBeforeByChannel[channelKey] + if !ok || entry.Value == nil { return "—" } - return fmt.Sprintf("%.0f₽", *value) + label := s.costBeforeTypeLabelForEntry(entry) + text := fmt.Sprintf("%s %.0f₽", label, *entry.Value) + return fmt.Sprintf("%s", text) } func (s *PurchaseOptionalDetails) hasCopyBuffer(param string) bool { switch param { case "placement": return s.PlacementCopy != nil + case "payment_date": + return s.PaymentDateCopy != nil case "cost": return s.CostCopy != nil case "cost_before": return s.CostBeforeCopy != nil + case "purchase_type": + return s.PurchaseTypeCopy != nil + case "comment": + return s.CommentCopy != nil case "format": return s.FormatCopy != nil default: @@ -1256,12 +1738,21 @@ func (s *PurchaseOptionalDetails) hasChannelValue(param, channelKey string) bool case "placement": value, ok := s.PlacementByChannel[channelKey] return ok && value != nil + case "payment_date": + value, ok := s.PaymentDateByChannel[channelKey] + return ok && value != nil case "cost": entry, ok := s.CostByChannel[channelKey] return ok && entry.Value != nil case "cost_before": - value, ok := s.CostBeforeByChannel[channelKey] - return ok && value != nil + entry, ok := s.CostBeforeByChannel[channelKey] + return ok && entry.Value != nil + case "purchase_type": + value, ok := s.PurchaseTypeByChannel[channelKey] + return ok && value != "" + case "comment": + value, ok := s.CommentByChannel[channelKey] + return ok && value != "" case "format": value, ok := s.FormatByChannel[channelKey] return ok && value != "" @@ -1282,6 +1773,18 @@ func (s *PurchaseOptionalDetails) hasPlacementValue() bool { return s.PlacementDateTime != nil } +func (s *PurchaseOptionalDetails) hasPaymentDateValue() bool { + if s.PaymentDateMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.PaymentDateByChannel { + if value != nil { + return true + } + } + return false + } + return s.PaymentDate != nil +} + func (s *PurchaseOptionalDetails) hasCostValue() bool { if s.CostMode == "per_channel" && len(s.Channels) > 1 { for _, entry := range s.CostByChannel { @@ -1296,14 +1799,14 @@ func (s *PurchaseOptionalDetails) hasCostValue() bool { func (s *PurchaseOptionalDetails) hasCostBeforeValue() bool { if s.CostBeforeMode == "per_channel" && len(s.Channels) > 1 { - for _, value := range s.CostBeforeByChannel { - if value != nil { + for _, entry := range s.CostBeforeByChannel { + if entry.Value != nil { return true } } return false } - return s.CostBeforeBargain != nil + return s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil } func (s *PurchaseOptionalDetails) hasFormatValue() bool { @@ -1318,6 +1821,30 @@ func (s *PurchaseOptionalDetails) hasFormatValue() bool { return s.Format != "" } +func (s *PurchaseOptionalDetails) hasPurchaseTypeValue() bool { + if s.PurchaseTypeMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.PurchaseTypeByChannel { + if value != "" { + return true + } + } + return false + } + return s.PurchaseType != "" +} + +func (s *PurchaseOptionalDetails) hasCommentValue() bool { + if s.CommentMode == "per_channel" && len(s.Channels) > 1 { + for _, value := range s.CommentByChannel { + if value != "" { + return true + } + } + return false + } + return s.Comment != "" +} + func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string { if entry.Type == "cpm" { return "СРМ" @@ -1325,6 +1852,13 @@ func (s *PurchaseOptionalDetails) costTypeLabelForEntry(entry CostEntry) string return "Фикс" } +func (s *PurchaseOptionalDetails) costBeforeTypeLabelForEntry(entry CostEntry) string { + if entry.Type == "cpm" { + return "СРМ" + } + return "Фикс" +} + func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string { if s.CurrentChannel == "" { return s.costTypeLabel() @@ -1336,6 +1870,17 @@ func (s *PurchaseOptionalDetails) costTypeLabelForCurrent() string { return s.costTypeLabelForEntry(entry) } +func (s *PurchaseOptionalDetails) costBeforeTypeLabelForCurrent() string { + if s.CurrentChannel == "" { + return s.costBeforeTypeLabel() + } + entry, ok := s.CostBeforeByChannel[s.CurrentChannel] + if !ok || entry.Type == "" { + return s.costBeforeTypeLabel() + } + return s.costBeforeTypeLabelForEntry(entry) +} + func (s *PurchaseOptionalDetails) toggleCostType() { if s.CurrentChannel == "" { if s.costTypeLabel() == "СРМ" { @@ -1357,6 +1902,30 @@ func (s *PurchaseOptionalDetails) toggleCostType() { s.CostByChannel[s.CurrentChannel] = entry } +func (s *PurchaseOptionalDetails) toggleCostBeforeType() { + if s.CurrentChannel == "" { + if s.CostBeforeBargain == nil { + s.CostBeforeBargain = &CostEntry{} + } + if s.costBeforeTypeLabel() == "СРМ" { + s.CostBeforeBargain.Type = "fixed" + } else { + s.CostBeforeBargain.Type = "cpm" + } + return + } + entry := s.CostBeforeByChannel[s.CurrentChannel] + if entry.Type == "" { + entry.Type = s.CostBeforeType + } + if s.costBeforeTypeLabelForEntry(entry) == "СРМ" { + entry.Type = "fixed" + } else { + entry.Type = "cpm" + } + s.CostBeforeByChannel[s.CurrentChannel] = entry +} + func (s *PurchaseOptionalDetails) setCostValue(value float64) { if s.CurrentChannel == "" { s.CostValue = &value @@ -1372,10 +1941,21 @@ func (s *PurchaseOptionalDetails) setCostValue(value float64) { func (s *PurchaseOptionalDetails) setCostBeforeValue(value float64) { if s.CurrentChannel == "" { - s.CostBeforeBargain = &value + if s.CostBeforeBargain == nil { + s.CostBeforeBargain = &CostEntry{} + } + s.CostBeforeBargain.Value = &value + if s.CostBeforeBargain.Type == "" { + s.CostBeforeBargain.Type = s.CostBeforeType + } return } - s.CostBeforeByChannel[s.CurrentChannel] = &value + entry := s.CostBeforeByChannel[s.CurrentChannel] + entry.Value = &value + if entry.Type == "" { + entry.Type = s.CostBeforeType + } + s.CostBeforeByChannel[s.CurrentChannel] = entry } func (s *PurchaseOptionalDetails) setFormatValue(value string) { @@ -1472,16 +2052,28 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) * details.PlacementAt = &formatted } } - if s.PaymentDate != nil { - formatted := s.PaymentDate.Format(time.RFC3339) - details.PaymentAt = &formatted + if s.PaymentDateMode != "per_channel" || len(s.Channels) <= 1 { + if s.PaymentDate != nil { + formatted := s.PaymentDate.Format(time.RFC3339) + details.PaymentAt = &formatted + } } if s.CostMode != "per_channel" || len(s.Channels) <= 1 { details.Cost = s.buildCostInfo(s.CostType, s.CostValue) } if s.CostBeforeMode != "per_channel" || len(s.Channels) <= 1 { - if s.CostBeforeBargain != nil { - details.CostBeforeBargain = s.CostBeforeBargain + if s.CostBeforeBargain != nil && s.CostBeforeBargain.Value != nil { + details.CostBeforeBargain = s.buildCostInfo(s.CostBeforeBargain.Type, s.CostBeforeBargain.Value) + } + } + if s.PurchaseTypeMode != "per_channel" || len(s.Channels) <= 1 { + if s.PurchaseType != "" { + details.PlacementType = &s.PurchaseType + } + } + if s.CommentMode != "per_channel" || len(s.Channels) <= 1 { + if s.Comment != "" { + details.Comment = &s.Comment } } if s.FormatMode != "per_channel" || len(s.Channels) <= 1 { @@ -1489,10 +2081,7 @@ func (s *PurchaseOptionalDetails) buildPlacementDetails(placementType *string) * details.Format = &s.Format } } - if s.Comment != "" { - details.Comment = &s.Comment - } - if placementType != nil { + if placementType != nil && (s.PurchaseTypeMode == "per_channel" || len(s.Channels) <= 1) { details.PlacementType = placementType } @@ -1511,13 +2100,29 @@ func (s *PurchaseOptionalDetails) buildChannelDetails(channelKey string) *backen details.PlacementAt = &formatted } } + if s.PaymentDateMode == "per_channel" { + if value, ok := s.PaymentDateByChannel[channelKey]; ok && value != nil { + formatted := value.Format(time.RFC3339) + details.PaymentAt = &formatted + } + } if s.CostMode == "per_channel" { entry := s.CostByChannel[channelKey] details.Cost = s.buildCostInfo(entry.Type, entry.Value) } if s.CostBeforeMode == "per_channel" { - if value, ok := s.CostBeforeByChannel[channelKey]; ok && value != nil { - details.CostBeforeBargain = value + if entry, ok := s.CostBeforeByChannel[channelKey]; ok && entry.Value != nil { + details.CostBeforeBargain = s.buildCostInfo(entry.Type, entry.Value) + } + } + if s.PurchaseTypeMode == "per_channel" { + if value, ok := s.PurchaseTypeByChannel[channelKey]; ok && value != "" { + details.PlacementType = &value + } + } + if s.CommentMode == "per_channel" { + if value, ok := s.CommentByChannel[channelKey]; ok && value != "" { + details.Comment = &value } } if s.FormatMode == "per_channel" { diff --git a/tg_bot/screens/select_channels_for_purchase.go b/tg_bot/screens/select_channels_for_purchase.go index cad15bc..f683d59 100644 --- a/tg_bot/screens/select_channels_for_purchase.go +++ b/tg_bot/screens/select_channels_for_purchase.go @@ -3,7 +3,6 @@ package screens import ( "context" "fmt" - "regexp" "strings" "github.com/NicoNex/echotron/v3" @@ -33,10 +32,16 @@ type SelectChannelsForPurchase struct { Channels []PurchaseChannelInput CurrentPage int InvalidUsernames []string + Duplicates []string // дубликаты каналов + ParsingErrors []ParseError // ошибки парсинга с предложениями BackState bot.State } -var channelUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`) +// ParseError представляет ошибку парсинга с предложением исправления +type ParseError struct { + Input string + Suggestion string +} func (s *SelectChannelsForPurchase) Enter(b *bot.Bot, mode bot.RenderMode) { s.renderChannelSelection(b, mode) @@ -118,6 +123,30 @@ func (s *SelectChannelsForPurchase) renderChannelSelection(b *bot.Bot, mode bot. s.InvalidUsernames = nil } + // Показываем дубликаты + if len(s.Duplicates) > 0 { + text += fmt.Sprintf("\n\n⏭️ Пропущены (дубликаты): %s", strings.Join(s.Duplicates, ", ")) + s.Duplicates = nil + } + + // Показываем ошибки с предложениями + if len(s.ParsingErrors) > 0 { + text += "\n\n❌ Ошибки форматирования:\n" + for i, err := range s.ParsingErrors { + if i < 3 { // Показываем максимум 3 ошибки + if err.Suggestion != "" { + text += fmt.Sprintf("• %s → возможно: %s\n", err.Input, err.Suggestion) + } else { + text += fmt.Sprintf("• %s\n", err.Input) + } + } + } + if len(s.ParsingErrors) > 3 { + text += fmt.Sprintf("• ... и еще %d\n", len(s.ParsingErrors)-3) + } + s.ParsingErrors = nil + } + // Кнопки навигации if len(s.Channels) > 0 { buttons = append(buttons, Row( @@ -164,18 +193,18 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat return } - // Создаем/обновляем каналы в БД через API - jwt := b.Session.JWT - if jwt == "" { - log.Error().Msg("JWT is empty in session") - b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) - return + // Проверяем, что все каналы резолвлены (имеют ChannelID) + var unresolved []string + for _, ch := range s.Channels { + if ch.ChannelID == "" { + label := channelLabel(ch) + unresolved = append(unresolved, label) + } } - failed := s.resolveChannels(b, jwt) - if len(failed) > 0 { - text := "❌ Не удалось добавить каналы:\n\n" - for _, label := range failed { + if len(unresolved) > 0 { + text := "❌ Некоторые каналы не были найдены:\n\n" + for _, label := range unresolved { text += fmt.Sprintf("• %s\n", label) } text += "\nУдалите их из списка и попробуйте снова" @@ -186,6 +215,7 @@ func (s *SelectChannelsForPurchase) HandleCallback(b *bot.Bot, u *echotron.Updat return } + // Каналы уже резолвлены в HandleMessage, переходим к следующему шагу b.SetState(&PurchaseOptionalDetails{ ProjectID: s.ProjectID, ProjectTitle: s.ProjectTitle, @@ -290,12 +320,25 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update return } + // Проверяем JWT + jwt := b.Session.JWT + if jwt == "" { + log.Error().Msg("JWT is empty in session") + b.SendNew("❌ Ошибка авторизации. Попробуйте /start", Keyboard()) + return + } + + // Разбиваем ввод на токены tokens := strings.FieldsFunc(raw, func(r rune) bool { return r == ' ' || r == '\n' || r == '\t' || r == ',' || r == ';' }) added := 0 - var invalid []string + s.Duplicates = nil + s.InvalidUsernames = nil + s.ParsingErrors = nil + +tokensLoop: for _, token := range tokens { entry := strings.TrimSpace(token) entry = strings.Trim(entry, ",;") @@ -303,53 +346,144 @@ func (s *SelectChannelsForPurchase) HandleMessage(b *bot.Bot, u *echotron.Update continue } - var username string - var inviteLink string - if isInviteLink(entry) { - inviteLink = entry - } else { - username = strings.TrimPrefix(entry, "@") - username = strings.Trim(username, "@") - if !channelUsernameRe.MatchString(username) { - invalid = append(invalid, entry) - continue - } - } + // Используем новый парсер + parsed := ParseChannelInput(entry) - exists := false - for _, ch := range s.Channels { - if inviteLink != "" && ch.InviteLink == inviteLink { - exists = true - break + if !parsed.Valid { + // Пробуем предложить исправление + if suggestion, ok := SuggestFix(entry); ok { + s.ParsingErrors = append(s.ParsingErrors, ParseError{ + Input: entry, + Suggestion: suggestion, + }) + } else { + s.InvalidUsernames = append(s.InvalidUsernames, entry) } - if username != "" && ch.Username == username { - exists = true - break - } - } - if exists { continue } + // Проверяем дубликаты по username/invite link + if IsDuplicate(parsed, s.Channels) { + label := FormatChannelLabel(parsed) + s.Duplicates = append(s.Duplicates, label) + continue + } + + // Сразу резолвим канал через бэкенд + channel, err := s.resolveSingleChannel(b, jwt, parsed) + if err != nil { + // Канал не найден или ошибка + s.InvalidUsernames = append(s.InvalidUsernames, entry) + log.Error().Err(err).Str("input", entry).Msg("Failed to resolve channel") + continue + } + + // Проверяем дубликаты по ID канала (после резолва) + for _, ch := range s.Channels { + if ch.ChannelID != "" && ch.ChannelID == channel.ID { + var title, username string + if channel.Title != nil { + title = *channel.Title + } + if channel.Username != nil { + username = *channel.Username + } + label := channelLabelByID(&channel.ID, &title, &username) + s.Duplicates = append(s.Duplicates, label) + continue tokensLoop // <-- Выход из внешнего цикла, а не внутреннего! + } + + // Дополнительная проверка по username (case-insensitive) + // На случай, если канал еще не резолвлен или ID отличается + if channel.Username != nil && ch.Username != "" { + if strings.EqualFold(ch.Username, *channel.Username) { + var title string + if channel.Title != nil { + title = *channel.Title + } + label := channelLabelByID(&channel.ID, &title, channel.Username) + s.Duplicates = append(s.Duplicates, label) + continue tokensLoop + } + } + } + + // Добавляем канал с полными данными + var title string + if channel.Title != nil { + title = *channel.Title + } s.Channels = append(s.Channels, PurchaseChannelInput{ - Username: username, - InviteLink: inviteLink, + ChannelID: channel.ID, + Username: parsed.Username, + Title: title, + InviteLink: parsed.InviteLink, }) added++ } - if added == 0 { - if len(invalid) == 0 { - return - } + // Если ничего не добавлено и нет ошибок - не обновляем экран + if added == 0 && len(s.InvalidUsernames) == 0 && len(s.Duplicates) == 0 && len(s.ParsingErrors) == 0 { + return } - s.InvalidUsernames = invalid - // Обновляем экран s.Enter(b, bot.NewMessage) } +// resolveSingleChannel резолвит один канал через бэкенд +func (s *SelectChannelsForPurchase) resolveSingleChannel(b *bot.Bot, jwt string, parsed ChannelInput) (*backend.Channel, error) { + var input backend.CreateChannelInput + if parsed.InviteLink != "" { + link := parsed.InviteLink + input = backend.CreateChannelInput{InviteLink: &link} + } else { + username := parsed.Username + input = backend.CreateChannelInput{Username: &username} + } + + resp, err := b.Backend.CreateChannels(context.Background(), jwt, backend.CreateChannelsInput{ + Channels: []backend.CreateChannelInput{input}, + }) + if err != nil { + return nil, err + } + + if len(resp.Results) == 0 { + return nil, fmt.Errorf("no response from backend") + } + + result := resp.Results[0] + if result.Status == "failed" || result.Channel == nil || result.Channel.ID == "" { + errMsg := "channel not found" + if result.Error != nil { + errMsg = *result.Error + } + return nil, fmt.Errorf(errMsg) + } + + // Если пришел username из ответа, обновляем его + if result.Channel.Username != nil && *result.Channel.Username != "" { + parsed.Username = *result.Channel.Username + } + + return result.Channel, nil +} + +// channelLabelByID формирует метку канала по ID +func channelLabelByID(id, title, username *string) string { + if title != nil && *title != "" { + return *title + } + if username != nil && *username != "" { + return "@" + *username + } + if id != nil && *id != "" { + return *id + } + return "канал" +} + func (s *SelectChannelsForPurchase) resolveChannels(b *bot.Bot, jwt string) []string { inputs := make([]backend.CreateChannelInput, 0, len(s.Channels)) for _, ch := range s.Channels {