Add files via upload

This commit is contained in:
imletbruh
2026-05-31 18:24:39 +05:00
committed by GitHub
parent fcaa53b5ce
commit 9de1a40f1f
6 changed files with 1392 additions and 1227 deletions
+124 -178
View File
@@ -1,27 +1,41 @@
# 🚀 QIYANA AUTO-BUMP BOT для Lolz.live
Production-ready Telegram бот для автоматического поднятия тем на форуме Lolz.live с современной архитектурой, batch API и полной типизацией.
Telegram бот для автоматического поднятия тем на форуме Lolz.live. Batch API, динамические настройки (меняются на лету без перезапуска), персистентная статистика.
## ✨ Возможности
- **Добавление тем** - через запятую (12345, 67890, 11111) с batch API
- 🗑️ **Удаление тем** - через интерактивное меню
- 📋 **Список тем** - с датой последнего поднятия
- 🚀 **Ручное поднятие** - поднять все темы немедленно через batch API
- **Автоподнятие** - каждые N часов автоматически
- 📊 **Статистика** - успешность, количество поднятий, uptime
- 🔔 **Уведомления** - о каждом поднятии темы
- 🛡️ **Обработка ошибок** - rate limits, network failures, invalid tokens
- **Batch API** - до 10 тем за один запрос (10x быстрее)
- **Добавление тем** через запятую, названия загружаются сразу через batch API
- 🗑️ **Удаление тем** через интерактивное меню
- 📋 **Список тем** с названиями из БД и датой последнего поднятия
- 🚀 **Ручное поднятие** все темы немедленно через batch API
- 🔄 **Обновление названий** — синхронизация названий тем с форумом по кнопке
- **Автоподнятие** — каждые N часов автоматически
- 🛠️ **Динамические настройки** — интервал, batch size, автобамп меняются через меню без перезапуска
- 📊 **Статистика** — сохраняется в БД, не теряется при перезапуске
- 🔔 **Уведомления** — о результате каждого поднятия
- 🛡️ **Защита доступа** — ботом управляет только админ (по Telegram ID)
-**Batch API** — до 10 тем за один запрос
## 🎮 Интерфейс
```
┌─────────────────────────────────┐
Добавить темы │ 📋 Список тем
│ 🗑️ Удалить тему │ 🚀 Поднять темы
📊 Статистика │ 👤 Автор
└─────────────────────────────────┘
┌──────────────────────────────────────
Add topics │ 📋 List of topics
│ 🗑️ Delete topic │ 🚀 Bump topics
🔄 Refresh │ 📊 Statistics
│ 🛠️ Settings │ 👤 Author │
└──────────────────────────────────────┘
```
### Меню настроек
```
┌──────────────────────────────────┐
│ ⏰ Set Interval │
│ 📦 Set Batch Size │
│ 🔄 Toggle Auto-Bump │
│ ↩️ Back to Menu │
└──────────────────────────────────┘
```
## 📦 Установка
@@ -29,9 +43,9 @@ Production-ready Telegram бот для автоматического подн
### Требования
- Python 3.12+
- pip или uv
- pip
### 1. Установите зависимости
### 1. Клонируйте и установите зависимости
```bash
pip install -r requirements.txt
@@ -49,29 +63,32 @@ pip install -r requirements.txt
2. Создайте токен с правами `read`, `post`
3. Скопируйте токен
### 3. Настройте config.json
**Ваш Telegram ID:**
1. Напишите [@userinfobot](https://t.me/userinfobot) и получите свой ID
```json
{
"bot": {
"api_token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz",
"img_url": "https://wallpapers-clan.com/wp-content/uploads/2024/04/dark-anime-girl-with-red-eyes-desktop-wallpaper-preview.jpg",
"author_url": "https://lolz.live/kqlol/"
},
"api": {
"base_url": "https://prod-api.lolz.live",
"auth_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"batch_size": 10
},
"database": {
"path": "threads.db"
},
"scheduling": {
"bump_interval_hours": 12,
"bump_delay_seconds": 2,
"enable_auto_bump": true
}
}
### 3. Настройте `.env`
```env
# Telegram Bot
BOT_API_TOKEN=1234567890:ABCdefGHIjklMNOpqrsTUVwxyz
BOT_IMG_URL=https://wallpapers-clan.com/wp-content/uploads/2024/04/dark-anime-girl-with-red-eyes-desktop-wallpaper-preview.jpg
BOT_AUTHOR_URL=https://lolz.live/kqlol/
# Lolz API
API_BASE_URL=https://prod-api.lolz.live
API_AUTH_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGc...
API_BATCH_SIZE=10
# Database
DB_PATH=threads.db
# Scheduling
BUMP_INTERVAL_HOURS=12
BUMP_DELAY_SECONDS=2
ENABLE_AUTO_BUMP=true
# Admin (ваш Telegram user ID)
ADMIN_USER_ID=123456789
```
### 4. Запустите бота
@@ -84,210 +101,139 @@ python app.py
### Добавление тем
1. Нажмите ** Добавить темы**
1. Нажмите ** Add topics**
2. Введите ID через запятую: `12345, 67890, 11111`
3. Бот автоматически получит названия тем через batch API (до 10 за запрос)
3. Бот сразу загрузит реальные названия через batch API и сохранит в БД
**Пример:**
```
Ввод: 9247920, 9247921, 9247922
### Обновление названий
Результат:
✅ 9247920 - Qiyanas steam idler
✅ 9247921 - Discord bot
✅ 9247922 - VPN service
```
1. Нажмите **🔄 Refresh**
2. Бот загрузит актуальные названия всех тем через batch API
3. Обновлённые названия сохранятся в БД
**Batch API преимущества:**
- Добавление 10 тем = 1 batch запрос вместо 10 обычных
- Добавление 25 тем = 3 batch запроса вместо 25 обычных
- Экономия API лимитов в 10 раз
Полезно, если темы переименовали на форуме — не нужно удалять и добавлять заново.
### Удаление темы
1. Нажмите **🗑️ Удалить тему**
1. Нажмите **🗑️ Delete topic**
2. Выберите тему из списка
3. Подтвердите удаление
### Ручное поднятие
1. Нажмите **🚀 Поднять темы**
2. Бот поднимет все темы через batch API (до 10 за запрос)
1. Нажмите **🚀 Bump topics**
2. Бот поднимет все темы через batch API
3. Получите уведомление о каждой теме:
```
[1/3] ✅ Тема 9247920 поднята успешно
[2/3] ✅ Тема 9247921 поднята успешно
[3/3] Тема 9247922 поднята успешно
[3/3] Тема 9247922: нужно подождать
```
**Batch API преимущества:**
- Поднятие 10 тем = 1 batch запрос (0.1 * 10 = 1 batch)
- Поднятие 50 тем = 5 batch запросов вместо 50 обычных
- Скорость выполнения увеличена в ~10 раз
### Просмотр списка
Нажмите **📋 Список тем** - покажет:
- ID темы
- Название темы
- Дату последнего поднятия
### Статистика
Нажмите **📊 Статистика** - покажет:
- Количество тем
- Готовые к поднятию
- Всего попыток
- Успешность (%)
- Настройки интервала
Нажмите **📊 Statistics** покажет:
- Количество тем (всего / готовы к поднятию)
- Статистика поднятий (всего / успешно / %)
- Дата последнего бампа
- Текущие настройки
- Время работы бота
## ⚙️ Настройки
Статистика сохраняется в SQLite и не сбрасывается при перезапуске.
### Интервал автоподнятия
## ⚙️ Настройки (меняются на лету)
В `config.json` измените `bump_interval_hours`:
Все настройки изменяются через кнопку **🛠️ Settings** без остановки бота:
| Значение | Интервал |
|----------|----------|
| `12` | 12 часов |
| `6` | 6 часов |
| `24` | 24 часа |
| Кнопка | Что меняет | Применяется |
|--------|-----------|-------------|
| ⏰ Set Interval | Интервал автобампа (часы) | Мгновенно, перезапускает цикл |
| 📦 Set Batch Size | Размер batch (1–10) | Мгновенно для следующих запросов |
| 🔄 Toggle Auto-Bump | Включить/выключить автобамп | Мгновенно |
### Размер batch запросов
В `config.json` измените `batch_size`:
| Значение | Описание |
|----------|----------|
| `10` | Максимум (рекомендуется) - 10 тем за запрос |
| `5` | Средний - 5 тем за запрос |
| `1` | Отключить batch - по 1 теме |
**Примечание:** Batch API Lolz.live поддерживает до 10 запросов в одном batch. Каждый запрос = 0.1 batch, итого 10 запросов = 1 полный batch.
### Тестовый режим (5 минут)
```json
"scheduling": {
"bump_interval_hours": 0.0833,
"bump_delay_seconds": 1,
"enable_auto_bump": true
}
```
Настройки хранятся в БД и сохраняются между перезапусками.
## 🔄 Как работает автоподнятие
1. Запускаете бота
2. Вручную поднимаете темы через 🚀 (первый раз)
3. Бот ждет указанный интервал (например, 12 часов)
4. Автоматически поднимает темы, у которых прошло 12+ часов
5. Повторяет каждые 12 часов
3. Бот ждёт указанный интервал (например, 12 часов)
4. Автоматически поднимает темы, у которых прошёл интервал
5. Цикл повторяется
**Пример:**
```
00:00 - Запуск бота
00:05 - Вы вручную нажали "Поднять темы"
12:05 - Автоматическое поднятие
24:05 - Автоматическое поднятие
36:05 - Автоматическое поднятие
00:00 Запуск бота
00:05 — Ручное поднятие
12:05 Автоматическое поднятие
24:05 Автоматическое поднятие
```
## 🏗️ Архитектура
### Модульная структура
```
app.py # Main bot logic with FSM
config_manager.py # Type-safe configuration
api_client.py # Lolz batch API client with retry logic
database.py # SQLite with connection pooling
app.py # Бот: хендлеры, middleware аутентификации, авто-бамп
config_manager.py # Загрузка и валидация .env
api_client.py # Lolz batch API клиент с retry логикой
database.py # SQLite: темы, настройки, статистика
```
### Ключевые улучшения
### Что внутри БД (SQLite через aiosqlite)
- **Batch API**: До 10 тем за один запрос (10x эффективнее)
- **Type Safety**: Полная типизация с Python 3.12+ (PEP 695)
- **SOLID Principles**: Каждый модуль имеет одну ответственность
- **Connection Pooling**: Эффективное управление соединениями
- **Error Handling**: Специфичные исключения для каждого случая
- **Async Context Managers**: Автоматическая очистка ресурсов
- **Immutable Config**: Frozen dataclasses для безопасности
- **Logging**: Структурированные логи с rotation
- **Graceful Shutdown**: Корректное завершение всех задач
| Таблица | Назначение |
|---------|-----------|
| `threads` | Темы: ID, название, дата последнего бампа |
| `settings` | Динамические настройки (интервал, batch_size, автобамп) |
| `bump_stats` | Персистентная статистика (всего/успешно/последний бамп) |
### Batch API Implementation
### Ключевые особенности
**Как работает:**
```python
# Старый способ (10 запросов):
for thread_id in [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]:
POST /threads/{thread_id}/bump
# Новый способ (1 batch запрос):
POST /batch
[
{"uri": "/threads/1/bump", "method": "POST"},
{"uri": "/threads/2/bump", "method": "POST"},
...
{"uri": "/threads/10/bump", "method": "POST"}
]
```
**Преимущества:**
- Экономия API лимитов в 10 раз
- Скорость выполнения увеличена в ~10 раз
- Меньше нагрузка на сеть
- Атомарная обработка результатов
### Edge Cases
- Rate limiting от API (обработка в batch ответах)
- Network timeouts (retry для batch запросов)
- Invalid tokens (валидация перед batch)
- Partial batch failures (индивидуальная обработка каждого результата)
- Concurrent access (connection pooling)
- Database locks (async context managers)
- Memory leaks prevention (frozen dataclasses, proper cleanup)
- **Batch API** — до 10 тем за запрос (экономия лимитов в 10 раз)
- **Динамические настройки** — меняются в БД, подхватываются ботом мгновенно
- **Персистентная статистика** — не теряется при перезапуске
- **Аутентификация** — middleware проверяет Telegram ID админа
- **Async context managers** — автоматическая очистка ресурсов
- **Frozen dataclasses с `__slots__`** — иммутабельность и экономия памяти
- **WAL-режим SQLite** — лучшая конкурентность
## 📝 Логи
- **Консоль** - основные события
- **bot.log** - детальные логи с traceback
- **Консоль** основные события
- **bot.log** детальные логи с traceback
## 🔧 Технические детали
## 🔧 Зависимости
- **Python**: 3.12+
- **aiogram**: 3.4.1
- **aiohttp**: 3.10.0+
- **aiogram**: 3.15.0
- **aiohttp**: bundled with aiogram
- **aiosqlite**: 0.20.0+
- **База данных**: SQLite с индексами
- **API**: https://prod-api.lolz.live
- **Batch API**: До 10 запросов за 1 batch (каждый = 0.1 batch)
- **python-dotenv**: 1.0.0+
## 🐛 Troubleshooting
### Ошибка: "bot.api_token not configured"
### Ошибка: «BOT_API_TOKEN not configured»
Заполните `config.json` реальными токенами.
Проверьте `.env` — заполните `BOT_API_TOKEN` реальным токеном от @BotFather.
### Ошибка: "Invalid API token"
### Ошибка: «ADMIN_USER_ID not configured»
Укажите в `.env` ваш Telegram ID (получить через @userinfobot).
### Ошибка: «API_AUTH_TOKEN not configured»
Проверьте токен на [zelenka.guru/account/api](https://zelenka.guru/account/api).
### Ошибка: "Network error"
### Ошибка: «⛔ Доступ запрещён»
Проверьте интернет-соединение и доступность API.
Ботом может управлять только пользователь, чей Telegram ID указан в `ADMIN_USER_ID`.
### Темы не поднимаются автоматически
1. Проверьте `enable_auto_bump: true` в config.json
1. Проверьте, что автобамп включён (🔄 Toggle Auto-Bump)
2. Сделайте первое поднятие вручную через 🚀
3. Проверьте логи в bot.log
3. Проверьте логи в `bot.log`
## 👤 Автор
[QIYANA](https://lolz.live/kqlol/) - создатель бота
[QIYANA](https://lolz.live/kqlol/)
---
+40 -7
View File
@@ -352,10 +352,10 @@ class APIClient:
continue
job_data = jobs[uri]
logger.debug(f"Thread {thread_id} bump response: {job_data}")
logger.info(f"Thread {thread_id} raw bump response: {job_data}")
# Empty dict means success for bump
if isinstance(job_data, dict) and len(job_data) == 0:
# Empty list [] or empty dict {} means success
if isinstance(job_data, (list, dict)) and len(job_data) == 0:
logger.info(f"Thread {thread_id} bumped successfully (empty response)")
results.append(BumpResult(
success=True,
@@ -364,7 +364,6 @@ class APIClient:
status=BumpStatus.SUCCESS
))
elif isinstance(job_data, dict) and "errors" in job_data:
# Has errors
error_msg = self._extract_error_message(str(job_data["errors"]))
logger.error(
f"Thread {thread_id} bump failed | "
@@ -377,12 +376,46 @@ class APIClient:
thread_id=thread_id,
status=BumpStatus.ERROR
))
elif isinstance(job_data, dict) and "_job_result" in job_data:
job_result = str(job_data.get("_job_result", ""))
job_message = str(job_data.get("_job_message", ""))
if job_result == "error":
error_text = job_message
if not error_text.strip():
errors = job_data.get("errors")
if errors:
if isinstance(errors, list):
error_text = str(errors[0]) if errors else ""
elif isinstance(errors, str):
error_text = errors
else:
# Unknown response
logger.warning(f"Thread {thread_id} unknown bump response: {job_data}")
error_text = str(errors)
if not error_text.strip():
error_text = str(job_data.get("error", ""))
error_msg = self._extract_error_message(error_text) or "Ошибка API (см. логи)"
logger.error(
f"Thread {thread_id} bump failed | "
f"Job error: {error_msg}"
)
results.append(BumpResult(
success=False,
message=f"Тема {thread_id}: Неизвестный ответ",
message=f"Тема {thread_id}: {error_msg}",
thread_id=thread_id,
status=BumpStatus.ERROR
))
else:
logger.info(f"Thread {thread_id} bumped successfully (job_result={job_result}, job_message={job_message})")
results.append(BumpResult(
success=True,
message=f"✅ Тема {thread_id} поднята успешно",
thread_id=thread_id,
status=BumpStatus.SUCCESS
))
else:
logger.warning(f"Thread {thread_id} unknown bump response (type={type(job_data).__name__}): {job_data}")
results.append(BumpResult(
success=False,
message=f"Тема {thread_id}: Неизвестный ответ ({type(job_data).__name__})",
thread_id=thread_id,
status=BumpStatus.ERROR
))
+367 -206
View File
@@ -1,16 +1,17 @@
"""Telegram bot for automatic thread bumping on Lolz.live."""
"""Telegram bot for automatic thread bumping on Lolz.live — with dynamic settings & auth."""
import asyncio
import logging
import sys
from datetime import datetime
from typing import Sequence
from typing import Any, Awaitable, Callable, Sequence
from aiogram import Bot, Dispatcher, F, Router
from aiogram import BaseMiddleware, Bot, Dispatcher, F, Router
from aiogram.client.default import DefaultBotProperties
from aiogram.enums import ParseMode
from aiogram.filters import Command, StateFilter
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
from aiogram.types import TelegramObject
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.fsm.storage.memory import MemoryStorage
@@ -18,47 +19,88 @@ from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
from config_manager import Config
from api_client import APIClient, BumpResult
from database import Database, Thread
from database import Database, BumpStats, Thread
# Constants
NOTIFICATION_DELAY_SECONDS = 0.8
BUTTON_TEXT_MAX_LENGTH = 30
AUTO_BUMP_RETRY_DELAY_SECONDS = 60
# Configure logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[
logging.StreamHandler(sys.stdout),
logging.FileHandler("bot.log", encoding="utf-8")
]
logging.FileHandler("bot.log", encoding="utf-8"),
],
)
logger = logging.getLogger(__name__)
class BotStates(StatesGroup):
"""FSM states for bot."""
waiting_for_thread_ids = State()
waiting_for_interval = State()
waiting_for_batch_size = State()
class AuthMiddleware(BaseMiddleware):
def __init__(self, admin_user_id: int):
super().__init__()
self._admin_user_id = admin_user_id
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
from_user = getattr(event, "from_user", None)
if from_user is None:
cq = getattr(event, "callback_query", None)
if cq is not None:
from_user = cq.from_user
else:
msg = getattr(event, "message", None)
if msg is not None:
from_user = msg.from_user
if from_user is None:
return await handler(event, data)
if from_user.id != self._admin_user_id:
chat_id = None
msg = getattr(event, "message", None)
if msg is not None:
chat_id = msg.chat.id
else:
cq = getattr(event, "callback_query", None)
if cq is not None and cq.message is not None:
chat_id = cq.message.chat.id
if chat_id:
try:
bot: Bot = data["bot"]
await bot.send_message(chat_id, "⛔ Доступ запрещён.")
except Exception:
pass
return None
return await handler(event, data)
class AutoBumpBot:
"""Main bot class with clean separation of concerns."""
__slots__ = (
"_config", "_bot", "_dp", "_router", "_api", "_db",
"_is_running", "_total_bumps", "_successful_bumps", "_start_time"
"_is_running", "_start_time", "_auto_bump_task",
)
def __init__(self, config: Config) -> None:
self._config = config
# Initialize bot with default properties (aiogram 3.15.0 best practice)
self._bot = Bot(
token=config.bot.api_token,
default=DefaultBotProperties(parse_mode=ParseMode.HTML)
default=DefaultBotProperties(parse_mode=ParseMode.HTML),
)
self._dp = Dispatcher(storage=MemoryStorage())
@@ -67,139 +109,151 @@ class AutoBumpBot:
self._api = APIClient(
config.api.base_url,
config.api.auth_token,
config.api.batch_size
config.api.batch_size,
)
self._db = Database(config.database.path)
# Statistics
self._is_running = False
self._total_bumps = 0
self._successful_bumps = 0
self._start_time: datetime | None = None
self._auto_bump_task: asyncio.Task | None = None
# Register handlers
self._register_handlers()
# Include router in dispatcher
self._dp.include_router(self._router)
self._dp.update.middleware(AuthMiddleware(config.admin_user_id))
def _register_handlers(self) -> None:
"""Register all bot message and callback handlers."""
# Message handlers
# Commands
self._router.message.register(
self._handle_start_command,
Command("start")
self._handle_start_command, Command("start")
)
# FSM state handlers
self._router.message.register(
self._handle_thread_ids_input,
StateFilter(BotStates.waiting_for_thread_ids)
StateFilter(BotStates.waiting_for_thread_ids),
)
self._router.message.register(
self._handle_interval_input,
StateFilter(BotStates.waiting_for_interval),
)
self._router.message.register(
self._handle_batch_size_input,
StateFilter(BotStates.waiting_for_batch_size),
)
# Callbacks
self._router.callback_query.register(self._handle_add_thread_callback, F.data == "add_thread")
self._router.callback_query.register(self._handle_list_threads_callback, F.data == "list_threads")
self._router.callback_query.register(self._handle_delete_menu_callback, F.data == "delete_menu")
self._router.callback_query.register(self._handle_delete_thread_callback, F.data.startswith("delete_"))
self._router.callback_query.register(self._handle_bump_now_callback, F.data == "bump_now")
self._router.callback_query.register(self._handle_stats_callback, F.data == "stats")
self._router.callback_query.register(self._handle_refresh_titles_callback, F.data == "refresh_titles")
self._router.callback_query.register(self._handle_settings_callback, F.data == "settings")
self._router.callback_query.register(self._handle_set_interval_callback, F.data == "set_interval")
self._router.callback_query.register(self._handle_set_batch_size_callback, F.data == "set_batch_size")
self._router.callback_query.register(self._handle_toggle_auto_bump_callback, F.data == "toggle_auto_bump")
self._router.callback_query.register(self._handle_back_to_menu_callback, F.data == "back_to_menu")
# Callback query handlers
self._router.callback_query.register(
self._handle_add_thread_callback,
F.data == "add_thread"
)
self._router.callback_query.register(
self._handle_list_threads_callback,
F.data == "list_threads"
)
self._router.callback_query.register(
self._handle_delete_menu_callback,
F.data == "delete_menu"
)
self._router.callback_query.register(
self._handle_delete_thread_callback,
F.data.startswith("delete_")
)
self._router.callback_query.register(
self._handle_bump_now_callback,
F.data == "bump_now"
)
self._router.callback_query.register(
self._handle_stats_callback,
F.data == "stats"
)
# ─── Keyboards ─────────────────────────────────────────────
def _create_main_menu_keyboard(self) -> InlineKeyboardMarkup:
"""Create main menu inline keyboard."""
def _main_menu_kb(self) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(text=" Add topics", callback_data="add_thread"),
InlineKeyboardButton(text="📋 List of topics", callback_data="list_threads")
InlineKeyboardButton(text="📋 List of topics", callback_data="list_threads"),
],
[
InlineKeyboardButton(text="🗑️ Delete topic", callback_data="delete_menu"),
InlineKeyboardButton(text="🚀 Bump topics", callback_data="bump_now")
InlineKeyboardButton(text="🚀 Bump topics", callback_data="bump_now"),
],
[
InlineKeyboardButton(text="🔄 Refresh", callback_data="refresh_titles"),
InlineKeyboardButton(text="📊 Statistics", callback_data="stats"),
InlineKeyboardButton(text="👤 Author", url=self._config.bot.author_url)
]
],
[
InlineKeyboardButton(text="🛠️ Settings", callback_data="settings"),
InlineKeyboardButton(text="👤 Author", url=self._config.bot.author_url),
],
])
def _settings_kb(self) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[InlineKeyboardButton(text="⏰ Set Interval", callback_data="set_interval")],
[InlineKeyboardButton(text="📦 Set Batch Size", callback_data="set_batch_size")],
[InlineKeyboardButton(text="🔄 Toggle Auto-Bump", callback_data="toggle_auto_bump")],
[InlineKeyboardButton(text="↩️ Back to Menu", callback_data="back_to_menu")],
])
async def _send_main_menu(self, chat_id: int, text: str = "Choose an action:") -> None:
"""Send main menu to user with photo for consistent width."""
await self._bot.send_photo(
chat_id,
photo=self._config.bot.img_url,
caption=text,
reply_markup=self._create_main_menu_keyboard()
reply_markup=self._main_menu_kb(),
)
# ─── Utilities ──────────────────────────────────────────────
@staticmethod
def _truncate_text_safely(text: str, max_length: int) -> str:
"""Safely truncate text without breaking unicode characters."""
if len(text) <= max_length:
return text
truncated = text[:max_length]
try:
truncated.encode('utf-8')
truncated.encode("utf-8")
return truncated
except UnicodeEncodeError:
return AutoBumpBot._truncate_text_safely(text[:max_length - 1], max_length - 1)
return AutoBumpBot._truncate_text_safely(text[: max_length - 1], max_length - 1)
@staticmethod
def _calculate_uptime(start_time: datetime) -> str:
"""Calculate uptime from start time."""
delta = datetime.now() - start_time
total_seconds = int(delta.total_seconds())
days = total_seconds // 86400
hours = (total_seconds % 86400) // 3600
minutes = (total_seconds % 3600) // 60
if days > 0:
return f"{days}д {hours}ч {minutes}м"
return f"{days}d {hours}h {minutes}m"
elif hours > 0:
return f"{hours}ч {minutes}м"
return f"{hours}h {minutes}m"
else:
return f"{minutes}м"
return f"{minutes}m"
async def _get_interval(self) -> float:
val = await self._db.get_setting("bump_interval_hours", "12")
return float(val)
async def _get_batch_size(self) -> int:
val = await self._db.get_setting("batch_size", "10")
return int(val)
async def _is_auto_bump_enabled(self) -> bool:
val = await self._db.get_setting("enable_auto_bump", "true")
return val.lower() == "true"
# ─── Start ─────────────────────────────────────────────────
async def _handle_start_command(self, message: Message) -> None:
"""Handle /start command."""
try:
interval = await self._get_interval()
await message.answer_photo(
photo=self._config.bot.img_url,
caption=(
"🤖 <b>QIYANA AUTO-BUMP BOT</b>\n\n"
"Бот для автоматического поднятия тем на Lolz.live\n\n"
f"⏰ Автоподнятие каждые <b>{self._config.scheduling.bump_interval_hours}ч</b>"
f"⏰ Автоподнятие каждые <b>{interval:.0f}ч</b>"
),
reply_markup=self._create_main_menu_keyboard()
reply_markup=self._main_menu_kb(),
)
except TelegramBadRequest as e:
logger.error(f"Failed to send start message: {e}")
await message.answer("❌ Ошибка отправки сообщения. Попробуйте /start снова.")
async def _handle_add_thread_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
"""Handle add thread button callback."""
await callback.answer()
# ─── Add Thread ─────────────────────────────────────────────
async def _handle_add_thread_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer()
if not callback.message:
return
await callback.message.answer(
"📝 Введите ID тем через запятую для добавления:\n"
"Пример: <code>12345, 67890, 11111</code>"
@@ -207,7 +261,6 @@ class AutoBumpBot:
await state.set_state(BotStates.waiting_for_thread_ids)
async def _handle_thread_ids_input(self, message: Message, state: FSMContext) -> None:
"""Handle thread IDs input from user."""
try:
thread_ids = [tid.strip() for tid in message.text.split(",") if tid.strip()]
@@ -215,7 +268,6 @@ class AutoBumpBot:
await message.answer("❌ Не указаны ID тем")
return
# Validate format
valid_ids: list[str] = []
errors: list[str] = []
@@ -230,29 +282,35 @@ class AutoBumpBot:
return
status_msg = await message.answer(
f"⏳ Добавляю {len(valid_ids)} тем в базу данных..."
f"⏳ Добавляю {len(valid_ids)} тем и загружаю названия..."
)
# Add threads to database directly (without fetching titles)
# Fetch real titles via batch API
titles_map: dict[str, str] = {}
try:
threads_info = await self._api.get_threads_info_batch(valid_ids)
for i, info in enumerate(threads_info):
tid = valid_ids[i]
titles_map[tid] = info.title if info else f"Thread {tid}"
except Exception as e:
logger.error(f"Error fetching titles during add: {e}")
for tid in valid_ids:
titles_map.setdefault(tid, f"Thread {tid}")
added: list[str] = []
for thread_id in valid_ids:
# Use thread ID as title initially (will be fetched on first bump/list)
success = await self._db.add_thread(thread_id, f"Thread {thread_id}")
title = titles_map.get(thread_id, f"Thread {thread_id}")
success = await self._db.add_thread(thread_id, title)
if success:
added.append(f"{thread_id}")
added.append(f"{thread_id}{title}")
else:
errors.append(f"⚠️ {thread_id} - уже добавлена")
# Build result message
result_parts = ["📊 Результат добавления:\n"]
if added:
result_parts.append("\n✅ <b>Добавлено:</b>\n")
result_parts.append("\n".join(added))
result_parts.append("\n")
if errors:
result_parts.append("\n❌ <b>Ошибки:</b>\n")
result_parts.append("\n".join(errors))
@@ -266,41 +324,22 @@ class AutoBumpBot:
finally:
await state.clear()
async def _handle_list_threads_callback(self, callback: CallbackQuery) -> None:
"""Handle list threads button callback."""
await callback.answer()
# ─── List Threads ───────────────────────────────────────────
async def _handle_list_threads_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
try:
threads = await self._db.get_all_threads()
if not threads:
await callback.message.answer("📭 Список тем пуст\n\nДобавьте темы через кнопку ➕")
await self._send_main_menu(callback.message.chat.id)
return
# Fetch real titles from API using batch
status_msg = await callback.message.answer(
f"⏳ Загружаю информацию о {len(threads)} темах..."
)
thread_ids = [t.id for t in threads]
try:
threads_info = await self._api.get_threads_info_batch(thread_ids)
except Exception as e:
logger.error(f"Error fetching thread titles: {e}", exc_info=True)
threads_info = [None] * len(thread_ids)
# Build formatted list with thread info
text_parts = [f"📋 <b>Список тем ({len(threads)}):</b>\n\n"]
for thread, thread_info in zip(threads, threads_info):
# Use fetched title or fallback to database title
title = thread_info.title if thread_info else thread.title
# Format last bump status
for thread in threads:
status = "🆕 Новая"
if thread.last_bumped:
try:
@@ -309,30 +348,65 @@ class AutoBumpBot:
except ValueError:
status = f"{thread.last_bumped[:16]}"
# Format thread entry
text_parts.append(
f"<b>ID:</b> {thread.id}\n"
f"<b>Название:</b> {title}\n"
f"<b>Название:</b> {thread.title}\n"
f"<b>Статус:</b> {status}\n\n"
)
await status_msg.edit_text("".join(text_parts))
await callback.message.answer("".join(text_parts))
await self._send_main_menu(callback.message.chat.id)
except Exception as e:
logger.error(f"Error listing threads: {e}", exc_info=True)
await callback.message.answer(f"❌ Ошибка: {str(e)}")
async def _handle_delete_menu_callback(self, callback: CallbackQuery) -> None:
"""Handle delete menu button callback."""
await callback.answer()
# ─── Refresh Titles ─────────────────────────────────────────
async def _handle_refresh_titles_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
try:
threads = await self._db.get_all_threads()
if not threads:
await callback.message.answer("📭 Список тем пуст")
await self._send_main_menu(callback.message.chat.id)
return
status_msg = await callback.message.answer(
f"🔄 Обновляю названия {len(threads)} тем..."
)
thread_ids = [t.id for t in threads]
threads_info = await self._api.get_threads_info_batch(thread_ids)
updated = 0
for i, info in enumerate(threads_info):
if info and info.title and info.title != threads[i].title:
await self._db.update_thread_title(threads[i].id, info.title)
updated += 1
await status_msg.edit_text(
f"🔄 <b>Названия обновлены!</b>\n\n"
f"📝 Всего тем: {len(threads)}\n"
f"✅ Обновлено: {updated}\n"
f"⏭️ Без изменений: {len(threads) - updated}"
)
await self._send_main_menu(callback.message.chat.id)
except Exception as e:
logger.error(f"Error refreshing titles: {e}", exc_info=True)
await callback.message.answer(f"❌ Ошибка обновления: {str(e)}")
# ─── Delete Thread ──────────────────────────────────────────
async def _handle_delete_menu_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
try:
threads = await self._db.get_all_threads()
if not threads:
await callback.message.answer("📭 Список тем пуст")
await self._send_main_menu(callback.message.chat.id)
@@ -341,53 +415,43 @@ class AutoBumpBot:
buttons = [
[InlineKeyboardButton(
text=f"{thread.id} - {self._truncate_text_safely(thread.title, BUTTON_TEXT_MAX_LENGTH)}",
callback_data=f"delete_{thread.id}"
callback_data=f"delete_{thread.id}",
)]
for thread in threads
]
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
await callback.message.answer(
"🗑️ Выберите тему для удаления:",
reply_markup=keyboard
reply_markup=InlineKeyboardMarkup(inline_keyboard=buttons),
)
except Exception as e:
logger.error(f"Error showing delete menu: {e}", exc_info=True)
await callback.message.answer(f"❌ Ошибка: {str(e)}")
async def _handle_delete_thread_callback(self, callback: CallbackQuery) -> None:
"""Handle delete specific thread callback."""
if not callback.message:
await callback.answer("❌ Ошибка: сообщение не найдено", show_alert=True)
return
try:
thread_id = callback.data.split("_", 1)[1]
success = await self._db.delete_thread(thread_id)
if success:
await callback.answer(f"✅ Тема {thread_id} удалена", show_alert=True)
await callback.message.answer(f"✅ Тема {thread_id} успешно удалена из базы данных")
await self._send_main_menu(callback.message.chat.id)
else:
await callback.answer(f"❌ Тема {thread_id} не найдена", show_alert=True)
await self._send_main_menu(callback.message.chat.id)
except Exception as e:
logger.error(f"Error deleting thread: {e}", exc_info=True)
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
async def _handle_bump_now_callback(self, callback: CallbackQuery) -> None:
"""Handle bump now button callback."""
await callback.answer()
# ─── Bump Now ───────────────────────────────────────────────
async def _handle_bump_now_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
try:
threads = await self._db.get_threads_to_bump(0)
if not threads:
await callback.message.answer("📭 Нет тем для поднятия\n\nДобавьте темы через кнопку ➕")
await self._send_main_menu(callback.message.chat.id)
@@ -400,45 +464,43 @@ class AutoBumpBot:
)
results = await self._execute_bump_with_notifications(
threads,
chat_id=callback.message.chat.id
threads, chat_id=callback.message.chat.id
)
success_count = sum(1 for r in results if r.success)
await status_msg.edit_text(
f"📊 <b>Поднятие завершено!</b>\n\n"
f"✅ Успешно: {success_count}\n"
f"❌ Ошибок: {len(threads) - success_count}\n"
f"📝 Всего тем: {len(threads)}"
)
await self._send_main_menu(callback.message.chat.id)
except Exception as e:
logger.error(f"Error bumping threads: {e}", exc_info=True)
await callback.message.answer(f"❌ Ошибка: {str(e)}")
async def _handle_stats_callback(self, callback: CallbackQuery) -> None:
"""Handle statistics button callback."""
await callback.answer()
# ─── Statistics ─────────────────────────────────────────────
async def _handle_stats_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
try:
threads = await self._db.get_all_threads()
threads_ready = await self._db.get_threads_to_bump(
self._config.scheduling.bump_interval_hours
)
interval = await self._get_interval()
threads_ready = await self._db.get_threads_to_bump(interval)
stats = await self._db.get_bump_stats()
batch_size = await self._get_batch_size()
auto_enabled = await self._is_auto_bump_enabled()
uptime = "0 минут"
if self._start_time:
uptime = self._calculate_uptime(self._start_time)
success_rate = 0.0
if self._total_bumps > 0:
success_rate = (self._successful_bumps / self._total_bumps) * 100
if stats.total_bumps > 0:
success_rate = (stats.successful_bumps / stats.total_bumps) * 100
text = (
"📊 <b>Статистика бота</b>\n\n"
@@ -447,12 +509,14 @@ class AutoBumpBot:
f"• Готовы к поднятию: {len(threads_ready)}\n"
f"• Ожидают времени: {len(threads) - len(threads_ready)}\n\n"
f"🚀 <b>Поднятия:</b>\n"
f"• Всего попыток: {self._total_bumps}\n"
f"• Успешных: {self._successful_bumps}\n"
f"• Успешность: {success_rate:.1f}%\n\n"
f"• Всего попыток: {stats.total_bumps}\n"
f"• Успешных: {stats.successful_bumps}\n"
f"• Успешность: {success_rate:.1f}%\n"
f"• Последний бамп: {stats.last_bump_time or ''}\n\n"
f"⚙️ <b>Настройки:</b>\n"
f"• Интервал: {self._config.scheduling.bump_interval_hours}ч\n"
f"• Batch size: {self._config.api.batch_size}\n\n"
f"• Интервал: {interval:.0f}ч\n"
f"• Batch size: {batch_size}\n"
f"• Автобамп: {'✅ Вкл' if auto_enabled else '❌ Выкл'}\n\n"
f"⏱️ <b>Работа:</b>\n"
f"• Время работы: {uptime}"
)
@@ -464,23 +528,133 @@ class AutoBumpBot:
logger.error(f"Error showing stats: {e}", exc_info=True)
await callback.message.answer(f"❌ Ошибка: {str(e)}")
# ─── Settings ───────────────────────────────────────────────
async def _handle_settings_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
interval = await self._get_interval()
batch_size = await self._get_batch_size()
auto_enabled = await self._is_auto_bump_enabled()
text = (
"🛠️ <b>Текущие настройки</b>\n\n"
f"⏰ Интервал бампа: <b>{interval:.0f}ч</b>\n"
f"📦 Batch size: <b>{batch_size}</b>\n"
f"🔄 Автобамп: <b>{'✅ Вкл' if auto_enabled else '❌ Выкл'}</b>\n"
)
await callback.message.answer(text, reply_markup=self._settings_kb())
async def _handle_set_interval_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer()
if not callback.message:
return
interval = await self._get_interval()
await callback.message.answer(
f"Текущий интервал: <b>{interval:.0f}ч</b>\n\n"
"Введите новый интервал в часах (например: <code>6</code> или <code>24</code>):"
)
await state.set_state(BotStates.waiting_for_interval)
async def _handle_interval_input(self, message: Message, state: FSMContext) -> None:
try:
new_interval = float(message.text.strip())
if new_interval <= 0:
await message.answer("❌ Интервал должен быть положительным числом")
return
await self._db.set_setting("bump_interval_hours", str(new_interval))
logger.info(f"Bump interval changed to {new_interval}h by user {message.from_user.id}")
# Restart auto-bump loop if running
if self._is_running and await self._is_auto_bump_enabled():
self._restart_auto_bump()
await message.answer(f"✅ Интервал изменён на <b>{new_interval:.0f}ч</b>")
await self._send_main_menu(message.chat.id)
except ValueError:
await message.answer("❌ Введите число (например: <code>12</code>)")
except Exception as e:
logger.error(f"Error changing interval: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
finally:
await state.clear()
async def _handle_set_batch_size_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
await callback.answer()
if not callback.message:
return
batch_size = await self._get_batch_size()
await callback.message.answer(
f"Текущий batch size: <b>{batch_size}</b>\n\n"
"Введите новый размер (от 1 до 10):"
)
await state.set_state(BotStates.waiting_for_batch_size)
async def _handle_batch_size_input(self, message: Message, state: FSMContext) -> None:
try:
new_size = int(message.text.strip())
if new_size < 1 or new_size > 10:
await message.answer("❌ Batch size должен быть от 1 до 10")
return
await self._db.set_setting("batch_size", str(new_size))
self._api._batch_size = new_size
logger.info(f"Batch size changed to {new_size} by user {message.from_user.id}")
await message.answer(f"✅ Batch size изменён на <b>{new_size}</b>")
await self._send_main_menu(message.chat.id)
except ValueError:
await message.answer("❌ Введите целое число (от 1 до 10)")
except Exception as e:
logger.error(f"Error changing batch size: {e}")
await message.answer(f"❌ Ошибка: {str(e)}")
finally:
await state.clear()
async def _handle_toggle_auto_bump_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
current = await self._is_auto_bump_enabled()
new_state = not current
await self._db.set_setting("enable_auto_bump", str(new_state).lower())
if new_state:
self._restart_auto_bump()
status = "✅ Включён"
else:
status = "❌ Выключен"
logger.info(f"Auto-bump toggled to {new_state} by user {callback.from_user.id}")
await callback.message.answer(f"🔄 Автобамп: <b>{status}</b>")
await self._send_main_menu(callback.message.chat.id)
# ─── Back to Menu ───────────────────────────────────────────
async def _handle_back_to_menu_callback(self, callback: CallbackQuery) -> None:
await callback.answer()
if not callback.message:
return
await self._send_main_menu(callback.message.chat.id)
# ─── Bump Execution ─────────────────────────────────────────
async def _execute_bump_with_notifications(
self,
threads: Sequence[Thread],
chat_id: int | None = None
self, threads: Sequence[Thread], chat_id: int | None = None
) -> list[BumpResult]:
"""Execute thread bumping with optional notifications."""
if not threads:
return []
thread_ids = [thread.id for thread in threads]
logger.info(f"Starting bump for {len(thread_ids)} threads: {thread_ids}")
# Use batch API to bump all threads
results = await self._api.bump_threads_batch(thread_ids)
# Log detailed results
for result in results:
if result.success:
logger.info(
@@ -493,20 +667,18 @@ class AutoBumpBot:
f"Status: {result.status.value} | Message: {result.message}"
)
# Update statistics and database
success_count = 0
for result in results:
self._total_bumps += 1
if result.success:
self._successful_bumps += 1
success_count += 1
await self._db.update_last_bumped(result.thread_id)
logger.info(f"Database updated for thread {result.thread_id}")
# Send notifications if requested
total = len(results)
await self._db.increment_bump_stats(success_count, total)
if chat_id:
await self._send_bump_notifications(chat_id, results)
# Log summary
success_count = sum(1 for r in results if r.success)
logger.info(
f"Batch bump completed: {success_count}/{len(results)} successful | "
f"Failed: {len(results) - success_count}"
@@ -515,56 +687,47 @@ class AutoBumpBot:
return results
async def _send_bump_notifications(self, chat_id: int, results: list[BumpResult]) -> None:
"""Send bump result notifications to user with rate limit handling."""
for i, result in enumerate(results, 1):
try:
await self._bot.send_message(
chat_id,
f"[{i}/{len(results)}] {result.message}"
)
# Delay between notifications to avoid rate limits
await self._bot.send_message(chat_id, f"[{i}/{len(results)}] {result.message}")
if i < len(results):
await asyncio.sleep(NOTIFICATION_DELAY_SECONDS)
except TelegramRetryAfter as e:
# Handle rate limit - wait and retry
logger.warning(f"Rate limit hit, waiting {e.retry_after} seconds")
await asyncio.sleep(e.retry_after)
# Retry sending this message
try:
await self._bot.send_message(
chat_id,
f"[{i}/{len(results)}] {result.message}"
)
await self._bot.send_message(chat_id, f"[{i}/{len(results)}] {result.message}")
except Exception as retry_error:
logger.error(f"Failed to send notification after retry: {retry_error}")
except Exception as e:
logger.error(f"Failed to send notification: {e}")
async def _auto_bump_scheduler_loop(self) -> None:
"""Automatic bump scheduler loop."""
logger.info(
f"Auto-bump scheduler started (interval: {self._config.scheduling.bump_interval_hours}h)"
)
# ─── Auto-Bump Scheduler ───────────────────────────────────
def _restart_auto_bump(self) -> None:
if self._auto_bump_task and not self._auto_bump_task.done():
self._auto_bump_task.cancel()
self._auto_bump_task = asyncio.create_task(self._auto_bump_scheduler_loop())
async def _auto_bump_scheduler_loop(self) -> None:
logger.info("Auto-bump scheduler started")
while self._is_running:
try:
sleep_seconds = self._config.scheduling.bump_interval_hours * 3600
logger.info(f"Next scheduled bump in {self._config.scheduling.bump_interval_hours} hours")
interval = await self._get_interval()
sleep_seconds = interval * 3600
logger.info(f"Next scheduled bump in {interval:.0f} hours")
await asyncio.sleep(sleep_seconds)
if not self._is_running:
break
logger.info("Starting scheduled bump...")
auto_enabled = await self._is_auto_bump_enabled()
if not auto_enabled:
logger.info("Auto-bump disabled, skipping cycle")
continue
threads = await self._db.get_threads_to_bump(
self._config.scheduling.bump_interval_hours
)
logger.info("Starting scheduled bump...")
threads = await self._db.get_threads_to_bump(interval)
if threads:
logger.info(f"Found {len(threads)} threads to bump")
@@ -581,51 +744,50 @@ class AutoBumpBot:
logger.error(f"Error in auto_bump_loop: {e}", exc_info=True)
await asyncio.sleep(AUTO_BUMP_RETRY_DELAY_SECONDS)
# ─── Lifecycle ──────────────────────────────────────────────
async def start(self) -> None:
"""Start bot and all services."""
try:
# Initialize services
await self._db.connect()
logger.info("Database connected")
await self._db.seed_defaults(self._config)
logger.info("Database seeded with default settings")
await self._api.start()
logger.info("API client started")
self._start_time = datetime.now()
self._is_running = True
# Start auto-bump loop if enabled
if self._config.scheduling.enable_auto_bump:
asyncio.create_task(self._auto_bump_scheduler_loop())
if await self._is_auto_bump_enabled():
self._auto_bump_task = asyncio.create_task(self._auto_bump_scheduler_loop())
logger.info("Bot started successfully!")
await self._dp.start_polling(self._bot)
except Exception as e:
logger.error(f"Error starting bot: {e}", exc_info=True)
# Cleanup on startup failure
await self._cleanup_resources()
raise
async def stop(self) -> None:
"""Stop bot and cleanup resources."""
logger.info("Stopping bot...")
self._is_running = False
if self._auto_bump_task and not self._auto_bump_task.done():
self._auto_bump_task.cancel()
await self._cleanup_resources()
logger.info("Bot stopped")
async def _cleanup_resources(self) -> None:
"""Cleanup all resources safely."""
try:
await self._api.close()
except Exception as e:
logger.error(f"Error closing API client: {e}")
try:
await self._db.close()
except Exception as e:
logger.error(f"Error closing database: {e}")
try:
await self._bot.session.close()
except Exception as e:
@@ -633,10 +795,9 @@ class AutoBumpBot:
async def main() -> None:
"""Main entry point."""
try:
config = Config.load()
logger.info("Configuration loaded successfully")
logger.info("Configuration loaded from .env successfully")
except (FileNotFoundError, ValueError) as e:
logger.error(f"Configuration error: {e}")
sys.exit(1)
+62 -74
View File
@@ -1,14 +1,14 @@
"""Configuration management with validation and type safety."""
"""Configuration management with .env validation and type safety."""
import json
from pathlib import Path
import os
from dataclasses import dataclass
from typing import Self
from dotenv import load_dotenv
@dataclass(frozen=True, slots=True)
class BotConfig:
"""Telegram bot configuration."""
api_token: str
img_url: str
author_url: str
@@ -16,7 +16,6 @@ class BotConfig:
@dataclass(frozen=True, slots=True)
class APIConfig:
"""Lolz API configuration."""
base_url: str
auth_token: str
batch_size: int
@@ -24,13 +23,11 @@ class APIConfig:
@dataclass(frozen=True, slots=True)
class DatabaseConfig:
"""Database configuration."""
path: str
@dataclass(frozen=True, slots=True)
class SchedulingConfig:
"""Scheduling configuration."""
bump_interval_hours: float
bump_delay_seconds: float
enable_auto_bump: bool
@@ -38,83 +35,74 @@ class SchedulingConfig:
@dataclass(frozen=True, slots=True)
class Config:
"""Application configuration."""
bot: BotConfig
api: APIConfig
database: DatabaseConfig
scheduling: SchedulingConfig
admin_user_id: int
@classmethod
def load(cls, config_path: str = "config.json") -> Self:
"""Load and validate configuration from JSON file."""
path = Path(config_path)
if not path.exists():
raise FileNotFoundError(f"Config file not found: {config_path}")
def load(cls, env_path: str = ".env") -> Self:
if not os.path.exists(env_path):
raise FileNotFoundError(f".env file not found: {env_path}")
with open(path, encoding="utf-8") as f:
data = json.load(f)
load_dotenv(env_path)
# Validate required fields
cls._validate_config(data)
bot_token = os.getenv("BOT_API_TOKEN", "")
api_token = os.getenv("API_AUTH_TOKEN", "")
admin_user_id = int(os.getenv("ADMIN_USER_ID", "0"))
cls._validate_tokens(bot_token, api_token, admin_user_id)
bot = BotConfig(
api_token=bot_token,
img_url=os.getenv("BOT_IMG_URL", ""),
author_url=os.getenv("BOT_AUTHOR_URL", ""),
)
batch_size = int(os.getenv("API_BATCH_SIZE", "10"))
if batch_size < 1 or batch_size > 10:
raise ValueError("API_BATCH_SIZE must be between 1 and 10")
api = APIConfig(
base_url=os.getenv("API_BASE_URL", "").rstrip("/"),
auth_token=api_token,
batch_size=batch_size,
)
db = DatabaseConfig(
path=os.getenv("DB_PATH", "threads.db"),
)
interval = float(os.getenv("BUMP_INTERVAL_HOURS", "12"))
if interval <= 0:
raise ValueError("BUMP_INTERVAL_HOURS must be positive")
scheduling = SchedulingConfig(
bump_interval_hours=interval,
bump_delay_seconds=float(os.getenv("BUMP_DELAY_SECONDS", "2")),
enable_auto_bump=os.getenv("ENABLE_AUTO_BUMP", "true").lower() == "true",
)
return cls(
bot=BotConfig(
api_token=data["bot"]["api_token"],
img_url=data["bot"]["img_url"],
author_url=data["bot"]["author_url"]
),
api=APIConfig(
base_url=data["api"]["base_url"].rstrip("/"),
auth_token=data["api"]["auth_token"],
batch_size=int(data["api"].get("batch_size", 10))
),
database=DatabaseConfig(
path=data["database"]["path"]
),
scheduling=SchedulingConfig(
bump_interval_hours=float(data["scheduling"]["bump_interval_hours"]),
bump_delay_seconds=float(data["scheduling"].get("bump_delay_seconds", 2)),
enable_auto_bump=bool(data["scheduling"]["enable_auto_bump"])
)
bot=bot,
api=api,
database=db,
scheduling=scheduling,
admin_user_id=admin_user_id,
)
@staticmethod
def _validate_config(data: dict) -> None:
"""Validate configuration structure and required fields."""
required_fields = {
"bot": ["api_token", "img_url", "author_url"],
"api": ["base_url", "auth_token"],
"database": ["path"],
"scheduling": ["bump_interval_hours", "enable_auto_bump"]
}
for section, fields in required_fields.items():
if section not in data:
raise ValueError(f"Missing config section: {section}")
for field in fields:
if field not in data[section]:
raise ValueError(f"Missing config field: {section}.{field}")
value = data[section][field]
if isinstance(value, str) and not value.strip():
raise ValueError(f"Empty config field: {section}.{field}")
# Validate token formats
bot_token = data["bot"]["api_token"]
if "YOUR_" in bot_token or not bot_token:
raise ValueError("bot.api_token not configured - please set your Telegram bot token")
api_token = data["api"]["auth_token"]
if "YOUR_" in api_token or not api_token:
raise ValueError("api.auth_token not configured - please set your Lolz API token")
# Validate interval
interval = data["scheduling"]["bump_interval_hours"]
if not isinstance(interval, (int, float)) or interval <= 0:
raise ValueError("scheduling.bump_interval_hours must be positive number")
# Validate batch size
batch_size = data["api"].get("batch_size", 10)
if not isinstance(batch_size, int) or batch_size < 1 or batch_size > 10:
raise ValueError("api.batch_size must be between 1 and 10")
def _validate_tokens(bot_token: str, api_token: str, admin_user_id: int) -> None:
if not bot_token or "YOUR_" in bot_token:
raise ValueError(
"BOT_API_TOKEN not configured — set your Telegram bot token in .env"
)
if not api_token or "YOUR_" in api_token:
raise ValueError(
"API_AUTH_TOKEN not configured — set your Lolz API token in .env"
)
if admin_user_id == 0:
raise ValueError(
"ADMIN_USER_ID not configured — set your Telegram user ID in .env"
)
+126 -90
View File
@@ -1,25 +1,31 @@
"""Database layer with connection pooling and type safety."""
"""Database layer with dynamic settings and stats persistence."""
import aiosqlite
import logging
from typing import Self
from dataclasses import dataclass
from config_manager import Config
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class Thread:
"""Thread model with immutable fields."""
id: str
title: str
last_bumped: str | None = None
class Database:
"""SQLite database manager with connection pooling and proper error handling."""
@dataclass(frozen=True, slots=True)
class BumpStats:
total_bumps: int
successful_bumps: int
last_bump_time: str | None = None
class Database:
__slots__ = ("_db_path", "_connection")
def __init__(self, db_path: str) -> None:
@@ -30,21 +36,18 @@ class Database:
self._connection: aiosqlite.Connection | None = None
async def __aenter__(self) -> Self:
"""Async context manager entry."""
await self.connect()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Async context manager exit."""
await self.close()
async def connect(self) -> None:
"""Establish database connection and initialize schema."""
if self._connection is None:
try:
self._connection = await aiosqlite.connect(self._db_path)
# Enable WAL mode for better concurrency
await self._connection.execute("PRAGMA journal_mode=WAL")
await self._connection.execute("PRAGMA foreign_keys=ON")
await self._initialize_schema()
logger.info(f"Database connected: {self._db_path}")
except Exception as e:
@@ -52,7 +55,6 @@ class Database:
raise
async def close(self) -> None:
"""Close database connection safely."""
if self._connection:
try:
await self._connection.close()
@@ -62,7 +64,6 @@ class Database:
logger.error(f"Error closing database connection: {e}")
async def _initialize_schema(self) -> None:
"""Initialize database schema with tables and indexes."""
if not self._connection:
raise RuntimeError("Database not connected")
@@ -76,87 +77,161 @@ class Database:
)
""")
# Create index for efficient queries on last_bumped
await self._connection.execute("""
CREATE INDEX IF NOT EXISTS idx_last_bumped
ON threads(last_bumped)
""")
await self._connection.execute("""
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
""")
await self._connection.execute("""
CREATE TABLE IF NOT EXISTS bump_stats (
id INTEGER PRIMARY KEY CHECK (id = 1),
total_bumps INTEGER DEFAULT 0,
successful_bumps INTEGER DEFAULT 0,
last_bump_time TEXT
)
""")
await self._connection.commit()
logger.info("Database schema initialized")
except Exception as e:
logger.error(f"Failed to initialize schema: {e}")
raise
async def seed_defaults(self, config: Config) -> None:
await self._seed_settings(config)
await self._seed_stats()
async def _seed_settings(self, config: Config) -> None:
defaults = {
"bump_interval_hours": str(config.scheduling.bump_interval_hours),
"bump_delay_seconds": str(config.scheduling.bump_delay_seconds),
"enable_auto_bump": str(config.scheduling.enable_auto_bump).lower(),
"batch_size": str(config.api.batch_size),
}
for key, value in defaults.items():
await self._connection.execute(
"INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)",
(key, value),
)
await self._connection.commit()
async def _seed_stats(self) -> None:
await self._connection.execute(
"INSERT OR IGNORE INTO bump_stats (id, total_bumps, successful_bumps) VALUES (1, 0, 0)"
)
await self._connection.commit()
def _ensure_connected(self) -> None:
"""Ensure database is connected before operations."""
if not self._connection:
raise RuntimeError("Database not connected. Call connect() first.")
async def add_thread(self, thread_id: str, title: str) -> bool:
"""
Add thread to database.
# ─── Settings ───────────────────────────────────────────────
Args:
thread_id: Unique thread identifier
title: Thread title
Returns:
True if added successfully, False if thread already exists
"""
async def get_setting(self, key: str, default: str | None = None) -> str | None:
self._ensure_connected()
async with self._connection.execute(
"SELECT value FROM settings WHERE key = ?", (key,)
) as cursor:
row = await cursor.fetchone()
return row[0] if row else default
async def set_setting(self, key: str, value: str) -> None:
self._ensure_connected()
await self._connection.execute(
"INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))",
(key, value),
)
await self._connection.commit()
async def get_all_settings(self) -> dict[str, str]:
self._ensure_connected()
async with self._connection.execute("SELECT key, value FROM settings") as cursor:
rows = await cursor.fetchall()
return {row[0]: row[1] for row in rows}
# ─── Bump Statistics ────────────────────────────────────────
async def get_bump_stats(self) -> BumpStats:
self._ensure_connected()
async with self._connection.execute(
"SELECT total_bumps, successful_bumps, last_bump_time FROM bump_stats WHERE id = 1"
) as cursor:
row = await cursor.fetchone()
if row:
return BumpStats(
total_bumps=row[0],
successful_bumps=row[1],
last_bump_time=row[2],
)
return BumpStats(total_bumps=0, successful_bumps=0)
async def increment_bump_stats(self, success_count: int, total_count: int) -> None:
self._ensure_connected()
await self._connection.execute(
"""
UPDATE bump_stats SET
total_bumps = total_bumps + ?,
successful_bumps = successful_bumps + ?,
last_bump_time = datetime('now')
WHERE id = 1
""",
(total_count, success_count),
)
await self._connection.commit()
# ─── Threads ────────────────────────────────────────────────
async def add_thread(self, thread_id: str, title: str) -> bool:
self._ensure_connected()
try:
await self._connection.execute(
"INSERT INTO threads (id, title) VALUES (?, ?)",
(thread_id, title)
(thread_id, title),
)
await self._connection.commit()
return True
except aiosqlite.IntegrityError:
# Thread already exists (PRIMARY KEY constraint)
return False
except Exception as e:
logger.error(f"Error adding thread {thread_id}: {e}")
raise
async def get_all_threads(self) -> list[Thread]:
"""
Get all threads ordered by ID.
Returns:
List of Thread objects
"""
async def update_thread_title(self, thread_id: str, title: str) -> bool:
self._ensure_connected()
try:
cursor = await self._connection.execute(
"UPDATE threads SET title = ? WHERE id = ?", (title, thread_id)
)
await self._connection.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error(f"Error updating title for thread {thread_id}: {e}")
raise
async def get_all_threads(self) -> list[Thread]:
self._ensure_connected()
try:
async with self._connection.execute(
"SELECT id, title, last_bumped FROM threads ORDER BY id"
) as cursor:
rows = await cursor.fetchall()
return [
Thread(id=row[0], title=row[1], last_bumped=row[2])
for row in rows
]
return [Thread(id=row[0], title=row[1], last_bumped=row[2]) for row in rows]
except Exception as e:
logger.error(f"Error fetching all threads: {e}")
raise
async def get_threads_to_bump(self, interval_hours: float) -> list[Thread]:
"""
Get threads ready for bumping based on interval.
Args:
interval_hours: Minimum hours since last bump
Returns:
List of Thread objects ready to bump
"""
self._ensure_connected()
if interval_hours < 0:
raise ValueError("interval_hours must be non-negative")
try:
async with self._connection.execute(
"""
@@ -165,30 +240,20 @@ class Database:
OR datetime(last_bumped, '+' || ? || ' hours') <= datetime('now')
ORDER BY last_bumped ASC NULLS FIRST
""",
(interval_hours,)
(interval_hours,),
) as cursor:
rows = await cursor.fetchall()
return [
Thread(id=row[0], title=row[1], last_bumped=row[2])
for row in rows
]
return [Thread(id=row[0], title=row[1], last_bumped=row[2]) for row in rows]
except Exception as e:
logger.error(f"Error fetching threads to bump: {e}")
raise
async def update_last_bumped(self, thread_id: str) -> None:
"""
Update last bumped timestamp for thread to current time.
Args:
thread_id: Thread identifier to update
"""
self._ensure_connected()
try:
await self._connection.execute(
"UPDATE threads SET last_bumped = datetime('now') WHERE id = ?",
(thread_id,)
(thread_id,),
)
await self._connection.commit()
except Exception as e:
@@ -196,21 +261,10 @@ class Database:
raise
async def delete_thread(self, thread_id: str) -> bool:
"""
Delete thread from database.
Args:
thread_id: Thread identifier to delete
Returns:
True if deleted successfully, False if thread not found
"""
self._ensure_connected()
try:
cursor = await self._connection.execute(
"DELETE FROM threads WHERE id = ?",
(thread_id,)
"DELETE FROM threads WHERE id = ?", (thread_id,)
)
await self._connection.commit()
return cursor.rowcount > 0
@@ -219,14 +273,7 @@ class Database:
raise
async def get_thread_count(self) -> int:
"""
Get total number of threads in database.
Returns:
Total thread count
"""
self._ensure_connected()
try:
async with self._connection.execute("SELECT COUNT(*) FROM threads") as cursor:
row = await cursor.fetchone()
@@ -236,21 +283,10 @@ class Database:
raise
async def thread_exists(self, thread_id: str) -> bool:
"""
Check if thread exists in database.
Args:
thread_id: Thread identifier to check
Returns:
True if thread exists, False otherwise
"""
self._ensure_connected()
try:
async with self._connection.execute(
"SELECT 1 FROM threads WHERE id = ? LIMIT 1",
(thread_id,)
"SELECT 1 FROM threads WHERE id = ? LIMIT 1", (thread_id,)
) as cursor:
row = await cursor.fetchone()
return row is not None
+1
View File
@@ -1,2 +1,3 @@
aiogram==3.15.0
aiosqlite>=0.20.0
python-dotenv>=1.0.0