diff --git a/.env.example b/.env.example index d6fff5b..2687088 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 72411ec..646449a 100644 --- a/README.md +++ b/README.md @@ -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 в копируемом виде (`...`), чтобы вставить в `TG_ADMIN_IDS` или `stale_alert_ids`. + `vk_source` принимает screen name, полный URL (`vk.com` и `vk.ru`) или `owner_id` — можно указывать как есть, без ручной нормализации. Файл — обычный JSON, но допускает построчные комментарии `// текст`, чтобы подписывать, где какой маршрут (полноценных JSON-комментариев не существует, здесь это добавлено отдельно — строка, у которой после пробелов идёт `//`, вырезается перед парсингом): diff --git a/routes.json.example b/routes.json.example index e33482c..928dfdc 100644 --- a/routes.json.example +++ b/routes.json.example @@ -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 выключен diff --git a/src/admin_notifier.py b/src/admin_notifier.py index 43321f9..bc01097 100644 --- a/src/admin_notifier.py +++ b/src/admin_notifier.py @@ -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"[VK Poster] Итоги проверки — {now_str}\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"⏰ {route_name_e} (VK) — " + 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"{route_name_e} получателю(ям): {ids_str}. " + f"Проверьте, что бот может им писать (получатель должен сам " + f"сначала написать боту любое сообщение)." + ) + await self.send_to(self.admin_ids, fallback_text) diff --git a/src/config.py b/src/config.py index 2e3981e..9b01676 100644 --- a/src/config.py +++ b/src/config.py @@ -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), diff --git a/src/database.py b/src/database.py index 0b80ba6..3eac922 100644 --- a/src/database.py +++ b/src/database.py @@ -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() diff --git a/src/main.py b/src/main.py index 2d7758b..5ff1656 100644 --- a/src/main.py +++ b/src/main.py @@ -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"], diff --git a/src/routes.py b/src/routes.py index ab10d49..da3ef98 100644 --- a/src/routes.py +++ b/src/routes.py @@ -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, ) )