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

176
.gitignore vendored Normal file
View File

@@ -0,0 +1,176 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
pytestdebug.log
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
.python-version
# pipenv
Pipfile.lock
# poetry
poetry.lock
# pdm
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
VENV/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# Ruff
.ruff_cache/
# PyCharm
.idea/
# VS Code
.vscode/
# Data directories
data/
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
desktop.ini
# Temporary files
*.tmp
*.temp
*.swp
*.swo
*~
# Backup files
*.bak
*.backup
# Local configuration
*.local
config.local.*

151
README.md Normal file
View File

@@ -0,0 +1,151 @@
# Would You Rather Bot
Telegram бот для проведения игр "Would You Rather" с автоматической публикацией вопросов по расписанию.
## Возможности
- 🎮 Публикация вопросов "Would You Rather" в виде опросов
- 📊 Статистика ответов на вопросы
- ⏰ Автоматическая публикация по расписанию (cron)
- 🛠️ Управление вопросами и расписаниями через Telegram
- 📝 Поддержка множественных источников вопросов через API
## Основные команды
- `/start` - Начало работы с ботом
- `/manage` - Панель управления (только для администраторов)
- Добавление/редактирование/удаление вопросов
- Просмотр статистики
- Управление расписаниями публикаций
## Система расписаний
Бот поддерживает автоматическую публикацию вопросов по расписанию в формате **cron**.
### Быстрый старт:
1. Откройте `/manage` → **⏰ Расписания**
2. Нажмите **➕ Добавить расписание**
3. Отправьте: `Ежедневно в 12:00 | 0 12 * * * | 1`
### Примеры расписаний:
- `0 9 * * 1-5` - каждый будний день в 9:00 UTC
- `0 */4 * * *` - каждые 4 часа
- `0 12 * * 6,0` - каждые выходные в 12:00 UTC
📖 **Подробное руководство:** [SCHEDULER_GUIDE.md](./SCHEDULER_GUIDE.md)
## Установка и запуск
### Требования:
- Python 3.11+
- Telegram Bot Token
- Telegram API credentials (api_id, api_hash)
### Шаги установки:
1. Клонируйте репозиторий:
```bash
git clone <repository-url>
cd WouldYouRather
```
2. Создайте виртуальное окружение и установите зависимости:
```bash
python -m venv .venv
source .venv/bin/activate # Linux/Mac
# или
.venv\Scripts\activate # Windows
pip install -r requirements.txt
```
3. Создайте файл `.env` с вашими credentials:
```env
APP_ID=your_app_id
API_HASH=your_api_hash
BOT_TOKEN=your_bot_token
SESSION_NAME=would-you-rather-bot
```
4. Настройте `data/settings.toml`:
```toml
# ID администраторов бота
admin_ids = [123456789]
# ID каналов для публикации
poll_channel_ids = [-1001234567890]
# Источники вопросов (опционально)
[[question_apis]]
name = "default-source"
url = "https://example.com/api/questions/random"
handler = "default"
```
5. Запустите бота:
```bash
python main.py
```
## Структура проекта
```
WouldYouRather/
├── bot/ # Telegram бот
│ ├── bot.py # Основной файл бота
│ ├── commands.py # Команды бота
│ ├── manage.py # Панель управления
│ └── scheduler.py # Система расписаний
├── game/ # Бизнес-логика
│ ├── database.py # Работа с БД
│ ├── game.py # Игровая логика
│ ├── settings.py # Настройки приложения
│ └── api_service.py # API для загрузки вопросов
├── data/ # Данные (БД, сессии, конфиг)
│ ├── game.sqlite3 # База данных
│ └── settings.toml # Конфигурация
├── main.py # Точка входа
└── requirements.txt # Зависимости
```
## База данных
Бот использует SQLite для хранения:
- Вопросов и ответов
- Статистики голосований
- Расписаний публикаций
- Истории запусков расписаний
Структура создается автоматически при первом запуске.
## Возможности управления
### Вопросы:
- ✅ Просмотр всех вопросов с пагинацией
- ✅ Добавление новых вопросов
- ✅ Редактирование существующих вопросов
- ✅ Просмотр статистики по вопросам
### Расписания:
- ✅ Создание расписаний с cron-выражениями
- ✅ Редактирование расписаний
- ✅ Включение/выключение расписаний
- ✅ Удаление расписаний
- ✅ Просмотр всех расписаний
## Логирование
Логи сохраняются в `data/logs/` с ротацией:
- `bot.log` - основной лог
- Автоматическая ротация по дням
- Хранение последних 7 дней
## Лицензия
MIT License
## Автор
Developed by @azimkin.dev

