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.
This commit is contained in:
2026-08-18 18:53:44 +05:00
parent 2b0481b6a1
commit ab69fff33b
3 changed files with 46 additions and 6 deletions
+14 -6
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
from loguru import logger from loguru import logger
import html
from datetime import datetime from datetime import datetime
from typing import Any, Optional from typing import Any, Optional
from aiogram import Bot from aiogram import Bot
@@ -34,10 +35,17 @@ class AdminNotifier:
logger.warning("Failed to send report to admin {}: {}", admin_id, exc) 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]: 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: if error:
return ( return (
f"⚠️ <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — ошибка проверки:\n" f"⚠️ <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — ошибка проверки:\n"
f"<code>{error}</code>" f"<code>{html.escape(error)}</code>"
) )
if not found_posts: if not found_posts:
@@ -48,9 +56,9 @@ class AdminNotifier:
lines = [f"🚀 <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — опубликовано: {len(found_posts)}"] lines = [f"🚀 <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — опубликовано: {len(found_posts)}"]
for idx, p in enumerate(found_posts, 1): for idx, p in enumerate(found_posts, 1):
post_id = p.get("vk_post_id") post_id = p.get("vk_post_id")
vk_post_url = p.get("vk_post_url") vk_post_url = html.escape(str(p.get("vk_post_url") or ""), quote=True)
tg_url = p.get("tg_url") tg_url = html.escape(str(p.get("tg_url") or ""), quote=True)
max_url = p.get("max_url") max_url = html.escape(str(p.get("max_url") or ""), quote=True)
tg_status = p.get("tg_status") tg_status = p.get("tg_status")
max_status = p.get("max_status") max_status = p.get("max_status")
tg_err = p.get("tg_error") tg_err = p.get("tg_error")
@@ -63,13 +71,13 @@ class AdminNotifier:
tg_link = f"<a href=\"{tg_url}\">Ссылка</a>" if tg_url else "Опубликовано" tg_link = f"<a href=\"{tg_url}\">Ссылка</a>" if tg_url else "Опубликовано"
lines.append(f"• Telegram: ✅ {tg_link}") lines.append(f"• Telegram: ✅ {tg_link}")
elif tg_err: elif tg_err:
lines.append(f"• Telegram: ❌ Ошибка: <code>{tg_err[:100]}</code>") lines.append(f"• Telegram: ❌ Ошибка: <code>{html.escape(str(tg_err)[:100])}</code>")
if max_status == "published": if max_status == "published":
max_link = f"<a href=\"{max_url}\">Ссылка</a>" if max_url else "Опубликовано" max_link = f"<a href=\"{max_url}\">Ссылка</a>" if max_url else "Опубликовано"
lines.append(f"• MAX: ✅ {max_link}") lines.append(f"• MAX: ✅ {max_link}")
elif max_err: elif max_err:
lines.append(f"• MAX: ❌ Ошибка: <code>{max_err[:100]}</code>") lines.append(f"• MAX: ❌ Ошибка: <code>{html.escape(str(max_err)[:100])}</code>")
return "\n".join(lines) return "\n".join(lines)
+25
View File
@@ -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: <code>{user_id}</code>", 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)
+7
View File
@@ -10,6 +10,7 @@ try:
from .cleaner import cleanup_stale_cache, run_cleaner_loop from .cleaner import cleanup_stale_cache, run_cleaner_loop
from .config import settings from .config import settings
from .database import Database from .database import Database
from .id_bot import run_id_bot
from .max_poster import MAXPoster from .max_poster import MAXPoster
from .media_processor import MediaProcessor from .media_processor import MediaProcessor
from .routes import Route, load_routes from .routes import Route, load_routes
@@ -20,6 +21,7 @@ except (ImportError, ValueError):
from cleaner import cleanup_stale_cache, run_cleaner_loop from cleaner import cleanup_stale_cache, run_cleaner_loop
from config import settings from config import settings
from database import Database from database import Database
from id_bot import run_id_bot
from max_poster import MAXPoster from max_poster import MAXPoster
from media_processor import MediaProcessor from media_processor import MediaProcessor
from routes import Route, load_routes from routes import Route, load_routes
@@ -38,6 +40,7 @@ class ServiceApp:
self.route_info: dict[str, dict[str, Any]] = {} self.route_info: dict[str, dict[str, Any]] = {}
self.running = False self.running = False
self.cleaner_task: Optional[asyncio.Task] = None self.cleaner_task: Optional[asyncio.Task] = None
self.id_bot_task: Optional[asyncio.Task] = None
async def init(self) -> None: async def init(self) -> None:
logger.remove() logger.remove()
@@ -83,6 +86,8 @@ class ServiceApp:
) )
self.cleaner_task = asyncio.create_task(run_cleaner_loop(interval_minutes=15)) 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]: 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}" vk_url = f"https://vk.com/wall{post.owner_id}_{post.post_id}"
@@ -330,6 +335,8 @@ class ServiceApp:
self.running = False self.running = False
if self.cleaner_task: if self.cleaner_task:
self.cleaner_task.cancel() self.cleaner_task.cancel()
if self.id_bot_task:
self.id_bot_task.cancel()
await self.tg_poster.close() await self.tg_poster.close()
await cleanup_stale_cache(max_age_minutes=0) await cleanup_stale_cache(max_age_minutes=0)
logger.info("Service stopped cleanly.") logger.info("Service stopped cleanly.")