Add scheduler system with cron support

- Implement QuestionPublisherScheduler with full cron expression parser
- Add schedule management in /manage command (add, edit, delete)
- Create schedules and schedule_runs tables in database
- Add delete_schedule method to repository
- Create comprehensive SCHEDULER_GUIDE.md with examples
- Add README.md with project overview
- Support for active/inactive schedules
- Automatic question publishing based on cron schedules
- Protection against duplicate runs in the same minute

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
2026-04-05 12:18:08 +02:00
commit b7c1905671
17 changed files with 2246 additions and 0 deletions

1
bot/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .bot import main

64
bot/bot.py Normal file
View File

@@ -0,0 +1,64 @@
import os
import logging
from pyrogram import Client, idle
from game import AppSettingsService, SQLiteWouldYouRatherRepository
from .command_system import CommandContext
from .commands import registry
from .manage import ManageController
from .scheduler import QuestionPublisherScheduler
logger = logging.getLogger(__name__)
def _env(name: str) -> str:
value = os.getenv(name)
if value is None or not value.strip():
raise ValueError(f"Environment variable '{name}' is required.")
return value.strip()
def _build_context() -> CommandContext:
settings = AppSettingsService().load_or_create()
logger.info(
"Loaded settings: admins=%s channels=%s api_sources=%s",
len(settings.admin_ids),
len(settings.poll_channel_ids),
len(settings.question_apis),
)
return CommandContext(
admin_ids=set(settings.admin_ids),
poll_channel_ids=settings.poll_channel_ids,
)
def _build_client() -> Client:
return Client(
name=os.getenv("SESSION_NAME", "would-you-rather-bot"),
api_id=int(_env("APP_ID")),
api_hash=_env("API_HASH"),
bot_token=_env("BOT_TOKEN"),
workdir="data",
)
async def main():
client = _build_client()
context = _build_context()
registry.bind(client, context)
ManageController(context).register(client)
scheduler = QuestionPublisherScheduler(
client=client,
repository=SQLiteWouldYouRatherRepository(),
channel_ids=context.poll_channel_ids,
)
logger.info("Bot client initialized and command handlers registered.")
async with client:
logger.info("Bot started and ready.")
scheduler.start()
try:
await idle()
finally:
await scheduler.stop()

