DispelEvilBot/bot/services/notifications.py

144 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Планировщик рассылок.
Расписания читаются из БД (таблица ``bot.schedules``, редактируется в админке).
Если база недоступна, используется расписание по умолчанию из кода и последний
успешно прочитанный набор. Тексты берутся из ``bot.message_templates`` через
``common.texts.store`` (TTL-кеш, правки без перезапуска).
"""
from __future__ import annotations
import asyncio
import logging
import time
from dataclasses import dataclass
from datetime import UTC, datetime
from zoneinfo import ZoneInfo
from aiogram import Bot
from bot.services import users as users_service
from bot.services.sequences import send_template
from common.config import get_settings
from common.repositories import schedules_repo
from common.texts import format_usernames
logger = logging.getLogger(__name__)
#: Размер порции упоминаний в одном сообщении.
BATCH_SIZE = 10
#: Как часто писать в лог, если БД с расписаниями недоступна (секунды).
WARNING_INTERVAL = 300.0
@dataclass(frozen=True, slots=True)
class ScheduledMessage:
"""Одна запланированная рассылка из БД (или из значений по умолчанию).
:param weekdays: дни недели по ISO (1=Пн … 7=Вс). Пусто — ежедневно.
:param timezone: таймзона расписания (IANA), время в ней.
"""
hour: int
minute: int
template_key: str
weekdays: tuple[int, ...] = ()
timezone: str = "Europe/Moscow"
schedule_id: int | None = None
def matches(self, now: datetime) -> bool:
"""Совпадает ли момент ``now`` (aware) с этим расписанием."""
local = now.astimezone(ZoneInfo(self.timezone))
if self.hour != local.hour or self.minute != local.minute:
return False
return not self.weekdays or local.isoweekday() in self.weekdays
#: Расписание по умолчанию — на случай недоступной БД (совпадает с V16-сидом).
DEFAULT_SCHEDULES: tuple[ScheduledMessage, ...] = (
ScheduledMessage(16, 30, "notify.blago_30"),
ScheduledMessage(16, 45, "notify.blago_45"),
ScheduledMessage(16, 55, "notify.blago_55"),
ScheduledMessage(17, 0, "notify.blago"),
ScheduledMessage(15, 0, "notify.vklad", weekdays=(5,)), # пятница
ScheduledMessage(14, 0, "notify.vklad", weekdays=(7,)), # воскресенье
)
#: Последний успешно прочитанный набор — чтобы пережить временный сбой БД.
_last_good: tuple[ScheduledMessage, ...] | None = None
_last_warning_at = 0.0
def _from_row(row: dict) -> ScheduledMessage:
return ScheduledMessage(
hour=int(row["hour"]),
minute=int(row["minute"]),
template_key=str(row["template_key"]),
weekdays=tuple(int(day) for day in (row["weekdays"] or ())),
timezone=str(row["timezone"] or "Europe/Moscow"),
schedule_id=int(row["id"]) if row.get("id") is not None else None,
)
async def _load_schedules() -> tuple[ScheduledMessage, ...]:
"""Прочитать активные расписания; при сбое — последние известные или дефолт."""
global _last_good, _last_warning_at
try:
rows = await schedules_repo.list_active()
except Exception:
now = time.monotonic()
if now - _last_warning_at > WARNING_INTERVAL:
logger.warning(
"Не удалось прочитать расписания из БД — использую последние известные",
exc_info=True,
)
_last_warning_at = now
return _last_good if _last_good is not None else DEFAULT_SCHEDULES
_last_good = tuple(_from_row(row) for row in rows)
return _last_good
async def _send_broadcast(bot: Bot, template_key: str, subscribers: list[dict]) -> None:
chunks = [subscribers[i : i + BATCH_SIZE] for i in range(0, len(subscribers), BATCH_SIZE)] or [[]]
for chunk in chunks:
await send_template(
bot,
get_settings().chat_id,
template_key,
{"usernames": format_usernames(chunk)},
)
async def _tick(bot: Bot, now: datetime) -> None:
for schedule in await _load_schedules():
if not schedule.matches(now):
continue
# Защита от повторной отправки после перезапуска в ту же минуту.
if schedule.schedule_id is not None:
run_at = now.astimezone(UTC).replace(second=0, microsecond=0)
if not await schedules_repo.claim_run(schedule.schedule_id, run_at):
logger.info("Рассылка %s за %s уже отправлена — пропускаю", schedule.schedule_id, run_at)
continue
subscribers = await users_service.list_subscribers()
await _send_broadcast(bot, schedule.template_key, subscribers)
async def run_notifications(bot: Bot) -> None:
"""Бесконечно проверять расписание раз в минуту."""
tz = get_settings().tzinfo
while True:
now = datetime.now(tz).replace(second=0, microsecond=0)
try:
await _tick(bot, now)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("Ошибка планировщика рассылок")
# Спать до начала следующей минуты, чтобы обрабатывать каждую минуту один раз.
await asyncio.sleep(60 - datetime.now(tz).second)