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