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