From d8231c13a45a439552f1caa019a500f7e8508e32 Mon Sep 17 00:00:00 2001 From: Verum Date: Thu, 2 Apr 2026 20:59:04 +0700 Subject: [PATCH] post message --- README.md | 77 +++++------ config/actors.json | 18 +++ session_bot/bot.py | 302 +++++++++++++++++++++--------------------- session_bot/render.py | 103 ++++++++------ tests/test_render.py | 7 +- 5 files changed, 266 insertions(+), 241 deletions(-) diff --git a/README.md b/README.md index adc3c6c..527c53d 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,51 @@ # Session Status Bot -Бот на `aiogram 3` для управления статусами персонажей через кнопки и автоматического обновления одного сообщения в канале. +Бот на `aiogram 3` для управления статусами актеров и обновления одного поста в канале. ## Что умеет -- показывает пользователю только его кнопку, если он привязан по `operator_user_id` или `operator_user_ids` -- позволяет админам видеть все кнопки -- после выбора персонажа предлагает статус: `open`, `backstage`, `delay`, `rest` -- после выбора статуса все основные действия доступны кнопками: шаблон, без фразы, своя фраза, назад -- обновляет заданное сообщение в канале через `edit_message_text` -- рендерит пост как HTML с кликабельными ссылками -- хранит текущее состояние в `data/state.json` +- показывает пользователю только его кнопку актера +- после выбора актера сразу дает один экран кнопок: + `Открыть`, `Закулисье`, `Задержка`, `Антракт`, `Своя фраза` +- статусы применяются сразу с шаблонной фразой +- `Своя фраза` меняет текст для текущего статуса +- обновляет сообщение канала в `MarkdownV2` +- умеет хранить шаблон поста через `/post` + +## Команды + +- `/start` или `/panel` — открыть панель +- `/help` — справка +- `/refresh` — перерисовать пост в канале +- `/cancel` — сбросить текущий ввод +- `/post` — сохранить шаблон поста + +## Шаблон поста + +Шаблон должен содержать: + +- `{{actors}}` — сюда бот вставляет блок актеров +- `{{hidden_link}}` — необязательно, сюда бот вставляет скрытую ссылку для превью + +Если `{{hidden_link}}` не указан, но в [config/actors.json](/c:/Users/admin/Desktop/pidoras/config/actors.json) заполнен `hidden_link_url`, бот добавит скрытую ссылку в начало сам. + +Важно: если просто переслать уже оформленный Telegram-пост, Telegram не гарантирует точное восстановление исходного `MarkdownV2`. Надежнее отправлять шаблон как обычный текст или реплаем на текстовый пост. ## Настройка -1. Установите зависимости: +1. Установить зависимости: ```powershell uv sync ``` -2. Заполните `.env` по примеру из `[.env.example](/c:/Users/admin/Desktop/pidoras/.env.example)`. +2. Заполнить `.env`. -Обязательные переменные: +3. Проверить [config/actors.json](/c:/Users/admin/Desktop/pidoras/config/actors.json): -- `BOT_TOKEN` — токен бота -- `CHANNEL_ID` — ID канала, где лежит обновляемое сообщение -- `CHANNEL_MESSAGE_ID` — ID сообщения в канале -- `ADMIN_IDS` — список Telegram user id через запятую - -3. Отредактируйте `[config/actors.json](/c:/Users/admin/Desktop/pidoras/config/actors.json)`. - -Для каждого участника задаются: - -- `key` — внутренний ключ -- `button_text` — текст на кнопке -- `display_name` — имя в посте -- `link` — ссылка на бота -- `operator_user_id` или `operator_user_ids` — Telegram user id человека, который может менять этот статус -- `phrases` — шаблонные фразы под каждый статус +- `hidden_link_url` — ссылка для скрытого превью +- `phrases` — шаблонные фразы под статусы +- `operator_user_id` или `operator_user_ids` — кто может менять статус ## Запуск @@ -46,26 +53,8 @@ uv sync uv run python main.py ``` -## Команды - -- `/start` или `/panel` — открыть панель -- `/help` — показать справку -- `/refresh` — принудительно перерисовать сообщение канала -- `/cancel` — сбросить текущий ввод и вернуться к панели - -## Как это работает - -1. Пользователь пишет `/start`. -2. Бот показывает доступные кнопки персонажей. -3. После выбора персонажа бот показывает кнопки статусов. -4. После выбора статуса бот показывает кнопки вариантов фразы. -5. Если выбрать `Своя фраза`, бот ждет одно текстовое сообщение. -6. После выбора бот редактирует сообщение в канале. - ## Проверка -Тест рендера: - ```powershell uv run --with pytest python -m pytest ``` diff --git a/config/actors.json b/config/actors.json index a75de7e..b3ca732 100644 --- a/config/actors.json +++ b/config/actors.json @@ -5,6 +5,8 @@ "actors_title": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n\"𝓣ℎ𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠\" 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟", "legend": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟", "footer": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n 😻 🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫 (https://t.me/lifeOverheardPRCM) & 🤩𝖱𝖢𝖧𝖨𝖵𝖤 (https://t.me/archiveOverheardRPCM) 😻", + "hidden_link_url": "", + "hidden_link_char": "⁣", "header_html": "🌟🌟 🎀𝑎𝑣𝑖𝑔𝑎𝑡𝑖𝑜𝑛 ⠘ 𝒪𝖵𝖤𝖱𝖧𝖤𝖠𝖱𝒟 🌟\n⎯ 🤩ꫝᥱ 𝐒ℎ𝑜𝑤 𝑚ꪊ𝑠𝑡 𝑔𝑜 𝑜ꪀ.⬜", "intro_links_html": " 𝘳𝘶𝘭𝘦𝘴 😻 𝗉𝐥𝐨𝗍 😻 𝖻𝗈𝗈𝗌𝗍\n 😻 𝖱𝖳 𝐩𝗈𝗌𝗍 𝖬𝖯 𝗂𝗇𝖿𝗈 𝒑𝑟𝑖𝑐𝑒 😻", "projects_block_html": "🌟🌟🌟 𝐊аmалᦢги⠘\n🌟 𝖱𝖯 𝗉𝗋𝗈𝗃𝖾𝖼𝗍𝗌 & 𝖱𝖯𝖢𝖬 𝖼𝗁𝖺𝗇𝗇𝖾𝗅𝗌", @@ -26,9 +28,11 @@ "button_text": "Либэ", "display_name": "𝐋𝐈𝐄𝐁𝐄", "display_html": "𝐋𝐈𝐄𝐁𝐄", + "display_md": "𝐋𝐈𝐄𝐁𝐄", "link": "http://t.me/harurpcmoverheardbot", "pronouns": "she/her", "meta_html": "🤩 𝑠ℎ𝑒/her😻", + "meta_md": "🤩 𝑠ℎ𝑒/her😻", "emoji": "🌟", "operator_user_id": 5203570193, "default_status": "backstage", @@ -44,9 +48,11 @@ "button_text": "Моцэ", "display_name": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋", "display_html": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋", + "display_md": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋", "link": "http://t.me/OverheardRPCM_motsiel_bot", "pronouns": "he/him", "meta_html": "🤩 ℎ𝑒/him😻 ", + "meta_md": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_ids": [1378007191, 6364049891], "default_status": "delay", @@ -62,9 +68,11 @@ "button_text": "Астат", "display_name": "𝐀𝐒𝐓𝐀𝐓", "display_html": "𝐀𝐒𝐓𝐀𝐓", + "display_md": "𝐀𝐒𝐓𝐀𝐓", "link": "http://t.me/astat_ovhdRPCM_bot", "pronouns": "he/him", "meta_html": "🤩 ℎ𝑒/him😻", + "meta_md": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 1021293938, "default_status": "backstage", @@ -80,9 +88,11 @@ "button_text": "Скаф", "display_name": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑", "display_html": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑", + "display_md": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑", "link": "http://t.me/Alxschfovhd_bot", "pronouns": "he/him", "meta_html": "🤩 ℎ𝑒/him😻", + "meta_md": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 7647479588, "default_status": "backstage", @@ -98,9 +108,11 @@ "button_text": "Мари", "display_name": "𝐌𝐀𝐑𝐈", "display_html": "𝐌𝐀𝐑𝐈", + "display_md": "𝐌𝐀𝐑𝐈", "link": "http://t.me/Marioverheard_bot", "pronouns": "she/her", "meta_html": "🤩 𝑠ℎ𝑒/her😻", + "meta_md": "🤩 𝑠ℎ𝑒/her😻", "emoji": "🌟", "operator_user_id": 2114793249, "default_status": "open", @@ -116,9 +128,11 @@ "button_text": "Маркус", "display_name": "𝐌𝐀𝐑𝐂𝐔𝐒", "display_html": "𝐌𝐀𝐑𝐂𝐔𝐒", + "display_md": "𝐌𝐀𝐑𝐂𝐔𝐒", "link": "http://t.me/Marcus_OVHD_bot", "pronouns": "he/him", "meta_html": "🤩 ℎ𝑒/him😻", + "meta_md": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 637396085, "default_status": "backstage", @@ -134,9 +148,11 @@ "button_text": "Лео", "display_name": "𝐋𝐄𝐎", "display_html": "𝐋𝐄𝐎", + "display_md": "𝐋𝐄𝐎", "link": "http://t.me/LEOoverh_bot", "pronouns": "he/him", "meta_html": "🤩 ℎ𝑒/him😻", + "meta_md": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 6730780021, "default_status": "backstage", @@ -152,9 +168,11 @@ "button_text": "Лейн", "display_name": "𝐋𝐄𝐈𝐍", "display_html": "𝐋𝐄𝐈𝐍", + "display_md": "𝐋𝐄𝐈𝐍", "link": "http://t.me/Lein_OVHD_Bot", "pronouns": "he/him", "meta_html": "🥹ℎ𝑒/him 🥹", + "meta_md": "🥹ℎ𝑒/him 🥹", "emoji": "🌟", "operator_user_id": 6751720805, "default_status": "backstage", diff --git a/session_bot/bot.py b/session_bot/bot.py index 1cda961..83d5891 100644 --- a/session_bot/bot.py +++ b/session_bot/bot.py @@ -28,26 +28,28 @@ STATUS_CHOICES = { } HELP_TEXT = ( - "Команды\n" + "Команды\n" "/start или /panel - открыть панель\n" - "/help - показать эту справку\n" - "/refresh - принудительно обновить сообщение канала\n" - "/cancel - сбросить текущий ввод и вернуться к панели\n\n" - "Как пользоваться\n" + "/help - показать справку\n" + "/refresh - перерисовать пост в канале\n" + "/cancel - сбросить текущий ввод\n" + "/post - сохранить шаблон поста с плейсхолдером {{actors}}\n\n" + "Как пользоваться\n" "1. Откройте панель.\n" "2. Выберите своего актера.\n" - "3. Выберите статус.\n" - "4. Выберите вариант фразы кнопкой: шаблон, без фразы или своя.\n" - "5. Если выбрана своя фраза, отправьте текст одним сообщением.\n\n" - "Настройка\n" - "ENV: BOT_TOKEN, CHANNEL_ID, CHANNEL_MESSAGE_ID, ADMIN_IDS.\n" - "Актеры и шаблоны лежат в config/actors.json.\n" - "Текущие статусы сохраняются в data/state.json." + "3. Нажмите один из статусов. Он применится сразу с шаблонной фразой.\n" + "4. Если нужен свой текст, нажмите 'Своя фраза' и отправьте его.\n\n" + "Шаблон поста\n" + "В шаблоне должен быть {{actors}} - туда бот подставляет актерский блок.\n" + "Можно добавить {{hidden_link}} в начало, либо указать hidden_link_url в config/actors.json.\n" + "Шаблон лучше отправлять обычным текстом или реплаем на текстовый пост.\n" + "Точное восстановление исходного MarkdownV2 из пересланного оформленного поста Telegram не гарантируется." ) class SessionForm(StatesGroup): waiting_for_custom_phrase = State() + waiting_for_post_template = State() def actor_operator_ids(actor: dict[str, Any]) -> set[int]: @@ -81,34 +83,12 @@ def build_status_keyboard(actor_key: str) -> InlineKeyboardBuilder: keyboard = InlineKeyboardBuilder() for status_key, title in STATUS_CHOICES.items(): keyboard.button(text=title, callback_data=f"status:{actor_key}:{status_key}") + keyboard.button(text="Своя фраза", callback_data=f"custom:{actor_key}") keyboard.button(text="⬅️ Назад", callback_data="nav:panel") keyboard.adjust(2) return keyboard -def build_phrase_keyboard(actor_key: str, status_key: str, default_phrase: str) -> InlineKeyboardBuilder: - keyboard = InlineKeyboardBuilder() - if default_phrase: - keyboard.button( - text="Шаблон", - callback_data=f"apply:template:{actor_key}:{status_key}", - ) - keyboard.button( - text="Без фразы", - callback_data=f"apply:empty:{actor_key}:{status_key}", - ) - keyboard.button( - text="Своя фраза", - callback_data=f"custom:{actor_key}:{status_key}", - ) - keyboard.button( - text="⬅️ Назад", - callback_data=f"nav:actor:{actor_key}", - ) - keyboard.adjust(2) - return keyboard - - def build_back_to_panel_keyboard() -> InlineKeyboardBuilder: keyboard = InlineKeyboardBuilder() keyboard.button(text="К панели", callback_data="nav:panel") @@ -119,12 +99,23 @@ def find_actor(app_config: dict, actor_key: str) -> dict[str, Any] | None: return next((item for item in app_config["actors"] if item["key"] == actor_key), None) +def get_actor_runtime_state(actor: dict[str, Any], state_storage: JsonStateStorage) -> dict[str, Any]: + payload = state_storage.load() + return payload.get("actors", {}).get(actor["key"], {}) + + +def extract_template_text(message: Message) -> str | None: + source = message.reply_to_message or message + if source.text: + return source.md_text + return None + + async def safe_edit_message(callback: CallbackQuery, text: str, reply_markup=None) -> None: try: await callback.message.edit_text( text, reply_markup=reply_markup, - parse_mode=ParseMode.HTML, disable_web_page_preview=True, ) except TelegramBadRequest as exc: @@ -144,13 +135,23 @@ async def show_panel(target: Message | CallbackQuery, user_id: int, app_config: await target.answer(text, reply_markup=keyboard.as_markup()) -async def show_actor_status_menu(callback: CallbackQuery, actor: dict[str, Any]) -> None: - keyboard = build_status_keyboard(actor["key"]) - await safe_edit_message( - callback, - f"Выбран {actor['display_name']}.\nКакой статус поставить?", - keyboard.as_markup(), - ) +async def show_actor_status_menu( + callback: CallbackQuery, + actor: dict[str, Any], + state_storage: JsonStateStorage, +) -> None: + runtime_state = get_actor_runtime_state(actor, state_storage) + current_status = runtime_state.get("status", actor.get("default_status", "backstage")) + current_phrase = runtime_state.get("phrase", actor.get("phrases", {}).get(current_status, "")) + + lines = [ + f"Выбран {actor['display_name']}.", + f"Текущий статус: {STATUS_CHOICES.get(current_status, current_status)}.", + ] + if current_phrase: + lines.append(f"Текущая фраза: {current_phrase}") + + await safe_edit_message(callback, "\n".join(lines), build_status_keyboard(actor["key"]).as_markup()) async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonStateStorage, settings) -> None: @@ -160,11 +161,38 @@ async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonSta chat_id=settings.channel_id, message_id=settings.channel_message_id, text=text, - parse_mode=ParseMode.HTML, - disable_web_page_preview=True, + parse_mode=ParseMode.MARKDOWN_V2, + disable_web_page_preview=False, ) +async def apply_status_update( + bot: Bot, + actor_key: str, + status_key: str, + phrase: str, + user_id: int, + app_config: dict, + state_storage: JsonStateStorage, + settings, +) -> None: + payload = state_storage.load() + payload.setdefault("actors", {}) + payload["actors"][actor_key] = { + "status": status_key, + "phrase": phrase, + "updated_by": user_id, + } + state_storage.save(payload) + await update_channel_post(bot, app_config, state_storage, settings) + + +def save_post_template(state_storage: JsonStateStorage, template: str) -> None: + payload = state_storage.load() + payload["template"] = {"text": template} + state_storage.save(payload) + + @router.message(CommandStart()) @router.message(Command("panel")) async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None: @@ -180,7 +208,7 @@ async def start_handler(message: Message, state: FSMContext, app_config: dict, a @router.message(Command("help")) async def help_handler(message: Message) -> None: - await message.answer(HELP_TEXT, parse_mode=ParseMode.HTML, disable_web_page_preview=True) + await message.answer(HELP_TEXT, disable_web_page_preview=True) @router.message(Command("cancel")) @@ -211,6 +239,37 @@ async def refresh_handler( await message.answer("Сообщение канала обновлено.") +@router.message(Command("post")) +async def post_handler( + message: Message, + state: FSMContext, + state_storage: JsonStateStorage, + actor_lookup: dict, + settings, +) -> None: + user_id = message.from_user.id + if not is_allowed(user_id, actor_lookup, settings.admin_ids): + await message.answer("У вас нет доступа к шаблонам поста.") + return + + template = extract_template_text(message) + if template is not None: + save_post_template(state_storage, template) + await state.clear() + if "{{actors}}" not in template: + await message.answer("Шаблон сохранен, но в нем нет {{actors}}.") + return + await message.answer("Шаблон поста сохранен.") + return + + await state.set_state(SessionForm.waiting_for_post_template) + await message.answer( + "Перешлите или отправьте текст шаблона одним сообщением.\n" + "Внутри должен быть {{actors}}.\n" + "Для скрытой ссылки можно использовать {{hidden_link}}." + ) + + @router.callback_query(F.data == "nav:panel") async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None: await state.clear() @@ -222,7 +281,14 @@ async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config: @router.callback_query(F.data.startswith("actor:")) -async def actor_handler(callback: CallbackQuery, state: FSMContext, settings, actor_lookup: dict, app_config: dict) -> None: +async def actor_handler( + callback: CallbackQuery, + state: FSMContext, + settings, + actor_lookup: dict, + app_config: dict, + state_storage: JsonStateStorage, +) -> None: await state.clear() user_id = callback.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): @@ -239,35 +305,18 @@ async def actor_handler(callback: CallbackQuery, state: FSMContext, settings, ac await callback.answer("Можно менять только свой статус.", show_alert=True) return - await show_actor_status_menu(callback, actor) - await callback.answer() - - -@router.callback_query(F.data.startswith("nav:actor:")) -async def actor_back_handler(callback: CallbackQuery, state: FSMContext, settings, actor_lookup: dict, app_config: dict) -> None: - await state.clear() - actor_key = callback.data.split(":", maxsplit=2)[2] - user_id = callback.from_user.id - if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await callback.answer("Нет доступа.", show_alert=True) - return - actor = find_actor(app_config, actor_key) - if actor is None: - await callback.answer("Персонаж не найден.", show_alert=True) - return - if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor): - await callback.answer("Можно менять только свой статус.", show_alert=True) - return - await show_actor_status_menu(callback, actor) + await show_actor_status_menu(callback, actor, state_storage) await callback.answer() @router.callback_query(F.data.startswith("status:")) async def status_handler( callback: CallbackQuery, + bot: Bot, settings, actor_lookup: dict, app_config: dict, + state_storage: JsonStateStorage, ) -> None: user_id = callback.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): @@ -284,80 +333,12 @@ async def status_handler( await callback.answer("Можно менять только свой статус.", show_alert=True) return - default_phrase = actor.get("phrases", {}).get(status_key, "") - prompt_parts = [ - f"Статус для {actor['display_name']}: {STATUS_CHOICES[status_key]}.", - "Выберите, как оформить подпись.", - ] - if default_phrase: - prompt_parts.append(f"Шаблон: {default_phrase}") - else: - prompt_parts.append("Шаблон не задан.") - - await safe_edit_message( - callback, - "\n".join(prompt_parts), - build_phrase_keyboard(actor_key, status_key, default_phrase).as_markup(), - ) - await callback.answer() - - -async def apply_status_update( - bot: Bot, - actor_key: str, - status_key: str, - phrase: str, - user_id: int, - app_config: dict, - state_storage: JsonStateStorage, - settings, -) -> None: - payload = state_storage.load() - payload.setdefault("actors", {}) - payload["actors"][actor_key] = { - "status": status_key, - "phrase": phrase, - "updated_by": user_id, - } - state_storage.save(payload) - await update_channel_post(bot, app_config, state_storage, settings) - - -@router.callback_query(F.data.startswith("apply:")) -async def apply_phrase_handler( - callback: CallbackQuery, - state: FSMContext, - bot: Bot, - app_config: dict, - state_storage: JsonStateStorage, - settings, - actor_lookup: dict, -) -> None: - user_id = callback.from_user.id - if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await callback.answer("Нет доступа.", show_alert=True) - return - - _, mode, actor_key, status_key = callback.data.split(":") - actor = find_actor(app_config, actor_key) - if actor is None: - await callback.answer("Персонаж не найден.", show_alert=True) - return - - if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor): - await callback.answer("Можно менять только свой статус.", show_alert=True) - return - - phrase = "" - if mode == "template": - phrase = actor.get("phrases", {}).get(status_key, "") - try: await apply_status_update( bot=bot, actor_key=actor_key, status_key=status_key, - phrase=phrase, + phrase=actor.get("phrases", {}).get(status_key, ""), user_id=user_id, app_config=app_config, state_storage=state_storage, @@ -368,16 +349,14 @@ async def apply_phrase_handler( await callback.answer("Не удалось обновить сообщение канала.", show_alert=True) await safe_edit_message( callback, - f"Статус сохранен, но сообщение канала не обновилось:\n{exc}", + f"Статус сохранен, но сообщение канала не обновилось:\n{exc}", build_back_to_panel_keyboard().as_markup(), ) - await state.clear() return - await state.clear() await safe_edit_message( callback, - f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.", + f"Готово.\n{actor['display_name']} -> {STATUS_CHOICES[status_key]}", build_back_to_panel_keyboard().as_markup(), ) await callback.answer("Готово") @@ -390,13 +369,14 @@ async def custom_phrase_handler( settings, actor_lookup: dict, app_config: dict, + state_storage: JsonStateStorage, ) -> None: user_id = callback.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): await callback.answer("Нет доступа.", show_alert=True) return - _, actor_key, status_key = callback.data.split(":") + _, actor_key = callback.data.split(":") actor = find_actor(app_config, actor_key) if actor is None: await callback.answer("Персонаж не найден.", show_alert=True) @@ -406,16 +386,19 @@ async def custom_phrase_handler( await callback.answer("Можно менять только свой статус.", show_alert=True) return + runtime_state = get_actor_runtime_state(actor, state_storage) + status_key = runtime_state.get("status", actor.get("default_status", "backstage")) + await state.set_state(SessionForm.waiting_for_custom_phrase) await state.update_data(actor_key=actor_key, status_key=status_key) await safe_edit_message( callback, ( - f"Введите свою фразу для {actor['display_name']}.\n" - "Она будет добавлена под строкой статуса.\n" - "Если передумали, нажмите кнопку ниже." + f"Введите свою фразу для {actor['display_name']}.\n" + f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}.\n" + "Если хотите сначала сменить статус, вернитесь назад." ), - build_phrase_keyboard(actor_key, status_key, actor.get("phrases", {}).get(status_key, "")).as_markup(), + build_status_keyboard(actor_key).as_markup(), ) await callback.answer() @@ -460,21 +443,38 @@ async def phrase_handler( ) except TelegramBadRequest as exc: logging.exception("Failed to edit channel message") - await message.answer( - f"Статус сохранен, но сообщение канала не обновилось:\n{exc}", - parse_mode=ParseMode.HTML, - ) + await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}") await state.clear() return await state.clear() await message.answer( - f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.", - parse_mode=ParseMode.HTML, + f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.", reply_markup=build_back_to_panel_keyboard().as_markup(), ) +@router.message(SessionForm.waiting_for_post_template) +async def post_template_handler( + message: Message, + state: FSMContext, + state_storage: JsonStateStorage, +) -> None: + template = extract_template_text(message) + if template is None: + await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.") + return + + save_post_template(state_storage, template) + await state.clear() + + if "{{actors}}" not in template: + await message.answer("Шаблон сохранен, но в нем нет {{actors}}.") + return + + await message.answer("Шаблон поста сохранен.") + + def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher: dispatcher = Dispatcher(storage=MemoryStorage()) dispatcher["app_config"] = app_config diff --git a/session_bot/render.py b/session_bot/render.py index 4ee0834..f6eeb4b 100644 --- a/session_bot/render.py +++ b/session_bot/render.py @@ -1,7 +1,5 @@ from __future__ import annotations -from html import escape - DEFAULT_STATUS_LABELS = { "open": "исполняет роль", @@ -10,64 +8,83 @@ DEFAULT_STATUS_LABELS = { "rest": "антракт", } - -def _get_block(config: dict, html_key: str, plain_key: str) -> str: - html_value = config.get(html_key, "").strip() - if html_value: - return html_value - - plain_value = config.get(plain_key, "").strip() - if plain_value: - return escape(plain_value) - - return "" +MARKDOWN_V2_SPECIALS = r"_*[]()~`>#+-=|{}.!" -def build_channel_text(config: dict, state: dict) -> str: - parts: list[str] = [] +def escape_markdown_v2(value: str) -> str: + return "".join(f"\\{char}" if char in MARKDOWN_V2_SPECIALS else char for char in value) - for html_key, plain_key in ( - ("header_html", "header"), - ("intro_links_html", "intro_links"), - ("projects_block_html", "projects_block"), - ("actors_title_html", "actors_title"), - ): - block = _get_block(config, html_key, plain_key) - if block: - parts.append(block) +def escape_markdown_v2_url(value: str) -> str: + return value.replace("\\", "\\\\").replace(")", "\\)") + + +def build_hidden_link(config: dict) -> str: + url = config.get("hidden_link_url", "").strip() + if not url: + return "" + invisible = config.get("hidden_link_char", "\u2063") + return f"[{invisible}]({escape_markdown_v2_url(url)})" + + +def build_actor_lines(config: dict, state: dict) -> str: actor_lines: list[str] = [] actor_state = state.get("actors", {}) status_labels = {**DEFAULT_STATUS_LABELS, **config.get("status_labels", {})} + for actor in config["actors"]: current = actor_state.get(actor["key"], {}) status = current.get("status", actor.get("default_status", "backstage")) phrase = current.get("phrase", actor.get("phrases", {}).get(status, "")) label = status_labels.get(status, status) - - emoji = actor.get("emoji_html") or escape(actor.get("emoji", "")) - name_html = actor.get("display_html") or escape(actor["display_name"]) - meta_html = actor.get("meta_html") - if not meta_html: - meta_html = f" {escape(actor['pronouns'])} " + display_name = actor.get("display_md", actor["display_name"]) + meta = actor.get("meta_md", actor["pronouns"]) + emoji = actor.get("emoji_md", actor.get("emoji", "")) line = ( - f'{emoji} {name_html}' - f"{meta_html}{escape(label)}." + f"{emoji} " + f"[{escape_markdown_v2(display_name)}]({escape_markdown_v2_url(actor['link'])}) " + f"{escape_markdown_v2(meta)} " + f"{escape_markdown_v2(label)}\\." ) if phrase: - line = f"{line}\n {escape(phrase)}" + line = f"{line}\n {escape_markdown_v2(phrase)}" actor_lines.append(line) - actor_lines.extend(config.get("static_actor_lines_html", [])) - parts.append("\n".join(actor_lines)) + actor_lines.extend(config.get("static_actor_lines_md", [])) + return "\n".join(actor_lines) - for html_key, plain_key in ( - ("legend_html", "legend"), - ("footer_html", "footer"), - ): - block = _get_block(config, html_key, plain_key) - if block: - parts.append(block) - return "\n\n".join(part for part in parts if part) +def build_default_template(config: dict) -> str: + blocks = [] + hidden = build_hidden_link(config) + if hidden: + blocks.append(hidden) + + for key in ("header", "intro_links", "projects_block", "actors_title"): + value = config.get(key, "").strip() + if value: + blocks.append(escape_markdown_v2(value)) + + blocks.append("{{actors}}") + + for key in ("legend", "footer"): + value = config.get(key, "").strip() + if value: + blocks.append(escape_markdown_v2(value)) + + return "\n\n".join(blocks) + + +def build_channel_text(config: dict, state: dict) -> str: + template = state.get("template", {}).get("text") or config.get("template_text") or build_default_template(config) + actors_block = build_actor_lines(config, state) + hidden_link = build_hidden_link(config) + + text = template.replace("{{actors}}", actors_block) + text = text.replace("{{hidden_link}}", hidden_link) + + if "{{hidden_link}}" not in template and hidden_link: + text = f"{hidden_link}\n{text}" + + return text diff --git a/tests/test_render.py b/tests/test_render.py index 59169fb..3b2e5bb 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -3,7 +3,8 @@ from session_bot.render import build_channel_text def test_build_channel_text_includes_phrase_and_status() -> None: config = { - "header_html": "header", + "template_text": "{{hidden_link}}\nheader\n\n{{actors}}", + "hidden_link_url": "https://example.com/image.png", "actors": [ { "key": "astat", @@ -21,7 +22,7 @@ def test_build_channel_text_includes_phrase_and_status() -> None: text = build_channel_text(config, state) - assert "header" in text - assert 'ASTAT' in text + assert "[⁣](https://example.com/image.png)" in text + assert "[ASTAT](https://t.me/example)" in text assert "исполняет роль" in text assert "готов к игре" in text