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:
1
bot/__init__.py
Normal file
1
bot/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .bot import main
|
||||
64
bot/bot.py
Normal file
64
bot/bot.py
Normal 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
95
bot/command_system.py
Normal 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
47
bot/commands.py
Normal 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
49
bot/logging_config.py
Normal 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
523
bot/manage.py
Normal 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
183
bot/scheduler.py
Normal 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,
|
||||
)
|
||||
Reference in New Issue
Block a user