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:
176
.gitignore
vendored
Normal file
176
.gitignore
vendored
Normal 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
151
README.md
Normal 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
188
SCHEDULER_GUIDE.md
Normal 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
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,
|
||||||
|
)
|
||||||
27
game/__init__.py
Normal file
27
game/__init__.py
Normal 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
133
game/api_service.py
Normal 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
388
game/database.py
Normal 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
49
game/game.py
Normal 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
149
game/settings.py
Normal 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
20
main.py
Normal 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
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
python-dotenv
|
||||||
|
kurigram
|
||||||
|
tgcrypto
|
||||||
Reference in New Issue
Block a user