95
bot/command_system.py Normal file
View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
import logging
from typing import Awaitable, Callable, Iterable
from pyrogram import Client, filters
from pyrogram.handlers import MessageHandler
from pyrogram.types import Message
CommandCallback = Callable[[Client, Message, "CommandContext"], Awaitable[None]]
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class CommandContext:
admin_ids: set[int]
poll_channel_ids: list[int]
@dataclass(frozen=True)
class BotCommand:
name: str
aliases: tuple[str, ...]
description: str
admin_only: bool
callback: CommandCallback
@property
def names(self) -> tuple[str, ...]:
return (self.name, *self.aliases)
class CommandRegistry:
def __init__(self) -> None:
self._commands: list[BotCommand] = []
def command(
self,
name: str,
*,
aliases: Iterable[str] = (),
description: str = "",
admin_only: bool = False,
) -> Callable[[CommandCallback], CommandCallback]:
normalized_name = name.strip().lstrip("/")
normalized_aliases = tuple(alias.strip().lstrip("/") for alias in aliases)
if not normalized_name:
raise ValueError("Command name cannot be empty.")
def decorator(callback: CommandCallback) -> CommandCallback:
self._commands.append(
BotCommand(
name=normalized_name,
aliases=normalized_aliases,
description=description.strip(),
admin_only=admin_only,
callback=callback,
)
)
return callback
return decorator
@property
def commands(self) -> tuple[BotCommand, ...]:
return tuple(self._commands)
def bind(self, client: Client, context: CommandContext) -> None:
for command in self._commands:
callback = self._build_callback(command, context)
command_filter = filters.command(list(command.names))
client.add_handler(MessageHandler(callback, command_filter))
logger.info("Registered command /%s", command.name)
def _build_callback(
self,
command: BotCommand,
context: CommandContext,
) -> Callable[[Client, Message], Awaitable[None]]:
async def callback(client: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if command.admin_only:
if user_id is None or user_id not in context.admin_ids:
logger.warning(
"Admin-only command denied: /%s user_id=%s",
command.name,
user_id,
)
await message.reply_text("Эта команда доступна только администраторам.")
return
logger.info("Command called: /%s user_id=%s", command.name, user_id)
await command.callback(client, message, context)
return callback

47
bot/commands.py Normal file
View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from .command_system import CommandContext, CommandRegistry
registry = CommandRegistry()
@registry.command("start", description="Запустить бота")
async def start_command(_, message, __: CommandContext) -> None:
await message.reply_text("Бот запущен. Используй /help для списка команд.")
@registry.command("help", aliases=("commands",), description="Показать список команд")
async def help_command(_, message, __: CommandContext) -> None:
lines = ["Доступные команды:"]
for command in registry.commands:
aliases = f" ({', '.join('/' + alias for alias in command.aliases)})" if command.aliases else ""
admin_badge = " [admin]" if command.admin_only else ""
description = f" - {command.description}" if command.description else ""
lines.append(f"/{command.name}{aliases}{admin_badge}{description}")
lines.append("/manage [admin] - Панель управления вопросами")
await message.reply_text("\n".join(lines))
@registry.command("ping", description="Проверка доступности бота")
async def ping_command(_, message, __: CommandContext) -> None:
await message.reply_text("pong")
@registry.command("id", description="Показать ваш ID и ID текущего чата")
async def id_command(_, message, __: CommandContext) -> None:
user_id = message.from_user.id if message.from_user else None
chat_id = message.chat.id if message.chat else None
user_value = str(user_id) if user_id is not None else "не удалось определить"
chat_value = str(chat_id) if chat_id is not None else "не удалось определить"
await message.reply_text(f"Ваш ID: {user_value}\nID чата: {chat_value}")
@registry.command("channels", description="Показать каналы для опросов", admin_only=True)
async def channels_command(_, message, context: CommandContext) -> None:
if not context.poll_channel_ids:
await message.reply_text("Каналы для опросов не настроены.")
return
channels = "\n".join(str(channel_id) for channel_id in context.poll_channel_ids)
await message.reply_text(f"Каналы для опросов:\n{channels}")

49
bot/logging_config.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
import logging
import os
from logging.handlers import RotatingFileHandler
from pathlib import Path
def _get_level() -> int:
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, None)
if not isinstance(level, int):
raise ValueError(f"Invalid LOG_LEVEL value: {level_name}")
return level
def configure_logging() -> logging.Logger:
root_logger = logging.getLogger()
if getattr(configure_logging, "_configured", False):
return logging.getLogger("would_you_rather")
log_level = _get_level()
formatter = logging.Formatter(
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
log_dir = Path(os.getenv("LOG_DIR", "data\\logs"))
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = RotatingFileHandler(
filename=log_dir / "app.log",
maxBytes=int(os.getenv("LOG_MAX_BYTES", "5242880")),
backupCount=int(os.getenv("LOG_BACKUP_COUNT", "5")),
encoding="utf-8",
)
file_handler.setFormatter(formatter)
root_logger.setLevel(log_level)
root_logger.addHandler(console_handler)
root_logger.addHandler(file_handler)
logging.getLogger("pyrogram").setLevel(os.getenv("PYROGRAM_LOG_LEVEL", "WARNING").upper())
setattr(configure_logging, "_configured", True)
return logging.getLogger("would_you_rather")

523
bot/manage.py Normal file
View File

@@ -0,0 +1,523 @@
from __future__ import annotations
from dataclasses import dataclass
import math
from pyrogram import Client, filters
from pyrogram.errors import MessageNotModified
from pyrogram.handlers import CallbackQueryHandler, MessageHandler
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from game import SQLiteWouldYouRatherRepository
from .command_system import CommandContext
from .scheduler import validate_cron_expression
@dataclass(frozen=True)
class PendingManageAction:
mode: str
question_id: int | None = None
schedule_id: int | None = None
class ManageController:
def __init__(self, context: CommandContext, page_size: int = 5) -> None:
self.context = context
self.page_size = page_size
self.repository = SQLiteWouldYouRatherRepository()
self.pending_actions: dict[int, PendingManageAction] = {}
def register(self, client: Client) -> None:
client.add_handler(
MessageHandler(self._on_manage_command, filters.command(["manage"]))
)
client.add_handler(
CallbackQueryHandler(self._on_callback_query, filters.regex(r"^mg:"))
)
client.add_handler(
MessageHandler(self._on_text_message, filters.text & ~filters.regex(r"^/"))
)
async def _on_manage_command(self, _: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
await message.reply_text("Команда доступна только администраторам.")
return
self.pending_actions.pop(user_id, None)
await message.reply_text(
"Панель управления вопросами:",
reply_markup=self._menu_keyboard(),
)
async def _on_callback_query(self, _: Client, callback: CallbackQuery) -> None:
user_id = callback.from_user.id if callback.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
await callback.answer("Только для администраторов.", show_alert=True)
return
data = callback.data or ""
if data == "mg:menu":
self.pending_actions.pop(user_id, None)
await self._edit(callback, "Панель управления вопросами:", self._menu_keyboard())
await callback.answer()
return
if data.startswith("mg:view:"):
page = self._extract_page(data)
await self._render_questions_page(callback, page)
await callback.answer()
return
if data == "mg:add":
self.pending_actions[user_id] = PendingManageAction(mode="add")
await self._edit(
callback,
"Отправьте новый вопрос в формате:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data == "mg:schedule:list":
await self._render_schedules(callback)
await callback.answer()
return
if data == "mg:schedule:add":
self.pending_actions[user_id] = PendingManageAction(mode="schedule_add")
await self._edit(
callback,
"Отправьте расписание в формате:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>\n"
"Пример: Утро | 0 9 * * 1-5 | 1",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data == "mg:schedule:edit:list":
await self._render_schedule_edit_list(callback)
await callback.answer()
return
if data.startswith("mg:schedule:edit:"):
try:
schedule_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
schedule = self.repository.get_schedule(schedule_id)
if schedule is None:
await callback.answer("Расписание не найдено.", show_alert=True)
return
self.pending_actions[user_id] = PendingManageAction(
mode="schedule_edit",
schedule_id=schedule_id,
)
await self._edit(
callback,
"Отправьте обновлённое расписание в формате:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>\n"
f"Текущее: {schedule.name} | {schedule.cron} | {1 if schedule.is_active else 0}",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data == "mg:schedule:delete:list":
await self._render_schedule_delete_list(callback)
await callback.answer()
return
if data.startswith("mg:schedule:delete:"):
try:
schedule_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
try:
schedule = self.repository.get_schedule(schedule_id)
if schedule is None:
await callback.answer("Расписание не найдено.", show_alert=True)
return
self.repository.delete_schedule(schedule_id)
await callback.answer(f"Расписание '{schedule.name}' удалено.", show_alert=True)
await self._render_schedule_delete_list(callback)
except ValueError as error:
await callback.answer(str(error), show_alert=True)
return
if data.startswith("mg:edit:"):
page = self._extract_page(data)
await self._render_edit_page(callback, page)
await callback.answer()
return
if data.startswith("mg:editq:"):
try:
question_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
self.pending_actions[user_id] = PendingManageAction(mode="edit", question_id=question_id)
await self._edit(
callback,
"Отправьте обновлённый вопрос в формате:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]",
self._back_to_menu_keyboard(),
)
await callback.answer()
return
if data.startswith("mg:stats:"):
page = self._extract_page(data)
await self._render_stats_page(callback, page)
await callback.answer()
return
if data.startswith("mg:statq:"):
try:
question_id = self._extract_id(data)
except ValueError:
await callback.answer("Некорректный идентификатор.", show_alert=True)
return
await self._render_question_stats(callback, question_id)
await callback.answer()
return
await callback.answer("Неизвестное действие.")
async def _on_text_message(self, _: Client, message: Message) -> None:
user_id = message.from_user.id if message.from_user else None
if user_id is None or user_id not in self.context.admin_ids:
return
action = self.pending_actions.get(user_id)
if action is None:
return
text = message.text or ""
try:
if action.mode == "add":
question_text, answers = self._parse_question_input(text)
question_id = self.repository.add_question(question_text, answers)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Вопрос добавлен (ID: {question_id}).",
reply_markup=self._menu_keyboard(),
)
return
if action.mode == "edit":
if action.question_id is None:
raise ValueError("Question ID is missing for edit mode.")
question_text, answers = self._parse_question_input(text)
self.repository.update_question(action.question_id, question_text, answers)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Вопрос #{action.question_id} обновлён.",
reply_markup=self._menu_keyboard(),
)
return
if action.mode == "schedule_add":
name, cron, is_active = self._parse_schedule_input(text)
self.repository.add_schedule(name=name, cron=cron, is_active=is_active)
self.pending_actions.pop(user_id, None)
await message.reply_text(
"Расписание добавлено.",
reply_markup=self._menu_keyboard(),
)
return
if action.mode == "schedule_edit":
if action.schedule_id is None:
raise ValueError("Schedule ID is missing for edit mode.")
name, cron, is_active = self._parse_schedule_input(text)
self.repository.update_schedule(
schedule_id=action.schedule_id,
name=name,
cron=cron,
is_active=is_active,
)
self.pending_actions.pop(user_id, None)
await message.reply_text(
f"Расписание #{action.schedule_id} обновлено.",
reply_markup=self._menu_keyboard(),
)
return
except ValueError as error:
if action.mode in ("schedule_add", "schedule_edit"):
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<название> | <cron 5 полей> | <active: 1 или 0>"
)
else:
await message.reply_text(
f"{error}\n\nИспользуйте формат:\n"
"<вопрос> | <вариант 1> | <вариант 2> [| <вариант 3> ...]"
)
async def _render_questions_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
text = "Вопросов пока нет."
else:
lines = [f"Список вопросов (страница {safe_page}/{total_pages}):"]
for item in rows:
short_text = item.text if len(item.text) <= 70 else f"{item.text[:67]}..."
lines.append(f"#{item.id} ({item.answers_count} вариантов): {short_text}")
text = "\n".join(lines)
await self._edit(callback, text, self._pagination_keyboard("mg:view", safe_page, total_pages))
async def _render_edit_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
await self._edit(callback, "Нет вопросов для изменения.", self._back_to_menu_keyboard())
return
lines = [f"Выберите вопрос для изменения ({safe_page}/{total_pages}):"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for item in rows:
short_text = item.text if len(item.text) <= 50 else f"{item.text[:47]}..."
lines.append(f"#{item.id}: {short_text}")
keyboard_rows.append(
[InlineKeyboardButton(f"✏️ #{item.id}", callback_data=f"mg:editq:{item.id}")]
)
keyboard_rows.extend(self._pagination_rows("mg:edit", safe_page, total_pages))
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_stats_page(self, callback: CallbackQuery, page: int) -> None:
total = self.repository.count_questions()
total_pages = max(1, math.ceil(total / self.page_size))
safe_page = min(max(page, 1), total_pages)
offset = (safe_page - 1) * self.page_size
rows = self.repository.list_questions(limit=self.page_size, offset=offset)
if not rows:
await self._edit(callback, "Нет вопросов для статистики.", self._back_to_menu_keyboard())
return
lines = [f"Выберите вопрос для статистики ({safe_page}/{total_pages}):"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for item in rows:
short_text = item.text if len(item.text) <= 50 else f"{item.text[:47]}..."
lines.append(f"#{item.id}: {short_text}")
keyboard_rows.append(
[InlineKeyboardButton(f"📊 #{item.id}", callback_data=f"mg:statq:{item.id}")]
)
keyboard_rows.extend(self._pagination_rows("mg:stats", safe_page, total_pages))
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_question_stats(self, callback: CallbackQuery, question_id: int) -> None:
question = self.repository.get_question(question_id)
if question is None:
await self._edit(
callback,
"Вопрос не найден.",
InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В статистику", callback_data="mg:stats:1")]]),
)
return
stats = self.repository.get_question_statistics(question_id)
total_votes = sum(item.count for item in stats)
lines = [f"Статистика для вопроса #{question_id}:", question.question.text, ""]
if not stats:
lines.append("Нет вариантов ответа.")
else:
for item in stats:
percent = (item.count / total_votes * 100) if total_votes else 0.0
lines.append(f"{item.answer_text}: {item.count} ({percent:.1f}%)")
lines.append("")
lines.append(f"Всего голосов: {total_votes}")
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("⬅️ К вопросам статистики", callback_data="mg:stats:1")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await self._edit(callback, "\n".join(lines), keyboard)
async def _render_schedules(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
text = "Расписаний пока нет."
else:
lines = ["Текущие расписания:"]
for schedule in schedules:
status = "🟢 active" if schedule.is_active else "⚪️ inactive"
lines.append(
f"#{schedule.id} [{status}] {schedule.name}: `{schedule.cron}`"
)
text = "\n".join(lines)
keyboard = InlineKeyboardMarkup(
[
[InlineKeyboardButton("➕ Добавить расписание", callback_data="mg:schedule:add")],
[InlineKeyboardButton("✏️ Изменить расписание", callback_data="mg:schedule:edit:list")],
[InlineKeyboardButton("🗑️ Удалить расписание", callback_data="mg:schedule:delete:list")],
[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")],
]
)
await self._edit(callback, text, keyboard)
async def _render_schedule_edit_list(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
await self._edit(
callback,
"Нет расписаний для изменения.",
InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")]]
),
)
return
lines = ["Выберите расписание для изменения:"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for schedule in schedules:
status = "🟢" if schedule.is_active else "⚪️"
lines.append(f"#{schedule.id} {schedule.name}: {schedule.cron} ({status})")
keyboard_rows.append(
[
InlineKeyboardButton(
f"✏️ #{schedule.id} {schedule.name}",
callback_data=f"mg:schedule:edit:{schedule.id}",
)
]
)
keyboard_rows.append([InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")])
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
async def _render_schedule_delete_list(self, callback: CallbackQuery) -> None:
schedules = self.repository.list_schedules()
if not schedules:
await self._edit(
callback,
"Нет расписаний для удаления.",
InlineKeyboardMarkup(
[[InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")]]
),
)
return
lines = ["⚠️ Выберите расписание для удаления:"]
keyboard_rows: list[list[InlineKeyboardButton]] = []
for schedule in schedules:
status = "🟢" if schedule.is_active else "⚪️"
lines.append(f"#{schedule.id} {schedule.name}: {schedule.cron} ({status})")
keyboard_rows.append(
[
InlineKeyboardButton(
f"🗑️ #{schedule.id} {schedule.name}",
callback_data=f"mg:schedule:delete:{schedule.id}",
)
]
)
keyboard_rows.append([InlineKeyboardButton("⬅️ К расписаниям", callback_data="mg:schedule:list")])
keyboard_rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
await self._edit(callback, "\n".join(lines), InlineKeyboardMarkup(keyboard_rows))
def _menu_keyboard(self) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(
[
[InlineKeyboardButton("📋 Просмотреть вопросы", callback_data="mg:view:1")],
[InlineKeyboardButton("➕ Добавить вопрос", callback_data="mg:add")],
[InlineKeyboardButton("✏️ Изменить вопрос", callback_data="mg:edit:1")],
[InlineKeyboardButton("📊 Смотреть статистику", callback_data="mg:stats:1")],
[InlineKeyboardButton("⏰ Расписания", callback_data="mg:schedule:list")],
]
)
def _back_to_menu_keyboard(self) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([[InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")]])
def _pagination_keyboard(self, prefix: str, page: int, total_pages: int) -> InlineKeyboardMarkup:
rows = self._pagination_rows(prefix, page, total_pages)
rows.append([InlineKeyboardButton("⬅️ В меню", callback_data="mg:menu")])
return InlineKeyboardMarkup(rows)
def _pagination_rows(self, prefix: str, page: int, total_pages: int) -> list[list[InlineKeyboardButton]]:
buttons: list[InlineKeyboardButton] = []
if page > 1:
buttons.append(InlineKeyboardButton("⬅️", callback_data=f"{prefix}:{page - 1}"))
if page < total_pages:
buttons.append(InlineKeyboardButton("➡️", callback_data=f"{prefix}:{page + 1}"))
if not buttons:
return []
return [buttons]
async def _edit(
self,
callback: CallbackQuery,
text: str,
reply_markup: InlineKeyboardMarkup,
) -> None:
if callback.message:
try:
await callback.message.edit_text(text=text, reply_markup=reply_markup)
except MessageNotModified:
return
def _parse_question_input(self, text: str) -> tuple[str, list[str]]:
parts = [part.strip() for part in text.split("|")]
if len(parts) < 3:
raise ValueError("Нужно указать вопрос и минимум 2 варианта ответа.")
question_text = parts[0]
answers = [part for part in parts[1:] if part]
if not question_text:
raise ValueError("Текст вопроса не может быть пустым.")
if len(answers) < 2:
raise ValueError("Нужно указать минимум 2 непустых варианта.")
return question_text, answers
def _parse_schedule_input(self, text: str) -> tuple[str, str, bool]:
parts = [part.strip() for part in text.split("|")]
if len(parts) != 3:
raise ValueError("Нужно указать имя, cron и active-флаг (1 или 0).")
name, cron, active_token = parts
if not name:
raise ValueError("Имя расписания не может быть пустым.")
validate_cron_expression(cron)
if active_token not in ("0", "1"):
raise ValueError("active-флаг должен быть 1 (активно) или 0 (выключено).")
return name, cron, active_token == "1"
def _extract_page(self, data: str) -> int:
try:
return max(1, int(data.rsplit(":", 1)[1]))
except (IndexError, ValueError):
return 1
def _extract_id(self, data: str) -> int:
try:
value = int(data.rsplit(":", 1)[1])
except (IndexError, ValueError) as error:
raise ValueError("Invalid callback payload.") from error
return value

183
bot/scheduler.py Normal file
View File

@@ -0,0 +1,183 @@
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
import logging
from pyrogram import Client
from game import SQLiteWouldYouRatherRepository
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _CronField:
allowed: set[int]
def matches(self, value: int) -> bool:
return value in self.allowed
class CronExpression:
def __init__(self, expression: str) -> None:
parts = expression.strip().split()
if len(parts) != 5:
raise ValueError("Cron must have 5 fields: minute hour day month weekday.")
self.expression = expression.strip()
self._minute = self._parse_field(parts[0], 0, 59)
self._hour = self._parse_field(parts[1], 0, 23)
self._day = self._parse_field(parts[2], 1, 31)
self._month = self._parse_field(parts[3], 1, 12)
self._weekday = self._parse_field(parts[4], 0, 7)
def matches(self, dt: datetime) -> bool:
weekday = (dt.weekday() + 1) % 7
return (
self._minute.matches(dt.minute)
and self._hour.matches(dt.hour)
and self._day.matches(dt.day)
and self._month.matches(dt.month)
and (weekday in self._weekday.allowed or (7 in self._weekday.allowed and weekday == 0))
)
def _parse_field(self, token: str, minimum: int, maximum: int) -> _CronField:
allowed: set[int] = set()
for part in token.split(","):
part = part.strip()
if not part:
raise ValueError("Invalid empty cron token.")
step = 1
base = part
if "/" in part:
base, step_part = part.split("/", 1)
if not step_part.isdigit() or int(step_part) < 1:
raise ValueError(f"Invalid step value in cron token: {part}")
step = int(step_part)
if base == "*":
start, end = minimum, maximum
elif "-" in base:
start_str, end_str = base.split("-", 1)
start = int(start_str)
end = int(end_str)
else:
start = int(base)
end = int(base)
if start < minimum or end > maximum or start > end:
raise ValueError(f"Cron value out of range: {part}")
for value in range(start, end + 1, step):
allowed.add(value)
if not allowed:
raise ValueError("Cron field has no values.")
return _CronField(allowed=allowed)
def validate_cron_expression(expression: str) -> None:
CronExpression(expression)
class QuestionPublisherScheduler:
def __init__(
self,
client: Client,
repository: SQLiteWouldYouRatherRepository,
channel_ids: list[int],
) -> None:
self.client = client
self.repository = repository
self.channel_ids = channel_ids
self._task: asyncio.Task[None] | None = None
def start(self) -> None:
if self._task is None:
self._task = asyncio.create_task(self._run(), name="question-publisher-scheduler")
logger.info("Question scheduler started.")
async def stop(self) -> None:
if self._task is None:
return
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
finally:
self._task = None
logger.info("Question scheduler stopped.")
async def _run(self) -> None:
while True:
now = datetime.now(timezone.utc).replace(second=0, microsecond=0)
try:
await self._tick(now)
except Exception:
logger.exception("Scheduler tick failed.")
next_minute = now + timedelta(minutes=1)
sleep_for = max((next_minute - datetime.now(timezone.utc)).total_seconds(), 0.5)
await asyncio.sleep(sleep_for)
async def _tick(self, now: datetime) -> None:
if not self.channel_ids:
return
schedules = self.repository.list_schedules(active_only=True)
if not schedules:
return
for schedule in schedules:
try:
matches = CronExpression(schedule.cron).matches(now)
except ValueError:
logger.error(
"Invalid cron for schedule #%s '%s': %s",
schedule.id,
schedule.name,
schedule.cron,
)
continue
if not matches:
continue
run_key = now.strftime("%Y%m%d%H%M")
if not self.repository.record_schedule_run(schedule.id, run_key):
continue
await self._publish_for_schedule(schedule.name)
async def _publish_for_schedule(self, schedule_name: str) -> None:
question = self.repository.pop_unused_question()
if question is None:
logger.warning("No questions available for schedule '%s'.", schedule_name)
return
options = [answer.text for answer in question.answers]
for channel_id in self.channel_ids:
try:
await self.client.send_poll(
chat_id=channel_id,
question=question.question.text,
options=options,
is_anonymous=True,
)
logger.info(
"Published question #%s to channel %s via schedule '%s'.",
question.question.id,
channel_id,
schedule_name,
)
except Exception:
logger.exception(
"Failed to publish question #%s to channel %s via schedule '%s'.",
question.question.id,
channel_id,
schedule_name,
)