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 REPORT_ON_ERROR=true
# Clean stale temp files older than X minutes (default: 30) # Clean stale temp files older than X minutes (default: 30)
CACHE_MAX_AGE_MINUTES=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 # 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_chat_id": "-1001303630155",
"tg_enabled": true, "tg_enabled": true,
"max_chat_id": "123456", "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 | | `vk_source` | Группа-донор ВК: screen name, URL или owner_id |
| `tg_chat_id` / `max_chat_id` | Куда публиковать (chat/channel ID) | | `tg_chat_id` / `max_chat_id` | Куда публиковать (chat/channel ID) |
| `tg_enabled` / `max_enabled` | Переключатель — публиковать ли в эту платформу для этого маршрута (только TG, только МАКС, или оба) | | `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`) общие на все маршруты — один бот пишет в разные чаты. Можно завести несколько маршрутов с разными `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` — можно указывать как есть, без ручной нормализации. `vk_source` принимает screen name, полный URL (`vk.com` и `vk.ru`) или `owner_id` — можно указывать как есть, без ручной нормализации.
Файл — обычный JSON, но допускает построчные комментарии `// текст`, чтобы подписывать, где какой маршрут (полноценных JSON-комментариев не существует, здесь это добавлено отдельно — строка, у которой после пробелов идёт `//`, вырезается перед парсингом): Файл — обычный JSON, но допускает построчные комментарии `// текст`, чтобы подписывать, где какой маршрут (полноценных JSON-комментариев не существует, здесь это добавлено отдельно — строка, у которой после пробелов идёт `//`, вырезается перед парсингом):
+4 -2
View File
@@ -1,6 +1,6 @@
[ [
// redairsoft_main // redairsoft_main - с оповещением о простое донора (нужен STALE_DONOR_ALERT_DAYS в .env)
{ {
"id": "redairsoft_main", "id": "redairsoft_main",
"name": "Red Airsoft (основная группа)", "name": "Red Airsoft (основная группа)",
@@ -8,7 +8,9 @@
"tg_chat_id": "-1001303630155", "tg_chat_id": "-1001303630155",
"tg_enabled": true, "tg_enabled": true,
"max_chat_id": "123456", "max_chat_id": "123456",
"max_enabled": true "max_enabled": true,
"stale_alert_enabled": true,
"stale_alert_ids": [123456789]
}, },
// second_donor - только в TG, MAX выключен // second_donor - только в TG, MAX выключен
+53 -7
View File
@@ -18,21 +18,30 @@ class AdminNotifier:
self.bot = bot self.bot = bot
self.admin_ids = settings.admin_id_list self.admin_ids = settings.admin_id_list
async def send_to_all(self, text: str) -> None: async def send_to(self, chat_ids: list[int], text: str) -> list[int]:
if not self.admin_ids: """Sends text (HTML, chunked) to each chat_id. Returns the chat_ids that
return 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 chunk in split_message_chunks(text, 4000):
for admin_id in self.admin_ids: for chat_id in chat_ids:
try: try:
await self.bot.send_message( await self.bot.send_message(
chat_id=admin_id, chat_id=chat_id,
text=chunk, text=chunk,
parse_mode="HTML", parse_mode="HTML",
disable_web_page_preview=True, disable_web_page_preview=True,
) )
except Exception as exc: 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]: 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 # 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") 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) text = f"<b>[VK Poster] Итоги проверки</b> — <code>{now_str}</code>\n\n" + "\n\n".join(sections)
await self.send_to_all(text) 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 # Polling, Worker & Bootstrap Settings
check_interval_minutes: int = 15 check_interval_minutes: int = 15
report_empty_runs: bool = False 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 report_on_error: bool = True
# bootstrap_mode: "skip_existing" (default: ignores old historical posts on first start, only tracks new posts), # 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), # "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_id", "route_id TEXT")
await self._ensure_column(db, "publication_runs", "route_name", "route_name 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() await db.commit()
async def has_any_posts(self, route_id: str, owner_id: int) -> bool: 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), (route_id, route_name, found_count, tg_count, max_count, status, error),
) )
await db.commit() 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 asyncio
import signal import signal
import sys import sys
import time
from typing import Any, Optional from typing import Any, Optional
from loguru import logger from loguru import logger
try: try:
@@ -195,6 +196,37 @@ class ServiceApp:
return result_summary 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]: async def run_route_cycle(self, vk: VKClient, route: Route) -> dict[str, Any]:
info = self.route_info[route.id] info = self.route_info[route.id]
route_error: Optional[str] = None route_error: Optional[str] = None
@@ -284,6 +316,8 @@ class ServiceApp:
error=route_error, error=route_error,
) )
await self.check_stale_donor(route, info)
return { return {
"route_id": route.id, "route_id": route.id,
"route_name": info["name"], "route_name": info["name"],
+26
View File
@@ -5,6 +5,7 @@ import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
try: try:
from .config import settings from .config import settings
except (ImportError, ValueError): except (ImportError, ValueError):
@@ -34,6 +35,10 @@ class Route:
tg_enabled: bool = True tg_enabled: bool = True
max_chat_id: str = "" max_chat_id: str = ""
max_enabled: bool = True 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: def _legacy_route() -> Route:
@@ -58,6 +63,8 @@ def _write_routes(path: Path, routes: list[Route]) -> None:
"tg_enabled": r.tg_enabled, "tg_enabled": r.tg_enabled,
"max_chat_id": r.max_chat_id, "max_chat_id": r.max_chat_id,
"max_enabled": r.max_enabled, "max_enabled": r.max_enabled,
"stale_alert_enabled": r.stale_alert_enabled,
"stale_alert_ids": list(r.stale_alert_ids),
} }
for r in routes 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") 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]: def load_routes() -> list[Route]:
"""Loads donor->recipient routes from ROUTES_CONFIG_PATH (default data/routes.json). """Loads donor->recipient routes from ROUTES_CONFIG_PATH (default data/routes.json).
@@ -125,6 +146,9 @@ def load_routes() -> list[Route]:
route_id, 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( routes.append(
Route( Route(
id=route_id, id=route_id,
@@ -134,6 +158,8 @@ def load_routes() -> list[Route]:
tg_enabled=tg_enabled, tg_enabled=tg_enabled,
max_chat_id=max_chat_id, max_chat_id=max_chat_id,
max_enabled=max_enabled, max_enabled=max_enabled,
stale_alert_enabled=stale_alert_enabled,
stale_alert_ids=stale_alert_ids,
) )
) )