From 8fb6da84aef453dda1a850a0c330828e39bbd2fc Mon Sep 17 00:00:00 2001 From: Verum Date: Thu, 2 Apr 2026 21:32:26 +0700 Subject: [PATCH] 21 --- session_bot/bot.py | 123 +++++++++++++++++++++++------------------- session_bot/config.py | 23 ++++---- session_bot/render.py | 64 +++++++++++++++------- tests/test_render.py | 36 +++++++++++++ 4 files changed, 160 insertions(+), 86 deletions(-) diff --git a/session_bot/bot.py b/session_bot/bot.py index efc7d69..d84944c 100644 --- a/session_bot/bot.py +++ b/session_bot/bot.py @@ -34,17 +34,13 @@ HELP_TEXT = ( "/help - показать справку\n" "/refresh - перерисовать пост в канале\n" "/cancel - сбросить текущий ввод\n" - "/post - сохранить шаблон поста с плейсхолдером {{actors}}\n\n" - "Как пользоваться\n" - "1. Откройте панель.\n" - "2. Выберите своего актера.\n" - "3. Нажмите один из статусов. Он применится сразу с шаблонной фразой.\n" - "4. Если нужен свой текст, нажмите 'Своя фраза' и отправьте его.\n\n" + "/post - сохранить шаблон поста\n" + "/template_dump - показать HTML из сообщения-реплая\n\n" "Шаблон поста\n" - "В шаблоне должен быть {{actors}} - туда бот подставляет актерский блок.\n" - "Можно добавить {{hidden_link}} в начало, либо указать hidden_link_url в config/actors.json.\n" - "Шаблон лучше отправлять обычным текстом или реплаем на текстовый пост.\n" - "Точное восстановление исходного MarkdownV2 из пересланного оформленного поста Telegram не гарантируется." + "Можно использовать один общий {{actors}} для всего блока актеров.\n" + "Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n" + "Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n" + "Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение." ) @@ -90,6 +86,12 @@ def build_status_keyboard(actor_key: str) -> InlineKeyboardBuilder: return keyboard +def build_back_to_actor_keyboard(actor_key: str) -> InlineKeyboardBuilder: + keyboard = InlineKeyboardBuilder() + keyboard.button(text="⬅️ Назад", callback_data=f"actor:{actor_key}") + return keyboard + + def build_back_to_panel_keyboard() -> InlineKeyboardBuilder: keyboard = InlineKeyboardBuilder() keyboard.button(text="К панели", callback_data="nav:panel") @@ -122,15 +124,34 @@ def normalize_template_placeholders(template: str) -> str: normalized = re.sub(r"\\\{\\\{\s*actors\s*\\\}\\\}", "{{actors}}", normalized, flags=re.IGNORECASE) normalized = re.sub(r"\{\{\s*actors\s*\}\}", "{{actors}}", normalized, flags=re.IGNORECASE) normalized = re.sub( - r"\\\{\\\{\s*hidden_link\s*\\\}\\\}", - "{{hidden_link}}", + r"\\\{\\\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\\\}\\\}", + lambda match: f"{{{{actor:{match.group(1).lower()}}}}}", normalized, flags=re.IGNORECASE, ) + normalized = re.sub( + r"\{\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\}\}", + lambda match: f"{{{{actor:{match.group(1).lower()}}}}}", + normalized, + flags=re.IGNORECASE, + ) + normalized = re.sub(r"\\\{\\\{\s*hidden_link\s*\\\}\\\}", "{{hidden_link}}", normalized, flags=re.IGNORECASE) normalized = re.sub(r"\{\{\s*hidden_link\s*\}\}", "{{hidden_link}}", normalized, flags=re.IGNORECASE) return normalized +def validate_template_structure(template: str) -> str | None: + normalized = normalize_template_placeholders(template) + common_count = normalized.count("{{actors}}") + specific_count = len(re.findall(r"\{\{actor:[a-z0-9_\-]+\}\}", normalized, flags=re.IGNORECASE)) + + if common_count == 0 and specific_count == 0: + return "Шаблон сохранен, но в нем нет {{actors}} или {{actor:key}}." + if common_count > 1: + return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}." + return None + + async def safe_edit_message(callback: CallbackQuery, text: str, reply_markup=None) -> None: try: await callback.message.edit_text( @@ -155,11 +176,7 @@ 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], - state_storage: JsonStateStorage, -) -> None: +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, "")) @@ -213,26 +230,20 @@ def save_post_template(state_storage: JsonStateStorage, template: str) -> None: state_storage.save(payload) -def count_actor_placeholders(template: str) -> int: - return normalize_template_placeholders(template).count("{{actors}}") - - @router.message(CommandStart()) @router.message(Command("panel")) async def start_handler(message: Message, state: FSMContext, app_config: dict, actor_lookup: dict, settings) -> None: await state.clear() user_id = message.from_user.id - 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("help")) async def help_handler(message: Message) -> None: - await message.answer(HELP_TEXT, disable_web_page_preview=True) + await message.answer(HELP_TEXT) @router.message(Command("cancel")) @@ -263,6 +274,22 @@ async def refresh_handler( await message.answer("Сообщение канала обновлено.") +@router.message(Command("template_dump")) +async def template_dump_handler(message: Message, 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 + if message.reply_to_message is None: + await message.answer("Используйте /template_dump ответом на оформленное сообщение.") + return + source = message.reply_to_message + if source.html_text: + await message.answer(source.html_text) + return + await message.answer("У сообщения нет HTML-представления, которое можно извлечь.") + + @router.message(Command("post")) async def post_handler( message: Message, @@ -281,32 +308,26 @@ async def post_handler( normalized = normalize_template_placeholders(template) save_post_template(state_storage, normalized) await state.clear() - placeholder_count = count_actor_placeholders(normalized) - if placeholder_count == 0: - await message.answer( - "Шаблон сохранен, но в нем нет {{actors}}.\n" - "Плейсхолдер должен быть именно {{actors}} в любом регистре." - ) - return - if placeholder_count > 1: - await message.answer( - "Шаблон сохранен, но в нем несколько {{actors}}.\n" - "Нужен только один общий {{actors}} на месте всего блока актеров, иначе список будет дублироваться." - ) + + validation_error = validate_template_structure(normalized) + if validation_error: + await message.answer(validation_error) return + if message.text and message.text.startswith("/post "): await message.answer( "Шаблон сохранен.\n" - "Но если нужна разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение, а не вставлять шаблон текстом после команды." + "Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение." ) return + await message.answer("Шаблон поста сохранен.") return await state.set_state(SessionForm.waiting_for_post_template) await message.answer( - "Перешлите или отправьте текст шаблона одним сообщением.\n" - "Внутри должен быть {{actors}}.\n" + "Перешлите или отправьте шаблон одним сообщением.\n" + "Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n" "Для скрытой ссылки можно использовать {{hidden_link}}." ) @@ -436,10 +457,9 @@ async def custom_phrase_handler( callback, ( f"Введите свою фразу для {actor['display_name']}.\n" - f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}.\n" - "Если хотите сначала сменить статус, вернитесь назад." + f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}." ), - build_status_keyboard(actor_key).as_markup(), + build_back_to_actor_keyboard(actor_key).as_markup(), ) await callback.answer() @@ -510,18 +530,9 @@ async def post_template_handler( save_post_template(state_storage, normalized) await state.clear() - placeholder_count = count_actor_placeholders(normalized) - if placeholder_count == 0: - await message.answer( - "Шаблон сохранен, но в нем нет {{actors}}.\n" - "Плейсхолдер должен быть именно {{actors}} в любом регистре." - ) - return - if placeholder_count > 1: - await message.answer( - "Шаблон сохранен, но в нем несколько {{actors}}.\n" - "Нужен только один общий {{actors}} на месте всего блока актеров, иначе список будет дублироваться." - ) + validation_error = validate_template_structure(normalized) + if validation_error: + await message.answer(validation_error) return await message.answer("Шаблон поста сохранен.") @@ -541,6 +552,10 @@ async def main() -> None: logging.basicConfig(level=logging.INFO) settings = load_settings() app_config = load_actor_config(settings.config_path) + if settings.hidden_link_url: + app_config["hidden_link_url"] = settings.hidden_link_url + if settings.hidden_link_char: + app_config["hidden_link_char"] = settings.hidden_link_char state_storage = JsonStateStorage(settings.state_path) bot = Bot(token=settings.bot_token) diff --git a/session_bot/config.py b/session_bot/config.py index 1202198..a24c65e 100644 --- a/session_bot/config.py +++ b/session_bot/config.py @@ -16,6 +16,8 @@ class BotSettings: config_path: Path state_path: Path admin_ids: set[int] + hidden_link_url: str + hidden_link_char: str def _parse_admin_ids(value: str) -> set[int]: @@ -30,20 +32,15 @@ def _parse_admin_ids(value: str) -> set[int]: def load_settings() -> BotSettings: load_dotenv() - bot_token = os.environ["BOT_TOKEN"] - channel_id = int(os.environ["CHANNEL_ID"]) - channel_message_id = int(os.environ["CHANNEL_MESSAGE_ID"]) - config_path = Path(os.environ.get("CONFIG_PATH", "config/actors.json")) - state_path = Path(os.environ.get("STATE_PATH", "data/state.json")) - admin_ids = _parse_admin_ids(os.environ.get("ADMIN_IDS", "")) - return BotSettings( - bot_token=bot_token, - channel_id=channel_id, - channel_message_id=channel_message_id, - config_path=config_path, - state_path=state_path, - admin_ids=admin_ids, + bot_token=os.environ["BOT_TOKEN"], + channel_id=int(os.environ["CHANNEL_ID"]), + channel_message_id=int(os.environ["CHANNEL_MESSAGE_ID"]), + config_path=Path(os.environ.get("CONFIG_PATH", "config/actors.json")), + state_path=Path(os.environ.get("STATE_PATH", "data/state.json")), + admin_ids=_parse_admin_ids(os.environ.get("ADMIN_IDS", "")), + hidden_link_url=os.environ.get("HIDDEN_LINK_URL", ""), + hidden_link_char=os.environ.get("HIDDEN_LINK_CHAR", "​"), ) diff --git a/session_bot/render.py b/session_bot/render.py index 33e6790..b357b76 100644 --- a/session_bot/render.py +++ b/session_bot/render.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from html import escape @@ -10,6 +11,8 @@ DEFAULT_STATUS_LABELS = { "rest": "антракт", } +ACTOR_PLACEHOLDER_RE = re.compile(r"\{\{\s*actor\s*:\s*([a-z0-9_\-]+)\s*\}\}", re.IGNORECASE) + def build_hidden_link(config: dict) -> str: url = config.get("hidden_link_url", "").strip() @@ -19,33 +22,55 @@ def build_hidden_link(config: dict) -> str: return f'{invisible}' -def build_actor_lines(config: dict, state: dict) -> str: - actor_lines: list[str] = [] +def build_actor_line(actor: dict, state: dict, config: dict) -> str: actor_state = state.get("actors", {}) status_labels = {**DEFAULT_STATUS_LABELS, **config.get("status_labels", {})} + 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) + display_name = actor.get("display_html", escape(actor["display_name"])) + meta = actor.get("meta_html", escape(actor["pronouns"])) + emoji = actor.get("emoji_html", escape(actor.get("emoji", ""))) + + line = ( + f'{emoji} ' + f'{display_name}' + f"{meta}{escape(label)}." + ) + if phrase: + line = f"{line}\n {escape(phrase)}" + return line + + +def build_actor_lines(config: dict, state: dict, skip_keys: set[str] | None = None) -> str: + actor_lines: list[str] = [] + skip_keys = skip_keys or set() 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) - display_name = actor.get("display_html", escape(actor["display_name"])) - meta = actor.get("meta_html", escape(actor["pronouns"])) - emoji = actor.get("emoji_html", escape(actor.get("emoji", ""))) - - line = ( - f'{emoji} ' - f'{display_name}' - f"{meta}{escape(label)}." - ) - if phrase: - line = f"{line}\n {escape(phrase)}" - actor_lines.append(line) + if actor["key"] in skip_keys: + continue + actor_lines.append(build_actor_line(actor, state, config)) actor_lines.extend(config.get("static_actor_lines_html", [])) return "\n".join(actor_lines) +def replace_actor_placeholders(template: str, config: dict, state: dict) -> tuple[str, set[str]]: + used_keys: set[str] = set() + actors_by_key = {actor["key"].lower(): actor for actor in config["actors"]} + + def repl(match: re.Match[str]) -> str: + actor_key = match.group(1).lower() + actor = actors_by_key.get(actor_key) + if actor is None: + return match.group(0) + used_keys.add(actor["key"]) + return build_actor_line(actor, state, config) + + return ACTOR_PLACEHOLDER_RE.sub(repl, template), used_keys + + def build_default_template(config: dict) -> str: blocks = [] hidden = build_hidden_link(config) @@ -69,7 +94,8 @@ def build_default_template(config: dict) -> str: 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) + template, used_keys = replace_actor_placeholders(template, config, state) + actors_block = build_actor_lines(config, state, skip_keys=used_keys) hidden_link = build_hidden_link(config) text = template.replace("{{actors}}", actors_block) diff --git a/tests/test_render.py b/tests/test_render.py index 920cd12..57694bf 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -28,3 +28,39 @@ def test_build_channel_text_includes_phrase_and_status() -> None: assert 'ASTAT' in text assert "исполняет роль" in text assert "готов к игре" in text + + +def test_build_channel_text_supports_per_actor_placeholders() -> None: + config = { + "template_text": "HEAD\n\n{{actor:astat}}\n\nMID\n\n{{actor:mari}}\n\nTAIL", + "actors": [ + { + "key": "astat", + "display_name": "ASTAT", + "display_html": "ASTAT", + "link": "https://t.me/astat", + "pronouns": "he/him", + "meta_html": " he/him ", + "emoji": "🌟", + "default_status": "backstage", + "phrases": {"backstage": "в закулисье."}, + }, + { + "key": "mari", + "display_name": "MARI", + "display_html": "MARI", + "link": "https://t.me/mari", + "pronouns": "she/her", + "meta_html": " she/her ", + "emoji": "🌟", + "default_status": "open", + "phrases": {"open": "исполняет роль."}, + }, + ], + } + state = {"actors": {}} + + text = build_channel_text(config, state) + + assert text.count("ASTAT") == 1 + assert text.count("MARI") == 1