12312421
Some checks failed
CI / Lint (ruff + mypy) (push) Failing after 31s
CI / Run tests (push) Has been skipped
CI / Docker build test (push) Successful in 10s

This commit is contained in:
2026-04-02 20:36:20 +07:00
parent ef40fc25ee
commit c286039df7
5 changed files with 405 additions and 77 deletions

View File

@@ -7,8 +7,9 @@
- показывает пользователю только его кнопку, если он привязан по `operator_user_id` или `operator_user_ids` - показывает пользователю только его кнопку, если он привязан по `operator_user_id` или `operator_user_ids`
- позволяет админам видеть все кнопки - позволяет админам видеть все кнопки
- после выбора персонажа предлагает статус: `open`, `backstage`, `delay`, `rest` - после выбора персонажа предлагает статус: `open`, `backstage`, `delay`, `rest`
- после выбора статуса просит фразу для публикации - после выбора статуса все основные действия доступны кнопками: шаблон, без фразы, своя фраза, назад
- обновляет заданное сообщение в канале через `edit_message_text` - обновляет заданное сообщение в канале через `edit_message_text`
- рендерит пост как HTML с кликабельными ссылками
- хранит текущее состояние в `data/state.json` - хранит текущее состояние в `data/state.json`
## Настройка ## Настройка
@@ -45,15 +46,21 @@ uv sync
uv run python main.py uv run python main.py
``` ```
## Команды
- `/start` или `/panel` — открыть панель
- `/help` — показать справку
- `/refresh` — принудительно перерисовать сообщение канала
- `/cancel` — сбросить текущий ввод и вернуться к панели
## Как это работает ## Как это работает
1. Пользователь пишет `/start`. 1. Пользователь пишет `/start`.
2. Бот показывает доступные кнопки персонажей. 2. Бот показывает доступные кнопки персонажей.
3. После выбора персонажа бот показывает кнопки статусов. 3. После выбора персонажа бот показывает кнопки статусов.
4. После выбора статуса бот просит ввести фразу. 4. После выбора статуса бот показывает кнопки вариантов фразы.
5. Введенная фраза сохраняется и бот редактирует сообщение в канале. 5. Если выбрать `Своя фраза`, бот ждет одно текстовое сообщение.
6. После выбора бот редактирует сообщение в канале.
Если отправить точку `.` вместо текста, бот возьмет шаблонную фразу из `config/actors.json`.
## Проверка ## Проверка

View File

