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:
+14
-6
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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.")
|
||||||
|
|||||||
Reference in New Issue
Block a user