83 lines
3.1 KiB
Python
83 lines
3.1 KiB
Python
"""Планировщик рассылок.
|
||
|
||
Исполняется внутри процесса бота. Расписание пока задано в коде (данные, а не
|
||
магические числа); в Блоке 4 переедет в таблицу ``schedules`` и будет
|
||
редактироваться через админку.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from dataclasses import dataclass, field
|
||
from datetime import datetime
|
||
|
||
from aiogram import Bot
|
||
|
||
from bot.services import users as users_service
|
||
from common.config import get_settings
|
||
from common.texts import format_usernames, render
|
||
from common.texts import notifications as notify_texts
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
#: Размер порции упоминаний в одном сообщении.
|
||
BATCH_SIZE = 10
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class ScheduledMessage:
|
||
"""Одна запланированная рассылка.
|
||
|
||
:param weekdays: дни недели (0=Пн … 6=Вс). None — ежедневно.
|
||
"""
|
||
|
||
hour: int
|
||
minute: int
|
||
template: str
|
||
weekdays: tuple[int, ...] | None = field(default=None)
|
||
|
||
def matches(self, now: datetime) -> bool:
|
||
if self.hour != now.hour or self.minute != now.minute:
|
||
return False
|
||
return self.weekdays is None or now.weekday() in self.weekdays
|
||
|
||
|
||
SCHEDULES: tuple[ScheduledMessage, ...] = (
|
||
ScheduledMessage(16, 30, notify_texts.MSG_BLAGO_30),
|
||
ScheduledMessage(16, 45, notify_texts.MSG_BLAGO_45),
|
||
ScheduledMessage(16, 55, notify_texts.MSG_BLAGO_55),
|
||
ScheduledMessage(17, 0, notify_texts.MSG_BLAGO),
|
||
ScheduledMessage(15, 0, notify_texts.MSG_VKLAD, weekdays=(4,)), # пятница
|
||
ScheduledMessage(14, 0, notify_texts.MSG_VKLAD, weekdays=(6,)), # воскресенье
|
||
)
|
||
|
||
|
||
async def _send_broadcast(bot: Bot, template: 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:
|
||
text = render(template, {"usernames": format_usernames(chunk)})
|
||
await bot.send_message(chat_id=get_settings().chat_id, text=text)
|
||
|
||
|
||
async def _tick(bot: Bot, now: datetime) -> None:
|
||
for schedule in SCHEDULES:
|
||
if schedule.matches(now):
|
||
subscribers = await users_service.list_subscribers()
|
||
await _send_broadcast(bot, schedule.template, 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)
|