@@ -5,6 +5,15 @@
"actors_title": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n\"𝓣𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠\" 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟", "actors_title": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n\"𝓣𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠\" 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟",
"legend": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟", "legend": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟",
"footer": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n 😻 🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫 (https://t.me/lifeOverheardPRCM) & 🤩𝖱𝖢𝖧𝖨𝖵𝖤 (https://t.me/archiveOverheardRPCM) 😻", "footer": " ﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n 😻 🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫 (https://t.me/lifeOverheardPRCM) & 🤩𝖱𝖢𝖧𝖨𝖵𝖤 (https://t.me/archiveOverheardRPCM) 😻",
"header_html": "🌟🌟 🎀𝑎𝑣𝑖𝑔𝑎𝑡𝑖𝑜𝑛 ⠘ 𝒪𝖵𝖤𝖱𝖧𝖤𝖠𝖱𝒟 🌟\n⎯ 🤩ꫝᥱ 𝐒𝑜𝑤 𝑚ꪊ𝑠𝑡 𝑔𝑜 𝑜ꪀ.⬜",
"intro_links_html": " <a href=\"https://telegra.ph/%F0%9D%96%B1%F0%9D%96%AF%F0%9D%96%A2%F0%9D%96%AC-%F0%9D%92%AA%F0%9D%96%B5%F0%9D%96%A4%F0%9D%96%B1%F0%9D%96%A7%F0%9D%96%A4%F0%9D%96%A0%F0%9D%96%B1%F0%9D%92%9F--ehto-11-23\">𝘳𝘶𝘭𝘦𝘴</a> 😻 𝗉𝐥𝐨𝗍 😻 <a href=\"https://t.me/boost/OverheardRPCM\">𝖻𝗈𝗈𝗌𝗍</a>\n 😻 <a href=\"https://t.me/overheardinfo/3\">𝖱𝖳 𝐩𝗈𝗌𝗍</a> <a href=\"https://telegra.ph/C%F0%9D%92%AANDITI%F0%9D%92%AANS-12-03\">𝖬𝖯 𝗂𝗇𝖿𝗈</a> <a href=\"https://telegra.ph/%F0%9D%92%AAUR-PR%E2%84%90CE--uslugi-12-03\">𝒑𝑟𝑖𝑐𝑒</a> 😻",
"projects_block_html": "🌟🌟🌟 𝐊аmалᦢги⠘\n🌟 <a href=\"https://t.me/archiveOverheardRPCM/7\">𝖱𝖯 𝗉𝗋𝗈𝗃𝖾𝖼𝗍𝗌</a> &amp; <a href=\"https://t.me/archiveOverheardRPCM/18\">𝖱𝖯𝖢𝖬 𝖼𝗁𝖺𝗇𝗇𝖾𝗅𝗌</a>",
"actors_title_html": "﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n&quot;𝓣𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠&quot; 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟",
"legend_html": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟",
"footer_html": "﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n😻 <a href=\"https://t.me/lifeOverheardPRCM\">🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫</a> &amp; <a href=\"https://t.me/archiveOverheardRPCM\">🤩𝖱𝖢𝖧𝖨𝖵𝖤</a> 😻",
"static_actor_lines_html": [
"🌟 <a href=\"http://t.me/VictorOVHD_bot\"><b>𝐕𝐈𝐂𝐓𝐎𝐑</b></a> 🤩 <i>tech. bot</i> 😻наблюдает за спектаклем."
],
"status_labels": { "status_labels": {
"open": "исполняет роль", "open": "исполняет роль",
"backstage": "в закулисье", "backstage": "в закулисье",
@@ -15,9 +24,11 @@
{ {
"key": "liebe", "key": "liebe",
"button_text": "Либэ", "button_text": "Либэ",
"display_name": "LIEBE", "display_name": "𝐋𝐈𝐄𝐁𝐄",
"display_html": "𝐋𝐈𝐄𝐁𝐄",
"link": "http://t.me/harurpcmoverheardbot", "link": "http://t.me/harurpcmoverheardbot",
"pronouns": "she/her", "pronouns": "she/her",
"meta_html": "🤩 <i>𝑠𝑒/her</i>😻",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 5203570193, "operator_user_id": 5203570193,
"default_status": "backstage", "default_status": "backstage",
@@ -31,9 +42,11 @@
{ {
"key": "motsiel", "key": "motsiel",
"button_text": "Моцэ", "button_text": "Моцэ",
"display_name": "MOTSIEL", "display_name": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋",
"display_html": "𝐌𝐎𝐓𝐒𝐈𝐄𝐋",
"link": "http://t.me/OverheardRPCM_motsiel_bot", "link": "http://t.me/OverheardRPCM_motsiel_bot",
"pronouns": "he/him", "pronouns": "he/him",
"meta_html": "🤩 <i>𝑒/him</i>😻 ",
"emoji": "🌟", "emoji": "🌟",
"operator_user_ids": [1378007191, 6364049891], "operator_user_ids": [1378007191, 6364049891],
"default_status": "delay", "default_status": "delay",
@@ -47,9 +60,11 @@
{ {
"key": "astat", "key": "astat",
"button_text": "Астат", "button_text": "Астат",
"display_name": "ASTAT", "display_name": "𝐀𝐒𝐓𝐀𝐓",
"display_html": "𝐀𝐒𝐓𝐀𝐓",
"link": "http://t.me/astat_ovhdRPCM_bot", "link": "http://t.me/astat_ovhdRPCM_bot",
"pronouns": "he/him", "pronouns": "he/him",
"meta_html": "🤩 <i>𝑒/him</i>😻",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 1021293938, "operator_user_id": 1021293938,
"default_status": "backstage", "default_status": "backstage",
@@ -63,9 +78,11 @@
{ {
"key": "skafandr", "key": "skafandr",
"button_text": "Скаф", "button_text": "Скаф",
"display_name": "SKAFANDR", "display_name": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑",
"display_html": "𝐒𝐊𝐀𝐅𝐀𝐍𝐃𝐑",
"link": "http://t.me/Alxschfovhd_bot", "link": "http://t.me/Alxschfovhd_bot",
"pronouns": "he/him", "pronouns": "he/him",
"meta_html": "🤩 <i>𝑒/him</i>😻",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 7647479588, "operator_user_id": 7647479588,
"default_status": "backstage", "default_status": "backstage",
@@ -79,9 +96,11 @@
{ {
"key": "mari", "key": "mari",
"button_text": "Мари", "button_text": "Мари",
"display_name": "MARI", "display_name": "𝐌𝐀𝐑𝐈",
"display_html": "𝐌𝐀𝐑𝐈",
"link": "http://t.me/Marioverheard_bot", "link": "http://t.me/Marioverheard_bot",
"pronouns": "she/her", "pronouns": "she/her",
"meta_html": "🤩 <i>𝑠𝑒/her</i>😻",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 2114793249, "operator_user_id": 2114793249,
"default_status": "open", "default_status": "open",
@@ -95,9 +114,11 @@
{ {
"key": "marcus", "key": "marcus",
"button_text": "Маркус", "button_text": "Маркус",
"display_name": "MARCUS", "display_name": "𝐌𝐀𝐑𝐂𝐔𝐒",
"display_html": "𝐌𝐀𝐑𝐂𝐔𝐒",
"link": "http://t.me/Marcus_OVHD_bot", "link": "http://t.me/Marcus_OVHD_bot",
"pronouns": "he/him", "pronouns": "he/him",
"meta_html": "🤩 <i>𝑒/him</i>😻",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 637396085, "operator_user_id": 637396085,
"default_status": "backstage", "default_status": "backstage",
@@ -111,9 +132,11 @@
{ {
"key": "leo", "key": "leo",
"button_text": "Лео", "button_text": "Лео",
"display_name": "LEO", "display_name": "𝐋𝐄𝐎",
"display_html": "𝐋𝐄𝐎",
"link": "http://t.me/LEOoverh_bot", "link": "http://t.me/LEOoverh_bot",
"pronouns": "he/him", "pronouns": "he/him",
"meta_html": "🤩 <i>𝑒/him</i>😻",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 6730780021, "operator_user_id": 6730780021,
"default_status": "backstage", "default_status": "backstage",
@@ -127,9 +150,11 @@
{ {
"key": "lein", "key": "lein",
"button_text": "Лейн", "button_text": "Лейн",
"display_name": "LEIN", "display_name": "𝐋𝐄𝐈𝐍",
"display_html": "𝐋𝐄𝐈𝐍",
"link": "http://t.me/Lein_OVHD_Bot", "link": "http://t.me/Lein_OVHD_Bot",
"pronouns": "he/him", "pronouns": "he/him",
"meta_html": "🥹<i>𝑒/him</i> 🥹",
"emoji": "🌟", "emoji": "🌟",
"operator_user_id": 6751720805, "operator_user_id": 6751720805,
"default_status": "backstage", "default_status": "backstage",

View File

@@ -5,8 +5,9 @@ import logging
from typing import Any from typing import Any
from aiogram import Bot, Dispatcher, F, Router from aiogram import Bot, Dispatcher, F, Router
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest 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.context import FSMContext
from aiogram.fsm.state import State, StatesGroup from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage from aiogram.fsm.storage.memory import MemoryStorage
@@ -26,9 +27,27 @@ STATUS_CHOICES = {
"rest": "Антракт", "rest": "Антракт",
} }
HELP_TEXT = (
"<b>Команды</b>\n"
"/start или /panel - открыть панель\n"
"/help - показать эту справку\n"
"/refresh - принудительно обновить сообщение канала\n"
"/cancel - сбросить текущий ввод и вернуться к панели\n\n"
"<b>Как пользоваться</b>\n"
"1. Откройте панель.\n"
"2. Выберите своего актера.\n"
"3. Выберите статус.\n"
"4. Выберите вариант фразы кнопкой: шаблон, без фразы или своя.\n"
"5. Если выбрана своя фраза, отправьте текст одним сообщением.\n\n"
"<b>Настройка</b>\n"
"ENV: BOT_TOKEN, CHANNEL_ID, CHANNEL_MESSAGE_ID, ADMIN_IDS.\n"
"Актеры и шаблоны лежат в config/actors.json.\n"
"Текущие статусы сохраняются в data/state.json."
)
class SessionForm(StatesGroup): class SessionForm(StatesGroup):
waiting_for_phrase = State() waiting_for_custom_phrase = State()
def actor_operator_ids(actor: dict[str, Any]) -> set[int]: def actor_operator_ids(actor: dict[str, Any]) -> set[int]:
@@ -62,10 +81,78 @@ def build_status_keyboard(actor_key: str) -> InlineKeyboardBuilder:
keyboard = InlineKeyboardBuilder() keyboard = InlineKeyboardBuilder()
for status_key, title in STATUS_CHOICES.items(): for status_key, title in STATUS_CHOICES.items():
keyboard.button(text=title, callback_data=f"status:{actor_key}:{status_key}") keyboard.button(text=title, callback_data=f"status:{actor_key}:{status_key}")
keyboard.button(text="⬅️ Назад", callback_data="nav:panel")
keyboard.adjust(2) keyboard.adjust(2)
return keyboard 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"Выбран <b>{actor['display_name']}</b>.\nКакой статус поставить?",
keyboard.as_markup(),
)
async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonStateStorage, settings) -> None: async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonStateStorage, settings) -> None:
state = state_storage.load() state = state_storage.load()
text = build_channel_text(app_config, state) 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, chat_id=settings.channel_id,
message_id=settings.channel_message_id, message_id=settings.channel_message_id,
text=text, text=text,
parse_mode=ParseMode.HTML,
disable_web_page_preview=True, disable_web_page_preview=True,
) )
@router.message(CommandStart()) @router.message(CommandStart())
@router.message(Command("panel"))
async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None: async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None:
await state.clear() await state.clear()
user_id = message.from_user.id 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("У вас нет доступа к этой панели.") await message.answer("У вас нет доступа к этой панели.")
return return
keyboard = build_actor_keyboard(app_config, user_id, settings.admin_ids) await show_panel(message, user_id, app_config, settings)
await message.answer("Выберите персонажа для смены статуса.", reply_markup=keyboard.as_markup())
@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:")) @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 user_id = callback.from_user.id
if not is_allowed(user_id, actor_lookup, settings.admin_ids): if not is_allowed(user_id, actor_lookup, settings.admin_ids):
await callback.answer("Нет доступа.", show_alert=True) await callback.answer("Нет доступа.", show_alert=True)
return return
actor_key = callback.data.split(":", maxsplit=1)[1] 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: if actor is None:
await callback.answer("Персонаж не найден.", show_alert=True) await callback.answer("Персонаж не найден.", show_alert=True)
return return
@@ -107,16 +239,152 @@ async def actor_handler(callback: CallbackQuery, settings, actor_lookup: dict, a
await callback.answer("Можно менять только свой статус.", show_alert=True) await callback.answer("Можно менять только свой статус.", show_alert=True)
return return
keyboard = build_status_keyboard(actor_key) await show_actor_status_menu(callback, actor)
await callback.message.edit_text( await callback.answer()
f"Выбран {actor['display_name']}. Какой статус поставить?",
reply_markup=keyboard.as_markup(),
) @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() await callback.answer()
@router.callback_query(F.data.startswith("status:")) @router.callback_query(F.data.startswith("status:"))
async def status_handler( 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"Статус для <b>{actor['display_name']}</b>: <b>{STATUS_CHOICES[status_key]}</b>.",
"Выберите, как оформить подпись.",
]
if default_phrase:
prompt_parts.append(f"Шаблон: <code>{default_phrase}</code>")
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<code>{exc}</code>",
build_back_to_panel_keyboard().as_markup(),
)
await state.clear()
return
await state.clear()
await safe_edit_message(
callback,
f"Обновлено: <b>{actor['display_name']}</b> -> <b>{STATUS_CHOICES[status_key].lower()}</b>.",
build_back_to_panel_keyboard().as_markup(),
)
await callback.answer("Готово")
@router.callback_query(F.data.startswith("custom:"))
async def custom_phrase_handler(
callback: CallbackQuery, callback: CallbackQuery,
state: FSMContext, state: FSMContext,
settings, settings,
@@ -129,7 +397,7 @@ async def status_handler(
return return
_, actor_key, status_key = callback.data.split(":") _, 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: if actor is None:
await callback.answer("Персонаж не найден.", show_alert=True) await callback.answer("Персонаж не найден.", show_alert=True)
return return
@@ -138,21 +406,21 @@ async def status_handler(
await callback.answer("Можно менять только свой статус.", show_alert=True) await callback.answer("Можно менять только свой статус.", show_alert=True)
return return
default_phrase = actor.get("phrases", {}).get(status_key, "") await state.set_state(SessionForm.waiting_for_custom_phrase)
await state.set_state(SessionForm.waiting_for_phrase)
await state.update_data(actor_key=actor_key, status_key=status_key) await state.update_data(actor_key=actor_key, status_key=status_key)
await safe_edit_message(
prompt = ( callback,
f"Статус для {actor['display_name']}: {STATUS_CHOICES[status_key]}.\n" (
"Отправьте фразу для публикации.\n" f"Введите свою фразу для <b>{actor['display_name']}</b>.\n"
"Если хотите использовать шаблон, отправьте точку: .\n" "Она будет добавлена под строкой статуса.\n"
f"Шаблон сейчас: {default_phrase or 'не задан'}" "Если передумали, нажмите кнопку ниже."
),
build_phrase_keyboard(actor_key, status_key, actor.get("phrases", {}).get(status_key, "")).as_markup(),
) )
await callback.message.edit_text(prompt)
await callback.answer() await callback.answer()
@router.message(SessionForm.waiting_for_phrase) @router.message(SessionForm.waiting_for_custom_phrase)
async def phrase_handler( async def phrase_handler(
message: Message, message: Message,
state: FSMContext, state: FSMContext,
@@ -168,31 +436,43 @@ async def phrase_handler(
data = await state.get_data() data = await state.get_data()
actor_key = data["actor_key"] actor_key = data["actor_key"]
status_key = data["status_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() phrase = message.text.strip()
if phrase == ".": if not phrase:
phrase = actor.get("phrases", {}).get(status_key, "") await message.answer("Фраза не должна быть пустой.")
return
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)
try: 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: except TelegramBadRequest as exc:
logging.exception("Failed to edit channel message") logging.exception("Failed to edit channel message")
await message.answer(f"Статус сохранен, но сообщение канала не обновилось: {exc}") await message.answer(
f"Статус сохранен, но сообщение канала не обновилось:\n<code>{exc}</code>",
parse_mode=ParseMode.HTML,
)
await state.clear() await state.clear()
return return
await state.clear() await state.clear()
await message.answer(f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.") await message.answer(
f"Обновлено: <b>{actor['display_name']}</b> -> <b>{STATUS_CHOICES[status_key].lower()}</b>.",
parse_mode=ParseMode.HTML,
reply_markup=build_back_to_panel_keyboard().as_markup(),
)
def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher: def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher:

View File

@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from html import escape
DEFAULT_STATUS_LABELS = { DEFAULT_STATUS_LABELS = {
"open": "исполняет роль", "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: def build_channel_text(config: dict, state: dict) -> str:
parts: list[str] = [] parts: list[str] = []
header = config.get("header", "").strip() for html_key, plain_key in (
if header: ("header_html", "header"),
parts.append(header) ("intro_links_html", "intro_links"),
("projects_block_html", "projects_block"),
intro_links = config.get("intro_links", "").strip() ("actors_title_html", "actors_title"),
if intro_links: ):
parts.append(intro_links) block = _get_block(config, html_key, plain_key)
if block:
projects = config.get("projects_block", "").strip() parts.append(block)
if projects:
parts.append(projects)
actors_title = config.get("actors_title", "").strip()
if actors_title:
parts.append(actors_title)
actor_lines: list[str] = [] actor_lines: list[str] = []
actor_state = state.get("actors", {}) 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, "")) phrase = current.get("phrase", actor.get("phrases", {}).get(status, ""))
label = status_labels.get(status, 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 = ( line = (
f'{actor["emoji"]} {actor["display_name"]} ({actor["link"]}) ' f'{emoji} <a href="{escape(actor["link"], quote=True)}"><b>{name_html}</b></a>'
f'{actor["pronouns"]} {label}.' f"{meta_html}{escape(label)}."
) )
if phrase: if phrase:
line = f"{line}\n {phrase}" line = f"{line}\n {escape(phrase)}"
actor_lines.append(line) actor_lines.append(line)
actor_lines.extend(config.get("static_actor_lines_html", []))
parts.append("\n".join(actor_lines)) parts.append("\n".join(actor_lines))
legend = config.get("legend", "").strip() for html_key, plain_key in (
if legend: ("legend_html", "legend"),
parts.append(legend) ("footer_html", "footer"),
):
footer = config.get("footer", "").strip() block = _get_block(config, html_key, plain_key)
if footer: if block:
parts.append(footer) parts.append(block)
return "\n\n".join(part for part in parts if part) return "\n\n".join(part for part in parts if part)

View File

@@ -3,7 +3,7 @@ from session_bot.render import build_channel_text
def test_build_channel_text_includes_phrase_and_status() -> None: def test_build_channel_text_includes_phrase_and_status() -> None:
config = { config = {
"header": "header", "header_html": "<b>header</b>",
"actors": [ "actors": [
{ {
"key": "astat", "key": "astat",
@@ -21,6 +21,7 @@ def test_build_channel_text_includes_phrase_and_status() -> None:
text = build_channel_text(config, state) text = build_channel_text(config, state)
assert "ASTAT" in text assert "<b>header</b>" in text
assert '<a href="https://t.me/example"><b>ASTAT</b></a>' in text
assert "исполняет роль" in text assert "исполняет роль" in text
assert "готов к игре" in text assert "готов к игре" in text