188
SCHEDULER_GUIDE.md Normal file
View File

@@ -0,0 +1,188 @@
# Руководство по системе расписаний
## Описание
Бот поддерживает автоматическую публикацию вопросов "Would You Rather" в каналы по расписанию. Система использует формат **cron** для настройки времени публикации.
## Управление расписаниями
Все расписания управляются через команду `/manage` → **⏰ Расписания**
### Доступные действия:
- **➕ Добавить расписание** - создать новое расписание
- **✏️ Изменить расписание** - редактировать существующее расписание
- **🗑️ Удалить расписание** - удалить расписание
- **Просмотр** - список всех расписаний с их статусами
## Формат добавления/изменения расписания
При добавлении или изменении расписания нужно отправить сообщение в формате:
```
<название> | <cron выражение> | <активность>
```
### Параметры:
- **название** - произвольное имя расписания (например: "Утро будних дней", "Вечерняя публикация")
- **cron выражение** - 5 полей для настройки времени (см. ниже)
- **активность** - `1` для активного расписания, `0` для выключенного
### Примеры:
```
Утро будних дней | 0 9 * * 1-5 | 1
```
Публикация каждый будний день в 9:00 UTC
```
Вечерняя публикация | 0 18 * * * | 1
```
Публикация каждый день в 18:00 UTC
```
Выходные | 0 12 * * 0,6 | 1
```
Публикация в субботу и воскресенье в 12:00 UTC
```
Каждые 4 часа | 0 */4 * * * | 1
```
Публикация каждые 4 часа
## Формат Cron выражения
Cron выражение состоит из **5 полей**, разделённых пробелами:
```
минута час день месяц день_недели
```
### Поля:
| Поле | Значения | Описание |
|------|----------|----------|
| **минута** | 0-59 | Минуты в часе |
| **час** | 0-23 | Часы в сутках (UTC) |
| **день** | 1-31 | День месяца |
| **месяц** | 1-12 | Месяц года |
| **день недели** | 0-7 | День недели (0 и 7 = воскресенье, 1 = понедельник, ..., 6 = суббота) |
### Специальные символы:
- `*` - любое значение
- `-` - диапазон (например: `1-5` = с 1 по 5)
- `,` - список значений (например: `1,3,5` = 1, 3 и 5)
- `/` - шаг (например: `*/2` = каждое второе значение)
## Примеры cron выражений
### Базовые примеры:
| Cron | Описание |
|------|----------|
| `0 9 * * *` | Каждый день в 9:00 UTC |
| `30 14 * * *` | Каждый день в 14:30 UTC |
| `0 0 * * *` | Каждый день в полночь UTC |
| `0 12 * * 0` | Каждое воскресенье в 12:00 UTC |
| `0 9 * * 1-5` | Каждый будний день (Пн-Пт) в 9:00 UTC |
| `0 20 * * 6,0` | Каждую субботу и воскресенье в 20:00 UTC |
### Продвинутые примеры:
| Cron | Описание |
|------|----------|
| `*/15 * * * *` | Каждые 15 минут |
| `0 */3 * * *` | Каждые 3 часа |
| `0 9-17 * * 1-5` | Каждый час с 9:00 до 17:00 по будням |
| `0,30 * * * *` | Каждый час в :00 и :30 минут |
| `0 8,12,18 * * *` | В 8:00, 12:00 и 18:00 каждый день |
| `0 10 1 * *` | 1-го числа каждого месяца в 10:00 UTC |
| `0 9 1-7 * 1` | Каждый понедельник первой недели месяца в 9:00 UTC |
### Практические сценарии:
**Утренняя публикация по будням:**
```
Утренний вопрос | 0 8 * * 1-5 | 1
```
**Обеденная публикация:**
```
Обед | 0 12 * * * | 1
```
**Вечерние публикации каждый день:**
```
Вечер | 0 19 * * * | 1
```
**Два раза в день:**
```
Утро и вечер | 0 9,21 * * * | 1
```
**Каждые 6 часов:**
```
Регулярные публикации | 0 */6 * * * | 1
```
**Только выходные:**
```
Выходные | 0 12 * * 6,0 | 1
```
## Как работает система
1. **Проверка расписаний** - каждую минуту система проверяет все активные расписания
2. **Сопоставление времени** - если текущее время UTC совпадает с cron выражением
3. **Публикация** - система выбирает случайный неиспользованный вопрос и публикует его во все каналы из конфигурации
4. **Защита от дублирования** - каждое срабатывание записывается в базу, чтобы один и тот же вопрос не публиковался дважды в одну минуту
## Временная зона
⚠️ **Важно:** Все расписания работают в часовом поясе **UTC**.
Чтобы настроить публикацию по вашему местному времени, пересчитайте время в UTC:
- Москва (MSK): UTC+3
- Чтобы публиковать в 12:00 по Москве, используйте `0 9 * * *` (12-3=9)
- Киев/Минск (EET): UTC+2
- Чтобы публиковать в 12:00, используйте `0 10 * * *` (12-2=10)
## Примеры использования через /manage
### Добавить расписание:
1. `/manage`
2. Нажать **⏰ Расписания**
3. Нажать **➕ Добавить расписание**
4. Отправить: `Ежедневно в обед | 0 12 * * * | 1`
### Изменить расписание:
1. `/manage`
2. **⏰ Расписания** → **✏️ Изменить расписание**
3. Выбрать нужное расписание
4. Отправить новые параметры: `Обновленное имя | 0 15 * * * | 1`
### Деактивировать расписание (не удаляя):
1. Изменить расписание, установив последний параметр в `0`
2. Например: `Утреннее | 0 9 * * 1-5 | 0`
### Удалить расписание:
1. `/manage`
2. **⏰ Расписания** → **🗑️ Удалить расписание**
3. Выбрать расписание для удаления
## Советы
1. **Начните с простого** - используйте базовые расписания типа `0 9 * * *` (каждый день в 9:00)
2. **Тестируйте** - создайте расписание на ближайшие минуты для теста (например, если сейчас 14:25, используйте `26 14 * * *`)
3. **Используйте осмысленные имена** - это поможет различать расписания
4. **Деактивируйте вместо удаления** - так проще временно отключить публикацию
5. **Проверяйте UTC** - не забывайте про разницу с вашим часовым поясом
## Проверка работы
Чтобы убедиться, что система работает:
1. Создайте тестовое расписание на ближайшую минуту
2. Проверьте логи бота
3. Проверьте канал на наличие публикации
4. Удалите тестовое расписание после проверки

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

