From ab69fff33ba932588b97043277a8cee2370158a1 Mon Sep 17 00:00:00 2001 From: exostring Date: Tue, 18 Aug 2026 18:53:44 +0500 Subject: [PATCH] Fix admin report HTML-injection crash, add /id bot command Error text and VK group names were inserted into admin Telegram reports unescaped under parse_mode=HTML - any '<'/'>'/'&' in an exception message (common) made Telegram reject the whole message with "can't parse entities", silently swallowed as a warning log. Admins got nothing. Escape everything user/exception-controlled before embedding. Also adds a minimal /id command (long-polling, shares the existing bot session) so people can DM the bot and get their Telegram ID in a copy-paste-ready format, for configuring per-route notification recipients. --- src/admin_notifier.py | 20 ++++++++++++++------ src/id_bot.py | 25 +++++++++++++++++++++++++ src/main.py | 7 +++++++ 3 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 src/id_bot.py diff --git a/src/admin_notifier.py b/src/admin_notifier.py index 85752e4..43321f9 100644 --- a/src/admin_notifier.py +++ b/src/admin_notifier.py @@ -1,6 +1,7 @@ from __future__ import annotations from loguru import logger +import html from datetime import datetime from typing import Any, Optional from aiogram import Bot @@ -34,10 +35,17 @@ class AdminNotifier: logger.warning("Failed to send report to admin {}: {}", admin_id, exc) def _route_section(self, route_name: str, vk_url: str, found_posts: list[dict[str, Any]], error: Optional[str]) -> Optional[str]: + # Route names come from VK (group title) and error/exception text is + # arbitrary - both are outside our control and can contain raw '<'/'>'/'&' + # that would otherwise break Telegram's HTML parser and silently drop the + # whole report (send_to_all only logs a warning on failure, invisible here). + route_name = html.escape(route_name) + vk_url = html.escape(vk_url, quote=True) + if error: return ( f"⚠️ {route_name} (VK) — ошибка проверки:\n" - f"{error}" + f"{html.escape(error)}" ) if not found_posts: @@ -48,9 +56,9 @@ class AdminNotifier: lines = [f"🚀 {route_name} (VK) — опубликовано: {len(found_posts)}"] for idx, p in enumerate(found_posts, 1): post_id = p.get("vk_post_id") - vk_post_url = p.get("vk_post_url") - tg_url = p.get("tg_url") - max_url = p.get("max_url") + vk_post_url = html.escape(str(p.get("vk_post_url") or ""), quote=True) + tg_url = html.escape(str(p.get("tg_url") or ""), quote=True) + max_url = html.escape(str(p.get("max_url") or ""), quote=True) tg_status = p.get("tg_status") max_status = p.get("max_status") tg_err = p.get("tg_error") @@ -63,13 +71,13 @@ class AdminNotifier: tg_link = f"Ссылка" if tg_url else "Опубликовано" lines.append(f"• Telegram: ✅ {tg_link}") elif tg_err: - lines.append(f"• Telegram: ❌ Ошибка: {tg_err[:100]}") + lines.append(f"• Telegram: ❌ Ошибка: {html.escape(str(tg_err)[:100])}") if max_status == "published": max_link = f"Ссылка" if max_url else "Опубликовано" lines.append(f"• MAX: ✅ {max_link}") elif max_err: - lines.append(f"• MAX: ❌ Ошибка: {max_err[:100]}") + lines.append(f"• MAX: ❌ Ошибка: {html.escape(str(max_err)[:100])}") return "\n".join(lines) diff --git a/src/id_bot.py b/src/id_bot.py new file mode 100644 index 0000000..8c1b064 --- /dev/null +++ b/src/id_bot.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from loguru import logger + +from aiogram import Bot, Dispatcher +from aiogram.filters import Command +from aiogram.types import Message + +router = Dispatcher() + + +@router.message(Command("id")) +async def cmd_id(message: Message) -> None: + user_id = message.from_user.id if message.from_user else message.chat.id + await message.answer(f"Ваш Telegram ID: {user_id}", parse_mode="HTML") + + +async def run_id_bot(bot: Bot) -> None: + """Long-polls for incoming updates so /id works - the rest of the service + only ever sends messages, this is the one thing that listens. + + close_bot_session=False: this Bot instance is owned by TelegramPoster and + shared for outgoing sends - polling stopping (e.g. on shutdown cancellation) + must not tear down its session out from under the rest of the service.""" + logger.info("Starting /id command listener...") + await router.start_polling(bot, handle_signals=False, close_bot_session=False) diff --git a/src/main.py b/src/main.py index 5c9d55b..2d7758b 100644 --- a/src/main.py +++ b/src/main.py @@ -10,6 +10,7 @@ try: from .cleaner import cleanup_stale_cache, run_cleaner_loop from .config import settings from .database import Database + from .id_bot import run_id_bot from .max_poster import MAXPoster from .media_processor import MediaProcessor from .routes import Route, load_routes @@ -20,6 +21,7 @@ except (ImportError, ValueError): from cleaner import cleanup_stale_cache, run_cleaner_loop from config import settings from database import Database + from id_bot import run_id_bot from max_poster import MAXPoster from media_processor import MediaProcessor from routes import Route, load_routes @@ -38,6 +40,7 @@ class ServiceApp: self.route_info: dict[str, dict[str, Any]] = {} self.running = False self.cleaner_task: Optional[asyncio.Task] = None + self.id_bot_task: Optional[asyncio.Task] = None async def init(self) -> None: logger.remove() @@ -83,6 +86,8 @@ class ServiceApp: ) self.cleaner_task = asyncio.create_task(run_cleaner_loop(interval_minutes=15)) + if self.tg_poster.bot: + self.id_bot_task = asyncio.create_task(run_id_bot(self.tg_poster.bot)) async def process_new_post(self, route: Route, info: dict[str, Any], post: VKPost) -> dict[str, Any]: vk_url = f"https://vk.com/wall{post.owner_id}_{post.post_id}" @@ -330,6 +335,8 @@ class ServiceApp: self.running = False if self.cleaner_task: self.cleaner_task.cancel() + if self.id_bot_task: + self.id_bot_task.cancel() await self.tg_poster.close() await cleanup_stale_cache(max_age_minutes=0) logger.info("Service stopped cleanly.")