Add stale-donor alerting with per-route recipients and delivery fallback

New STALE_DONOR_ALERT_DAYS (.env, global threshold, 0=off) + per-route
stale_alert_enabled/stale_alert_ids (routes.json). Checked once per route
per cycle against MAX(posted_at) in the DB - self-resetting via a single
route_alerts.stale_alert_sent_at timestamp compared against the last post
time, so it fires once per quiet spell and re-arms automatically once the
donor posts again, no separate ack/clear step needed.

If a configured stale_alert_ids recipient can't be reached (never started
a chat with the bot), the global TG_ADMIN_IDS get a separate notice about
that delivery failure instead of the alert silently vanishing.
This commit is contained in:
2026-08-18 19:13:01 +05:00
parent ab69fff33b
commit 3a086d9e54
8 changed files with 180 additions and 10 deletions
+5
View File
@@ -76,6 +76,11 @@ REPORT_EMPTY_RUNS=false
REPORT_ON_ERROR=true
# Clean stale temp files older than X minutes (default: 30)
CACHE_MAX_AGE_MINUTES=30
# "Donor gone quiet" alerts: global threshold in days, 0 = feature off entirely.
# Only fires for routes with "stale_alert_enabled": true in routes.json (which also
# sets who gets notified - see routes.json.example). Self-resetting: alerts once
# per quiet spell, re-arms automatically once the donor posts again.
STALE_DONOR_ALERT_DAYS=0
# ==========================================
# Media & Video Limits
+13 -1
View File
@@ -77,7 +77,9 @@ ssh -i ~/.ssh/id_ed25519_proxmox root@192.168.1.222 'pct exec 107 -- docker logs
"tg_chat_id": "-1001303630155",
"tg_enabled": true,
"max_chat_id": "123456",
"max_enabled": true
"max_enabled": true,
"stale_alert_enabled": true,
"stale_alert_ids": [123456789]
}
```
@@ -88,9 +90,19 @@ ssh -i ~/.ssh/id_ed25519_proxmox root@192.168.1.222 'pct exec 107 -- docker logs
| `vk_source` | Группа-донор ВК: screen name, URL или owner_id |
| `tg_chat_id` / `max_chat_id` | Куда публиковать (chat/channel ID) |
| `tg_enabled` / `max_enabled` | Переключатель — публиковать ли в эту платформу для этого маршрута (только TG, только МАКС, или оба) |
| `stale_alert_enabled` | Оповещать ли, если донор давно не постил (см. ниже). По умолчанию `false` |
| `stale_alert_ids` | Кому слать оповещение о простое (список TG ID). Пусто/не указано → шлётся `TG_ADMIN_IDS` |
Можно завести несколько маршрутов с разными `vk_source`, каждый — в свои TG/МАКС чаты. Токены ботов (`TG_BOT_TOKEN`, `MAX_BOT_TOKEN`, `VK_ACCESS_TOKEN`) общие на все маршруты — один бот пишет в разные чаты.
### Оповещение о "молчащем" доноре
Глобальный порог — `STALE_DONOR_ALERT_DAYS` в `.env` (0 = функция выключена целиком). Если у конкретного маршрута `stale_alert_enabled: true` и донор не постил дольше порога — уходит одно сообщение получателям из `stale_alert_ids` (или `TG_ADMIN_IDS`, если список пуст). Не спамит: пока донор молчит, повторно не напоминает; как только появляется новый пост — оповещение автоматически "перевзводится" для следующего простоя. Если бот не может достучаться до кого-то из `stale_alert_ids` (человек ни разу не писал боту) — отдельным сообщением получают `TG_ADMIN_IDS`.
### Узнать свой Telegram ID
Написать боту `/id` в личку — ответит вашим ID в копируемом виде (`<code>...</code>`), чтобы вставить в `TG_ADMIN_IDS` или `stale_alert_ids`.
`vk_source` принимает screen name, полный URL (`vk.com` и `vk.ru`) или `owner_id` — можно указывать как есть, без ручной нормализации.
Файл — обычный JSON, но допускает построчные комментарии `// текст`, чтобы подписывать, где какой маршрут (полноценных JSON-комментариев не существует, здесь это добавлено отдельно — строка, у которой после пробелов идёт `//`, вырезается перед парсингом):
+4 -2
View File
@@ -1,6 +1,6 @@
[
// redairsoft_main
// redairsoft_main - с оповещением о простое донора (нужен STALE_DONOR_ALERT_DAYS в .env)
{
"id": "redairsoft_main",
"name": "Red Airsoft (основная группа)",
@@ -8,7 +8,9 @@
"tg_chat_id": "-1001303630155",
"tg_enabled": true,
"max_chat_id": "123456",
"max_enabled": true
"max_enabled": true,
"stale_alert_enabled": true,
"stale_alert_ids": [123456789]
},
// second_donor - только в TG, MAX выключен
+53 -7
View File
@@ -18,21 +18,30 @@ class AdminNotifier:
self.bot = bot
self.admin_ids = settings.admin_id_list
async def send_to_all(self, text: str) -> None:
if not self.admin_ids:
return
async def send_to(self, chat_ids: list[int], text: str) -> list[int]:
"""Sends text (HTML, chunked) to each chat_id. Returns the chat_ids that
failed on at least one chunk, so callers can react (e.g. fall back to
notifying admins that a configured recipient is unreachable)."""
failed: list[int] = []
for chunk in split_message_chunks(text, 4000):
for admin_id in self.admin_ids:
for chat_id in chat_ids:
try:
await self.bot.send_message(
chat_id=admin_id,
chat_id=chat_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)
logger.warning("Failed to send message to {}: {}", chat_id, exc)
if chat_id not in failed:
failed.append(chat_id)
return failed
async def send_to_all(self, text: str) -> None:
if not self.admin_ids:
return
await self.send_to(self.admin_ids, text)
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
@@ -108,3 +117,40 @@ class AdminNotifier:
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)
async def notify_stale_donor(
self,
route_name: str,
vk_url: str,
days_since: int,
recipient_ids: list[int],
) -> None:
"""Alerts that a donor VK group hasn't posted in a while. Sends to
recipient_ids if the route configured any, else falls back to the
global admins. If a configured recipient can't be reached (e.g. never
started a chat with the bot), the global admins are told about that
delivery failure separately - a silently-unreachable alert recipient
is worse than no alert at all."""
route_name_e = html.escape(route_name)
vk_url_e = html.escape(vk_url, quote=True)
text = (
f"⏰ <b>{route_name_e}</b> (<a href=\"{vk_url_e}\">VK</a>) — "
f"нет новых постов уже {days_since} дн."
)
targets = list(recipient_ids) or self.admin_ids
if not targets:
return
failed = await self.send_to(targets, text)
unreachable = [c for c in failed if c not in self.admin_ids]
if unreachable and self.admin_ids:
ids_str = ", ".join(str(c) for c in unreachable)
fallback_text = (
f"⚠️ Не удалось доставить оповещение о простое донора "
f"<b>{route_name_e}</b> получателю(ям): <code>{ids_str}</code>. "
f"Проверьте, что бот может им писать (получатель должен сам "
f"сначала написать боту любое сообщение)."
)
await self.send_to(self.admin_ids, fallback_text)
+4
View File
@@ -52,6 +52,10 @@ class Settings(BaseSettings):
# Polling, Worker & Bootstrap Settings
check_interval_minutes: int = 15
report_empty_runs: bool = False
# Global threshold for "donor gone quiet" alerts - 0 disables the feature
# entirely regardless of any per-route stale_alert_enabled. Per-route
# recipients/opt-in live in routes.json, not here.
stale_donor_alert_days: int = 0
report_on_error: bool = True
# bootstrap_mode: "skip_existing" (default: ignores old historical posts on first start, only tracks new posts),
# "publish_latest_one" (publishes only the single latest post and skips older ones),
+41
View File
@@ -146,6 +146,15 @@ class Database:
)
await self._ensure_column(db, "publication_runs", "route_id", "route_id TEXT")
await self._ensure_column(db, "publication_runs", "route_name", "route_name TEXT")
await db.execute(
"""
CREATE TABLE IF NOT EXISTS route_alerts (
route_id TEXT PRIMARY KEY,
stale_alert_sent_at INTEGER
);
"""
)
await db.commit()
async def has_any_posts(self, route_id: str, owner_id: int) -> bool:
@@ -299,3 +308,35 @@ class Database:
(route_id, route_name, found_count, tg_count, max_count, status, error),
)
await db.commit()
async def get_last_post_time(self, route_id: str, owner_id: int) -> Optional[int]:
"""Unix timestamp of the donor's own most recent post (any status) for
this route, or None if we've never seen a post for it yet."""
async with self._connect() as db:
cursor = await db.execute(
"SELECT MAX(posted_at) FROM posts WHERE route_id = ? AND vk_owner_id = ?",
(route_id, owner_id),
)
row = await cursor.fetchone()
return int(row[0]) if row and row[0] is not None else None
async def get_stale_alert_sent_at(self, route_id: str) -> Optional[int]:
async with self._connect() as db:
cursor = await db.execute(
"SELECT stale_alert_sent_at FROM route_alerts WHERE route_id = ?",
(route_id,),
)
row = await cursor.fetchone()
return int(row[0]) if row and row[0] is not None else None
async def set_stale_alert_sent_at(self, route_id: str, sent_at: int) -> None:
async with self._connect() as db:
await db.execute(
"""
INSERT INTO route_alerts (route_id, stale_alert_sent_at)
VALUES (?, ?)
ON CONFLICT(route_id) DO UPDATE SET stale_alert_sent_at = excluded.stale_alert_sent_at;
""",
(route_id, sent_at),
)
await db.commit()
+34
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import asyncio
import signal
import sys
import time
from typing import Any, Optional
from loguru import logger
try:
@@ -195,6 +196,37 @@ class ServiceApp:
return result_summary
async def check_stale_donor(self, route: Route, info: dict[str, Any]) -> None:
"""Alerts if this donor hasn't posted in STALE_DONOR_ALERT_DAYS days.
Self-resetting: an alert is only sent once per "quiet spell" - it won't
repeat every cycle, and automatically re-arms once the donor posts
again (compares the last-alert timestamp against the last-post
timestamp rather than tracking a separate "acknowledged" flag)."""
if not (route.stale_alert_enabled and settings.stale_donor_alert_days > 0):
return
if not self.admin_notifier:
return
last_post_at = await self.db.get_last_post_time(route.id, info["owner_id"])
if last_post_at is None:
return # no posts seen for this route yet - nothing to measure from
days_since = (time.time() - last_post_at) / 86400
if days_since < settings.stale_donor_alert_days:
return
last_alert_at = await self.db.get_stale_alert_sent_at(route.id)
if last_alert_at is not None and last_alert_at > last_post_at:
return # already alerted for this quiet spell, donor hasn't posted since
await self.admin_notifier.notify_stale_donor(
route_name=info["name"],
vk_url=info["url"],
days_since=int(days_since),
recipient_ids=list(route.stale_alert_ids),
)
await self.db.set_stale_alert_sent_at(route.id, int(time.time()))
async def run_route_cycle(self, vk: VKClient, route: Route) -> dict[str, Any]:
info = self.route_info[route.id]
route_error: Optional[str] = None
@@ -284,6 +316,8 @@ class ServiceApp:
error=route_error,
)
await self.check_stale_donor(route, info)
return {
"route_id": route.id,
"route_name": info["name"],
+26
View File
@@ -5,6 +5,7 @@ import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
try:
from .config import settings
except (ImportError, ValueError):
@@ -34,6 +35,10 @@ class Route:
tg_enabled: bool = True
max_chat_id: str = ""
max_enabled: bool = True
# Per-route "donor gone quiet" alerting - off by default, no effect unless
# both this AND the global STALE_DONOR_ALERT_DAYS threshold are set.
stale_alert_enabled: bool = False
stale_alert_ids: tuple[int, ...] = ()
def _legacy_route() -> Route:
@@ -58,6 +63,8 @@ def _write_routes(path: Path, routes: list[Route]) -> None:
"tg_enabled": r.tg_enabled,
"max_chat_id": r.max_chat_id,
"max_enabled": r.max_enabled,
"stale_alert_enabled": r.stale_alert_enabled,
"stale_alert_ids": list(r.stale_alert_ids),
}
for r in routes
]
@@ -65,6 +72,20 @@ def _write_routes(path: Path, routes: list[Route]) -> None:
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
def _parse_stale_alert_ids(raw: Any, route_id: str, path: Path) -> tuple[int, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ValueError(f"Route '{route_id}' in {path}: 'stale_alert_ids' must be a JSON array")
ids: list[int] = []
for v in raw:
try:
ids.append(int(v))
except (TypeError, ValueError):
raise ValueError(f"Route '{route_id}' in {path}: invalid stale_alert_ids entry {v!r}")
return tuple(ids)
def load_routes() -> list[Route]:
"""Loads donor->recipient routes from ROUTES_CONFIG_PATH (default data/routes.json).
@@ -125,6 +146,9 @@ def load_routes() -> list[Route]:
route_id,
)
stale_alert_ids = _parse_stale_alert_ids(entry.get("stale_alert_ids"), route_id, path)
stale_alert_enabled = bool(entry.get("stale_alert_enabled", False))
routes.append(
Route(
id=route_id,
@@ -134,6 +158,8 @@ def load_routes() -> list[Route]:
tg_enabled=tg_enabled,
max_chat_id=max_chat_id,
max_enabled=max_enabled,
stale_alert_enabled=stale_alert_enabled,
stale_alert_ids=stale_alert_ids,
)
)