27
game/__init__.py Normal file
View File

@@ -0,0 +1,27 @@
from .api_service import (
ApiQuestion,
QuestionApiService,
QuestionPayloadHandler,
build_handler_registry,
parse_default_question_payload,
)
from .database import AnswerStatistic, SQLiteWouldYouRatherRepository, ScheduleRow
from .game import Question, WouldYouRather
from .settings import ApiSourceSettings, AppSettings, AppSettingsService, ConfiguredApiService
__all__ = [
"ApiQuestion",
"ApiSourceSettings",
"AppSettings",
"AppSettingsService",
"ConfiguredApiService",
"AnswerStatistic",
"Question",
"QuestionPayloadHandler",
"QuestionApiService",
"ScheduleRow",
"SQLiteWouldYouRatherRepository",
"WouldYouRather",
"build_handler_registry",
"parse_default_question_payload",
]

133
game/api_service.py Normal file
View File

@@ -0,0 +1,133 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from typing import Any, Callable
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from .database import SQLiteWouldYouRatherRepository
@dataclass(frozen=True)
class ApiQuestion:
text: str
answers: list[str]
QuestionPayloadHandler = Callable[[Any], ApiQuestion]
def parse_default_question_payload(payload: Any) -> ApiQuestion:
if isinstance(payload, list):
if not payload:
raise ValueError("API returned an empty list of questions.")
payload = payload[0]
if not isinstance(payload, dict):
raise ValueError("API response must be a JSON object or array.")
if "data" in payload and isinstance(payload["data"], (dict, list)):
return parse_default_question_payload(payload["data"])
question_text = _extract_question_text(payload)
answers = _extract_answers(payload)
if len(answers) < 2:
raise ValueError("Question must have at least 2 answers.")
return ApiQuestion(text=question_text, answers=answers)
def build_handler_registry() -> dict[str, QuestionPayloadHandler]:
return {
"default": parse_default_question_payload,
}
class QuestionApiService:
def __init__(
self,
url: str,
handler: QuestionPayloadHandler | None = None,
timeout: float = 10.0,
headers: dict[str, str] | None = None,
) -> None:
self.url = url
self.handler = handler or parse_default_question_payload
self.timeout = timeout
self.headers = headers or {"Accept": "application/json"}
def fetch_question(self) -> ApiQuestion:
payload = self._fetch_json()
return self.handler(payload)
def fetch_and_store(self, repository: SQLiteWouldYouRatherRepository) -> int:
question = self.fetch_question()
return repository.add_question(question.text, question.answers)
def _fetch_json(self) -> Any:
request = Request(self.url, headers=self.headers, method="GET")
try:
with urlopen(request, timeout=self.timeout) as response:
charset = response.headers.get_content_charset() or "utf-8"
body = response.read().decode(charset)
except HTTPError as error:
raise RuntimeError(
f"API request failed with status code {error.code}."
) from error
except URLError as error:
raise RuntimeError(f"API request failed: {error.reason}.") from error
try:
return json.loads(body)
except json.JSONDecodeError as error:
raise ValueError("API response is not valid JSON.") from error
def _extract_question_text(payload: dict[str, Any]) -> str:
for key in ("question", "text", "title", "prompt"):
value = payload.get(key)
if isinstance(value, str):
normalized = value.strip()
if normalized:
return normalized
raise ValueError("Question text was not found in API response.")
def _extract_answers(payload: dict[str, Any]) -> list[str]:
raw_answers: Any = None
for key in ("answers", "options", "variants", "choices"):
value = payload.get(key)
if value is not None:
raw_answers = value
break
if raw_answers is None:
raise ValueError("Answer variants were not found in API response.")
if isinstance(raw_answers, dict):
iterable = raw_answers.values()
elif isinstance(raw_answers, list):
iterable = raw_answers
else:
raise ValueError("Answer variants must be a list or object.")
answers: list[str] = []
for item in iterable:
if isinstance(item, str):
normalized = item.strip()
if normalized:
answers.append(normalized)
continue
if isinstance(item, dict):
for key in ("text", "answer", "title", "label"):
value = item.get(key)
if isinstance(value, str):
normalized = value.strip()
if normalized:
answers.append(normalized)
break
unique_answers = list(dict.fromkeys(answers))
return unique_answers

