Add multi-route donor->recipient support, fix VK hashtag truncation

Posts can now be sourced from multiple VK groups, each routed to its own
Telegram/MAX destination(s) with independent on/off switches, configured
via data/routes.json (supports // line comments). Falls back to a single
route auto-generated from the legacy VK_SOURCE/TG_CHAT_ID/MAX_CHAT_ID env
vars if routes.json doesn't exist yet, so existing deployments keep working.

Routes are processed strictly sequentially within a cycle (no concurrency)
to keep flood control on VK/TG/MAX correct, since bot tokens are shared
across routes. DB schema gains route_id in the posts uniqueness key so the
same VK donor can safely feed multiple routes without status collisions.

Also removes the trailing-hashtag-stripping logic in text_formatter, which
was silently deleting VK posts' own hashtags whenever COMMON_TAGS wasn't
configured (it always wasn't) - posts are now forwarded unchanged.
This commit is contained in:
2026-08-18 14:48:22 +05:00
parent ec65aaf57d
commit 20135263c4
11 changed files with 596 additions and 233 deletions
+50 -53
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
from loguru import logger
import asyncio
from datetime import datetime
from typing import Any, Optional
from aiogram import Bot
try:
from .config import settings
from .text_formatter import split_message_chunks
except (ImportError, ValueError):
from config import settings
from text_formatter import split_message_chunks
class AdminNotifier:
@@ -20,63 +21,31 @@ class AdminNotifier:
if not self.admin_ids:
return
for admin_id in self.admin_ids:
try:
await self.bot.send_message(
chat_id=admin_id,
text=text,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as exc:
logger.warning("Failed to send report to admin {}: {}", admin_id, exc)
async def notify_cycle_result(
self,
group_name: str,
vk_url: str,
found_posts: list[dict[str, Any]],
error: Optional[str] = None,
) -> None:
if not self.admin_ids:
return
# If 0 posts found and no error, check settings
if not found_posts and not error:
if not settings.report_empty_runs:
return
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for chunk in split_message_chunks(text, 4000):
for admin_id in self.admin_ids:
try:
await self.bot.send_message(
chat_id=admin_id,
text=chunk,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as 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]:
if error:
text = (
f"⚠️ <b>[VK Poster Alert] Ошибка при проверке группы</b>\n\n"
f"🏷 <b>Группа:</b> {group_name} (<a href=\"{vk_url}\">VK</a>)\n"
f"⏱ <b>Время:</b> <code>{now_str}</code>\n"
f"❌ <b>Ошибка:</b> <code>{error}</code>"
return (
f"⚠️ <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — ошибка проверки:\n"
f"<code>{error}</code>"
)
await self.send_to_all(text)
return
if not found_posts:
text = (
f"ℹ️ <b>[VK Poster] Проверка завершена</b>\n\n"
f"🏷 <b>Группа:</b> {group_name} (<a href=\"{vk_url}\">VK</a>)\n"
f"⏱ <b>Время:</b> <code>{now_str}</code>\n"
f"📥 Новых постов не обнаружено."
)
await self.send_to_all(text)
return
# We have published posts
lines = [
f"🚀 <b>[VK Poster] Опубликованы новые посты!</b>\n",
f"🏷 <b>Группа:</b> {group_name} (<a href=\"{vk_url}\">VK</a>)",
f"⏱ <b>Время:</b> <code>{now_str}</code>",
f"📊 <b>Количество:</b> {len(found_posts)}\n",
"<b>Список публикаций:</b>",
]
if not settings.report_empty_runs:
return None
return f"ℹ️ <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — новых постов не обнаружено."
lines = [f"🚀 <b>{route_name}</b> (<a href=\"{vk_url}\">VK</a>) — опубликовано: {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")
@@ -102,4 +71,32 @@ class AdminNotifier:
elif max_err:
lines.append(f"• MAX: ❌ Ошибка: <code>{max_err[:100]}</code>")
await self.send_to_all("\n".join(lines))
return "\n".join(lines)
async def notify_cycle_result(self, route_results: list[dict[str, Any]]) -> None:
"""route_results: one entry per route this cycle -
{"route_name": str, "vk_url": str, "reports": list[dict], "error": Optional[str]}
Sent as a single aggregated report per cycle (chunked if too long) rather
than one message per route, to avoid flooding admins when there are several
donor->recipient routes.
"""
if not self.admin_ids:
return
sections: list[str] = []
for rr in route_results:
section = self._route_section(
rr.get("route_name", rr.get("route_id", "?")),
rr.get("vk_url", ""),
rr.get("reports", []),
rr.get("error"),
)
if section:
sections.append(section)
if not sections:
return
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
text = f"<b>[VK Poster] Итоги проверки</b> — <code>{now_str}</code>\n\n" + "\n\n".join(sections)
await self.send_to_all(text)