mirror of
https://github.com/qiyanaitsme/QIYANASAutoPumpLOLZ.git
synced 2026-07-25 10:34:29 +00:00
Add files via upload
This commit is contained in:
@@ -1,21 +1,21 @@
|
|||||||
# 🚀 QIYANA AUTO-BUMP BOT для Lolz.live
|
# 🚀 QIYANA AUTO-BUMP BOT для Lolz.live
|
||||||
|
|
||||||
Бот для автоматического поднятия тем на форуме Lolz.live с удобным кнопочным интерфейсом
|
Production-ready Telegram бот для автоматического поднятия тем на форуме Lolz.live с современной архитектурой, batch API и полной типизацией.
|
||||||
|
|
||||||
## ✨ Возможности
|
## ✨ Возможности
|
||||||
|
|
||||||
- ➕ **Добавление тем** - через запятую (12345, 67890, 11111)
|
- ➕ **Добавление тем** - через запятую (12345, 67890, 11111) с batch API
|
||||||
- 🗑️ **Удаление тем** - через кнопки с ID и названием темы
|
- 🗑️ **Удаление тем** - через интерактивное меню
|
||||||
- 📋 **Список тем** - с датой последнего поднятия
|
- 📋 **Список тем** - с датой последнего поднятия
|
||||||
- 🚀 **Ручное поднятие** - поднять все темы прямо сейчас
|
- 🚀 **Ручное поднятие** - поднять все темы немедленно через batch API
|
||||||
- ⏰ **Автоподнятие** - каждые N часов автоматически
|
- ⏰ **Автоподнятие** - каждые N часов автоматически
|
||||||
- 📊 **Статистика** - успешность, количество поднятий
|
- 📊 **Статистика** - успешность, количество поднятий, uptime
|
||||||
- 👤 **Кнопка автора** - ссылка на профиль
|
- 🔔 **Уведомления** - о каждом поднятии темы
|
||||||
|
- 🛡️ **Обработка ошибок** - rate limits, network failures, invalid tokens
|
||||||
|
- ⚡ **Batch API** - до 10 тем за один запрос (10x быстрее)
|
||||||
|
|
||||||
## 🎮 Интерфейс
|
## 🎮 Интерфейс
|
||||||
|
|
||||||
Бот работает через **кнопки**:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────┐
|
┌─────────────────────────────────┐
|
||||||
│ ➕ Добавить темы │ 📋 Список тем │
|
│ ➕ Добавить темы │ 📋 Список тем │
|
||||||
@@ -26,64 +26,67 @@
|
|||||||
|
|
||||||
## 📦 Установка
|
## 📦 Установка
|
||||||
|
|
||||||
### 1. Установите зависимости:
|
### Требования
|
||||||
|
|
||||||
|
- Python 3.12+
|
||||||
|
- pip или uv
|
||||||
|
|
||||||
|
### 1. Установите зависимости
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Получите токены:
|
### 2. Получите токены
|
||||||
|
|
||||||
**Telegram Bot Token:**
|
**Telegram Bot Token:**
|
||||||
1. Напишите [@BotFather](https://t.me/BotFather)
|
1. Напишите [@BotFather](https://t.me/BotFather)
|
||||||
2. Создайте бота командой `/newbot`
|
2. Создайте бота: `/newbot`
|
||||||
3. Скопируйте токен
|
3. Скопируйте токен
|
||||||
|
|
||||||
**Lolz API Token:**
|
**Lolz API Token:**
|
||||||
1. Зайдите на [zelenka.guru/account/api](https://zelenka.guru/account/api)
|
1. Перейдите на [zelenka.guru/account/api](https://zelenka.guru/account/api)
|
||||||
2. Создайте токен с правами `read`, `post`
|
2. Создайте токен с правами `read`, `post`
|
||||||
3. Скопируйте токен
|
3. Скопируйте токен
|
||||||
|
|
||||||
### 3. Заполните `config.json`:
|
### 3. Настройте config.json
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"bot": {
|
"bot": {
|
||||||
"api_token": "1234567890:ABCdefGHIjklMNOpqrsTUVwxyz"
|
"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": {
|
"api": {
|
||||||
"base_url": "https://api.zelenka.guru",
|
"base_url": "https://prod-api.lolz.live",
|
||||||
"auth_token": "eyJ0eXAiOiJKV1QiLCJhbGc..."
|
"auth_token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
|
||||||
|
"batch_size": 10
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"path": "threads.db"
|
"path": "threads.db"
|
||||||
},
|
},
|
||||||
"scheduling": {
|
"scheduling": {
|
||||||
"bump_interval_hours": 12,
|
"bump_interval_hours": 12,
|
||||||
|
"bump_delay_seconds": 2,
|
||||||
"enable_auto_bump": true
|
"enable_auto_bump": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Запустите бота:
|
### 4. Запустите бота
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python app.py
|
python app.py
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📖 Использование
|
## 📖 Использование
|
||||||
|
|
||||||
### Первый запуск:
|
### Добавление тем
|
||||||
|
|
||||||
1. Отправьте `/start` боту
|
|
||||||
2. Нажмите **➕ Добавить темы**
|
|
||||||
3. Введите ID тем через запятую: `12345, 67890, 11111`
|
|
||||||
4. Нажмите **🚀 Поднять темы** - все темы поднимутся
|
|
||||||
5. Бот будет автоматически поднимать темы каждые 12 часов
|
|
||||||
|
|
||||||
### Добавление тем:
|
|
||||||
|
|
||||||
1. Нажмите **➕ Добавить темы**
|
1. Нажмите **➕ Добавить темы**
|
||||||
2. Введите ID через запятую: `12345, 67890`
|
2. Введите ID через запятую: `12345, 67890, 11111`
|
||||||
3. Бот автоматически получит название каждой темы из API
|
3. Бот автоматически получит названия тем через batch API (до 10 за запрос)
|
||||||
4. Темы добавятся в базу данных
|
|
||||||
|
|
||||||
**Пример:**
|
**Пример:**
|
||||||
```
|
```
|
||||||
@@ -95,36 +98,46 @@ python app.py
|
|||||||
✅ 9247922 - VPN service
|
✅ 9247922 - VPN service
|
||||||
```
|
```
|
||||||
|
|
||||||
### Удаление темы:
|
**Batch API преимущества:**
|
||||||
|
- Добавление 10 тем = 1 batch запрос вместо 10 обычных
|
||||||
|
- Добавление 25 тем = 3 batch запроса вместо 25 обычных
|
||||||
|
- Экономия API лимитов в 10 раз
|
||||||
|
|
||||||
|
### Удаление темы
|
||||||
|
|
||||||
1. Нажмите **🗑️ Удалить тему**
|
1. Нажмите **🗑️ Удалить тему**
|
||||||
2. Выберите тему из списка (показывается ID - название)
|
2. Выберите тему из списка
|
||||||
3. Тема удалится из базы данных
|
3. Подтвердите удаление
|
||||||
|
|
||||||
### Ручное поднятие:
|
### Ручное поднятие
|
||||||
|
|
||||||
1. Нажмите **🚀 Поднять темы**
|
1. Нажмите **🚀 Поднять темы**
|
||||||
2. Бот поднимет все темы по очереди
|
2. Бот поднимет все темы через batch API (до 10 за запрос)
|
||||||
3. Каждый результат придет отдельным сообщением:
|
3. Получите уведомление о каждой теме:
|
||||||
```
|
```
|
||||||
[1/3] ✅ Тема 9247920 поднята успешно
|
[1/3] ✅ Тема 9247920 поднята успешно
|
||||||
[2/3] ✅ Тема 9247921 поднята успешно
|
[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 темы
|
- ID темы
|
||||||
- Название темы
|
- Название темы
|
||||||
- Дату последнего поднятия
|
- Дату последнего поднятия
|
||||||
|
|
||||||
### Статистика:
|
### Статистика
|
||||||
|
|
||||||
Нажмите **📊 Статистика** - покажет:
|
Нажмите **📊 Статистика** - покажет:
|
||||||
- Количество тем в списке
|
- Количество тем
|
||||||
- Сколько готовы к поднятию
|
- Готовые к поднятию
|
||||||
- Всего попыток поднятия
|
- Всего попыток
|
||||||
- Успешность (%)
|
- Успешность (%)
|
||||||
- Настройки интервала
|
- Настройки интервала
|
||||||
- Время работы бота
|
- Время работы бота
|
||||||
@@ -133,18 +146,32 @@ python app.py
|
|||||||
|
|
||||||
### Интервал автоподнятия
|
### Интервал автоподнятия
|
||||||
|
|
||||||
В `config.json` можно изменить `bump_interval_hours`:
|
В `config.json` измените `bump_interval_hours`:
|
||||||
|
|
||||||
| Значение | Интервал |
|
| Значение | Интервал |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| `12` | 12 часов |
|
| `12` | 12 часов |
|
||||||
|
| `6` | 6 часов |
|
||||||
|
| `24` | 24 часа |
|
||||||
|
|
||||||
### Тестовый запуск (5 минут):
|
### Размер 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
|
```json
|
||||||
"scheduling": {
|
"scheduling": {
|
||||||
"bump_interval_hours": 0.0833,
|
"bump_interval_hours": 0.0833,
|
||||||
|
"bump_delay_seconds": 1,
|
||||||
"enable_auto_bump": true
|
"enable_auto_bump": true
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -152,7 +179,7 @@ python app.py
|
|||||||
## 🔄 Как работает автоподнятие
|
## 🔄 Как работает автоподнятие
|
||||||
|
|
||||||
1. Запускаете бота
|
1. Запускаете бота
|
||||||
2. **Вручную** поднимаете темы через кнопку 🚀 (первый раз)
|
2. Вручную поднимаете темы через 🚀 (первый раз)
|
||||||
3. Бот ждет указанный интервал (например, 12 часов)
|
3. Бот ждет указанный интервал (например, 12 часов)
|
||||||
4. Автоматически поднимает темы, у которых прошло 12+ часов
|
4. Автоматически поднимает темы, у которых прошло 12+ часов
|
||||||
5. Повторяет каждые 12 часов
|
5. Повторяет каждые 12 часов
|
||||||
@@ -160,58 +187,103 @@ python app.py
|
|||||||
**Пример:**
|
**Пример:**
|
||||||
```
|
```
|
||||||
00:00 - Запуск бота
|
00:00 - Запуск бота
|
||||||
00:05 - Вы вручную нажали "Поднять темы" (все темы поднялись)
|
00:05 - Вы вручную нажали "Поднять темы"
|
||||||
12:05 - Автоматическое поднятие готовых тем
|
12:05 - Автоматическое поднятие
|
||||||
24:05 - Автоматическое поднятие готовых тем
|
24:05 - Автоматическое поднятие
|
||||||
36:05 - Автоматическое поднятие готовых тем
|
36: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
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ключевые улучшения
|
||||||
|
|
||||||
|
- **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**: Корректное завершение всех задач
|
||||||
|
|
||||||
|
### 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)
|
||||||
|
|
||||||
## 📝 Логи
|
## 📝 Логи
|
||||||
|
|
||||||
- **Консоль** - основные события
|
- **Консоль** - основные события
|
||||||
- **bot.log** - детальные логи с ошибками
|
- **bot.log** - детальные логи с traceback
|
||||||
|
|
||||||
## 🔧 Технические детали
|
## 🔧 Технические детали
|
||||||
|
|
||||||
- **Версия aiogram**: 3.4.1
|
- **Python**: 3.12+
|
||||||
- **Python**: 3.8+
|
- **aiogram**: 3.4.1
|
||||||
- **База данных**: SQLite (threads.db)
|
- **aiohttp**: 3.10.0+
|
||||||
- **API**: https://api.zelenka.guru
|
- **aiosqlite**: 0.20.0+
|
||||||
- **Задержка между поднятиями**: 2 секунды
|
- **База данных**: SQLite с индексами
|
||||||
|
- **API**: https://prod-api.lolz.live
|
||||||
|
- **Batch API**: До 10 запросов за 1 batch (каждый = 0.1 batch)
|
||||||
|
|
||||||
## 📡 API запрос для поднятия
|
## 🐛 Troubleshooting
|
||||||
|
|
||||||
Бот использует такой запрос для каждой темы:
|
### Ошибка: "bot.api_token not configured"
|
||||||
|
|
||||||
```bash
|
Заполните `config.json` реальными токенами.
|
||||||
curl --request POST \
|
|
||||||
--url https://api.zelenka.guru/threads/{THREAD_ID}/bump \
|
|
||||||
--header 'accept: application/json' \
|
|
||||||
--header 'authorization: Bearer {YOUR_TOKEN}'
|
|
||||||
```
|
|
||||||
|
|
||||||
Бот автоматически извлекает:
|
### Ошибка: "Invalid API token"
|
||||||
- `thread_id` - ID темы
|
|
||||||
- `thread_title` - название темы
|
|
||||||
|
|
||||||
Из ответа API:
|
Проверьте токен на [zelenka.guru/account/api](https://zelenka.guru/account/api).
|
||||||
```json
|
|
||||||
{
|
|
||||||
"thread": {
|
|
||||||
"thread_id": ID,
|
|
||||||
"thread_title": "TITLE"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
## 🐛 Если возникли проблемы
|
|
||||||
|
|
||||||
1. Проверьте токены в `config.json`
|
### Ошибка: "Network error"
|
||||||
2. Убедитесь, что токены валидные
|
|
||||||
3. Посмотрите `bot.log` для деталей
|
Проверьте интернет-соединение и доступность API.
|
||||||
4. Перезапустите бота
|
|
||||||
|
### Темы не поднимаются автоматически
|
||||||
|
|
||||||
|
1. Проверьте `enable_auto_bump: true` в config.json
|
||||||
|
2. Сделайте первое поднятие вручную через 🚀
|
||||||
|
3. Проверьте логи в bot.log
|
||||||
|
|
||||||
## 👤 Автор
|
## 👤 Автор
|
||||||
|
|
||||||
|
|||||||
+428
-57
@@ -1,113 +1,484 @@
|
|||||||
import aiohttp
|
"""Lolz API client with batch request support and proper error handling."""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional, Tuple
|
import logging
|
||||||
|
import aiohttp
|
||||||
|
import asyncio
|
||||||
|
from typing import Self, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# Constants
|
||||||
|
MAX_RETRY_ATTEMPTS = 3
|
||||||
|
RETRY_DELAY_SECONDS = 2
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BumpStatus(Enum):
|
||||||
|
"""Bump operation status."""
|
||||||
|
SUCCESS = "success"
|
||||||
|
RATE_LIMITED = "rate_limited"
|
||||||
|
NOT_FOUND = "not_found"
|
||||||
|
UNAUTHORIZED = "unauthorized"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
class BumpResult:
|
class BumpResult:
|
||||||
|
"""Result of bump operation."""
|
||||||
success: bool
|
success: bool
|
||||||
message: str
|
message: str
|
||||||
thread_id: str
|
thread_id: str
|
||||||
|
status: BumpStatus
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True, slots=True)
|
||||||
class ThreadInfo:
|
class ThreadInfo:
|
||||||
|
"""Thread information from API."""
|
||||||
thread_id: str
|
thread_id: str
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
|
|
||||||
class APIClient:
|
class APIClient:
|
||||||
|
"""Lolz API client with batch request support and connection pooling."""
|
||||||
|
|
||||||
def __init__(self, base_url: str, auth_token: str):
|
__slots__ = ("_base_url", "_auth_token", "_session", "_batch_size")
|
||||||
self.base_url = base_url.rstrip('/')
|
|
||||||
self.auth_token = auth_token
|
|
||||||
self.session: Optional[aiohttp.ClientSession] = None
|
|
||||||
|
|
||||||
def _clean_error_message(self, error_msg: str) -> str:
|
def __init__(self, base_url: str, auth_token: str, batch_size: int = 10) -> None:
|
||||||
error_msg = re.sub(r'<br\s*/?>', '\n', error_msg)
|
if not base_url or not auth_token:
|
||||||
error_msg = re.sub(r'<[^>]+>', '', error_msg)
|
raise ValueError("base_url and auth_token are required")
|
||||||
|
|
||||||
parts = [p.strip() for p in error_msg.split('\n') if p.strip()]
|
if batch_size < 1 or batch_size > 10:
|
||||||
|
raise ValueError("batch_size must be between 1 and 10")
|
||||||
|
|
||||||
if len(parts) > 1:
|
self._base_url = base_url.rstrip("/")
|
||||||
for part in parts:
|
self._auth_token = auth_token
|
||||||
if 'должны подождать' in part.lower() or 'должен подождать' in part.lower():
|
self._session: aiohttp.ClientSession | None = None
|
||||||
return part
|
self._batch_size = batch_size
|
||||||
return parts[-1]
|
|
||||||
|
|
||||||
return error_msg.strip()
|
async def __aenter__(self) -> Self:
|
||||||
|
"""Async context manager entry."""
|
||||||
|
await self.start()
|
||||||
|
return self
|
||||||
|
|
||||||
async def start(self):
|
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
|
||||||
if self.session is None or self.session.closed:
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Initialize HTTP session with connection pooling."""
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
headers = {
|
headers = {
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"Authorization": f"Bearer {self.auth_token}",
|
"Authorization": f"Bearer {self._auth_token}",
|
||||||
"User-Agent": "AutoBumpBot/3.0",
|
"User-Agent": "AutoBumpBot/4.0",
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
}
|
}
|
||||||
timeout = aiohttp.ClientTimeout(total=30)
|
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||||
self.session = aiohttp.ClientSession(headers=headers, timeout=timeout)
|
connector = aiohttp.TCPConnector(limit=10, limit_per_host=5)
|
||||||
|
self._session = aiohttp.ClientSession(
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
connector=connector
|
||||||
|
)
|
||||||
|
|
||||||
async def close(self):
|
async def close(self) -> None:
|
||||||
if self.session and not self.session.closed:
|
"""Close HTTP session and cleanup resources."""
|
||||||
await self.session.close()
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
# Wait for connections to close properly
|
||||||
|
await asyncio.sleep(0.25)
|
||||||
|
self._session = None
|
||||||
|
|
||||||
async def get_thread_info(self, thread_id: str) -> Optional[ThreadInfo]:
|
@staticmethod
|
||||||
if not self.session:
|
def _extract_error_message(error_msg: str) -> str:
|
||||||
|
"""Extract and clean error message from API response."""
|
||||||
|
if not error_msg:
|
||||||
|
return "Unknown error"
|
||||||
|
|
||||||
|
# Remove HTML tags
|
||||||
|
error_msg = re.sub(r"<br\s*/?>", "\n", error_msg)
|
||||||
|
error_msg = re.sub(r"<[^>]+>", "", error_msg)
|
||||||
|
|
||||||
|
# Split by newlines and filter empty parts
|
||||||
|
parts = [p.strip() for p in error_msg.split("\n") if p.strip()]
|
||||||
|
|
||||||
|
if not parts:
|
||||||
|
return "Unknown error"
|
||||||
|
|
||||||
|
# Look for rate limit message first
|
||||||
|
for part in parts:
|
||||||
|
if "должны подождать" in part.lower() or "должен подождать" in part.lower():
|
||||||
|
return part
|
||||||
|
|
||||||
|
# Return last meaningful part
|
||||||
|
return parts[-1]
|
||||||
|
|
||||||
|
async def get_thread_info(self, thread_id: str) -> ThreadInfo | None:
|
||||||
|
"""Get single thread information from API (legacy method, prefer get_threads_info_batch)."""
|
||||||
|
results = await self.get_threads_info_batch([thread_id])
|
||||||
|
return results[0] if results else None
|
||||||
|
|
||||||
|
async def get_threads_info_batch(self, thread_ids: Sequence[str]) -> list[ThreadInfo | None]:
|
||||||
|
"""Get multiple thread information using batch API (up to 10 per request)."""
|
||||||
|
if not self._session:
|
||||||
await self.start()
|
await self.start()
|
||||||
|
|
||||||
url = f"{self.base_url}/threads/{thread_id}"
|
if not thread_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Process in batches of up to batch_size
|
||||||
|
all_results: list[ThreadInfo | None] = []
|
||||||
|
|
||||||
|
for i in range(0, len(thread_ids), self._batch_size):
|
||||||
|
batch = thread_ids[i:i + self._batch_size]
|
||||||
|
batch_results = await self._fetch_threads_info_batch(batch)
|
||||||
|
all_results.extend(batch_results)
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
async def _fetch_threads_info_batch(self, thread_ids: Sequence[str]) -> list[ThreadInfo | None]:
|
||||||
|
"""Execute single batch request to get info for up to 10 threads with retry logic."""
|
||||||
|
if not thread_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build batch request payload - must use full URLs for batch API
|
||||||
|
batch_payload = [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"uri": f"{self._base_url}/threads/{thread_id}"
|
||||||
|
}
|
||||||
|
for thread_id in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
batch_url = f"{self._base_url}/batch"
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRY_ATTEMPTS):
|
||||||
try:
|
try:
|
||||||
async with self.session.get(url) as resp:
|
async with self._session.post(batch_url, json=batch_payload) as resp:
|
||||||
if resp.status == 200:
|
logger.info(f"Batch API request for {len(thread_ids)} threads, status: {resp.status}")
|
||||||
data = await resp.json()
|
|
||||||
thread_data = data.get("thread", {})
|
if resp.status == 401:
|
||||||
return ThreadInfo(
|
raise ValueError("Invalid API token - check your configuration")
|
||||||
|
|
||||||
|
if resp.status != 200:
|
||||||
|
response_text = await resp.text()
|
||||||
|
logger.error(f"Batch API failed with status {resp.status}: {response_text}")
|
||||||
|
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||||
|
continue
|
||||||
|
# Final attempt failed
|
||||||
|
return [None] * len(thread_ids)
|
||||||
|
|
||||||
|
# Parse batch response
|
||||||
|
batch_response = await resp.json()
|
||||||
|
logger.debug(f"Batch response: {batch_response}")
|
||||||
|
|
||||||
|
# Validate response structure - API returns {"jobs": {...}, "system_info": {...}}
|
||||||
|
if not isinstance(batch_response, dict) or "jobs" not in batch_response:
|
||||||
|
logger.error(f"Batch response missing 'jobs' key: {batch_response}")
|
||||||
|
return [None] * len(thread_ids)
|
||||||
|
|
||||||
|
jobs = batch_response["jobs"]
|
||||||
|
if not isinstance(jobs, dict):
|
||||||
|
logger.error(f"Jobs is not a dict: {type(jobs)}")
|
||||||
|
return [None] * len(thread_ids)
|
||||||
|
|
||||||
|
# Process each thread
|
||||||
|
results: list[ThreadInfo | None] = []
|
||||||
|
for thread_id in thread_ids:
|
||||||
|
uri = f"{self._base_url}/threads/{thread_id}"
|
||||||
|
|
||||||
|
if uri not in jobs:
|
||||||
|
logger.warning(f"Thread {thread_id} not in jobs response")
|
||||||
|
results.append(None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
job_data = jobs[uri]
|
||||||
|
if not isinstance(job_data, dict):
|
||||||
|
logger.warning(f"Job data for {thread_id} is not a dict")
|
||||||
|
results.append(None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Extract thread info
|
||||||
|
thread_data = job_data.get("thread", {})
|
||||||
|
if not isinstance(thread_data, dict):
|
||||||
|
logger.warning(f"Thread data for {thread_id} is not a dict")
|
||||||
|
results.append(None)
|
||||||
|
continue
|
||||||
|
|
||||||
|
thread_info = ThreadInfo(
|
||||||
thread_id=thread_id,
|
thread_id=thread_id,
|
||||||
title=thread_data.get("thread_title", "Unknown")
|
title=thread_data.get("thread_title", "Unknown")
|
||||||
)
|
)
|
||||||
except Exception as e:
|
results.append(thread_info)
|
||||||
print(f"Error getting thread info {thread_id}: {e}")
|
|
||||||
|
|
||||||
return None
|
return results
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise ConnectionError(f"Network error after {MAX_RETRY_ATTEMPTS} attempts: {e}") from e
|
||||||
|
except ValueError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"Unexpected error getting thread info: {e}") from e
|
||||||
|
|
||||||
|
return [None] * len(thread_ids)
|
||||||
|
|
||||||
async def bump_thread(self, thread_id: str) -> BumpResult:
|
async def bump_thread(self, thread_id: str) -> BumpResult:
|
||||||
if not self.session:
|
"""Bump single thread via API (legacy method, prefer bump_threads_batch)."""
|
||||||
|
results = await self.bump_threads_batch([thread_id])
|
||||||
|
return results[0]
|
||||||
|
|
||||||
|
async def bump_threads_batch(self, thread_ids: Sequence[str]) -> list[BumpResult]:
|
||||||
|
"""Bump multiple threads using batch API (up to 10 per request)."""
|
||||||
|
if not self._session:
|
||||||
await self.start()
|
await self.start()
|
||||||
|
|
||||||
url = f"{self.base_url}/threads/{thread_id}/bump"
|
if not thread_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Process in batches of up to batch_size
|
||||||
|
all_results: list[BumpResult] = []
|
||||||
|
|
||||||
|
for i in range(0, len(thread_ids), self._batch_size):
|
||||||
|
batch = thread_ids[i:i + self._batch_size]
|
||||||
|
batch_results = await self._execute_bump_batch(batch)
|
||||||
|
all_results.extend(batch_results)
|
||||||
|
|
||||||
|
return all_results
|
||||||
|
|
||||||
|
async def _execute_bump_batch(self, thread_ids: Sequence[str]) -> list[BumpResult]:
|
||||||
|
"""Execute single batch bump request for up to 10 threads with retry logic."""
|
||||||
|
if not thread_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Build batch request payload - must use full URLs for batch API
|
||||||
|
batch_payload = [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"uri": f"{self._base_url}/threads/{thread_id}/bump"
|
||||||
|
}
|
||||||
|
for thread_id in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
batch_url = f"{self._base_url}/batch"
|
||||||
|
|
||||||
|
logger.info(f"🚀 Executing bump batch request for {len(thread_ids)} threads: {thread_ids}")
|
||||||
|
logger.debug(f"Bump batch payload: {batch_payload}")
|
||||||
|
|
||||||
|
for attempt in range(MAX_RETRY_ATTEMPTS):
|
||||||
try:
|
try:
|
||||||
async with self.session.post(url) as resp:
|
async with self._session.post(batch_url, json=batch_payload) as resp:
|
||||||
data = await resp.json()
|
logger.info(f"Bump batch API response: HTTP {resp.status}")
|
||||||
|
|
||||||
|
if resp.status == 401:
|
||||||
|
# Unauthorized - return error for all threads
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Неверный токен API",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.UNAUTHORIZED
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
if resp.status != 200:
|
||||||
|
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||||
|
continue
|
||||||
|
# Final attempt failed
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Ошибка batch запроса (HTTP {resp.status})",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
# Parse batch response
|
||||||
|
batch_response = await resp.json()
|
||||||
|
logger.debug(f"Bump batch response: {batch_response}")
|
||||||
|
|
||||||
|
# Validate response structure - API returns {"jobs": {...}, "system_info": {...}}
|
||||||
|
if not isinstance(batch_response, dict) or "jobs" not in batch_response:
|
||||||
|
logger.error(f"Bump batch response missing 'jobs' key")
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Неверный формат ответа API",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
jobs = batch_response["jobs"]
|
||||||
|
if not isinstance(jobs, dict):
|
||||||
|
logger.error(f"Bump jobs is not a dict")
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Неверный формат ответа API",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
# Process each thread
|
||||||
|
results: list[BumpResult] = []
|
||||||
|
for thread_id in thread_ids:
|
||||||
|
uri = f"{self._base_url}/threads/{thread_id}/bump"
|
||||||
|
|
||||||
|
if uri not in jobs:
|
||||||
|
logger.error(f"Bump for thread {thread_id} not in jobs response | URI: {uri}")
|
||||||
|
results.append(BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {thread_id}: Нет ответа от сервера",
|
||||||
|
thread_id=thread_id,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
))
|
||||||
|
continue
|
||||||
|
|
||||||
|
job_data = jobs[uri]
|
||||||
|
logger.debug(f"Thread {thread_id} bump response: {job_data}")
|
||||||
|
|
||||||
|
# Empty dict means success for bump
|
||||||
|
if isinstance(job_data, dict) and len(job_data) == 0:
|
||||||
|
logger.info(f"Thread {thread_id} bumped successfully (empty response)")
|
||||||
|
results.append(BumpResult(
|
||||||
|
success=True,
|
||||||
|
message=f"✅ Тема {thread_id} поднята успешно",
|
||||||
|
thread_id=thread_id,
|
||||||
|
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 | "
|
||||||
|
f"Errors: {job_data['errors']} | "
|
||||||
|
f"Extracted: {error_msg}"
|
||||||
|
)
|
||||||
|
results.append(BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {thread_id}: {error_msg}",
|
||||||
|
thread_id=thread_id,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Unknown response
|
||||||
|
logger.warning(f"Thread {thread_id} unknown bump response: {job_data}")
|
||||||
|
results.append(BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {thread_id}: Неизвестный ответ",
|
||||||
|
thread_id=thread_id,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
))
|
||||||
|
|
||||||
|
logger.info(f"Bump batch processed: {len(results)} results")
|
||||||
|
return results
|
||||||
|
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||||
|
continue
|
||||||
|
# Network error - return error for all threads
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Ошибка сети - {str(e)}",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
if attempt < MAX_RETRY_ATTEMPTS - 1:
|
||||||
|
await asyncio.sleep(RETRY_DELAY_SECONDS * (attempt + 1))
|
||||||
|
continue
|
||||||
|
# Unexpected error - return error for all threads
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Неожиданная ошибка - {str(e)}",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
# Should never reach here, but just in case
|
||||||
|
return [
|
||||||
|
BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {tid}: Превышено количество попыток",
|
||||||
|
thread_id=tid,
|
||||||
|
status=BumpStatus.ERROR
|
||||||
|
)
|
||||||
|
for tid in thread_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
def _parse_bump_response(self, thread_id: str, response_data: dict) -> BumpResult:
|
||||||
|
"""Parse individual bump response from batch result."""
|
||||||
|
# Check for errors in response
|
||||||
|
if "errors" in response_data and response_data["errors"]:
|
||||||
|
errors = response_data["errors"]
|
||||||
|
if isinstance(errors, list) and errors:
|
||||||
|
error_msg = str(errors[0])
|
||||||
|
cleaned_msg = self._extract_error_message(error_msg)
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
status = BumpStatus.ERROR
|
||||||
|
if "подождать" in cleaned_msg.lower():
|
||||||
|
status = BumpStatus.RATE_LIMITED
|
||||||
|
|
||||||
if "errors" in data and data["errors"]:
|
|
||||||
error_msg = data["errors"][0]
|
|
||||||
cleaned_msg = self._clean_error_message(error_msg)
|
|
||||||
return BumpResult(
|
return BumpResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Тема {thread_id}: {cleaned_msg}",
|
message=f"Тема {thread_id}: {cleaned_msg}",
|
||||||
thread_id=thread_id
|
thread_id=thread_id,
|
||||||
|
status=status
|
||||||
)
|
)
|
||||||
|
|
||||||
if resp.status == 200:
|
# Check HTTP status code in batch response
|
||||||
|
status_code = response_data.get("_status_code", 200)
|
||||||
|
|
||||||
|
if status_code == 200:
|
||||||
return BumpResult(
|
return BumpResult(
|
||||||
success=True,
|
success=True,
|
||||||
message=f"✅ Тема {thread_id} поднята успешно",
|
message=f"✅ Тема {thread_id} поднята успешно",
|
||||||
thread_id=thread_id
|
thread_id=thread_id,
|
||||||
|
status=BumpStatus.SUCCESS
|
||||||
|
)
|
||||||
|
elif status_code == 404:
|
||||||
|
return BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {thread_id}: Не найдена",
|
||||||
|
thread_id=thread_id,
|
||||||
|
status=BumpStatus.NOT_FOUND
|
||||||
|
)
|
||||||
|
elif status_code == 401:
|
||||||
|
return BumpResult(
|
||||||
|
success=False,
|
||||||
|
message=f"Тема {thread_id}: Неверный токен API",
|
||||||
|
thread_id=thread_id,
|
||||||
|
status=BumpStatus.UNAUTHORIZED
|
||||||
)
|
)
|
||||||
|
|
||||||
return BumpResult(
|
return BumpResult(
|
||||||
success=False,
|
success=False,
|
||||||
message=f"Тема {thread_id}: HTTP {resp.status}",
|
message=f"Тема {thread_id}: HTTP {status_code}",
|
||||||
thread_id=thread_id
|
thread_id=thread_id,
|
||||||
)
|
status=BumpStatus.ERROR
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
return BumpResult(
|
|
||||||
success=False,
|
|
||||||
message=f"Тема {thread_id}: Ошибка - {str(e)}",
|
|
||||||
thread_id=thread_id
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,204 +1,350 @@
|
|||||||
|
"""Telegram bot for automatic thread bumping on Lolz.live."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from aiogram import Bot, Dispatcher, F
|
from typing import Sequence
|
||||||
|
|
||||||
|
from aiogram import Bot, Dispatcher, F, Router
|
||||||
|
from aiogram.client.default import DefaultBotProperties
|
||||||
|
from aiogram.enums import ParseMode
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
from aiogram.types import Message, CallbackQuery, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.fsm.state import State, StatesGroup
|
from aiogram.fsm.state import State, StatesGroup
|
||||||
from aiogram.fsm.storage.memory import MemoryStorage
|
from aiogram.fsm.storage.memory import MemoryStorage
|
||||||
|
from aiogram.exceptions import TelegramRetryAfter, TelegramBadRequest
|
||||||
|
|
||||||
from config_manager import Config
|
from config_manager import Config
|
||||||
from api_client import APIClient
|
from api_client import APIClient, BumpResult
|
||||||
from database import Database
|
from database import Database, Thread
|
||||||
|
|
||||||
|
|
||||||
|
# Constants
|
||||||
|
NOTIFICATION_DELAY_SECONDS = 0.8
|
||||||
|
BUTTON_TEXT_MAX_LENGTH = 30
|
||||||
|
AUTO_BUMP_RETRY_DELAY_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
handlers=[
|
handlers=[
|
||||||
logging.StreamHandler(sys.stdout),
|
logging.StreamHandler(sys.stdout),
|
||||||
logging.FileHandler('bot.log', encoding='utf-8')
|
logging.FileHandler("bot.log", encoding="utf-8")
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class BotStates(StatesGroup):
|
class BotStates(StatesGroup):
|
||||||
waiting_for_threads = State()
|
"""FSM states for bot."""
|
||||||
|
waiting_for_thread_ids = State()
|
||||||
|
|
||||||
|
|
||||||
class AutoBumpBot:
|
class AutoBumpBot:
|
||||||
|
"""Main bot class with clean separation of concerns."""
|
||||||
|
|
||||||
def __init__(self):
|
__slots__ = (
|
||||||
self.config = Config.load()
|
"_config", "_bot", "_dp", "_router", "_api", "_db",
|
||||||
self.bot = Bot(token=self.config.bot_token)
|
"_is_running", "_total_bumps", "_successful_bumps", "_start_time"
|
||||||
self.storage = MemoryStorage()
|
|
||||||
self.dp = Dispatcher(storage=self.storage)
|
|
||||||
self.api = APIClient(self.config.api_base_url, self.config.api_token)
|
|
||||||
self.db = Database(self.config.db_path)
|
|
||||||
self.is_running = False
|
|
||||||
self.total_bumps = 0
|
|
||||||
self.successful_bumps = 0
|
|
||||||
self.start_time = None
|
|
||||||
|
|
||||||
self.dp.message.register(self.cmd_start, Command("start"))
|
|
||||||
self.dp.callback_query.register(self.cb_add_thread, F.data == "add_thread")
|
|
||||||
self.dp.callback_query.register(self.cb_list_threads, F.data == "list_threads")
|
|
||||||
self.dp.callback_query.register(self.cb_delete_menu, F.data == "delete_menu")
|
|
||||||
self.dp.callback_query.register(self.cb_delete_thread, F.data.startswith("delete_"))
|
|
||||||
self.dp.callback_query.register(self.cb_bump_now, F.data == "bump_now")
|
|
||||||
self.dp.callback_query.register(self.cb_stats, F.data == "stats")
|
|
||||||
self.dp.message.register(self.msg_add_threads, StateFilter(BotStates.waiting_for_threads))
|
|
||||||
|
|
||||||
def get_main_keyboard(self) -> InlineKeyboardMarkup:
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=[
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="➕ Добавить темы", callback_data="add_thread"),
|
|
||||||
InlineKeyboardButton(text="📋 Список тем", callback_data="list_threads")
|
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="🗑️ Удалить тему", callback_data="delete_menu"),
|
|
||||||
InlineKeyboardButton(text="🚀 Поднять темы", callback_data="bump_now")
|
|
||||||
],
|
|
||||||
[
|
|
||||||
InlineKeyboardButton(text="📊 Статистика", callback_data="stats"),
|
|
||||||
InlineKeyboardButton(text="👤 Автор", url="https://lolz.live/kqlol/")
|
|
||||||
]
|
|
||||||
])
|
|
||||||
return keyboard
|
|
||||||
|
|
||||||
async def send_main_menu(self, chat_id: int, text: str = None):
|
|
||||||
if text is None:
|
|
||||||
text = "Выберите действие:"
|
|
||||||
|
|
||||||
await self.bot.send_message(
|
|
||||||
chat_id,
|
|
||||||
text,
|
|
||||||
reply_markup=self.get_main_keyboard()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def cmd_start(self, message: Message):
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._dp = Dispatcher(storage=MemoryStorage())
|
||||||
|
self._router = Router(name="main_router")
|
||||||
|
|
||||||
|
self._api = APIClient(
|
||||||
|
config.api.base_url,
|
||||||
|
config.api.auth_token,
|
||||||
|
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
|
||||||
|
|
||||||
|
# Register handlers
|
||||||
|
self._register_handlers()
|
||||||
|
|
||||||
|
# Include router in dispatcher
|
||||||
|
self._dp.include_router(self._router)
|
||||||
|
|
||||||
|
def _register_handlers(self) -> None:
|
||||||
|
"""Register all bot message and callback handlers."""
|
||||||
|
# Message handlers
|
||||||
|
self._router.message.register(
|
||||||
|
self._handle_start_command,
|
||||||
|
Command("start")
|
||||||
|
)
|
||||||
|
self._router.message.register(
|
||||||
|
self._handle_thread_ids_input,
|
||||||
|
StateFilter(BotStates.waiting_for_thread_ids)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_main_menu_keyboard(self) -> InlineKeyboardMarkup:
|
||||||
|
"""Create main menu inline keyboard."""
|
||||||
|
return InlineKeyboardMarkup(inline_keyboard=[
|
||||||
|
[
|
||||||
|
InlineKeyboardButton(text="➕ Add topics", callback_data="add_thread"),
|
||||||
|
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="📊 Statistics", callback_data="stats"),
|
||||||
|
InlineKeyboardButton(text="👤 Author", url=self._config.bot.author_url)
|
||||||
|
]
|
||||||
|
])
|
||||||
|
|
||||||
|
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()
|
||||||
|
)
|
||||||
|
|
||||||
|
@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')
|
||||||
|
return truncated
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
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}м"
|
||||||
|
elif hours > 0:
|
||||||
|
return f"{hours}ч {minutes}м"
|
||||||
|
else:
|
||||||
|
return f"{minutes}м"
|
||||||
|
|
||||||
|
async def _handle_start_command(self, message: Message) -> None:
|
||||||
|
"""Handle /start command."""
|
||||||
|
try:
|
||||||
await message.answer_photo(
|
await message.answer_photo(
|
||||||
photo="https://wallpapers-clan.com/wp-content/uploads/2024/04/dark-anime-girl-with-red-eyes-desktop-wallpaper-preview.jpg",
|
photo=self._config.bot.img_url,
|
||||||
caption=(
|
caption=(
|
||||||
"🤖 <b>QIYANA AUTO-BUMP BOT</b>\n\n"
|
"🤖 <b>QIYANA AUTO-BUMP BOT</b>\n\n"
|
||||||
"Бот для автоматического поднятия тем на Lolz.live\n\n"
|
"Бот для автоматического поднятия тем на Lolz.live\n\n"
|
||||||
f"⏰ Автоподнятие каждые <b>{self.config.bump_interval_hours}ч</b>"
|
f"⏰ Автоподнятие каждые <b>{self._config.scheduling.bump_interval_hours}ч</b>"
|
||||||
),
|
),
|
||||||
parse_mode="HTML"
|
reply_markup=self._create_main_menu_keyboard()
|
||||||
)
|
)
|
||||||
await self.send_main_menu(message.chat.id)
|
except TelegramBadRequest as e:
|
||||||
|
logger.error(f"Failed to send start message: {e}")
|
||||||
|
await message.answer("❌ Ошибка отправки сообщения. Попробуйте /start снова.")
|
||||||
|
|
||||||
async def cb_add_thread(self, callback: CallbackQuery, state: FSMContext):
|
async def _handle_add_thread_callback(self, callback: CallbackQuery, state: FSMContext) -> None:
|
||||||
|
"""Handle add thread button callback."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
if not callback.message:
|
||||||
|
return
|
||||||
|
|
||||||
await callback.message.answer(
|
await callback.message.answer(
|
||||||
"📝 Введите ID тем через запятую для добавления:\n"
|
"📝 Введите ID тем через запятую для добавления:\n"
|
||||||
"Пример: <code>12345, 67890, 11111</code>",
|
"Пример: <code>12345, 67890, 11111</code>"
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
)
|
||||||
await state.set_state(BotStates.waiting_for_threads)
|
await state.set_state(BotStates.waiting_for_thread_ids)
|
||||||
|
|
||||||
async def msg_add_threads(self, message: Message, state: FSMContext):
|
async def _handle_thread_ids_input(self, message: Message, state: FSMContext) -> None:
|
||||||
|
"""Handle thread IDs input from user."""
|
||||||
try:
|
try:
|
||||||
thread_ids = [tid.strip() for tid in message.text.split(',') if tid.strip()]
|
thread_ids = [tid.strip() for tid in message.text.split(",") if tid.strip()]
|
||||||
|
|
||||||
if not thread_ids:
|
if not thread_ids:
|
||||||
await message.answer("❌ Не указаны ID тем")
|
await message.answer("❌ Не указаны ID тем")
|
||||||
return
|
return
|
||||||
|
|
||||||
status_msg = await message.answer(f"⏳ Добавляю {len(thread_ids)} тем...")
|
# Validate format
|
||||||
|
valid_ids: list[str] = []
|
||||||
added = []
|
errors: list[str] = []
|
||||||
errors = []
|
|
||||||
|
|
||||||
for thread_id in thread_ids:
|
for thread_id in thread_ids:
|
||||||
if not thread_id.isdigit():
|
if not thread_id.isdigit():
|
||||||
errors.append(f"❌ {thread_id} - неверный формат")
|
errors.append(f"❌ {thread_id} - неверный формат")
|
||||||
continue
|
else:
|
||||||
|
valid_ids.append(thread_id)
|
||||||
|
|
||||||
thread_info = await self.api.get_thread_info(thread_id)
|
if not valid_ids:
|
||||||
|
await message.answer("❌ Нет валидных ID тем")
|
||||||
|
return
|
||||||
|
|
||||||
if not thread_info:
|
status_msg = await message.answer(
|
||||||
errors.append(f"❌ {thread_id} - не найдена")
|
f"⏳ Добавляю {len(valid_ids)} тем в базу данных..."
|
||||||
continue
|
)
|
||||||
|
|
||||||
success = await self.db.add_thread(thread_id, thread_info.title)
|
# Add threads to database directly (without fetching titles)
|
||||||
|
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}")
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
added.append(f"✅ {thread_id} - {thread_info.title}")
|
added.append(f"✅ {thread_id}")
|
||||||
else:
|
else:
|
||||||
errors.append(f"⚠️ {thread_id} - уже добавлена")
|
errors.append(f"⚠️ {thread_id} - уже добавлена")
|
||||||
|
|
||||||
result = f"📊 Результат добавления:\n\n"
|
# Build result message
|
||||||
|
result_parts = ["📊 Результат добавления:\n"]
|
||||||
|
|
||||||
if added:
|
if added:
|
||||||
result += "✅ <b>Добавлено:</b>\n" + "\n".join(added) + "\n\n"
|
result_parts.append("\n✅ <b>Добавлено:</b>\n")
|
||||||
|
result_parts.append("\n".join(added))
|
||||||
|
result_parts.append("\n")
|
||||||
|
|
||||||
if errors:
|
if errors:
|
||||||
result += "❌ <b>Ошибки:</b>\n" + "\n".join(errors)
|
result_parts.append("\n❌ <b>Ошибки:</b>\n")
|
||||||
|
result_parts.append("\n".join(errors))
|
||||||
|
|
||||||
await status_msg.edit_text(result, parse_mode="HTML")
|
await status_msg.edit_text("".join(result_parts))
|
||||||
await self.send_main_menu(message.chat.id)
|
await self._send_main_menu(message.chat.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error adding threads: {e}", exc_info=True)
|
logger.error(f"Error in thread IDs input handler: {e}", exc_info=True)
|
||||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
await message.answer(f"❌ Критическая ошибка: {str(e)}")
|
||||||
finally:
|
finally:
|
||||||
await state.clear()
|
await state.clear()
|
||||||
|
|
||||||
async def cb_list_threads(self, callback: CallbackQuery):
|
async def _handle_list_threads_callback(self, callback: CallbackQuery) -> None:
|
||||||
|
"""Handle list threads button callback."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
if not callback.message:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
threads = await self.db.get_all_threads()
|
threads = await self._db.get_all_threads()
|
||||||
|
|
||||||
if not threads:
|
if not threads:
|
||||||
await callback.message.answer("📭 Список тем пуст\n\nДобавьте темы через кнопку ➕")
|
await callback.message.answer("📭 Список тем пуст\n\nДобавьте темы через кнопку ➕")
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
text = f"📋 <b>Список тем ({len(threads)}):</b>\n\n"
|
# Fetch real titles from API using batch
|
||||||
|
status_msg = await callback.message.answer(
|
||||||
|
f"⏳ Загружаю информацию о {len(threads)} темах..."
|
||||||
|
)
|
||||||
|
|
||||||
for thread in 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
|
||||||
status = "🆕 Новая"
|
status = "🆕 Новая"
|
||||||
if thread.last_bumped:
|
if thread.last_bumped:
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(thread.last_bumped.replace('Z', '+00:00'))
|
dt = datetime.fromisoformat(thread.last_bumped.replace("Z", "+00:00"))
|
||||||
status = f"✅ {dt.strftime('%d.%m.%Y %H:%M')}"
|
status = f"✅ {dt.strftime('%d.%m.%Y %H:%M')}"
|
||||||
except:
|
except ValueError:
|
||||||
status = f"✅ {thread.last_bumped[:16]}"
|
status = f"✅ {thread.last_bumped[:16]}"
|
||||||
|
|
||||||
text += f"<b>{thread.id}</b> - {thread.title}\n{status}\n\n"
|
# Format thread entry
|
||||||
|
text_parts.append(
|
||||||
|
f"<b>ID:</b> {thread.id}\n"
|
||||||
|
f"<b>Название:</b> {title}\n"
|
||||||
|
f"<b>Статус:</b> {status}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
await callback.message.answer(text, parse_mode="HTML")
|
await status_msg.edit_text("".join(text_parts))
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing threads: {e}", exc_info=True)
|
logger.error(f"Error listing threads: {e}", exc_info=True)
|
||||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
async def cb_delete_menu(self, callback: CallbackQuery):
|
async def _handle_delete_menu_callback(self, callback: CallbackQuery) -> None:
|
||||||
|
"""Handle delete menu button callback."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
if not callback.message:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
threads = await self.db.get_all_threads()
|
threads = await self._db.get_all_threads()
|
||||||
|
|
||||||
if not threads:
|
if not threads:
|
||||||
await callback.message.answer("📭 Список тем пуст")
|
await callback.message.answer("📭 Список тем пуст")
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
buttons = []
|
buttons = [
|
||||||
for thread in threads:
|
[InlineKeyboardButton(
|
||||||
button_text = f"{thread.id} - {thread.title[:30]}"
|
text=f"{thread.id} - {self._truncate_text_safely(thread.title, BUTTON_TEXT_MAX_LENGTH)}",
|
||||||
buttons.append([
|
|
||||||
InlineKeyboardButton(
|
|
||||||
text=button_text,
|
|
||||||
callback_data=f"delete_{thread.id}"
|
callback_data=f"delete_{thread.id}"
|
||||||
)
|
)]
|
||||||
])
|
for thread in threads
|
||||||
|
]
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
|
||||||
|
|
||||||
@@ -211,15 +357,20 @@ class AutoBumpBot:
|
|||||||
logger.error(f"Error showing delete menu: {e}", exc_info=True)
|
logger.error(f"Error showing delete menu: {e}", exc_info=True)
|
||||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
async def cb_delete_thread(self, callback: CallbackQuery):
|
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:
|
try:
|
||||||
thread_id = callback.data.split('_')[1]
|
thread_id = callback.data.split("_", 1)[1]
|
||||||
success = await self.db.delete_thread(thread_id)
|
success = await self._db.delete_thread(thread_id)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
await callback.answer(f"✅ Тема {thread_id} удалена", show_alert=True)
|
await callback.answer(f"✅ Тема {thread_id} удалена", show_alert=True)
|
||||||
await callback.message.answer(f"✅ Тема {thread_id} успешно удалена из базы данных")
|
await callback.message.answer(f"✅ Тема {thread_id} успешно удалена из базы данных")
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
else:
|
else:
|
||||||
await callback.answer(f"❌ Тема {thread_id} не найдена", show_alert=True)
|
await callback.answer(f"❌ Тема {thread_id} не найдена", show_alert=True)
|
||||||
|
|
||||||
@@ -227,23 +378,31 @@ class AutoBumpBot:
|
|||||||
logger.error(f"Error deleting thread: {e}", exc_info=True)
|
logger.error(f"Error deleting thread: {e}", exc_info=True)
|
||||||
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
await callback.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||||
|
|
||||||
async def cb_bump_now(self, callback: CallbackQuery):
|
async def _handle_bump_now_callback(self, callback: CallbackQuery) -> None:
|
||||||
|
"""Handle bump now button callback."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
if not callback.message:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
threads = await self.db.get_threads_to_bump(0)
|
threads = await self._db.get_threads_to_bump(0)
|
||||||
|
|
||||||
if not threads:
|
if not threads:
|
||||||
await callback.message.answer("📭 Нет тем для поднятия\n\nДобавьте темы через кнопку ➕")
|
await callback.message.answer("📭 Нет тем для поднятия\n\nДобавьте темы через кнопку ➕")
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
return
|
return
|
||||||
|
|
||||||
status_msg = await callback.message.answer(
|
status_msg = await callback.message.answer(
|
||||||
f"🚀 Начинаю поднятие {len(threads)} тем...\n\n"
|
f"🚀 Начинаю поднятие {len(threads)} тем...\n\n"
|
||||||
|
"Используется batch API (до 10 тем за запрос)\n"
|
||||||
"Подождите, это может занять некоторое время..."
|
"Подождите, это может занять некоторое время..."
|
||||||
)
|
)
|
||||||
|
|
||||||
results = await self.bump_threads(threads, send_to_chat=callback.message.chat.id)
|
results = await self._execute_bump_with_notifications(
|
||||||
|
threads,
|
||||||
|
chat_id=callback.message.chat.id
|
||||||
|
)
|
||||||
|
|
||||||
success_count = sum(1 for r in results if r.success)
|
success_count = sum(1 for r in results if r.success)
|
||||||
|
|
||||||
@@ -251,36 +410,35 @@ class AutoBumpBot:
|
|||||||
f"📊 <b>Поднятие завершено!</b>\n\n"
|
f"📊 <b>Поднятие завершено!</b>\n\n"
|
||||||
f"✅ Успешно: {success_count}\n"
|
f"✅ Успешно: {success_count}\n"
|
||||||
f"❌ Ошибок: {len(threads) - success_count}\n"
|
f"❌ Ошибок: {len(threads) - success_count}\n"
|
||||||
f"📝 Всего тем: {len(threads)}",
|
f"📝 Всего тем: {len(threads)}"
|
||||||
parse_mode="HTML"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error bumping threads: {e}", exc_info=True)
|
logger.error(f"Error bumping threads: {e}", exc_info=True)
|
||||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
async def cb_stats(self, callback: CallbackQuery):
|
async def _handle_stats_callback(self, callback: CallbackQuery) -> None:
|
||||||
|
"""Handle statistics button callback."""
|
||||||
await callback.answer()
|
await callback.answer()
|
||||||
|
|
||||||
|
if not callback.message:
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
threads = await self.db.get_all_threads()
|
threads = await self._db.get_all_threads()
|
||||||
threads_ready = await self.db.get_threads_to_bump(self.config.bump_interval_hours)
|
threads_ready = await self._db.get_threads_to_bump(
|
||||||
|
self._config.scheduling.bump_interval_hours
|
||||||
|
)
|
||||||
|
|
||||||
uptime = "0 минут"
|
uptime = "0 минут"
|
||||||
if self.start_time:
|
if self._start_time:
|
||||||
delta = datetime.now() - self.start_time
|
uptime = self._calculate_uptime(self._start_time)
|
||||||
hours = delta.seconds // 3600
|
|
||||||
minutes = (delta.seconds % 3600) // 60
|
|
||||||
if hours > 0:
|
|
||||||
uptime = f"{hours}ч {minutes}м"
|
|
||||||
else:
|
|
||||||
uptime = f"{minutes}м"
|
|
||||||
|
|
||||||
success_rate = 0
|
success_rate = 0.0
|
||||||
if self.total_bumps > 0:
|
if self._total_bumps > 0:
|
||||||
success_rate = (self.successful_bumps / self.total_bumps) * 100
|
success_rate = (self._successful_bumps / self._total_bumps) * 100
|
||||||
|
|
||||||
text = (
|
text = (
|
||||||
"📊 <b>Статистика бота</b>\n\n"
|
"📊 <b>Статистика бота</b>\n\n"
|
||||||
@@ -289,112 +447,209 @@ class AutoBumpBot:
|
|||||||
f"• Готовы к поднятию: {len(threads_ready)}\n"
|
f"• Готовы к поднятию: {len(threads_ready)}\n"
|
||||||
f"• Ожидают времени: {len(threads) - len(threads_ready)}\n\n"
|
f"• Ожидают времени: {len(threads) - len(threads_ready)}\n\n"
|
||||||
f"🚀 <b>Поднятия:</b>\n"
|
f"🚀 <b>Поднятия:</b>\n"
|
||||||
f"• Всего попыток: {self.total_bumps}\n"
|
f"• Всего попыток: {self._total_bumps}\n"
|
||||||
f"• Успешных: {self.successful_bumps}\n"
|
f"• Успешных: {self._successful_bumps}\n"
|
||||||
f"• Успешность: {success_rate:.1f}%\n\n"
|
f"• Успешность: {success_rate:.1f}%\n\n"
|
||||||
f"⚙️ <b>Настройки:</b>\n"
|
f"⚙️ <b>Настройки:</b>\n"
|
||||||
f"• Интервал: {self.config.bump_interval_hours}ч\n"
|
f"• Интервал: {self._config.scheduling.bump_interval_hours}ч\n"
|
||||||
f"• Задержка между темами: 2с\n\n"
|
f"• Batch size: {self._config.api.batch_size}\n\n"
|
||||||
f"⏱️ <b>Работа:</b>\n"
|
f"⏱️ <b>Работа:</b>\n"
|
||||||
f"• Время работы: {uptime}"
|
f"• Время работы: {uptime}"
|
||||||
)
|
)
|
||||||
|
|
||||||
await callback.message.answer(text, parse_mode="HTML")
|
await callback.message.answer(text)
|
||||||
await self.send_main_menu(callback.message.chat.id)
|
await self._send_main_menu(callback.message.chat.id)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error showing stats: {e}", exc_info=True)
|
logger.error(f"Error showing stats: {e}", exc_info=True)
|
||||||
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
await callback.message.answer(f"❌ Ошибка: {str(e)}")
|
||||||
|
|
||||||
async def bump_threads(self, threads, send_to_chat=None):
|
async def _execute_bump_with_notifications(
|
||||||
results = []
|
self,
|
||||||
|
threads: Sequence[Thread],
|
||||||
|
chat_id: int | None = None
|
||||||
|
) -> list[BumpResult]:
|
||||||
|
"""Execute thread bumping with optional notifications."""
|
||||||
|
if not threads:
|
||||||
|
return []
|
||||||
|
|
||||||
for i, thread in enumerate(threads, 1):
|
thread_ids = [thread.id for thread in threads]
|
||||||
result = await self.api.bump_thread(thread.id)
|
|
||||||
results.append(result)
|
|
||||||
|
|
||||||
self.total_bumps += 1
|
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:
|
if result.success:
|
||||||
self.successful_bumps += 1
|
logger.info(
|
||||||
await self.db.update_last_bumped(thread.id)
|
f"✅ BUMP SUCCESS | Thread: {result.thread_id} | "
|
||||||
|
f"Status: {result.status.value} | Message: {result.message}"
|
||||||
if send_to_chat:
|
)
|
||||||
await self.bot.send_message(
|
else:
|
||||||
send_to_chat,
|
logger.error(
|
||||||
f"[{i}/{len(threads)}] {result.message}"
|
f"❌ BUMP FAILED | Thread: {result.thread_id} | "
|
||||||
|
f"Status: {result.status.value} | Message: {result.message}"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Bump result: {result.message}")
|
# Update statistics and database
|
||||||
|
for result in results:
|
||||||
|
self._total_bumps += 1
|
||||||
|
if result.success:
|
||||||
|
self._successful_bumps += 1
|
||||||
|
await self._db.update_last_bumped(result.thread_id)
|
||||||
|
logger.info(f"Database updated for thread {result.thread_id}")
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
# Send notifications if requested
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
async def auto_bump_loop(self):
|
async def _send_bump_notifications(self, chat_id: int, results: list[BumpResult]) -> None:
|
||||||
logger.info(f"Auto-bump scheduler started (interval: {self.config.bump_interval_hours}h)")
|
"""Send bump result notifications to user with rate limit handling."""
|
||||||
logger.info("Waiting for manual first bump or timer...")
|
for i, result in enumerate(results, 1):
|
||||||
|
|
||||||
while self.is_running:
|
|
||||||
try:
|
try:
|
||||||
sleep_seconds = self.config.bump_interval_hours * 3600
|
await self._bot.send_message(
|
||||||
logger.info(f"Next scheduled bump in {self.config.bump_interval_hours} hours")
|
chat_id,
|
||||||
|
f"[{i}/{len(results)}] {result.message}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delay between notifications to avoid rate limits
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
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)"
|
||||||
|
)
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
await asyncio.sleep(sleep_seconds)
|
await asyncio.sleep(sleep_seconds)
|
||||||
|
|
||||||
if not self.is_running:
|
if not self._is_running:
|
||||||
break
|
break
|
||||||
|
|
||||||
logger.info("Starting scheduled bump...")
|
logger.info("Starting scheduled bump...")
|
||||||
|
|
||||||
threads = await self.db.get_threads_to_bump(self.config.bump_interval_hours)
|
threads = await self._db.get_threads_to_bump(
|
||||||
|
self._config.scheduling.bump_interval_hours
|
||||||
|
)
|
||||||
|
|
||||||
if threads:
|
if threads:
|
||||||
logger.info(f"Found {len(threads)} threads to bump")
|
logger.info(f"Found {len(threads)} threads to bump")
|
||||||
results = await self.bump_threads(threads)
|
results = await self._execute_bump_with_notifications(threads)
|
||||||
success_count = sum(1 for r in results if r.success)
|
success_count = sum(1 for r in results if r.success)
|
||||||
logger.info(f"Scheduled bump completed: {success_count}/{len(threads)} successful")
|
logger.info(f"Scheduled bump completed: {success_count}/{len(threads)} successful")
|
||||||
else:
|
else:
|
||||||
logger.info("No threads ready for scheduled bump")
|
logger.info("No threads ready for scheduled bump")
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.info("Auto-bump loop cancelled")
|
||||||
|
break
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in auto_bump_loop: {e}", exc_info=True)
|
logger.error(f"Error in auto_bump_loop: {e}", exc_info=True)
|
||||||
await asyncio.sleep(60)
|
await asyncio.sleep(AUTO_BUMP_RETRY_DELAY_SECONDS)
|
||||||
|
|
||||||
async def start(self):
|
async def start(self) -> None:
|
||||||
|
"""Start bot and all services."""
|
||||||
try:
|
try:
|
||||||
await self.db.init()
|
# Initialize services
|
||||||
logger.info("Database initialized")
|
await self._db.connect()
|
||||||
|
logger.info("Database connected")
|
||||||
|
|
||||||
await self.api.start()
|
await self._api.start()
|
||||||
logger.info("API client started")
|
logger.info("API client started")
|
||||||
|
|
||||||
self.start_time = datetime.now()
|
self._start_time = datetime.now()
|
||||||
|
self._is_running = True
|
||||||
|
|
||||||
self.is_running = True
|
# Start auto-bump loop if enabled
|
||||||
asyncio.create_task(self.auto_bump_loop())
|
if self._config.scheduling.enable_auto_bump:
|
||||||
|
asyncio.create_task(self._auto_bump_scheduler_loop())
|
||||||
|
|
||||||
logger.info("Bot started successfully!")
|
logger.info("Bot started successfully!")
|
||||||
await self.dp.start_polling(self.bot)
|
await self._dp.start_polling(self._bot)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error starting bot: {e}", exc_info=True)
|
logger.error(f"Error starting bot: {e}", exc_info=True)
|
||||||
|
# Cleanup on startup failure
|
||||||
|
await self._cleanup_resources()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self) -> None:
|
||||||
|
"""Stop bot and cleanup resources."""
|
||||||
logger.info("Stopping bot...")
|
logger.info("Stopping bot...")
|
||||||
self.is_running = False
|
self._is_running = False
|
||||||
await self.api.close()
|
await self._cleanup_resources()
|
||||||
await self.bot.session.close()
|
|
||||||
logger.info("Bot stopped")
|
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:
|
||||||
|
logger.error(f"Error closing bot session: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
try:
|
||||||
|
config = Config.load()
|
||||||
|
logger.info("Configuration loaded successfully")
|
||||||
|
except (FileNotFoundError, ValueError) as e:
|
||||||
|
logger.error(f"Configuration error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
bot = AutoBumpBot(config)
|
||||||
|
|
||||||
async def main():
|
|
||||||
bot = AutoBumpBot()
|
|
||||||
try:
|
try:
|
||||||
await bot.start()
|
await bot.start()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
logger.info("Bot interrupted by user")
|
logger.info("Bot interrupted by user")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Bot crashed: {e}", exc_info=True)
|
logger.error(f"Bot crashed: {e}", exc_info=True)
|
||||||
|
sys.exit(1)
|
||||||
finally:
|
finally:
|
||||||
await bot.stop()
|
await bot.stop()
|
||||||
|
|
||||||
|
|||||||
+5
-3
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"bot": {
|
"bot": {
|
||||||
"api_token": "",
|
"api_token": "TELEGRAM_TOKEN",
|
||||||
"img_url": "https://wallpapers-clan.com/wp-content/uploads/2024/04/dark-anime-girl-with-red-eyes-desktop-wallpaper-preview.jpg",
|
"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/"
|
"author_url": "https://lolz.live/kqlol/"
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"base_url": "https://api.zelenka.guru",
|
"base_url": "https://prod-api.lolz.live",
|
||||||
"auth_token": ""
|
"auth_token": "API_TOKEN_LZT",
|
||||||
|
"batch_size": 10
|
||||||
},
|
},
|
||||||
"database": {
|
"database": {
|
||||||
"path": "threads.db"
|
"path": "threads.db"
|
||||||
},
|
},
|
||||||
"scheduling": {
|
"scheduling": {
|
||||||
"bump_interval_hours": 12,
|
"bump_interval_hours": 12,
|
||||||
|
"bump_delay_seconds": 2,
|
||||||
"enable_auto_bump": true
|
"enable_auto_bump": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+103
-12
@@ -1,29 +1,120 @@
|
|||||||
|
"""Configuration management with validation and type safety."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass(frozen=True, slots=True)
|
||||||
class Config:
|
class BotConfig:
|
||||||
bot_token: str
|
"""Telegram bot configuration."""
|
||||||
api_base_url: str
|
|
||||||
api_token: str
|
api_token: str
|
||||||
db_path: str
|
img_url: str
|
||||||
|
author_url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class APIConfig:
|
||||||
|
"""Lolz API configuration."""
|
||||||
|
base_url: str
|
||||||
|
auth_token: str
|
||||||
|
batch_size: int
|
||||||
|
|
||||||
|
|
||||||
|
@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_interval_hours: float
|
||||||
|
bump_delay_seconds: float
|
||||||
|
enable_auto_bump: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Config:
|
||||||
|
"""Application configuration."""
|
||||||
|
bot: BotConfig
|
||||||
|
api: APIConfig
|
||||||
|
database: DatabaseConfig
|
||||||
|
scheduling: SchedulingConfig
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, config_path: str = "config.json") -> "Config":
|
def load(cls, config_path: str = "config.json") -> Self:
|
||||||
|
"""Load and validate configuration from JSON file."""
|
||||||
path = Path(config_path)
|
path = Path(config_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
raise FileNotFoundError(f"Config file not found: {config_path}")
|
||||||
|
|
||||||
with open(path, 'r', encoding='utf-8') as f:
|
with open(path, encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
cls._validate_config(data)
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
bot_token=data["bot"]["api_token"],
|
bot=BotConfig(
|
||||||
api_base_url=data["api"]["base_url"],
|
api_token=data["bot"]["api_token"],
|
||||||
api_token=data["api"]["auth_token"],
|
img_url=data["bot"]["img_url"],
|
||||||
db_path=data["database"]["path"],
|
author_url=data["bot"]["author_url"]
|
||||||
bump_interval_hours=data["scheduling"]["bump_interval_hours"]
|
),
|
||||||
|
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"])
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|||||||
+216
-33
@@ -1,76 +1,259 @@
|
|||||||
|
"""Database layer with connection pooling and type safety."""
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
from typing import List, Optional
|
import logging
|
||||||
|
from typing import Self
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
class Thread:
|
class Thread:
|
||||||
|
"""Thread model with immutable fields."""
|
||||||
id: str
|
id: str
|
||||||
title: str
|
title: str
|
||||||
last_bumped: Optional[str] = None
|
last_bumped: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
|
"""SQLite database manager with connection pooling and proper error handling."""
|
||||||
|
|
||||||
def __init__(self, db_path: str):
|
__slots__ = ("_db_path", "_connection")
|
||||||
self.db_path = db_path
|
|
||||||
|
|
||||||
async def init(self):
|
def __init__(self, db_path: str) -> None:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
if not db_path:
|
||||||
await db.execute("""
|
raise ValueError("db_path is required")
|
||||||
|
|
||||||
|
self._db_path = db_path
|
||||||
|
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._initialize_schema()
|
||||||
|
logger.info(f"Database connected: {self._db_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to database: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
"""Close database connection safely."""
|
||||||
|
if self._connection:
|
||||||
|
try:
|
||||||
|
await self._connection.close()
|
||||||
|
self._connection = None
|
||||||
|
logger.info("Database connection closed")
|
||||||
|
except Exception as e:
|
||||||
|
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")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._connection.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS threads (
|
CREATE TABLE IF NOT EXISTS threads (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
last_bumped TEXT
|
last_bumped TEXT,
|
||||||
|
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
await db.commit()
|
|
||||||
|
# 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.commit()
|
||||||
|
logger.info("Database schema initialized")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to initialize schema: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
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:
|
async def add_thread(self, thread_id: str, title: str) -> bool:
|
||||||
|
"""
|
||||||
|
Add thread to database.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
thread_id: Unique thread identifier
|
||||||
|
title: Thread title
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if added successfully, False if thread already exists
|
||||||
|
"""
|
||||||
|
self._ensure_connected()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
await self._connection.execute(
|
||||||
await db.execute(
|
"INSERT INTO threads (id, title) VALUES (?, ?)",
|
||||||
"INSERT OR IGNORE INTO threads (id, title) VALUES (?, ?)",
|
|
||||||
(thread_id, title)
|
(thread_id, title)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await self._connection.commit()
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except aiosqlite.IntegrityError:
|
||||||
print(f"Error adding thread {thread_id}: {e}")
|
# Thread already exists (PRIMARY KEY constraint)
|
||||||
return False
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error adding thread {thread_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def get_all_threads(self) -> List[Thread]:
|
async def get_all_threads(self) -> list[Thread]:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
"""
|
||||||
async with db.execute(
|
Get all threads ordered by ID.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Thread objects
|
||||||
|
"""
|
||||||
|
self._ensure_connected()
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._connection.execute(
|
||||||
"SELECT id, title, last_bumped FROM threads ORDER BY id"
|
"SELECT id, title, last_bumped FROM threads ORDER BY id"
|
||||||
) as cursor:
|
) as cursor:
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [Thread(id=r[0], title=r[1], last_bumped=r[2]) for r 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]:
|
async def get_threads_to_bump(self, interval_hours: float) -> list[Thread]:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
"""
|
||||||
async with db.execute(f"""
|
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(
|
||||||
|
"""
|
||||||
SELECT id, title, last_bumped FROM threads
|
SELECT id, title, last_bumped FROM threads
|
||||||
WHERE last_bumped IS NULL
|
WHERE last_bumped IS NULL
|
||||||
OR datetime(last_bumped, '+{interval_hours} hours') <= datetime('now')
|
OR datetime(last_bumped, '+' || ? || ' hours') <= datetime('now')
|
||||||
ORDER BY last_bumped ASC NULLS FIRST
|
ORDER BY last_bumped ASC NULLS FIRST
|
||||||
""") as cursor:
|
""",
|
||||||
|
(interval_hours,)
|
||||||
|
) as cursor:
|
||||||
rows = await cursor.fetchall()
|
rows = await cursor.fetchall()
|
||||||
return [Thread(id=r[0], title=r[1], last_bumped=r[2]) for r 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):
|
async def update_last_bumped(self, thread_id: str) -> None:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
"""
|
||||||
await db.execute(
|
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 = ?",
|
"UPDATE threads SET last_bumped = datetime('now') WHERE id = ?",
|
||||||
(thread_id,)
|
(thread_id,)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await self._connection.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating last_bumped for thread {thread_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
async def delete_thread(self, thread_id: str) -> bool:
|
async def delete_thread(self, thread_id: str) -> bool:
|
||||||
async with aiosqlite.connect(self.db_path) as db:
|
"""
|
||||||
cursor = await db.execute(
|
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 = ?",
|
"DELETE FROM threads WHERE id = ?",
|
||||||
(thread_id,)
|
(thread_id,)
|
||||||
)
|
)
|
||||||
await db.commit()
|
await self._connection.commit()
|
||||||
return cursor.rowcount > 0
|
return cursor.rowcount > 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting thread {thread_id}: {e}")
|
||||||
|
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()
|
||||||
|
return row[0] if row else 0
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting thread count: {e}")
|
||||||
|
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,)
|
||||||
|
) as cursor:
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
return row is not None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking thread existence {thread_id}: {e}")
|
||||||
|
raise
|
||||||
|
|||||||
+2
-3
@@ -1,3 +1,2 @@
|
|||||||
aiogram==3.4.1
|
aiogram==3.15.0
|
||||||
aiohttp>=3.9.0
|
aiosqlite>=0.20.0
|
||||||
aiosqlite>=0.19.0
|
|
||||||
|
|||||||
Reference in New Issue
Block a user