diff --git a/README.md b/README.md
index adc3c6c..527c53d 100644
--- a/README.md
+++ b/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
```
diff --git a/config/actors.json b/config/actors.json
index a75de7e..b3ca732 100644
--- a/config/actors.json
+++ b/config/actors.json
@@ -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": " 𝘳𝘶𝘭𝘦𝘴 😻 𝗉𝐥𝐨𝗍 😻 𝖻𝗈𝗈𝗌𝗍\n 😻 𝖱𝖳 𝐩𝗈𝗌𝗍 𝖬𝖯 𝗂𝗇𝖿𝗈 𝒑𝑟𝑖𝑐𝑒 😻",
"projects_block_html": "🌟🌟🌟 𝐊аmалᦢги⠘\n🌟 𝖱𝖯 𝗉𝗋𝗈𝗃𝖾𝖼𝗍𝗌 & 𝖱𝖯𝖢𝖬 𝖼𝗁𝖺𝗇𝗇𝖾𝗅𝗌",
@@ -26,9 +28,11 @@
"button_text": "Либэ",
"display_name": "𝐋𝐈𝐄𝐁𝐄",
"display_html": "𝐋𝐈𝐄𝐁𝐄",
+ "display_md": "𝐋𝐈𝐄𝐁𝐄",
"link": "http://t.me/harurpcmoverheardbot",
"pronouns": "she/her",
"meta_html": "🤩 𝑠ℎ𝑒/her😻",
+ "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": "🤩 ℎ𝑒/him😻 ",
+ "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": "🤩 ℎ𝑒/him😻",
+ "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": "🤩 ℎ𝑒/him😻",
+ "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": "🤩 𝑠ℎ𝑒/her😻",
+ "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": "🤩 ℎ𝑒/him😻",
+ "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": "🤩 ℎ𝑒/him😻",
+ "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": "🥹ℎ𝑒/him 🥹",
+ "meta_md": "🥹ℎ𝑒/him 🥹",
"emoji": "🌟",
"operator_user_id": 6751720805,
"default_status": "backstage",
diff --git a/session_bot/bot.py b/session_bot/bot.py
index 1cda961..83d5891 100644
--- a/session_bot/bot.py
+++ b/session_bot/bot.py
@@ -28,26 +28,28 @@ STATUS_CHOICES = {
}
HELP_TEXT = (
- "Команды\n"
+ "Команды\n"
"/start или /panel - открыть панель\n"
- "/help - показать эту справку\n"
- "/refresh - принудительно обновить сообщение канала\n"
- "/cancel - сбросить текущий ввод и вернуться к панели\n\n"
- "Как пользоваться\n"
+ "/help - показать справку\n"
+ "/refresh - перерисовать пост в канале\n"
+ "/cancel - сбросить текущий ввод\n"
+ "/post - сохранить шаблон поста с плейсхолдером {{actors}}\n\n"
+ "Как пользоваться\n"
"1. Откройте панель.\n"
"2. Выберите своего актера.\n"
- "3. Выберите статус.\n"
- "4. Выберите вариант фразы кнопкой: шаблон, без фразы или своя.\n"
- "5. Если выбрана своя фраза, отправьте текст одним сообщением.\n\n"
- "Настройка\n"
- "ENV: BOT_TOKEN, CHANNEL_ID, CHANNEL_MESSAGE_ID, ADMIN_IDS.\n"
- "Актеры и шаблоны лежат в config/actors.json.\n"
- "Текущие статусы сохраняются в data/state.json."
+ "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"Выбран {actor['display_name']}.\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"Статус для {actor['display_name']}: {STATUS_CHOICES[status_key]}.",
- "Выберите, как оформить подпись.",
- ]
- if default_phrase:
- prompt_parts.append(f"Шаблон: {default_phrase}")
- else:
- prompt_parts.append("Шаблон не задан.")
-
- await safe_edit_message(
- callback,
- "\n".join(prompt_parts),
- build_phrase_keyboard(actor_key, status_key, default_phrase).as_markup(),
- )
- await callback.answer()
-
-
-async def apply_status_update(
- bot: Bot,
- actor_key: str,
- status_key: str,
- phrase: str,
- user_id: int,
- app_config: dict,
- state_storage: JsonStateStorage,
- settings,
-) -> None:
- payload = state_storage.load()
- payload.setdefault("actors", {})
- payload["actors"][actor_key] = {
- "status": status_key,
- "phrase": phrase,
- "updated_by": user_id,
- }
- state_storage.save(payload)
- await update_channel_post(bot, app_config, state_storage, settings)
-
-
-@router.callback_query(F.data.startswith("apply:"))
-async def apply_phrase_handler(
- callback: CallbackQuery,
- state: FSMContext,
- bot: Bot,
- app_config: dict,
- state_storage: JsonStateStorage,
- settings,
- actor_lookup: dict,
-) -> None:
- user_id = callback.from_user.id
- if not is_allowed(user_id, actor_lookup, settings.admin_ids):
- await callback.answer("Нет доступа.", show_alert=True)
- return
-
- _, mode, actor_key, status_key = callback.data.split(":")
- actor = find_actor(app_config, actor_key)
- if actor is None:
- await callback.answer("Персонаж не найден.", show_alert=True)
- return
-
- if user_id not in settings.admin_ids and user_id not in actor_operator_ids(actor):
- await callback.answer("Можно менять только свой статус.", show_alert=True)
- return
-
- phrase = ""
- if mode == "template":
- phrase = actor.get("phrases", {}).get(status_key, "")
-
try:
await apply_status_update(
bot=bot,
actor_key=actor_key,
status_key=status_key,
- phrase=phrase,
+ 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{exc}",
+ f"Статус сохранен, но сообщение канала не обновилось:\n{exc}",
build_back_to_panel_keyboard().as_markup(),
)
- await state.clear()
return
- await state.clear()
await safe_edit_message(
callback,
- f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.",
+ 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"Введите свою фразу для {actor['display_name']}.\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{exc}",
- parse_mode=ParseMode.HTML,
- )
+ await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}")
await state.clear()
return
await state.clear()
await message.answer(
- f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.",
- 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
diff --git a/session_bot/render.py b/session_bot/render.py
index 4ee0834..f6eeb4b 100644
--- a/session_bot/render.py
+++ b/session_bot/render.py
@@ -1,7 +1,5 @@
from __future__ import annotations
-from html import escape
-
DEFAULT_STATUS_LABELS = {
"open": "исполняет роль",
@@ -10,64 +8,83 @@ DEFAULT_STATUS_LABELS = {
"rest": "антракт",
}
-
-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 ""
+MARKDOWN_V2_SPECIALS = r"_*[]()~`>#+-=|{}.!"
-def build_channel_text(config: dict, state: dict) -> str:
- parts: list[str] = []
+def escape_markdown_v2(value: str) -> str:
+ return "".join(f"\\{char}" if char in MARKDOWN_V2_SPECIALS else char for char in value)
- 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 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_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} {name_html}'
- 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
diff --git a/tests/test_render.py b/tests/test_render.py
index 59169fb..3b2e5bb 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -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": "header",
+ "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 "header" in text
- assert 'ASTAT' in text
+ assert "[](https://example.com/image.png)" in text
+ assert "[ASTAT](https://t.me/example)" in text
assert "исполняет роль" in text
assert "готов к игре" in text