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,
)
)