From a13f4e378ca82eec778037562506362e6110aa01 Mon Sep 17 00:00:00 2001 From: Verum Date: Fri, 3 Apr 2026 01:03:52 +0700 Subject: [PATCH] =?UTF-8?q?=D0=B8=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 + pyproject.toml | 1 + session_bot/bot.py | 182 +++++++++++++++++------------- session_bot/config.py | 14 +++ session_bot/render.py | 11 +- session_bot/telethon_publisher.py | 159 ++++++++++++++++++++++++++ tests/test_render.py | 13 +++ 7 files changed, 308 insertions(+), 78 deletions(-) create mode 100644 session_bot/telethon_publisher.py diff --git a/.env.example b/.env.example index 3e5f4d3..4b5c702 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,9 @@ CHANNEL_MESSAGE_ID= ADMIN_IDS= CONFIG_PATH=config/actors.json STATE_PATH=data/state.json +HIDDEN_LINK_URL= +HIDDEN_LINK_CHAR=​ +TELETHON_API_ID= +TELETHON_API_HASH= +TELETHON_SESSION_STRING= +TELETHON_CHANNEL= diff --git a/pyproject.toml b/pyproject.toml index 272fa90..f970afc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.11" dependencies = [ "aiogram>=3.21.0,<4.0.0", "python-dotenv>=1.0.1,<2.0.0", + "telethon>=1.42.0,<2.0.0", ] [project.optional-dependencies] diff --git a/session_bot/bot.py b/session_bot/bot.py index a3f5045..9c46f0d 100644 --- a/session_bot/bot.py +++ b/session_bot/bot.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from __future__ import annotations import asyncio import logging @@ -19,30 +19,31 @@ from session_bot.config import load_actor_config, load_settings from session_bot.html_entities import html_to_text_entities from session_bot.render import build_channel_text from session_bot.storage import JsonStateStorage +from session_bot.telethon_publisher import TelethonPublisher router = Router() STATUS_CHOICES = { - "open": "Открыть", - "backstage": "Закулисье", - "delay": "Задержка", - "rest": "Антракт", + "open": "Открыть", + "backstage": "Закулисье", + "delay": "Задержка", + "rest": "Антракт", } HELP_TEXT = ( - "Команды\n" - "/start или /panel - открыть панель\n" - "/help - показать справку\n" - "/refresh - перерисовать пост в канале\n" - "/post_test - отправить текущий пост себе в личку для проверки\n" - "/cancel - сбросить текущий ввод\n" - "/post - сохранить шаблон поста\n" - "/template_dump - показать HTML из сообщения-реплая\n\n" - "Шаблон поста\n" - "Можно использовать один общий {{actors}} для всего блока актеров.\n" - "Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n" - "Можно добавить {{hidden_link}} в начало, либо задать HIDDEN_LINK_URL в .env.\n" - "Если нужна готовая Telegram-разметка, используйте /post ответом на уже оформленное сообщение." + "Команды\n" + "/start или /panel - открыть панель\n" + "/help - показать справку\n" + "/refresh - перерисовать РїРѕСЃС‚ РІ канале\n" + "/post_test - отправить текущий РїРѕСЃС‚ себе РІ личку для проверки\n" + "/cancel - сбросить текущий РІРІРѕРґ\n" + "/post - сохранить шаблон поста\n" + "/template_dump - показать HTML РёР· сообщения-реплая\n\n" + "Шаблон поста\n" + "РњРѕР¶РЅРѕ использовать РѕРґРёРЅ общий {{actors}} для всего блока актеров.\n" + "Или точечные плейсхолдеры: {{actor:liebe}}, {{actor:mari}}.\n" + "РњРѕР¶РЅРѕ добавить {{hidden_link}} РІ начало, либо задать HIDDEN_LINK_URL РІ .env.\n" + "Если РЅСѓР¶РЅР° готовая Telegram-разметка, используйте /post ответом РЅР° СѓР¶Рµ оформленное сообщение." ) @@ -82,21 +83,21 @@ 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.button(text="РЎРІРѕСЏ фраза", callback_data=f"custom:{actor_key}") + keyboard.button(text="в¬…пёЏ Назад", callback_data="nav:panel") keyboard.adjust(2) return keyboard def build_back_to_actor_keyboard(actor_key: str) -> InlineKeyboardBuilder: keyboard = InlineKeyboardBuilder() - keyboard.button(text="⬅️ Назад", callback_data=f"actor:{actor_key}") + 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") + keyboard.button(text="Рљ панели", callback_data="nav:panel") return keyboard @@ -161,9 +162,9 @@ def validate_template_structure(template: str) -> str | None: 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}}." + return "Шаблон сохранен, РЅРѕ РІ нем нет {{actors}} или {{actor:key}}." if common_count > 1: - return "Шаблон сохранен, но в нем несколько {{actors}}. Нужен только один общий {{actors}}." + return "Шаблон сохранен, РЅРѕ РІ нем несколько {{actors}}. Нужен только РѕРґРёРЅ общий {{actors}}." return None @@ -181,7 +182,7 @@ async def safe_edit_message(callback: CallbackQuery, text: str, reply_markup=Non 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 = "Выберите персонажа для смены статуса." + text = "Выберите персонажа для смены статуса." if isinstance(target, CallbackQuery): await safe_edit_message(target, text, keyboard.as_markup()) @@ -197,16 +198,22 @@ async def show_actor_status_menu(callback: CallbackQuery, actor: dict[str, Any], 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)}.", + f"Выбран {actor['display_name']}.", + f"Текущий статус: {STATUS_CHOICES.get(current_status, current_status)}.", ] if current_phrase: - lines.append(f"Текущая фраза: {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: +async def update_channel_post( + bot: Bot, + app_config: dict, + state_storage: JsonStateStorage, + settings, + mtproto_publisher: TelethonPublisher | None = None, +) -> None: state = state_storage.load() if "template" in state and "text" in state["template"]: normalized_template = normalize_template_placeholders(state["template"]["text"]) @@ -214,6 +221,10 @@ async def update_channel_post(bot: Bot, app_config: dict, state_storage: JsonSta state["template"]["text"] = normalized_template state_storage.save(state) + if mtproto_publisher and mtproto_publisher.enabled: + await mtproto_publisher.edit_channel_post(app_config, state_storage, settings.channel_message_id) + return + link_preview_options = None if app_config.get("hidden_link_url", "").strip(): link_preview_options = LinkPreviewOptions( @@ -263,6 +274,7 @@ async def apply_status_update( app_config: dict, state_storage: JsonStateStorage, settings, + mtproto_publisher: TelethonPublisher | None = None, ) -> None: payload = state_storage.load() payload.setdefault("actors", {}) @@ -272,7 +284,7 @@ async def apply_status_update( "updated_by": user_id, } state_storage.save(payload) - await update_channel_post(bot, app_config, state_storage, settings) + await update_channel_post(bot, app_config, state_storage, settings, mtproto_publisher) def save_post_template(state_storage: JsonStateStorage, template: str) -> None: @@ -311,7 +323,7 @@ async def start_handler(message: Message, state: FSMContext, app_config: dict, a await state.clear() user_id = message.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await message.answer("У вас нет доступа к этой панели.") + await message.answer("РЈ вас нет доступа Рє этой панели.") return await show_panel(message, user_id, app_config, settings) @@ -326,7 +338,7 @@ async def cancel_handler(message: Message, state: FSMContext, app_config: dict, await state.clear() user_id = message.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await message.answer("Состояние очищено.") + await message.answer("Состояние очищено.") return await show_panel(message, user_id, app_config, settings) @@ -339,14 +351,15 @@ async def refresh_handler( state_storage: JsonStateStorage, actor_lookup: dict, settings, + mtproto_publisher: TelethonPublisher | None = None, ) -> None: user_id = message.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await message.answer("У вас нет доступа к обновлению.") + await message.answer("РЈ вас нет доступа Рє обновлению.") return - await update_channel_post(bot, app_config, state_storage, settings) - await message.answer("Сообщение канала обновлено.") + await update_channel_post(bot, app_config, state_storage, settings, mtproto_publisher) + await message.answer("Сообщение канала обновлено.") @router.message(Command("post_test")) @@ -360,32 +373,32 @@ async def post_test_handler( ) -> None: user_id = message.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await message.answer("У вас нет доступа к тестовой отправке.") + await message.answer("РЈ вас нет доступа Рє тестовой отправке.") return try: await send_test_post(bot, message.chat.id, app_config, state_storage) except TelegramBadRequest as exc: - await message.answer(f"Тестовая отправка не удалась:\n{exc}") + await message.answer(f"Тестовая отправка РЅРµ удалась:\n{exc}") return - await message.answer("Тестовый пост отправлен.") + 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("У вас нет доступа к шаблонам поста.") + await message.answer("РЈ вас нет доступа Рє шаблонам поста.") return if message.reply_to_message is None: - await message.answer("Используйте /template_dump ответом на оформленное сообщение.") + 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-представления, которое можно извлечь.") + await message.answer("РЈ сообщения нет HTML-представления, которое РјРѕР¶РЅРѕ извлечь.") @router.message(Command("post")) @@ -398,7 +411,7 @@ async def post_handler( ) -> None: user_id = message.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await message.answer("У вас нет доступа к шаблонам поста.") + await message.answer("РЈ вас нет доступа Рє шаблонам поста.") return template = extract_template_text(message) @@ -414,19 +427,19 @@ async def post_handler( if message.text and message.text.startswith("/post "): await message.answer( - "Шаблон сохранен.\n" - "Но если нужна готовая разметка, ссылки и premium emoji, лучше использовать /post ответом на уже оформленное сообщение." + "Шаблон сохранен.\n" + "РќРѕ если РЅСѓР¶РЅР° готовая разметка, ссылки Рё premium emoji, лучше использовать /post ответом РЅР° СѓР¶Рµ оформленное сообщение." ) return - await message.answer("Шаблон поста сохранен.") + await message.answer("Шаблон поста сохранен.") return await state.set_state(SessionForm.waiting_for_post_template) await message.answer( - "Перешлите или отправьте шаблон одним сообщением.\n" - "Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n" - "Для скрытой ссылки можно использовать {{hidden_link}}." + "Перешлите или отправьте шаблон РѕРґРЅРёРј сообщением.\n" + "Используйте {{actors}} для общего блока или {{actor:key}} для конкретного актера.\n" + "Для скрытой ссылки РјРѕР¶РЅРѕ использовать {{hidden_link}}." ) @@ -435,7 +448,7 @@ async def panel_callback(callback: CallbackQuery, state: FSMContext, app_config: 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) + await callback.answer("Нет доступа.", show_alert=True) return await show_panel(callback, user_id, app_config, settings) @@ -452,17 +465,17 @@ async def actor_handler( 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) + await callback.answer("Нет доступа.", show_alert=True) return actor_key = callback.data.split(":", maxsplit=1)[1] actor = find_actor(app_config, actor_key) if actor is None: - await callback.answer("Персонаж не найден.", show_alert=True) + 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) + await callback.answer("РњРѕР¶РЅРѕ менять только СЃРІРѕР№ статус.", show_alert=True) return await show_actor_status_menu(callback, actor, state_storage) @@ -477,20 +490,21 @@ async def status_handler( actor_lookup: dict, app_config: dict, state_storage: JsonStateStorage, + mtproto_publisher: TelethonPublisher | None = None, ) -> None: user_id = callback.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await callback.answer("Нет доступа.", show_alert=True) + 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) + 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) + await callback.answer("РњРѕР¶РЅРѕ менять только СЃРІРѕР№ статус.", show_alert=True) return try: @@ -503,23 +517,24 @@ async def status_handler( app_config=app_config, state_storage=state_storage, settings=settings, + mtproto_publisher=mtproto_publisher, ) - except TelegramBadRequest as exc: + except Exception as exc: logging.exception("Failed to edit channel message") - await callback.answer("Не удалось обновить сообщение канала.", show_alert=True) + await callback.answer("РќРµ удалось обновить сообщение канала.", show_alert=True) await safe_edit_message( callback, - f"Статус сохранен, но сообщение канала не обновилось:\n{exc}", + f"Статус сохранен, РЅРѕ сообщение канала РЅРµ обновилось:\n{exc}", build_back_to_panel_keyboard().as_markup(), ) return await safe_edit_message( callback, - f"Готово.\n{actor['display_name']} -> {STATUS_CHOICES[status_key]}", + f"Готово.\n{actor['display_name']} -> {STATUS_CHOICES[status_key]}", build_back_to_panel_keyboard().as_markup(), ) - await callback.answer("Готово") + await callback.answer("Готово") @router.callback_query(F.data.startswith("custom:")) @@ -533,17 +548,17 @@ async def custom_phrase_handler( ) -> None: user_id = callback.from_user.id if not is_allowed(user_id, actor_lookup, settings.admin_ids): - await callback.answer("Нет доступа.", show_alert=True) + await callback.answer("Нет доступа.", show_alert=True) return _, actor_key = callback.data.split(":") actor = find_actor(app_config, actor_key) if actor is None: - await callback.answer("Персонаж не найден.", show_alert=True) + 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) + await callback.answer("РњРѕР¶РЅРѕ менять только СЃРІРѕР№ статус.", show_alert=True) return runtime_state = get_actor_runtime_state(actor, state_storage) @@ -554,8 +569,8 @@ async def custom_phrase_handler( await safe_edit_message( callback, ( - f"Введите свою фразу для {actor['display_name']}.\n" - f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}." + f"Введите СЃРІРѕСЋ фразу для {actor['display_name']}.\n" + f"Текущий статус останется: {STATUS_CHOICES.get(status_key, status_key)}." ), build_back_to_actor_keyboard(actor_key).as_markup(), ) @@ -570,9 +585,10 @@ async def phrase_handler( app_config: dict, state_storage: JsonStateStorage, settings, + mtproto_publisher: TelethonPublisher | None = None, ) -> None: if message.text is None: - await message.answer("Нужен текст сообщения.") + await message.answer("Нужен текст сообщения.") return data = await state.get_data() @@ -581,12 +597,12 @@ async def phrase_handler( actor = find_actor(app_config, actor_key) if actor is None: await state.clear() - await message.answer("Персонаж не найден.") + await message.answer("Персонаж РЅРµ найден.") return phrase = message.text.strip() if not phrase: - await message.answer("Фраза не должна быть пустой.") + await message.answer("Фраза РЅРµ должна быть пустой.") return try: @@ -599,16 +615,17 @@ async def phrase_handler( app_config=app_config, state_storage=state_storage, settings=settings, + mtproto_publisher=mtproto_publisher, ) - except TelegramBadRequest as exc: + except Exception as exc: logging.exception("Failed to edit channel message") - await message.answer(f"Статус сохранен, но сообщение канала не обновилось:\n{exc}") + 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()}.", + f"Обновлено: {actor['display_name']} -> {STATUS_CHOICES[status_key].lower()}.", reply_markup=build_back_to_panel_keyboard().as_markup(), ) @@ -621,7 +638,7 @@ async def post_template_handler( ) -> None: template = extract_template_text(message) if template is None: - await message.answer("Нужен текстовый шаблон. Перешлите текстовый пост или отправьте текст.") + await message.answer("Нужен текстовый шаблон. Перешлите текстовый РїРѕСЃС‚ или отправьте текст.") return normalized = normalize_template_placeholders(template) @@ -633,15 +650,21 @@ async def post_template_handler( await message.answer(validation_error) return - await message.answer("Шаблон поста сохранен.") + await message.answer("Шаблон поста сохранен.") -def build_dispatcher(app_config: dict, settings, state_storage: JsonStateStorage) -> Dispatcher: +def build_dispatcher( + app_config: dict, + settings, + state_storage: JsonStateStorage, + mtproto_publisher: TelethonPublisher | None = None, +) -> Dispatcher: dispatcher = Dispatcher(storage=MemoryStorage()) dispatcher["app_config"] = app_config dispatcher["actor_lookup"] = build_actor_lookup(app_config) dispatcher["settings"] = settings dispatcher["state_storage"] = state_storage + dispatcher["mtproto_publisher"] = mtproto_publisher dispatcher.include_router(router) return dispatcher @@ -655,10 +678,17 @@ async def main() -> None: if settings.hidden_link_char: app_config["hidden_link_char"] = settings.hidden_link_char state_storage = JsonStateStorage(settings.state_path) + mtproto_publisher = TelethonPublisher(settings) if settings.telethon_enabled else None bot = Bot(token=settings.bot_token) - dispatcher = build_dispatcher(app_config, settings, state_storage) - await dispatcher.start_polling(bot) + dispatcher = build_dispatcher(app_config, settings, state_storage, mtproto_publisher) + try: + if mtproto_publisher is not None: + await mtproto_publisher.start() + await dispatcher.start_polling(bot) + finally: + if mtproto_publisher is not None: + await mtproto_publisher.close() def run() -> None: diff --git a/session_bot/config.py b/session_bot/config.py index a24c65e..65ce834 100644 --- a/session_bot/config.py +++ b/session_bot/config.py @@ -18,6 +18,14 @@ class BotSettings: admin_ids: set[int] hidden_link_url: str hidden_link_char: str + telethon_api_id: int | None + telethon_api_hash: str + telethon_session_string: str + telethon_channel: str + + @property + def telethon_enabled(self) -> bool: + return bool(self.telethon_api_id and self.telethon_api_hash and self.telethon_session_string) def _parse_admin_ids(value: str) -> set[int]: @@ -32,6 +40,8 @@ def _parse_admin_ids(value: str) -> set[int]: def load_settings() -> BotSettings: load_dotenv() + telethon_api_id = os.environ.get("TELETHON_API_ID", "").strip() + return BotSettings( bot_token=os.environ["BOT_TOKEN"], channel_id=int(os.environ["CHANNEL_ID"]), @@ -41,6 +51,10 @@ def load_settings() -> BotSettings: 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", "​"), + telethon_api_id=int(telethon_api_id) if telethon_api_id else None, + telethon_api_hash=os.environ.get("TELETHON_API_HASH", "").strip(), + telethon_session_string=os.environ.get("TELETHON_SESSION_STRING", "").strip(), + telethon_channel=os.environ.get("TELETHON_CHANNEL", "").strip(), ) diff --git a/session_bot/render.py b/session_bot/render.py index aa26c90..9f0a43c 100644 --- a/session_bot/render.py +++ b/session_bot/render.py @@ -9,7 +9,11 @@ PLAIN_LINK_RE = re.compile(r"(?P