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.")