388
game/database.py Normal file
View File

@@ -0,0 +1,388 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
@dataclass(frozen=True)
class QuestionRow:
id: int
text: str
used: bool
@dataclass(frozen=True)
class AnswerRow:
id: int
question_id: int
text: str
@dataclass(frozen=True)
class QuestionWithAnswers:
question: QuestionRow
answers: list[AnswerRow]
@dataclass(frozen=True)
class AnswerStatistic:
answer_id: int
answer_text: str
count: int
@dataclass(frozen=True)
class QuestionListItem:
id: int
text: str
used: bool
answers_count: int
@dataclass(frozen=True)
class ScheduleRow:
id: int
name: str
cron: str
is_active: bool
class SQLiteWouldYouRatherRepository:
def __init__(self, db_path: str | Path = "data\\game.sqlite3") -> None:
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = sqlite3.connect(self.db_path)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS questions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL UNIQUE,
used INTEGER NOT NULL DEFAULT 0 CHECK (used IN (0, 1))
);
CREATE TABLE IF NOT EXISTS answers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL,
text TEXT NOT NULL,
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE,
UNIQUE (question_id, text)
);
CREATE TABLE IF NOT EXISTS statistics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
question_id INTEGER NOT NULL,
answer_id INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0),
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE,
FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE,
UNIQUE (question_id, answer_id)
);
CREATE TABLE IF NOT EXISTS schedules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
cron TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1 CHECK (is_active IN (0, 1))
);
CREATE TABLE IF NOT EXISTS schedule_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
schedule_id INTEGER NOT NULL,
run_key TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (schedule_id) REFERENCES schedules(id) ON DELETE CASCADE,
UNIQUE (schedule_id, run_key)
);
"""
)
def add_question(self, text: str, answers: Iterable[str]) -> int:
prepared_text = text.strip()
prepared_answers = [answer.strip() for answer in answers if answer.strip()]
if not prepared_text:
raise ValueError("Question text cannot be empty.")
if len(prepared_answers) < 2:
raise ValueError("Question must have at least 2 answers.")
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO questions(text, used) VALUES (?, 0)",
(prepared_text,),
)
question_id = int(cursor.lastrowid)
connection.executemany(
"INSERT INTO answers(question_id, text) VALUES (?, ?)",
[(question_id, answer_text) for answer_text in prepared_answers],
)
return question_id
def get_question(self, question_id: int) -> QuestionWithAnswers | None:
with self._connect() as connection:
question_row = connection.execute(
"SELECT id, text, used FROM questions WHERE id = ?",
(question_id,),
).fetchone()
if question_row is None:
return None
answer_rows = connection.execute(
"SELECT id, question_id, text FROM answers WHERE question_id = ? ORDER BY id",
(question_id,),
).fetchall()
return QuestionWithAnswers(
question=QuestionRow(
id=question_row["id"],
text=question_row["text"],
used=bool(question_row["used"]),
),
answers=[
AnswerRow(
id=answer_row["id"],
question_id=answer_row["question_id"],
text=answer_row["text"],
)
for answer_row in answer_rows
],
)
def count_questions(self) -> int:
with self._connect() as connection:
row = connection.execute("SELECT COUNT(*) AS total FROM questions").fetchone()
return int(row["total"])
def list_questions(self, limit: int = 10, offset: int = 0) -> list[QuestionListItem]:
if limit < 1:
raise ValueError("Limit must be >= 1.")
if offset < 0:
raise ValueError("Offset must be >= 0.")
with self._connect() as connection:
rows = connection.execute(
"""
SELECT
q.id,
q.text,
q.used,
COUNT(a.id) AS answers_count
FROM questions q
LEFT JOIN answers a ON a.question_id = q.id
GROUP BY q.id, q.text, q.used
ORDER BY q.id
LIMIT ? OFFSET ?
""",
(limit, offset),
).fetchall()
return [
QuestionListItem(
id=row["id"],
text=row["text"],
used=bool(row["used"]),
answers_count=row["answers_count"],
)
for row in rows
]
def pop_unused_question(self) -> QuestionWithAnswers | None:
with self._connect() as connection:
row = connection.execute(
"SELECT id FROM questions WHERE used = 0 ORDER BY RANDOM() LIMIT 1"
).fetchone()
if row is None:
has_questions = connection.execute(
"SELECT 1 FROM questions LIMIT 1"
).fetchone()
if has_questions is None:
return None
connection.execute("UPDATE questions SET used = 0")
row = connection.execute(
"SELECT id FROM questions ORDER BY RANDOM() LIMIT 1"
).fetchone()
question_id = int(row["id"])
connection.execute("UPDATE questions SET used = 1 WHERE id = ?", (question_id,))
return self.get_question(question_id)
def record_answer(self, question_id: int, answer_id: int, increment: int = 1) -> None:
if increment < 1:
raise ValueError("Increment must be >= 1.")
with self._connect() as connection:
answer_row = connection.execute(
"SELECT 1 FROM answers WHERE id = ? AND question_id = ?",
(answer_id, question_id),
).fetchone()
if answer_row is None:
raise ValueError("Answer does not belong to the provided question.")
connection.execute(
"""
INSERT INTO statistics(question_id, answer_id, count)
VALUES (?, ?, ?)
ON CONFLICT(question_id, answer_id)
DO UPDATE SET count = count + excluded.count
""",
(question_id, answer_id, increment),
)
def get_question_statistics(self, question_id: int) -> list[AnswerStatistic]:
with self._connect() as connection:
rows = connection.execute(
"""
SELECT
a.id AS answer_id,
a.text AS answer_text,
COALESCE(s.count, 0) AS answer_count
FROM answers a
LEFT JOIN statistics s
ON s.answer_id = a.id AND s.question_id = a.question_id
WHERE a.question_id = ?
ORDER BY a.id
""",
(question_id,),
).fetchall()
return [
AnswerStatistic(
answer_id=row["answer_id"],
answer_text=row["answer_text"],
count=row["answer_count"],
)
for row in rows
]
def update_question(self, question_id: int, text: str, answers: Iterable[str]) -> None:
prepared_text = text.strip()
prepared_answers = [answer.strip() for answer in answers if answer.strip()]
if not prepared_text:
raise ValueError("Question text cannot be empty.")
if len(prepared_answers) < 2:
raise ValueError("Question must have at least 2 answers.")
with self._connect() as connection:
exists_row = connection.execute(
"SELECT 1 FROM questions WHERE id = ?",
(question_id,),
).fetchone()
if exists_row is None:
raise ValueError("Question does not exist.")
connection.execute(
"UPDATE questions SET text = ?, used = 0 WHERE id = ?",
(prepared_text, question_id),
)
connection.execute("DELETE FROM answers WHERE question_id = ?", (question_id,))
connection.executemany(
"INSERT INTO answers(question_id, text) VALUES (?, ?)",
[(question_id, answer_text) for answer_text in prepared_answers],
)
def add_schedule(self, name: str, cron: str, is_active: bool = True) -> int:
prepared_name = name.strip()
prepared_cron = cron.strip()
if not prepared_name:
raise ValueError("Schedule name cannot be empty.")
if not prepared_cron:
raise ValueError("Schedule cron cannot be empty.")
with self._connect() as connection:
cursor = connection.execute(
"INSERT INTO schedules(name, cron, is_active) VALUES (?, ?, ?)",
(prepared_name, prepared_cron, int(is_active)),
)
return int(cursor.lastrowid)
def update_schedule(self, schedule_id: int, name: str, cron: str, is_active: bool) -> None:
prepared_name = name.strip()
prepared_cron = cron.strip()
if not prepared_name:
raise ValueError("Schedule name cannot be empty.")
if not prepared_cron:
raise ValueError("Schedule cron cannot be empty.")
with self._connect() as connection:
updated = connection.execute(
"""
UPDATE schedules
SET name = ?, cron = ?, is_active = ?
WHERE id = ?
""",
(prepared_name, prepared_cron, int(is_active), schedule_id),
)
if updated.rowcount == 0:
raise ValueError("Schedule does not exist.")
def get_schedule(self, schedule_id: int) -> ScheduleRow | None:
with self._connect() as connection:
row = connection.execute(
"SELECT id, name, cron, is_active FROM schedules WHERE id = ?",
(schedule_id,),
).fetchone()
if row is None:
return None
return ScheduleRow(
id=row["id"],
name=row["name"],
cron=row["cron"],
is_active=bool(row["is_active"]),
)
def list_schedules(self, active_only: bool = False) -> list[ScheduleRow]:
with self._connect() as connection:
if active_only:
rows = connection.execute(
"SELECT id, name, cron, is_active FROM schedules WHERE is_active = 1 ORDER BY id"
).fetchall()
else:
rows = connection.execute(
"SELECT id, name, cron, is_active FROM schedules ORDER BY id"
).fetchall()
return [
ScheduleRow(
id=row["id"],
name=row["name"],
cron=row["cron"],
is_active=bool(row["is_active"]),
)
for row in rows
]
def record_schedule_run(self, schedule_id: int, run_key: str) -> bool:
with self._connect() as connection:
cursor = connection.execute(
"""
INSERT OR IGNORE INTO schedule_runs(schedule_id, run_key)
VALUES (?, ?)
""",
(schedule_id, run_key),
)
return cursor.rowcount > 0
def delete_schedule(self, schedule_id: int) -> None:
with self._connect() as connection:
deleted = connection.execute(
"DELETE FROM schedules WHERE id = ?",
(schedule_id,),
)
if deleted.rowcount == 0:
raise ValueError("Schedule does not exist.")
def reset_used_flags(self) -> None:
with self._connect() as connection:
connection.execute("UPDATE questions SET used = 0")

49
game/game.py Normal file
View File

@@ -0,0 +1,49 @@
from __future__ import annotations
from dataclasses import dataclass
from .database import AnswerStatistic, SQLiteWouldYouRatherRepository
@dataclass(frozen=True)
class Question:
id: int
title: str
variants: list[str]
variant_ids: list[int]
def answer_id(self, variant_index: int) -> int:
if variant_index < 0 or variant_index >= len(self.variant_ids):
raise IndexError("Answer variant index is out of range.")
return self.variant_ids[variant_index]
class WouldYouRather:
def __init__(self, data: SQLiteWouldYouRatherRepository):
self.data = data
def add_question(self, title: str, variants: list[str]) -> int:
return self.data.add_question(text=title, answers=variants)
def pop_question(self) -> Question | None:
question_with_answers = self.data.pop_unused_question()
if question_with_answers is None:
return None
return Question(
id=question_with_answers.question.id,
title=question_with_answers.question.text,
variants=[answer.text for answer in question_with_answers.answers],
variant_ids=[answer.id for answer in question_with_answers.answers],
)
def answer_question(self, question: Question, variant_index: int, increment: int = 1) -> None:
answer_id = question.answer_id(variant_index)
self.data.record_answer(
question_id=question.id,
answer_id=answer_id,
increment=increment,
)
def get_question_statistics(self, question_id: int) -> list[AnswerStatistic]:
return self.data.get_question_statistics(question_id)

149
game/settings.py Normal file
View File

@@ -0,0 +1,149 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
import tomllib
if TYPE_CHECKING:
from .api_service import QuestionApiService
@dataclass(frozen=True)
class ApiSourceSettings:
name: str
url: str
handler: str
@dataclass(frozen=True)
class AppSettings:
admin_ids: list[int]
poll_channel_ids: list[int]
question_apis: list[ApiSourceSettings]
@dataclass(frozen=True)
class ConfiguredApiService:
name: str
handler: str
service: QuestionApiService
DEFAULT_SETTINGS_TOML = """# Telegram admin user IDs
admin_ids = []
# Telegram channel IDs where polls should be sent
poll_channel_ids = []
[[question_apis]]
name = "default-source"
url = "https://example.com/api/questions/random"
handler = "default"
"""
class AppSettingsService:
def __init__(self, path: str | Path = "data\\settings.toml") -> None:
self.path = Path(path)
def ensure_exists(self) -> None:
if self.path.exists():
return
self.path.parent.mkdir(parents=True, exist_ok=True)
self.path.write_text(DEFAULT_SETTINGS_TOML, encoding="utf-8")
def load(self) -> AppSettings:
if not self.path.exists():
raise FileNotFoundError(
f"Settings file does not exist: {self.path}. "
"Call ensure_exists() to create a template file."
)
raw = tomllib.loads(self.path.read_text(encoding="utf-8"))
admin_ids = self._parse_int_list(raw, "admin_ids")
poll_channel_ids = self._parse_int_list(raw, "poll_channel_ids")
question_apis = self._parse_api_sources(raw)
return AppSettings(
admin_ids=admin_ids,
poll_channel_ids=poll_channel_ids,
question_apis=question_apis,
)
def load_or_create(self) -> AppSettings:
self.ensure_exists()
return self.load()
def build_api_services(
self,
settings: AppSettings,
timeout: float = 10.0,
headers: dict[str, str] | None = None,
) -> list[ConfiguredApiService]:
from .api_service import QuestionApiService, build_handler_registry
registry = build_handler_registry()
services: list[ConfiguredApiService] = []
for source in settings.question_apis:
handler = registry.get(source.handler)
if handler is None:
raise ValueError(
f"Unknown question API handler '{source.handler}' for source '{source.name}'."
)
services.append(
ConfiguredApiService(
name=source.name,
handler=source.handler,
service=QuestionApiService(
url=source.url,
handler=handler,
timeout=timeout,
headers=headers,
),
)
)
return services
def _parse_int_list(self, raw: dict, key: str) -> list[int]:
value = raw.get(key, [])
if not isinstance(value, list):
raise ValueError(f"'{key}' must be a list of integers.")
parsed: list[int] = []
for item in value:
if isinstance(item, bool) or not isinstance(item, int):
raise ValueError(f"'{key}' must contain only integers.")
parsed.append(item)
return parsed
def _parse_api_sources(self, raw: dict) -> list[ApiSourceSettings]:
value = raw.get("question_apis", [])
if not isinstance(value, list):
raise ValueError("'question_apis' must be an array of tables.")
parsed: list[ApiSourceSettings] = []
for index, item in enumerate(value):
if not isinstance(item, dict):
raise ValueError(f"'question_apis[{index}]' must be a table.")
name = item.get("name")
url = item.get("url")
handler = item.get("handler", "default")
if not isinstance(name, str) or not name.strip():
raise ValueError(f"'question_apis[{index}].name' must be a non-empty string.")
if not isinstance(url, str) or not url.strip():
raise ValueError(f"'question_apis[{index}].url' must be a non-empty string.")
if not isinstance(handler, str) or not handler.strip():
raise ValueError(
f"'question_apis[{index}].handler' must be a non-empty string."
)
parsed.append(
ApiSourceSettings(name=name.strip(), url=url.strip(), handler=handler.strip())
)
return parsed

20
main.py Normal file
View File

@@ -0,0 +1,20 @@
import asyncio
from dotenv import load_dotenv
import os
import logging
import bot
from bot.logging_config import configure_logging
if __name__ == "__main__":
# Хотя если запускать через докера, всё просто там указуется
load_dotenv()
logger = configure_logging()
if not os.path.exists("data"):
os.makedirs("data")
logger.info("Created data directory.")
logging.getLogger(__name__).info("Starting bot application.")
asyncio.run(bot.main())

3
requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
python-dotenv
kurigram
tgcrypto