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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user