diff --git a/README.md b/README.md index 791d9ec..adc3c6c 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,9 @@ - показывает пользователю только его кнопку, если он привязан по `operator_user_id` или `operator_user_ids` - позволяет админам видеть все кнопки - после выбора персонажа предлагает статус: `open`, `backstage`, `delay`, `rest` -- после выбора статуса просит фразу для публикации +- после выбора статуса все основные действия доступны кнопками: шаблон, без фразы, своя фраза, назад - обновляет заданное сообщение в канале через `edit_message_text` +- рендерит пост как HTML с кликабельными ссылками - хранит текущее состояние в `data/state.json` ## Настройка @@ -45,15 +46,21 @@ uv sync uv run python main.py ``` +## Команды + +- `/start` или `/panel` — открыть панель +- `/help` — показать справку +- `/refresh` — принудительно перерисовать сообщение канала +- `/cancel` — сбросить текущий ввод и вернуться к панели + ## Как это работает 1. Пользователь пишет `/start`. 2. Бот показывает доступные кнопки персонажей. 3. После выбора персонажа бот показывает кнопки статусов. -4. После выбора статуса бот просит ввести фразу. -5. Введенная фраза сохраняется и бот редактирует сообщение в канале. - -Если отправить точку `.` вместо текста, бот возьмет шаблонную фразу из `config/actors.json`. +4. После выбора статуса бот показывает кнопки вариантов фразы. +5. Если выбрать `Своя фраза`, бот ждет одно текстовое сообщение. +6. После выбора бот редактирует сообщение в канале. ## Проверка diff --git a/config/actors.json b/config/actors.json index 6ed308c..a75de7e 100644 --- a/config/actors.json +++ b/config/actors.json @@ -5,6 +5,15 @@ "actors_title": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n\"𝓣ℎ𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠\" 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟", "legend": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟", "footer": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n 😻 🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫 (https://t.me/lifeOverheardPRCM) & 🤩𝖱𝖢𝖧𝖨𝖵𝖤 (https://t.me/archiveOverheardRPCM) 😻", + "header_html": "🌟🌟 🎀𝑎𝑣𝑖𝑔𝑎𝑡𝑖𝑜𝑛 ⠘ 𝒪𝖵𝖤𝖱𝖧𝖤𝖠𝖱𝒟 🌟\n⎯ 🤩ꫝᥱ 𝐒ℎ𝑜𝑤 𝑚ꪊ𝑠𝑡 𝑔𝑜 𝑜ꪀ.⬜", + "intro_links_html": " 𝘳𝘶𝘭𝘦𝘴 😻 𝗉𝐥𝐨𝗍 😻 𝖻𝗈𝗈𝗌𝗍\n 😻 𝖱𝖳 𝐩𝗈𝗌𝗍 𝖬𝖯 𝗂𝗇𝖿𝗈 𝒑𝑟𝑖𝑐𝑒 😻", + "projects_block_html": "🌟🌟🌟 𝐊аmалᦢги⠘\n🌟 𝖱𝖯 𝗉𝗋𝗈𝗃𝖾𝖼𝗍𝗌 & 𝖱𝖯𝖢𝖬 𝖼𝗁𝖺𝗇𝗇𝖾𝗅𝗌", + "actors_title_html": "﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n"𝓣ℎ𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠" 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟", + "legend_html": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟", + "footer_html": "﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n😻 🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫 & 🤩𝖱𝖢𝖧𝖨𝖵𝖤 😻", + "static_actor_lines_html": [ + "🌟 𝐕𝐈𝐂𝐓𝐎𝐑 🤩 tech. bot 😻наблюдает за спектаклем." + ], "status_labels": { "open": "исполняет роль", "backstage": "в закулисье", @@ -15,9 +24,11 @@ { "key": "liebe", "button_text": "Либэ", - "display_name": "LIEBE", + "display_name": "𝐋𝐈𝐄𝐁𝐄", + "display_html": "𝐋𝐈𝐄𝐁𝐄", "link": "http://t.me/harurpcmoverheardbot", "pronouns": "she/her", + "meta_html": "🤩 𝑠ℎ𝑒/her😻", "emoji": "🌟", "operator_user_id": 5203570193, "default_status": "backstage", @@ -31,9 +42,11 @@ { "key": "motsiel", "button_text": "Моцэ", - "display_name": "MOTSIEL", + "display_name": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋", + "display_html": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋", "link": "http://t.me/OverheardRPCM_motsiel_bot", "pronouns": "he/him", + "meta_html": "🤩 ℎ𝑒/him😻 ", "emoji": "🌟", "operator_user_ids": [1378007191, 6364049891], "default_status": "delay", @@ -47,9 +60,11 @@ { "key": "astat", "button_text": "Астат", - "display_name": "ASTAT", + "display_name": "𝐀𝐒𝐓𝐀𝐓", + "display_html": "𝐀𝐒𝐓𝐀𝐓", "link": "http://t.me/astat_ovhdRPCM_bot", "pronouns": "he/him", + "meta_html": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 1021293938, "default_status": "backstage", @@ -63,9 +78,11 @@ { "key": "skafandr", "button_text": "Скаф", - "display_name": "SKAFANDR", + "display_name": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑", + "display_html": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑", "link": "http://t.me/Alxschfovhd_bot", "pronouns": "he/him", + "meta_html": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 7647479588, "default_status": "backstage", @@ -79,9 +96,11 @@ { "key": "mari", "button_text": "Мари", - "display_name": "MARI", + "display_name": "𝐌𝐀𝐑𝐈", + "display_html": "𝐌𝐀𝐑𝐈", "link": "http://t.me/Marioverheard_bot", "pronouns": "she/her", + "meta_html": "🤩 𝑠ℎ𝑒/her😻", "emoji": "🌟", "operator_user_id": 2114793249, "default_status": "open", @@ -95,9 +114,11 @@ { "key": "marcus", "button_text": "Маркус", - "display_name": "MARCUS", + "display_name": "𝐌𝐀𝐑𝐂𝐔𝐒", + "display_html": "𝐌𝐀𝐑𝐂𝐔𝐒", "link": "http://t.me/Marcus_OVHD_bot", "pronouns": "he/him", + "meta_html": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 637396085, "default_status": "backstage", @@ -111,9 +132,11 @@ { "key": "leo", "button_text": "Лео", - "display_name": "LEO", + "display_name": "𝐋𝐄𝐎", + "display_html": "𝐋𝐄𝐎", "link": "http://t.me/LEOoverh_bot", "pronouns": "he/him", + "meta_html": "🤩 ℎ𝑒/him😻", "emoji": "🌟", "operator_user_id": 6730780021, "default_status": "backstage", @@ -127,9 +150,11 @@ { "key": "lein", "button_text": "Лейн", - "display_name": "LEIN", + "display_name": "𝐋𝐄𝐈𝐍", + "display_html": "𝐋𝐄𝐈𝐍", "link": "http://t.me/Lein_OVHD_Bot", "pronouns": "he/him", + "meta_html": "🥹ℎ𝑒/him 🥹", "emoji": "🌟", "operator_user_id": 6751720805, "default_status": "backstage", diff --git a/session_bot/bot.py b/session_bot/bot.py index a488c4e..1cda961 100644 --- a/session_bot/bot.py +++ b/session_bot/bot.py @@ -5,8 +5,9 @@ import logging from typing import Any from aiogram import Bot, Dispatcher, F, Router +from aiogram.enums import ParseMode from aiogram.exceptions import TelegramBadRequest -from aiogram.filters import CommandStart +from aiogram.filters import Command, CommandStart from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.storage.memory import MemoryStorage @@ -26,9 +27,27 @@ STATUS_CHOICES = { "rest": "Антракт", } +HELP_TEXT = ( + "Команды\n" + "/start или /panel - открыть панель\n" + "/help - показать эту справку\n" + "/refresh - принудительно обновить сообщение канала\n" + "/cancel - сбросить текущий ввод и вернуться к панели\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." +) + class SessionForm(StatesGroup): - waiting_for_phrase = State() + waiting_for_custom_phrase = State() def actor_operator_ids(actor: dict[str, Any]) -> set[int]: @@ -62,10 +81,78 @@ 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="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") + return keyboard + + +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) + + +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: + if "message is not modified" not in str(exc).lower(): + raise + + +async def show_panel(target: Message | CallbackQuery, user_id: int, app_config: dict, settings) -> None: + keyboard = build_actor_keyboard(app_config, user_id, settings.admin_ids) + text = "Выберите персонажа для смены статуса." + + if isinstance(target, CallbackQuery): + await safe_edit_message(target, text, keyboard.as_markup()) + await target.answer() + return + + 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 update_channel_post(bot: Bot, app_config: dict, state_storage: JsonStateStorage, settings) -> None: state = state_storage.load() text = build_channel_text(app_config, state) @@ -73,11 +160,13 @@ 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, ) @router.message(CommandStart()) +@router.message(Command("panel")) async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None: await state.clear() user_id = message.from_user.id @@ -86,19 +175,62 @@ async def start_handler(message: Message, state: FSMContext, app_config: dict, a await message.answer("У вас нет доступа к этой панели.") return - keyboard = build_actor_keyboard(app_config, user_id, settings.admin_ids) - await message.answer("Выберите персонажа для смены статуса.", reply_markup=keyboard.as_markup()) + await show_panel(message, user_id, app_config, settings) + + +@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) + + +@router.message(Command("cancel")) +async def cancel_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None: + await state.clear() + user_id = message.from_user.id + if not is_allowed(user_id, actor_lookup, settings.admin_ids): + await message.answer("Состояние очищено.") + return + await show_panel(message, user_id, app_config, settings) + + +@router.message(Command("refresh")) +async def refresh_handler( + message: Message, + bot: Bot, + app_config: dict, + 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 + + await update_channel_post(bot, app_config, state_storage, settings) + await message.answer("Сообщение канала обновлено.") + + +@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() + user_id = callback.from_user.id + if not is_allowed(user_id, actor_lookup, settings.admin_ids): + await callback.answer("Нет доступа.", show_alert=True) + return + await show_panel(callback, user_id, app_config, settings) @router.callback_query(F.data.startswith("actor:")) -async def actor_handler(callback: CallbackQuery, settings, actor_lookup: dict, app_config: dict) -> None: +async def actor_handler(callback: CallbackQuery, state: FSMContext, settings, actor_lookup: dict, app_config: dict) -> None: + await state.clear() 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 = callback.data.split(":", maxsplit=1)[1] - actor = next((item for item in app_config["actors"] if item["key"] == actor_key), None) + actor = find_actor(app_config, actor_key) if actor is None: await callback.answer("Персонаж не найден.", show_alert=True) return @@ -107,16 +239,152 @@ async def actor_handler(callback: CallbackQuery, settings, actor_lookup: dict, a await callback.answer("Можно менять только свой статус.", show_alert=True) return - keyboard = build_status_keyboard(actor_key) - await callback.message.edit_text( - f"Выбран {actor['display_name']}. Какой статус поставить?", - reply_markup=keyboard.as_markup(), - ) + 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 callback.answer() @router.callback_query(F.data.startswith("status:")) async def status_handler( + callback: CallbackQuery, + settings, + actor_lookup: dict, + app_config: 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 + + _, 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 + + 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, + user_id=user_id, + app_config=app_config, + state_storage=state_storage, + settings=settings, + ) + except TelegramBadRequest as exc: + logging.exception("Failed to edit channel message") + await callback.answer("Не удалось обновить сообщение канала.", show_alert=True) + await safe_edit_message( + callback, + 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()}.", + build_back_to_panel_keyboard().as_markup(), + ) + await callback.answer("Готово") + + +@router.callback_query(F.data.startswith("custom:")) +async def custom_phrase_handler( callback: CallbackQuery, state: FSMContext, settings, @@ -129,7 +397,7 @@ async def status_handler( return _, actor_key, status_key = callback.data.split(":") - actor = next((item for item in app_config["actors"] if item["key"] == actor_key), None) + actor = find_actor(app_config, actor_key) if actor is None: await callback.answer("Персонаж не найден.", show_alert=True) return @@ -138,21 +406,21 @@ async def status_handler( await callback.answer("Можно менять только свой статус.", show_alert=True) return - default_phrase = actor.get("phrases", {}).get(status_key, "") - await state.set_state(SessionForm.waiting_for_phrase) + await state.set_state(SessionForm.waiting_for_custom_phrase) await state.update_data(actor_key=actor_key, status_key=status_key) - - prompt = ( - f"Статус для {actor['display_name']}: {STATUS_CHOICES[status_key]}.\n" - "Отправьте фразу для публикации.\n" - "Если хотите использовать шаблон, отправьте точку: .\n" - f"Шаблон сейчас: {default_phrase or 'не задан'}" + await safe_edit_message( + callback, + ( + f"Введите свою фразу для {actor['display_name']}.\n" + "Она будет добавлена под строкой статуса.\n" + "Если передумали, нажмите кнопку ниже." + ), + build_phrase_keyboard(actor_key, status_key, actor.get("phrases", {}).get(status_key, "")).as_markup(), ) - await callback.message.edit_text(prompt) await callback.answer() -@router.message(SessionForm.waiting_for_phrase) +@router.message(SessionForm.waiting_for_custom_phrase) async def phrase_handler( message: Message, state: FSMContext, @@ -168,31 +436,43 @@ async def phrase_handler( data = await state.get_data() actor_key = data["actor_key"] status_key = data["status_key"] - actor = next(item for item in app_config["actors"] if item["key"] == actor_key) + actor = find_actor(app_config, actor_key) + if actor is None: + await state.clear() + await message.answer("Персонаж не найден.") + return phrase = message.text.strip() - if phrase == ".": - phrase = actor.get("phrases", {}).get(status_key, "") - - payload = state_storage.load() - payload.setdefault("actors", {}) - payload["actors"][actor_key] = { - "status": status_key, - "phrase": phrase, - "updated_by": message.from_user.id, - } - state_storage.save(payload) + if not phrase: + await message.answer("Фраза не должна быть пустой.") + return try: - await update_channel_post(bot, app_config, state_storage, settings) + await apply_status_update( + bot=bot, + actor_key=actor_key, + status_key=status_key, + phrase=phrase, + user_id=message.from_user.id, + app_config=app_config, + state_storage=state_storage, + settings=settings, + ) except TelegramBadRequest as exc: logging.exception("Failed to edit channel message") - await message.answer(f"Статус сохранен, но сообщение канала не обновилось: {exc}") + await message.answer( + f"Статус сохранен, но сообщение канала не обновилось:\n{exc}", + parse_mode=ParseMode.HTML, + ) await state.clear() return await state.clear() - await message.answer(f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.") + await message.answer( + f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.", + parse_mode=ParseMode.HTML, + reply_markup=build_back_to_panel_keyboard().as_markup(), + ) def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher: diff --git a/session_bot/render.py b/session_bot/render.py index cc8f37c..4ee0834 100644 --- a/session_bot/render.py +++ b/session_bot/render.py @@ -1,5 +1,7 @@ from __future__ import annotations +from html import escape + DEFAULT_STATUS_LABELS = { "open": "исполняет роль", @@ -9,24 +11,30 @@ DEFAULT_STATUS_LABELS = { } +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 "" + + def build_channel_text(config: dict, state: dict) -> str: parts: list[str] = [] - header = config.get("header", "").strip() - if header: - parts.append(header) - - intro_links = config.get("intro_links", "").strip() - if intro_links: - parts.append(intro_links) - - projects = config.get("projects_block", "").strip() - if projects: - parts.append(projects) - - actors_title = config.get("actors_title", "").strip() - if actors_title: - parts.append(actors_title) + 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) actor_lines: list[str] = [] actor_state = state.get("actors", {}) @@ -37,22 +45,29 @@ def build_channel_text(config: dict, state: dict) -> str: 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'])} " + line = ( - f'{actor["emoji"]} {actor["display_name"]} ({actor["link"]}) ' - f'{actor["pronouns"]} {label}.' + f'{emoji} {name_html}' + f"{meta_html}{escape(label)}." ) if phrase: - line = f"{line}\n {phrase}" + line = f"{line}\n {escape(phrase)}" actor_lines.append(line) + actor_lines.extend(config.get("static_actor_lines_html", [])) parts.append("\n".join(actor_lines)) - legend = config.get("legend", "").strip() - if legend: - parts.append(legend) - - footer = config.get("footer", "").strip() - if footer: - parts.append(footer) + 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) diff --git a/tests/test_render.py b/tests/test_render.py index ba0a7d4..59169fb 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -3,7 +3,7 @@ from session_bot.render import build_channel_text def test_build_channel_text_includes_phrase_and_status() -> None: config = { - "header": "header", + "header_html": "header", "actors": [ { "key": "astat", @@ -21,6 +21,7 @@ def test_build_channel_text_includes_phrase_and_status() -> None: text = build_channel_text(config, state) - assert "ASTAT" in text + assert "header" in text + assert 'ASTAT' in text assert "исполняет роль" in text assert "готов к игре" in text