12312421
This commit is contained in:
17
README.md
17
README.md
@@ -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`.
|
|
||||||
|
|
||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
|
|||||||
@@ -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> & <a href=\"https://t.me/archiveOverheardRPCM/18\">𝖱𝖯𝖢𝖬 𝖼𝗁𝖺𝗇𝗇𝖾𝗅𝗌</a>",
|
||||||
|
"actors_title_html": "﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n"𝓣ℎ𝑒 𝔇𝑒𝑎𝑟𝑒𝑠𝑡 🎀𝑐𝑡𝑜𝑟𝑠" 😻𝖠𝖣𝖬𝖨𝖭𝖨𝖲𝖳𝖱𝖠𝖳𝖨𝖮𝖭🌟",
|
||||||
|
"legend_html": "🌟исполняет роль 😻 принимает тейки\nв закулисье 😻 не принимает тейки \nантракт 😻 рест🌟",
|
||||||
|
"footer_html": "﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋﹋\n😻 <a href=\"https://t.me/lifeOverheardPRCM\">🤩𝖨𝖥𝖤 𝖢𝖧𝖠𝖭𝖭𝖤𝖫</a> & <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",
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user