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