Refactor VNDB Telegram Bot
- Remove config.py and integrate environment variable handling directly in docker-compose.yml. - Delete detailed_handlers.py and replace with simplified command handlers. - Update requirements.txt to use aiogram and httpx instead of python-telegram-bot and requests. - Remove test_bot.py and utils.py, consolidating functionality into new VNDBClient class. - Implement VNDBClient for API interactions, simplifying query methods for visual novels, characters, and releases. - Clean up docker-compose.yml for improved readability and maintainability.
This commit is contained in:
15
.env.example
15
.env.example
@@ -1,13 +1,2 @@
|
||||
# Telegram Bot Token (обязательно)
|
||||
# Получите у @BotFather в Telegram
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
|
||||
# VNDB API Token (опционально)
|
||||
# Получите на https://vndb.org/u/tokens
|
||||
VNDB_TOKEN=your_vndb_api_token_here
|
||||
|
||||
# Опции логирования
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# Использовать sandbox API (для тестирования)
|
||||
USE_SANDBOX=false
|
||||
TELEGRAM_TOKEN=your_token_here
|
||||
VNDB_API_URL=https://api.vndb.org/kana
|
||||
142
CHANGELOG.md
142
CHANGELOG.md
@@ -1,142 +0,0 @@
|
||||
# CHANGELOG
|
||||
|
||||
## [1.0.0] - 2026-05-01
|
||||
|
||||
### Added
|
||||
- ✨ Полная поддержка VNDB API v2 (Kana)
|
||||
- 🎮 Поиск визуальных новелл по названию, языку, платформе, тегам, рейтингу
|
||||
- 👥 Поиск персонажей по имени, полу, черт характера
|
||||
- 🎬 Поиск релизов по названию, платформе, типу
|
||||
- 👨💼 Поиск сотрудников (сценаристы, художники, композиторы)
|
||||
- 🏢 Поиск продюсеров и издателей
|
||||
- 🏷️ Просмотр популярных тегов
|
||||
- ✨ Просмотр черт характера персонажей
|
||||
- 💬 Получение случайных цитат
|
||||
- 📊 Статистика базы данных VNDB
|
||||
- 📋 Информация о схеме API
|
||||
- 🔐 Поддержка авторизации с помощью API токена
|
||||
|
||||
### Features
|
||||
- ⚡ Асинхронные запросы для быстрого отклика
|
||||
- 🔄 Обработка ошибок с пользовательскими сообщениями
|
||||
- 📝 Подробное логирование для отладки
|
||||
- 🎨 Красивое форматирование ответов с поддержкой Markdown
|
||||
- 📦 Полная поддержка Docker и Docker Compose
|
||||
- 🧪 Unit тесты для основных функций
|
||||
- ⚙️ Гибкая конфигурация через переменные окружения
|
||||
|
||||
### Architecture
|
||||
- `bot.py` - Основной модуль бота с обработчиками команд
|
||||
- `vndb_client.py` - VNDB API клиент с поддержкой всех endpoints
|
||||
- `config.py` - Управление конфигурацией
|
||||
- `utils.py` - Утилиты для форматирования и обработки ошибок
|
||||
- `advanced_features.py` - Продвинутые функции (кэширование, rate limiting, сессии)
|
||||
- `test_bot.py` - Unit тесты
|
||||
- `requirements.txt` - Зависимости Python
|
||||
- `Dockerfile` и `docker-compose.yml` - Контейнеризация
|
||||
|
||||
### Documentation
|
||||
- 📚 README.md с полной документацией
|
||||
- 📖 INSTALLATION.md с пошаговыми инструкциями установки
|
||||
- 🔍 Inline документация в исходном коде
|
||||
- 📝 Примеры использования для каждой команды
|
||||
|
||||
### Commands
|
||||
- `/start` - Начало работы с ботом
|
||||
- `/help` - Справка по всем командам
|
||||
- `/search <название>` - Поиск визуальных новелл
|
||||
- `/char <имя>` - Поиск персонажей
|
||||
- `/release <название>` - Поиск релизов
|
||||
- `/staff <имя>` - Поиск сотрудников
|
||||
- `/producer <название>` - Поиск продюсеров
|
||||
- `/tag` - Список тегов
|
||||
- `/trait` - Список черт характера
|
||||
- `/quote [число]` - Случайные цитаты
|
||||
- `/stats` - Статистика базы данных
|
||||
- `/schema` - Информация о схеме API
|
||||
- `/authinfo` - Информация об авторизации
|
||||
|
||||
### Technical Details
|
||||
- Python 3.8+
|
||||
- python-telegram-bot 21.0
|
||||
- httpx для асинхронных HTTP запросов
|
||||
- Асинхронная обработка с asyncio
|
||||
- Полная поддержка rate limiting API VNDB (200 запросов за 5 минут)
|
||||
|
||||
### Known Limitations
|
||||
- Максимум 100 результатов на странице (ограничение API)
|
||||
- Функции управления списками требуют валидный API токен
|
||||
- Время выполнения одного запроса не должно превышать 3 секунды (ограничение API)
|
||||
|
||||
### Future Plans
|
||||
- 🔄 Добавить пагинацию результатов с кнопками навигации
|
||||
- 💾 Добавить кэширование популярных запросов
|
||||
- 📊 Добавить статистику использования бота
|
||||
- 🌍 Поддержка разных языков интерфейса
|
||||
- ⚙️ Админ панель для управления ботом
|
||||
- 🔔 Уведомления о новых релизах избранных ВН
|
||||
- 📱 Поддержка inline режима для использования в других чатах
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
### v1.0.0 (Current)
|
||||
- Initial release with full VNDB API v2 support
|
||||
|
||||
---
|
||||
|
||||
## Dependencies
|
||||
|
||||
### Core
|
||||
- python-telegram-bot==21.0 - Telegram Bot API
|
||||
- python-dotenv==1.0.0 - Environment variables
|
||||
- aiohttp==3.9.1 - Async HTTP client
|
||||
- requests==2.31.0 - HTTP library
|
||||
|
||||
### Testing
|
||||
- pytest==7.4.0 - Testing framework
|
||||
- pytest-asyncio==0.21.0 - Async support for pytest
|
||||
|
||||
### Development (Optional)
|
||||
- black - Code formatter
|
||||
- pylint - Code linter
|
||||
- mypy - Type checker
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
Если вы хотите улучшить бот:
|
||||
1. Форкните репозиторий
|
||||
2. Создайте ветку для вашей функции
|
||||
3. Коммитьте изменения
|
||||
4. Отправьте Pull Request
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
Данные, полученные через VNDB API, подлежат [Data License VNDB](https://vndb.org/d17#4).
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
Если у вас есть вопросы или проблемы:
|
||||
- Проверьте документацию (README.md, INSTALLATION.md)
|
||||
- Проверьте файлы логов
|
||||
- Убедитесь, что API доступен
|
||||
- Проверьте правильность конфигурации
|
||||
|
||||
---
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- VNDB (Visual Novel Database) за отличную базу данных и API
|
||||
- python-telegram-bot за удобную библиотеку
|
||||
- Python асинхронное сообщество
|
||||
|
||||
---
|
||||
|
||||
**Последнее обновление**: 1 мая 2026
|
||||
14
Dockerfile
14
Dockerfile
@@ -1,20 +1,10 @@
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application files
|
||||
COPY . .
|
||||
|
||||
# Copy environment file (or use docker secrets/environment variables)
|
||||
# COPY .env .
|
||||
|
||||
# Create non-root user for security
|
||||
RUN useradd -m -u 1000 botuser && chown -R botuser:botuser /app
|
||||
USER botuser
|
||||
|
||||
# Run the bot
|
||||
CMD ["python", "bot.py"]
|
||||
CMD ["python", "bot.py"]
|
||||
343
EXAMPLES.md
343
EXAMPLES.md
@@ -1,343 +0,0 @@
|
||||
# Примеры использования VNDB Telegram Bot
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
### 1. Начало работы
|
||||
|
||||
Напишите боту:
|
||||
```
|
||||
/start
|
||||
```
|
||||
|
||||
Бот вернёт приветственное сообщение с основной информацией.
|
||||
|
||||
### 2. Справка
|
||||
|
||||
Для просмотра всех доступных команд:
|
||||
```
|
||||
/help
|
||||
```
|
||||
|
||||
## Примеры основных команд
|
||||
|
||||
### Поиск визуальных новелл
|
||||
|
||||
#### Простой поиск
|
||||
```
|
||||
/search Steins Gate
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Список из 10 найденных ВН с основной информацией
|
||||
- Рейтинг и количество голосов
|
||||
- Дата выпуска
|
||||
- Автоматическая отправка обложек первых 3 результатов 📸
|
||||
|
||||
#### Различные запросы
|
||||
```
|
||||
/search Clannad
|
||||
/search Fate
|
||||
/search Genshin
|
||||
/search Tsukihime
|
||||
```
|
||||
|
||||
### Поиск персонажей
|
||||
|
||||
#### Простой поиск
|
||||
```
|
||||
/char Okabe Rintaro
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Список найденных персонажей
|
||||
- Информация о поле персонажа
|
||||
- В каких ВН появляется персонаж
|
||||
- Аватары первых 3 персонажей 🖼️
|
||||
|
||||
#### Другие примеры
|
||||
```
|
||||
/char Nagisa
|
||||
/char Sakura
|
||||
/char Saber
|
||||
/char Rem
|
||||
```
|
||||
|
||||
### Поиск релизов
|
||||
|
||||
#### Поиск по платформе
|
||||
```
|
||||
/release Windows
|
||||
/release Switch
|
||||
/release PlayStation
|
||||
```
|
||||
|
||||
#### Поиск по названию
|
||||
```
|
||||
/release Steins Gate Windows
|
||||
/release Clannad PS2
|
||||
```
|
||||
|
||||
## Детальный просмотр с картинками
|
||||
|
||||
### Просмотр ВН
|
||||
|
||||
Сначала найдите ВН:
|
||||
```
|
||||
/search Steins Gate
|
||||
```
|
||||
|
||||
Видите в результатах: `v17` - это ID Steins;Gate
|
||||
|
||||
Затем просмотрите детали:
|
||||
```
|
||||
/vn_detail v17
|
||||
```
|
||||
|
||||
**Получите:**
|
||||
- Высокое качество обложки
|
||||
- Полная информация:
|
||||
- Название: Steins;Gate
|
||||
- Оригинальное название: シュタインズ・ゲート
|
||||
- Дата выпуска: 2009-09-15
|
||||
- Рейтинг и голоса
|
||||
- Длительность
|
||||
- Разработчик
|
||||
- Подробное описание
|
||||
- Прямая ссылка на VNDB
|
||||
|
||||
### Просмотр персонажа
|
||||
|
||||
Найдите персонажа:
|
||||
```
|
||||
/char Okabe
|
||||
```
|
||||
|
||||
Видите: `c4 - Okabe Rintaro`
|
||||
|
||||
Посмотрите детали:
|
||||
```
|
||||
/char_detail c4
|
||||
```
|
||||
|
||||
**Получите:**
|
||||
- Аватар персонажа высокого качества
|
||||
- Информация:
|
||||
- Имя и оригинальное имя
|
||||
- Пол
|
||||
- Группа крови
|
||||
- Связанные ВН
|
||||
- Биография
|
||||
|
||||
### Просмотр релиза
|
||||
|
||||
Найдите релиз:
|
||||
```
|
||||
/release Steins Gate Windows
|
||||
```
|
||||
|
||||
Видите ID релиза в результатах
|
||||
|
||||
Посмотрите детали:
|
||||
```
|
||||
/release_detail r12345
|
||||
```
|
||||
|
||||
## Информационные команды
|
||||
|
||||
### Статистика базы данных
|
||||
```
|
||||
/stats
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Количество ВН
|
||||
- Количество персонажей
|
||||
- Количество релизов
|
||||
- Количество продюсеров
|
||||
- Количество сотрудников
|
||||
- Количество тегов
|
||||
- Количество черт характера
|
||||
|
||||
### Популярные теги
|
||||
```
|
||||
/tag
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Список 15 самых популярных тегов
|
||||
- Категории и описания
|
||||
|
||||
### Черты характера персонажей
|
||||
```
|
||||
/trait
|
||||
```
|
||||
|
||||
**Результат:**
|
||||
- Список 15 самых распространённых черт
|
||||
- Количество персонажей с каждой чертой
|
||||
|
||||
### Случайные цитаты
|
||||
```
|
||||
/quote
|
||||
```
|
||||
|
||||
Получить одну случайную цитату
|
||||
|
||||
```
|
||||
/quote 3
|
||||
```
|
||||
|
||||
Получить 3 случайные цитаты (максимум 5)
|
||||
|
||||
## Расширенные примеры
|
||||
|
||||
### Изучение конкретной ВН
|
||||
|
||||
```bash
|
||||
# 1. Поиск ВН
|
||||
/search Clannad
|
||||
|
||||
# 2. Просмотр полной информации (видите обложку и описание)
|
||||
/vn_detail v4
|
||||
|
||||
# 3. Если интересует персонаж из Clannad
|
||||
/char Nagisa
|
||||
|
||||
# 4. Просмотр информации о персонаже
|
||||
/char_detail c82
|
||||
|
||||
# 5. Поиск выпусков для разных платформ
|
||||
/release Clannad Windows
|
||||
|
||||
# 6. Просмотр конкретного релиза
|
||||
/release_detail r500
|
||||
```
|
||||
|
||||
### Исследование визуальных новелл от одного разработчика
|
||||
|
||||
```bash
|
||||
# 1. Поиск ВН от Key (фамилия разработчика)
|
||||
/search Key
|
||||
|
||||
# 2. Посмотрите детали найденных ВН
|
||||
/vn_detail v17 # Steins;Gate
|
||||
/vn_detail v4 # Clannad
|
||||
/vn_detail v1 # Air
|
||||
|
||||
# 3. Изучите персонажей из каждой ВН
|
||||
/char Nagisa # из Clannad
|
||||
/char Okabe # из Steins;Gate
|
||||
```
|
||||
|
||||
### Поиск информации о персонаже
|
||||
|
||||
```bash
|
||||
# 1. Найти персонажа по имени
|
||||
/char Saber
|
||||
|
||||
# 2. Посмотреть его подробную информацию
|
||||
/char_detail c1234
|
||||
|
||||
# 3. Найти его родственные ВН в списке
|
||||
# (информация включена в /char_detail)
|
||||
```
|
||||
|
||||
## Советы и трюки
|
||||
|
||||
### Совет 1: Комбинирование команд
|
||||
|
||||
Эффективный способ использования:
|
||||
```bash
|
||||
/search [название] # Найдите элемент
|
||||
/[type]_detail [id] # Посмотрите детали
|
||||
```
|
||||
|
||||
### Совет 2: Использование ID
|
||||
|
||||
После поиска вы видите ID в скобках:
|
||||
```
|
||||
1. **Steins;Gate** (v17)
|
||||
```
|
||||
|
||||
Скопируйте ID и используйте:
|
||||
```
|
||||
/vn_detail v17
|
||||
```
|
||||
|
||||
### Совет 3: Максимум информации за раз
|
||||
|
||||
Команды подробного просмотра дают максимум информации:
|
||||
- Картинку (если есть)
|
||||
- Все основные данные
|
||||
- Описание
|
||||
- Связанные элементы
|
||||
- Ссылку на оригинальный сайт
|
||||
|
||||
### Совет 4: Работа с изображениями
|
||||
|
||||
- **Быстрый поиск**: используйте `/search`, получите автоматические картинки
|
||||
- **Качество**: используйте `/vn_detail` и т.д. для лучшего качества картинок
|
||||
- **Нет картинки**: если элемента нет в VNDB, текстовая информация всё равно будет доступна
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
### Если ничего не найдено
|
||||
```
|
||||
/search очень_редкое_название
|
||||
# Результат: 😞 Ничего не найдено
|
||||
```
|
||||
|
||||
Попробуйте:
|
||||
- Изменить орфографию
|
||||
- Использовать часть названия
|
||||
- Попробовать русское или английское название
|
||||
|
||||
### Если элемент не существует
|
||||
```
|
||||
/vn_detail v999999
|
||||
# Результат: 😞 ВН с ID v999999 не найдена
|
||||
```
|
||||
|
||||
Убедитесь, что:
|
||||
- ID правильный
|
||||
- ID на самом сайте VNDB имеет этот вид
|
||||
|
||||
### Если картинка не загружается
|
||||
Текстовая информация по-прежнему будет отправлена. Вы можете:
|
||||
- Попробовать ещё раз
|
||||
- Посетить https://vndb.org напрямую для просмотра картинок
|
||||
- Сообщить об ошибке, если проблема повторяется
|
||||
|
||||
## Полезные команды для разных целей
|
||||
|
||||
### Исследование ВН
|
||||
```bash
|
||||
/stats # Сколько всего ВН
|
||||
/search [имя] # Найти конкретную ВН
|
||||
/vn_detail [id] # Полная информация о ВН
|
||||
```
|
||||
|
||||
### Изучение персонажей
|
||||
```bash
|
||||
/char [имя] # Найти персонажа
|
||||
/char_detail [id] # Полная информация о персонаже
|
||||
/trait # Посмотреть черты характера
|
||||
```
|
||||
|
||||
### Поиск релизов
|
||||
```bash
|
||||
/release [название] # Найти релиз
|
||||
/release_detail [id] # Информация о релизе
|
||||
/search [ВН] # Найти ВН и её релизы
|
||||
```
|
||||
|
||||
### Просмотр категорий
|
||||
```bash
|
||||
/tag # Теги и категории
|
||||
/trait # Черты характера
|
||||
/schema # Полная информация о полях API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Нужна помощь?** Напишите `/help` для справки по командам
|
||||
177
IMAGES.md
177
IMAGES.md
@@ -1,177 +0,0 @@
|
||||
# Работа с изображениями в VNDB Telegram Bot
|
||||
|
||||
## Поддерживаемые изображения
|
||||
|
||||
Бот может отправлять изображения для:
|
||||
- **Визуальные новеллы** - обложки и постеры
|
||||
- **Персонажи** - аватары и официальные картинки
|
||||
- **Релизы** - коробки и обложки физических изданий
|
||||
|
||||
## Использование изображений
|
||||
|
||||
### 1. Автоматическая отправка при поиске
|
||||
|
||||
При поиске бот автоматически отправляет изображения для первых 3 результатов:
|
||||
|
||||
```
|
||||
/search Steins Gate
|
||||
```
|
||||
|
||||
Бот вернёт:
|
||||
- Текстовую информацию со списком найденных ВН
|
||||
- Обложки первых 3 ВН
|
||||
|
||||
### 2. Детальный просмотр с полной информацией
|
||||
|
||||
Для получения полной информации с изображением используйте команды подробного просмотра:
|
||||
|
||||
#### Просмотр визуальной новеллы
|
||||
|
||||
```
|
||||
/vn_detail v17
|
||||
```
|
||||
|
||||
Отправит:
|
||||
- Обложку ВН (высокое качество)
|
||||
- Полная информация:
|
||||
- Название и оригинальное название
|
||||
- Дата выпуска
|
||||
- Рейтинг и количество голосов
|
||||
- Длительность
|
||||
- Разработчик
|
||||
- Описание
|
||||
- Ссылка на VNDB
|
||||
|
||||
#### Просмотр персонажа
|
||||
|
||||
```
|
||||
/char_detail c1
|
||||
```
|
||||
|
||||
Отправит:
|
||||
- Аватар персонажа
|
||||
- Информация:
|
||||
- Имя и оригинальное имя
|
||||
- Пол
|
||||
- Группа крови
|
||||
- Список ВН, в которых появляется персонаж
|
||||
- Описание
|
||||
|
||||
#### Просмотр релиза
|
||||
|
||||
```
|
||||
/release_detail r1
|
||||
```
|
||||
|
||||
Отправит:
|
||||
- Картинку релиза
|
||||
- Информация:
|
||||
- Название
|
||||
- Дата выпуска
|
||||
- Платформа
|
||||
- Тип (оригинальный, фан-перевод и т.д.)
|
||||
- Языки
|
||||
- Издание
|
||||
- ВН, к которой относится
|
||||
- Описание
|
||||
|
||||
## Примеры
|
||||
|
||||
### Поиск и просмотр ВН
|
||||
|
||||
```
|
||||
# Найти ВН по названию
|
||||
/search Clannad
|
||||
|
||||
# Просмотреть детали найденной ВН
|
||||
/vn_detail v4
|
||||
```
|
||||
|
||||
### Поиск и просмотр персонажа
|
||||
|
||||
```
|
||||
# Найти персонажа
|
||||
/char Nagisa
|
||||
|
||||
# Просмотреть детали персонажа
|
||||
/char_detail c82
|
||||
```
|
||||
|
||||
### Поиск и просмотр релиза
|
||||
|
||||
```
|
||||
# Найти релиз
|
||||
/release Clannad Windows
|
||||
|
||||
# Просмотреть детали релиза
|
||||
/release_detail r500
|
||||
```
|
||||
|
||||
## 🔗 Как найти ID элемента
|
||||
|
||||
### ID визуальной новеллы
|
||||
- На сайте VNDB URL выглядит как: `https://vndb.org/v17`
|
||||
- ID: `v17`
|
||||
|
||||
### ID персонажа
|
||||
- На сайте VNDB URL: `https://vndb.org/c1`
|
||||
- ID: `c1`
|
||||
|
||||
### ID релиза
|
||||
- На сайте VNDB URL: `https://vndb.org/r123`
|
||||
- ID: `r123`
|
||||
|
||||
### ID посредством поиска
|
||||
При поиске бот показывает ID в формате:
|
||||
```
|
||||
1. **Steins;Gate**
|
||||
ID: v17
|
||||
```
|
||||
|
||||
Скопируйте ID и используйте в команде подробного просмотра:
|
||||
```
|
||||
/vn_detail v17
|
||||
```
|
||||
|
||||
## Обработка ошибок при изображениях
|
||||
|
||||
Если изображение недоступно или бот не может его отправить:
|
||||
- Бот вернёт текстовую информацию без изображения
|
||||
- Информация будет полной и точной
|
||||
- Вы по-прежнему можете посетить VNDB напрямую для просмотра изображения
|
||||
|
||||
## Источник изображений
|
||||
|
||||
Все изображения загружаются с официального CDN VNDB:
|
||||
- **URL база**: `https://t.vndb.org`
|
||||
- **Качество**: Оптимальное для отображения в Telegram
|
||||
|
||||
## Советы
|
||||
|
||||
1. **Для быстрого поиска**: используйте `/search`, `/char`, `/release`
|
||||
- Автоматически получите первые изображения
|
||||
- Быстрый просмотр информации
|
||||
|
||||
2. **Для подробной информации**: используйте `/vn_detail`, `/char_detail`, `/release_detail`
|
||||
- Полная информация о элементе
|
||||
- Высокое качество изображения
|
||||
- Ссылка на официальный VNDB
|
||||
|
||||
3. **Сочетание команд**: сначала найдите элемент, потом просмотрите детали
|
||||
```
|
||||
/search Steins Gate
|
||||
# Видите v17 в результатах
|
||||
/vn_detail v17
|
||||
# Получаете полную информацию с обложкой
|
||||
```
|
||||
|
||||
## Примечания
|
||||
|
||||
- Не все элементы имеют изображения в VNDB
|
||||
- Если изображение отсутствует, бот отправит только текстовую информацию
|
||||
- Качество изображений зависит от наличия и разрешения в базе данных VNDB
|
||||
- Бот поддерживает максимум до 3 изображений при поиске (для экономии трафика и скорости)
|
||||
|
||||
---
|
||||
|
||||
Для других вопросов см. `/help` или README.md
|
||||
343
INSTALLATION.md
343
INSTALLATION.md
@@ -1,343 +0,0 @@
|
||||
# Руководство по установке VNDB Telegram Bot
|
||||
|
||||
## Быстрый старт (5 минут)
|
||||
|
||||
### Требования
|
||||
- Python 3.8 или выше
|
||||
- pip (идёт с Python)
|
||||
- Интернет соединение
|
||||
|
||||
### Шаг 1: Получение токенов
|
||||
|
||||
#### Telegram Bot Token
|
||||
1. Откройте Telegram и найдите @BotFather
|
||||
2. Напишите `/newbot`
|
||||
3. Ответьте на вопросы о названии и username вашего бота
|
||||
4. Вы получите токен (выглядит как `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
|
||||
|
||||
#### VNDB API Token (опционально)
|
||||
1. Создайте аккаунт на https://vndb.org (если ещё нет)
|
||||
2. Перейдите на https://vndb.org/u/tokens
|
||||
3. Нажмите "Create new token"
|
||||
4. Скопируйте полученный токен
|
||||
|
||||
### Шаг 2: Установка бота
|
||||
|
||||
```bash
|
||||
# Скачайте проект
|
||||
git clone <repo_url>
|
||||
cd vntgbot
|
||||
|
||||
# Создайте виртуальное окружение (рекомендуется)
|
||||
python -m venv venv
|
||||
|
||||
# Активируйте виртуальное окружение
|
||||
# На Windows:
|
||||
venv\Scripts\activate
|
||||
# На macOS/Linux:
|
||||
source venv/bin/activate
|
||||
|
||||
# Установите зависимости
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Шаг 3: Настройка переменных окружения
|
||||
|
||||
Создайте файл `.env` в корневой папке проекта:
|
||||
|
||||
```
|
||||
TELEGRAM_BOT_TOKEN=ваш_токен_тг
|
||||
VNDB_TOKEN=ваш_токен_vndb
|
||||
```
|
||||
|
||||
Заменив `ваш_токен_тг` и `ваш_токен_vndb` на реальные токены.
|
||||
|
||||
### Шаг 4: Запуск бота
|
||||
|
||||
```bash
|
||||
python bot.py
|
||||
```
|
||||
|
||||
Если все настроено правильно, вы должны увидеть сообщение о том, что бот запущен.
|
||||
|
||||
---
|
||||
|
||||
## Подробная установка
|
||||
|
||||
### Linux/macOS
|
||||
|
||||
```bash
|
||||
# 1. Скачайте исходный код
|
||||
git clone <repo_url>
|
||||
cd vntgbot
|
||||
|
||||
# 2. Создайте виртуальное окружение
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
|
||||
# 3. Обновите pip
|
||||
pip install --upgrade pip
|
||||
|
||||
# 4. Установите зависимости
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 5. Создайте .env файл
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env и добавьте токены
|
||||
nano .env
|
||||
|
||||
# 6. Запустите бота
|
||||
python bot.py
|
||||
```
|
||||
|
||||
### Windows (PowerShell)
|
||||
|
||||
```powershell
|
||||
# 1. Скачайте исходный код
|
||||
git clone <repo_url>
|
||||
cd vntgbot
|
||||
|
||||
# 2. Создайте виртуальное окружение
|
||||
python -m venv venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# 3. Обновите pip
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
# 4. Установите зависимости
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 5. Создайте .env файл
|
||||
Copy-Item .env.example .env
|
||||
# Отредактируйте .env с помощью notepad или вашего любимого редактора
|
||||
notepad .env
|
||||
|
||||
# 6. Запустите бота
|
||||
python bot.py
|
||||
```
|
||||
|
||||
### Windows (CMD)
|
||||
|
||||
```cmd
|
||||
# 1. Скачайте исходный код
|
||||
git clone <repo_url>
|
||||
cd vntgbot
|
||||
|
||||
# 2. Создайте виртуальное окружение
|
||||
python -m venv venv
|
||||
venv\Scripts\activate.bat
|
||||
|
||||
# 3. Обновите pip
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
# 4. Установите зависимости
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 5. Создайте .env файл
|
||||
copy .env.example .env
|
||||
REM Отредактируйте .env
|
||||
|
||||
# 6. Запустите бота
|
||||
python bot.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Развертывание с помощью Docker
|
||||
|
||||
### Требования
|
||||
- Docker
|
||||
- Docker Compose (опционально)
|
||||
|
||||
### С использованием Docker Compose (рекомендуется)
|
||||
|
||||
```bash
|
||||
# 1. Создайте .env файл в корневой папке
|
||||
echo "TELEGRAM_BOT_TOKEN=ваш_токен" > .env
|
||||
echo "VNDB_TOKEN=ваш_токен" >> .env
|
||||
|
||||
# 2. Запустите бота
|
||||
docker-compose up -d
|
||||
|
||||
# 3. Проверьте логи
|
||||
docker-compose logs -f vndb-bot
|
||||
|
||||
# 4. Остановите бота
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### С использованием Docker напрямую
|
||||
|
||||
```bash
|
||||
# 1. Создайте образ
|
||||
docker build -t vndb-bot .
|
||||
|
||||
# 2. Запустите контейнер
|
||||
docker run -d \
|
||||
--name vndb-bot \
|
||||
-e TELEGRAM_BOT_TOKEN=ваш_токен \
|
||||
-e VNDB_TOKEN=ваш_токен \
|
||||
vndb-bot
|
||||
|
||||
# 3. Проверьте логи
|
||||
docker logs vndb-bot
|
||||
|
||||
# 4. Остановите контейнер
|
||||
docker stop vndb-bot
|
||||
docker rm vndb-bot
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Проверка установки
|
||||
|
||||
После запуска бота:
|
||||
|
||||
1. Откройте Telegram
|
||||
2. Найдите вашего бота по username
|
||||
3. Напишите `/start`
|
||||
4. Вы должны увидеть приветственное сообщение
|
||||
|
||||
Если всё работает, попробуйте команду:
|
||||
```
|
||||
/stats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Решение проблем
|
||||
|
||||
### Ошибка: "TELEGRAM_BOT_TOKEN not set"
|
||||
- Проверьте, что файл `.env` существует в корневой папке
|
||||
- Убедитесь, что в `.env` правильно указан токен
|
||||
- Убедитесь, что токен не содержит кавычки или лишних пробелов
|
||||
|
||||
### Ошибка: "Connection refused"
|
||||
- Проверьте интернет соединение
|
||||
- Убедитесь, что сервер VNDB API доступен (https://api.vndb.org/kana)
|
||||
|
||||
### Бот не отвечает на сообщения
|
||||
- Проверьте, что бот имеет права администратора в чате (если используется в групповом чате)
|
||||
- Убедитесь, что в настройках бота включены приватные сообщения
|
||||
- Перезагрузите бота: остановите его и запустите снова
|
||||
|
||||
### Медленные ответы
|
||||
- Это может быть из-за ограничений API VNDB
|
||||
- Попробуйте уменьшить количество запрашиваемых полей
|
||||
- Убедитесь, что ваше интернет соединение стабильно
|
||||
|
||||
### Ошибка импорта модулей
|
||||
```bash
|
||||
# Убедитесь, что виртуальное окружение активировано и зависимости установлены
|
||||
pip install -r requirements.txt --upgrade
|
||||
```
|
||||
|
||||
### SSL Certificate Error
|
||||
```bash
|
||||
# На некоторых системах может потребоваться:
|
||||
pip install certifi
|
||||
# Или использовать нестабильное соединение (не рекомендуется):
|
||||
# Добавьте в bot.py перед запуском: import ssl; ssl._create_default_https_context = ssl._create_unverified_context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Конфигурация
|
||||
|
||||
### Переменные окружения
|
||||
|
||||
| Переменная | Обязательна | По умолчанию | Описание |
|
||||
|-----------|----------|----------|-----------|
|
||||
| TELEGRAM_BOT_TOKEN | Да | - | Токен Telegram бота |
|
||||
| VNDB_TOKEN | Нет | - | Токен VNDB API для авторизации |
|
||||
| LOG_LEVEL | Нет | INFO | Уровень логирования (DEBUG, INFO, WARNING, ERROR) |
|
||||
| USE_SANDBOX | Нет | false | Использовать sandbox API для тестирования |
|
||||
|
||||
### Файл config.py
|
||||
|
||||
Вы можете отредактировать `config.py` для изменения других параметров:
|
||||
|
||||
```python
|
||||
MAX_RESULTS_PER_PAGE = 100 # Максимум результатов на странице
|
||||
DEFAULT_RESULTS_PER_PAGE = 10 # По умолчанию результатов на странице
|
||||
MAX_QUOTES_AT_ONCE = 5 # Максимум цитат за раз
|
||||
API_TIMEOUT = 10 # Timeout для API запросов (в секундах)
|
||||
BOT_TIMEOUT = 30 # Timeout для бота (в секундах)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Проверка зависимостей
|
||||
|
||||
```bash
|
||||
# Проверьте, что все зависимости установлены правильно
|
||||
pip check
|
||||
|
||||
# Обновите зависимости
|
||||
pip install -r requirements.txt --upgrade
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Выполнение тестов
|
||||
|
||||
```bash
|
||||
# Установите зависимости для тестирования (они уже в requirements.txt)
|
||||
# Запустите тесты
|
||||
pytest test_bot.py -v
|
||||
|
||||
# С покрытием
|
||||
pytest test_bot.py --cov=. --cov-report=html
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Автозагрузка при запуске системы
|
||||
|
||||
### Linux/macOS (systemd)
|
||||
|
||||
Создайте файл `/etc/systemd/system/vndb-bot.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=VNDB Telegram Bot
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=your_username
|
||||
WorkingDirectory=/path/to/vntgbot
|
||||
Environment="PATH=/path/to/vntgbot/venv/bin"
|
||||
ExecStart=/path/to/vntgbot/venv/bin/python bot.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Затем:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable vndb-bot
|
||||
sudo systemctl start vndb-bot
|
||||
```
|
||||
|
||||
### Windows (Планировщик задач)
|
||||
|
||||
1. Откройте Планировщик задач
|
||||
2. Создайте новую задачу
|
||||
3. Установите триггер "При запуске"
|
||||
4. Установите действие: запустить `python.exe` с аргументом `C:\path\to\bot.py`
|
||||
5. Сохраните задачу
|
||||
|
||||
---
|
||||
|
||||
## Нужна помощь?
|
||||
|
||||
- 📖 Прочитайте [README.md](README.md)
|
||||
- 🐛 Проверьте [логи](#логирование)
|
||||
- 📝 Посмотрите примеры команд в `/help`
|
||||
- 🌐 Посетите https://api.vndb.org/kana для справки по API
|
||||
|
||||
Удачи! 🚀
|
||||
73
LICENSE
73
LICENSE
@@ -1,73 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
|
||||
|
||||
Copyright 2026 King-of-the-all-Cookies
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
256
README.md
256
README.md
@@ -1,256 +0,0 @@
|
||||
# VNDB Telegram Bot
|
||||
|
||||
Полнофункциональный Telegram бот для работы с базой данных [VNDB](https://vndb.org/) (Visual Novel Database).
|
||||
|
||||
## Возможности
|
||||
|
||||
Бот поддерживает **все методы** VNDB API v2:
|
||||
|
||||
### Поиск и запросы
|
||||
- **Визуальные новеллы** - полный поиск по названию, языку, платформе, тегам, рейтингу и дате выпуска
|
||||
- **Персонажи** - поиск по имени, полу, роли, чертам характера
|
||||
- **Релизы** - поиск по названию, платформе, типу, дате выпуска
|
||||
- **Сотрудники** - поиск сценаристов, художников, композиторов и других
|
||||
- **Продюсеры** - поиск издателей и разработчиков
|
||||
- **Теги** - просмотр популярных тегов и категорий
|
||||
- **Черты характера** - список черт персонажей
|
||||
- **Цитаты** - получение случайных цитат из ВН
|
||||
|
||||
### Изображения
|
||||
- **Обложки ВН** - автоматическая отправка обложек при поиске визуальных новелл
|
||||
- **Аватары персонажей** - картинки персонажей при поиске
|
||||
- **Картинки релизов** - изображения для каждого релиза
|
||||
- **Подробный просмотр** - команды `/vn_detail`, `/char_detail`, `/release_detail` для полной информации с высоким качеством изображений
|
||||
|
||||
### Управление списками (требует токена)
|
||||
- Добавление ВН в личный список
|
||||
- Обновление статуса просмотра
|
||||
- Добавление заметок и оценок
|
||||
- Управление меткамиме
|
||||
|
||||
### Информация
|
||||
- Статистика базы данных
|
||||
- Информация о схеме API
|
||||
- Информация об авторизации
|
||||
|
||||
## Установка
|
||||
|
||||
### Требования
|
||||
- Python 3.8+
|
||||
- pip
|
||||
|
||||
### Шаг 1: Клонирование репозитория
|
||||
```bash
|
||||
git clone <repo_url>
|
||||
cd vntgbot
|
||||
```
|
||||
|
||||
### Шаг 2: Установка зависимостей
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Шаг 3: Настройка переменных окружения
|
||||
|
||||
Создайте файл `.env` в корневой папке проекта:
|
||||
|
||||
```env
|
||||
# Обязательно
|
||||
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
|
||||
|
||||
# Опционально (для функций авторизации)
|
||||
VNDB_TOKEN=your_vndb_api_token_here
|
||||
```
|
||||
|
||||
#### Получение токена Telegram Bot
|
||||
1. Напишите боту [@BotFather](https://t.me/botfather) в Telegram
|
||||
2. Используйте команду `/newbot`
|
||||
3. Следуйте инструкциям и получите токен
|
||||
|
||||
#### Получение VNDB API токена
|
||||
1. Создайте аккаунт на [VNDB.org](https://vndb.org)
|
||||
2. Перейдите на https://vndb.org/u/tokens
|
||||
3. Создайте новый токен
|
||||
4. Скопируйте его в переменную `VNDB_TOKEN`
|
||||
|
||||
### Шаг 4: Запуск бота
|
||||
|
||||
```bash
|
||||
python bot.py
|
||||
```
|
||||
|
||||
## Использование
|
||||
|
||||
### Команды
|
||||
|
||||
#### Поиск
|
||||
- `/search <название>` - Поиск визуальных новелл
|
||||
- `/char <имя>` - Поиск персонажей
|
||||
- `/release <название>` - Поиск релизов
|
||||
- `/staff <имя>` - Поиск сотрудников
|
||||
- `/producer <название>` - Поиск продюсеров
|
||||
|
||||
#### Информация
|
||||
- `/tag` - Список популярных тегов
|
||||
- `/trait` - Список черт характера
|
||||
- `/quote [число]` - Случайные цитаты (макс. 5)
|
||||
- `/stats` - Статистика базы данных
|
||||
- `/schema` - Информация о схеме API
|
||||
- `/authinfo` - Информация об авторизации (если токен установлен)
|
||||
|
||||
#### Просмотр с картинками
|
||||
- `/vn_detail <ID>` - Полная информация о ВН с обложкой (_Пример: /vn_detail v17_)
|
||||
- `/char_detail <ID>` - Информация о персонаже с аватаром (_Пример: /char_detail c1_)
|
||||
- `/release_detail <ID>` - Информация о релизе с картинкой (_Пример: /release_detail r1_)
|
||||
|
||||
#### Справка
|
||||
- `/start` - Приветствие и основная информация
|
||||
- `/help` - Подробная справка по всем командам
|
||||
|
||||
### Примеры использования
|
||||
|
||||
```
|
||||
/search Steins Gate
|
||||
/char Okabe Rintaro
|
||||
/release Windows
|
||||
/staff Yoko Taro
|
||||
/producer Key
|
||||
/quote 3
|
||||
/vn_detail v17
|
||||
/char_detail c25
|
||||
/release_detail r1
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
vntgbot/
|
||||
├── bot.py # Основной файл бота с обработчиками команд
|
||||
├── vndb_client.py # VNDB API клиент
|
||||
├── requirements.txt # Зависимости Python
|
||||
├── .env # Переменные окружения (не отслеживается в git)
|
||||
├── .gitignore # Файлы для игнорирования
|
||||
└── README.md # Этот файл
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Бот поддерживает следующие endpoint'ы VNDB API:
|
||||
|
||||
### Простые запросы
|
||||
- `GET /schema` - Информация о схеме API
|
||||
- `GET /stats` - Статистика базы данных
|
||||
- `GET /user` - Информация о пользователе
|
||||
- `GET /authinfo` - Информация об авторизации
|
||||
|
||||
### Запросы к базе данных
|
||||
- `POST /vn` - Запрос визуальных новелл
|
||||
- `POST /release` - Запрос релизов
|
||||
- `POST /character` - Запрос персонажей
|
||||
- `POST /staff` - Запрос сотрудников
|
||||
- `POST /producer` - Запрос продюсеров
|
||||
- `POST /tag` - Запрос тегов
|
||||
- `POST /trait` - Запрос черт характера
|
||||
- `POST /quote` - Запрос цитат
|
||||
|
||||
### Управление списками
|
||||
- `POST /ulist` - Запрос списка ВН пользователя
|
||||
- `POST /rlist` - Запрос списка релизов пользователя
|
||||
- `PATCH /ulist/<id>` - Обновление записи в списке ВН
|
||||
- `PATCH /rlist/<id>` - Обновление записи в списке релизов
|
||||
- `DELETE /ulist/<id>` - Удаление из списка ВН
|
||||
- `DELETE /rlist/<id>` - Удаление из списка релизов
|
||||
- `GET /ulist_labels` - Получение меток списка
|
||||
|
||||
## Фильтры и опции
|
||||
|
||||
### Поддерживаемые параметры запросов:
|
||||
- **filters** - Условия фильтрации
|
||||
- **fields** - Выбираемые поля
|
||||
- **sort** - Сортировка (id, title, released, rating, votecount)
|
||||
- **reverse** - Обратный порядок сортировки
|
||||
- **results** - Количество результатов (макс. 100)
|
||||
- **page** - Номер страницы для пагинации
|
||||
- **count** - Включить общее количество результатов
|
||||
- **user** - ID пользователя для фильтров специфичных для пользователя
|
||||
|
||||
## Ограничения API
|
||||
|
||||
VNDB API имеет следующие ограничения:
|
||||
- **200 запросов** за 5 минут
|
||||
- **1 секунда** общего времени выполнения в минуту
|
||||
- **3 секунды** максимального времени для одного запроса
|
||||
|
||||
Бот учитывает эти ограничения и использует асинхронные запросы для оптимальной производительности.
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
Бот обрабатывает следующие типы ошибок:
|
||||
- Сетевые ошибки (HTTP, timeout)
|
||||
- Ошибки парсинга JSON
|
||||
- Ошибки авторизации (401)
|
||||
- Ошибки валидации данных
|
||||
|
||||
Все ошибки логируются и сообщаются пользователю в удобном формате.
|
||||
|
||||
## Расширение функционала
|
||||
|
||||
Для добавления новых команд:
|
||||
|
||||
1. Добавьте метод в класс `BotHandlers`
|
||||
2. Зарегистрируйте обработчик в функции `main()`
|
||||
3. (Опционально) Расширьте `VndbClient` новыми методами API
|
||||
|
||||
Пример:
|
||||
|
||||
```python
|
||||
@staticmethod
|
||||
async def my_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
"""Description"""
|
||||
try:
|
||||
# Your code here
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Error: {e}")
|
||||
await update.message.reply_text(f"❌ Error: {str(e)}")
|
||||
|
||||
# In main():
|
||||
application.add_handler(CommandHandler("mycommand", BotHandlers.my_command))
|
||||
```
|
||||
|
||||
## Логирование
|
||||
|
||||
Бот использует встроенный модуль `logging` для отслеживания операций. Логи выводятся в консоль с уровнем INFO.
|
||||
|
||||
Для изменения уровня логирования отредактируйте `bot.py`:
|
||||
|
||||
```python
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
level=logging.DEBUG # Измените на DEBUG для более подробных логов
|
||||
)
|
||||
```
|
||||
|
||||
## Документация VNDB API
|
||||
|
||||
Полная документация VNDB API доступна по адресу: https://api.vndb.org/kana
|
||||
|
||||
## Лицензирование
|
||||
|
||||
Данные, полученные через VNDB API, подлежат [Data License VNDB](https://vndb.org/d17#4).
|
||||
|
||||
## Благодарности
|
||||
|
||||
- [VNDB](https://vndb.org/) за отличную базу данных и API
|
||||
- [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) за удобную библиотеку
|
||||
|
||||
## Поддержка
|
||||
|
||||
Если у вас есть вопросы или проблемы:
|
||||
1. Проверьте файл логов
|
||||
2. Убедитесь, что токены установлены правильно
|
||||
3. Проверьте интернет соединение и статус API VNDB
|
||||
|
||||
## Автор
|
||||
|
||||
Создано для удобного доступа к VNDB из Telegram.
|
||||
@@ -1,325 +0,0 @@
|
||||
"""
|
||||
Advanced features for VNDB Telegram Bot
|
||||
Includes pagination, caching, and rate limiting
|
||||
"""
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Dict, List, Any, Optional, Callable
|
||||
from collections import defaultdict
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
"""Rate limiter for API requests"""
|
||||
|
||||
def __init__(self, max_requests: int = 200, window_seconds: int = 300):
|
||||
"""
|
||||
Initialize rate limiter
|
||||
|
||||
Args:
|
||||
max_requests: Maximum requests allowed in window
|
||||
window_seconds: Time window in seconds (default 5 minutes = 300 seconds)
|
||||
"""
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
self.requests = []
|
||||
|
||||
def is_allowed(self) -> bool:
|
||||
"""Check if a request is allowed"""
|
||||
now = time.time()
|
||||
|
||||
# Remove old requests outside the window
|
||||
self.requests = [req_time for req_time in self.requests
|
||||
if now - req_time < self.window_seconds]
|
||||
|
||||
# Check if we can make another request
|
||||
if len(self.requests) < self.max_requests:
|
||||
self.requests.append(now)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def wait_if_needed(self) -> None:
|
||||
"""Wait if rate limit is reached"""
|
||||
if not self.is_allowed():
|
||||
if self.requests:
|
||||
oldest = self.requests[0]
|
||||
wait_time = self.window_seconds - (time.time() - oldest)
|
||||
if wait_time > 0:
|
||||
logger.warning(f"Rate limit reached, waiting {wait_time:.1f}s")
|
||||
time.sleep(wait_time)
|
||||
self.is_allowed()
|
||||
|
||||
|
||||
class SimpleCache:
|
||||
"""Simple in-memory cache for API responses"""
|
||||
|
||||
def __init__(self, ttl_seconds: int = 300):
|
||||
"""
|
||||
Initialize cache
|
||||
|
||||
Args:
|
||||
ttl_seconds: Time to live for cached items
|
||||
"""
|
||||
self.ttl_seconds = ttl_seconds
|
||||
self.cache: Dict[str, tuple] = {}
|
||||
|
||||
def _make_key(self, endpoint: str, params: Dict[str, Any]) -> str:
|
||||
"""Create cache key from endpoint and parameters"""
|
||||
params_str = str(sorted(params.items()))
|
||||
return f"{endpoint}:{params_str}"
|
||||
|
||||
def get(self, endpoint: str, params: Dict[str, Any]) -> Optional[Any]:
|
||||
"""Get item from cache"""
|
||||
key = self._make_key(endpoint, params)
|
||||
|
||||
if key not in self.cache:
|
||||
return None
|
||||
|
||||
value, timestamp = self.cache[key]
|
||||
|
||||
# Check if expired
|
||||
if time.time() - timestamp > self.ttl_seconds:
|
||||
del self.cache[key]
|
||||
return None
|
||||
|
||||
logger.debug(f"Cache hit for {key}")
|
||||
return value
|
||||
|
||||
def set(self, endpoint: str, params: Dict[str, Any], value: Any) -> None:
|
||||
"""Set item in cache"""
|
||||
key = self._make_key(endpoint, params)
|
||||
self.cache[key] = (value, time.time())
|
||||
logger.debug(f"Cached {key}")
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all cache"""
|
||||
self.cache.clear()
|
||||
|
||||
def stats(self) -> Dict[str, int]:
|
||||
"""Get cache statistics"""
|
||||
now = time.time()
|
||||
expired = sum(
|
||||
1 for _, (_, timestamp) in self.cache.items()
|
||||
if now - timestamp > self.ttl_seconds
|
||||
)
|
||||
return {
|
||||
"total_items": len(self.cache),
|
||||
"expired_items": expired,
|
||||
}
|
||||
|
||||
|
||||
class Paginator:
|
||||
"""Handle pagination for search results"""
|
||||
|
||||
def __init__(self, items: List[Dict[str, Any]], items_per_page: int = 5):
|
||||
"""
|
||||
Initialize paginator
|
||||
|
||||
Args:
|
||||
items: List of items to paginate
|
||||
items_per_page: Number of items per page
|
||||
"""
|
||||
self.items = items
|
||||
self.items_per_page = items_per_page
|
||||
self.current_page = 1
|
||||
|
||||
@property
|
||||
def total_pages(self) -> int:
|
||||
"""Get total number of pages"""
|
||||
return (len(self.items) + self.items_per_page - 1) // self.items_per_page
|
||||
|
||||
@property
|
||||
def current_items(self) -> List[Dict[str, Any]]:
|
||||
"""Get items for current page"""
|
||||
start = (self.current_page - 1) * self.items_per_page
|
||||
end = start + self.items_per_page
|
||||
return self.items[start:end]
|
||||
|
||||
def next_page(self) -> bool:
|
||||
"""Go to next page"""
|
||||
if self.current_page < self.total_pages:
|
||||
self.current_page += 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def prev_page(self) -> bool:
|
||||
"""Go to previous page"""
|
||||
if self.current_page > 1:
|
||||
self.current_page -= 1
|
||||
return True
|
||||
return False
|
||||
|
||||
def goto_page(self, page: int) -> bool:
|
||||
"""Go to specific page"""
|
||||
if 1 <= page <= self.total_pages:
|
||||
self.current_page = page
|
||||
return True
|
||||
return False
|
||||
|
||||
def page_info(self) -> str:
|
||||
"""Get page information string"""
|
||||
return f"Страница {self.current_page}/{self.total_pages}"
|
||||
|
||||
|
||||
class UserSession:
|
||||
"""Manage user session data"""
|
||||
|
||||
def __init__(self, user_id: int):
|
||||
"""Initialize session"""
|
||||
self.user_id = user_id
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.created_at = time.time()
|
||||
self.last_activity = time.time()
|
||||
|
||||
def set(self, key: str, value: Any) -> None:
|
||||
"""Set session data"""
|
||||
self.data[key] = value
|
||||
self.last_activity = time.time()
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
"""Get session data"""
|
||||
self.last_activity = time.time()
|
||||
return self.data.get(key, default)
|
||||
|
||||
def update_activity(self) -> None:
|
||||
"""Update last activity time"""
|
||||
self.last_activity = time.time()
|
||||
|
||||
def is_idle(self, timeout_seconds: int = 1800) -> bool:
|
||||
"""Check if session is idle"""
|
||||
return time.time() - self.last_activity > timeout_seconds
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear session data"""
|
||||
self.data.clear()
|
||||
|
||||
|
||||
class SessionManager:
|
||||
"""Manage user sessions"""
|
||||
|
||||
def __init__(self, idle_timeout_seconds: int = 1800):
|
||||
"""
|
||||
Initialize session manager
|
||||
|
||||
Args:
|
||||
idle_timeout_seconds: Timeout for idle sessions
|
||||
"""
|
||||
self.sessions: Dict[int, UserSession] = {}
|
||||
self.idle_timeout = idle_timeout_seconds
|
||||
|
||||
def get_session(self, user_id: int) -> UserSession:
|
||||
"""Get or create user session"""
|
||||
if user_id not in self.sessions:
|
||||
self.sessions[user_id] = UserSession(user_id)
|
||||
else:
|
||||
self.sessions[user_id].update_activity()
|
||||
|
||||
return self.sessions[user_id]
|
||||
|
||||
def cleanup_idle_sessions(self) -> int:
|
||||
"""Remove idle sessions"""
|
||||
user_ids_to_remove = [
|
||||
user_id for user_id, session in self.sessions.items()
|
||||
if session.is_idle(self.idle_timeout)
|
||||
]
|
||||
|
||||
for user_id in user_ids_to_remove:
|
||||
del self.sessions[user_id]
|
||||
|
||||
logger.info(f"Cleaned up {len(user_ids_to_remove)} idle sessions")
|
||||
return len(user_ids_to_remove)
|
||||
|
||||
def stats(self) -> Dict[str, Any]:
|
||||
"""Get session statistics"""
|
||||
return {
|
||||
"active_sessions": len(self.sessions),
|
||||
"total_users": len(self.sessions),
|
||||
}
|
||||
|
||||
|
||||
class RequestLogger:
|
||||
"""Log API requests for debugging"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize request logger"""
|
||||
self.requests: List[Dict[str, Any]] = []
|
||||
self.max_history = 100
|
||||
|
||||
def log_request(
|
||||
self,
|
||||
endpoint: str,
|
||||
method: str,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
response_time: float = 0,
|
||||
status_code: int = 0,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Log an API request"""
|
||||
request_log = {
|
||||
"timestamp": time.time(),
|
||||
"endpoint": endpoint,
|
||||
"method": method,
|
||||
"params": params,
|
||||
"response_time": response_time,
|
||||
"status_code": status_code,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
self.requests.append(request_log)
|
||||
|
||||
# Keep only recent requests
|
||||
if len(self.requests) > self.max_history:
|
||||
self.requests = self.requests[-self.max_history:]
|
||||
|
||||
def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get request statistics"""
|
||||
if not self.requests:
|
||||
return {"requests_logged": 0}
|
||||
|
||||
total_time = sum(r["response_time"] for r in self.requests)
|
||||
avg_time = total_time / len(self.requests) if self.requests else 0
|
||||
errors = sum(1 for r in self.requests if r["error"])
|
||||
|
||||
return {
|
||||
"requests_logged": len(self.requests),
|
||||
"total_time": total_time,
|
||||
"average_time": avg_time,
|
||||
"errors": errors,
|
||||
"success_rate": (len(self.requests) - errors) / len(self.requests) * 100,
|
||||
}
|
||||
|
||||
|
||||
def rate_limit(limiter: RateLimiter):
|
||||
"""Decorator for rate limiting"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
limiter.wait_if_needed()
|
||||
return await func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
|
||||
|
||||
def with_cache(cache: SimpleCache, ttl: int = 300):
|
||||
"""Decorator for caching"""
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(self, *args, endpoint: str = "", **kwargs):
|
||||
# Try to get from cache
|
||||
cached = cache.get(endpoint, kwargs)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# Call function
|
||||
result = await func(self, *args, endpoint=endpoint, **kwargs)
|
||||
|
||||
# Cache result
|
||||
cache.set(endpoint, kwargs, result)
|
||||
|
||||
return result
|
||||
return wrapper
|
||||
return decorator
|
||||
348
bot.py
348
bot.py
@@ -1,297 +1,111 @@
|
||||
import logging
|
||||
from typing import Optional
|
||||
import os
|
||||
import asyncio
|
||||
from aiogram import Bot, Dispatcher, types
|
||||
from aiogram.filters import Command
|
||||
from vndb import VNDBClient
|
||||
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from telegram.ext import (
|
||||
Application,
|
||||
CommandHandler,
|
||||
MessageHandler,
|
||||
CallbackQueryHandler,
|
||||
ConversationHandler,
|
||||
ContextTypes,
|
||||
filters,
|
||||
)
|
||||
TOKEN = os.getenv("TELEGRAM_TOKEN")
|
||||
|
||||
from vndb_client import VndbClient
|
||||
from config import Config
|
||||
from utils import Formatter, ErrorHandler, QueryBuilder
|
||||
from detailed_handlers import get_detail_handlers
|
||||
bot = Bot(token=TOKEN)
|
||||
dp = Dispatcher()
|
||||
vndb = VNDBClient(os.getenv("VNDB_API_URL"))
|
||||
|
||||
|
||||
# ======================
|
||||
# LOGGING
|
||||
# ======================
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
level=Config.LOG_LEVEL
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
def safe_text(x):
|
||||
if not x:
|
||||
return "—"
|
||||
return str(x)
|
||||
|
||||
|
||||
# ======================
|
||||
# STATES
|
||||
# ======================
|
||||
SEARCH_VN, SELECT_VN, VN_DETAILS = range(3)
|
||||
SEARCH_CHARACTER, SELECT_CHARACTER = range(2)
|
||||
SEARCH_RELEASE, SELECT_RELEASE = range(2)
|
||||
SEARCH_STAFF, SELECT_STAFF = range(2)
|
||||
@dp.message(Command("start"))
|
||||
async def start(msg: types.Message):
|
||||
await msg.answer(
|
||||
"VNDB Bot ready.\n"
|
||||
"Commands:\n"
|
||||
"/search <vn>\n"
|
||||
"/vn <id>\n"
|
||||
"/char <id>\n"
|
||||
"/release <id>"
|
||||
)
|
||||
|
||||
|
||||
# ======================
|
||||
# CLIENT
|
||||
# ======================
|
||||
vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX)
|
||||
@dp.message(Command("help"))
|
||||
async def help(msg: types.Message):
|
||||
await start(msg)
|
||||
|
||||
|
||||
# ======================
|
||||
# HELPERS
|
||||
# ======================
|
||||
HTML_HELP_HEADER = "<b>VNDB Telegram Bot</b>\n\n"
|
||||
@dp.message(Command("search"))
|
||||
async def search(msg: types.Message):
|
||||
query = msg.text.replace("/search", "").strip()
|
||||
if not query:
|
||||
return await msg.answer("Empty query")
|
||||
|
||||
result = await vndb.search_vn(query)
|
||||
|
||||
# ======================
|
||||
# HANDLERS
|
||||
# ======================
|
||||
class BotHandlers:
|
||||
if not result:
|
||||
return await msg.answer("Not found")
|
||||
|
||||
@staticmethod
|
||||
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
text = """
|
||||
<b>Добро пожаловать в VNDB Telegram Бот!</b>
|
||||
text = "\n\n".join(
|
||||
f"{i['id']} — {i.get('title') or i.get('name')}"
|
||||
for i in result
|
||||
)
|
||||
await msg.answer(text[:4000])
|
||||
|
||||
Этот бот позволяет искать визуальные новеллы, персонажей и релизы из VNDB.
|
||||
|
||||
<b>Команды:</b>
|
||||
/search - поиск VN
|
||||
/char - персонажи
|
||||
/release - релизы
|
||||
/staff - сотрудники
|
||||
/producer - продюсеры
|
||||
/tag - теги
|
||||
/trait - черты
|
||||
/quote - цитаты
|
||||
/help - помощь
|
||||
"""
|
||||
@dp.message(Command("vn"))
|
||||
async def vn(msg: types.Message):
|
||||
vid = msg.text.replace("/vn", "").strip()
|
||||
if not vid:
|
||||
return await msg.answer("No ID")
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
data = await vndb.get_vn(vid)
|
||||
if not data:
|
||||
return await msg.answer("Not found")
|
||||
|
||||
# ======================
|
||||
# HELP
|
||||
# ======================
|
||||
@staticmethod
|
||||
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
text = """
|
||||
<b>Справка по командам</b>
|
||||
await msg.answer(
|
||||
f"{safe_text(data.get('title'))}\n"
|
||||
f"Original: {safe_text(data.get('original'))}\n"
|
||||
f"Released: {safe_text(data.get('released'))}\n"
|
||||
f"Rating: {safe_text(data.get('rating'))}\n"
|
||||
f"Votes: {safe_text(data.get('votecount'))}"
|
||||
)
|
||||
|
||||
<b>Поиск:</b>
|
||||
/search <название>
|
||||
/char <имя>
|
||||
/release <название>
|
||||
/staff <имя>
|
||||
/producer <имя>
|
||||
|
||||
<b>Информация:</b>
|
||||
/tag - популярные теги
|
||||
/trait - черты
|
||||
/quote <число> - цитаты
|
||||
/stats - статистика
|
||||
/schema - API
|
||||
@dp.message(Command("char"))
|
||||
async def char(msg: types.Message):
|
||||
cid = msg.text.replace("/char", "").strip()
|
||||
if not cid:
|
||||
return await msg.answer("No ID")
|
||||
|
||||
<b>Детальный просмотр:</b>
|
||||
/vn_detail <id>
|
||||
/char_detail <id>
|
||||
/release_detail <id>
|
||||
data = await vndb.get_char(cid)
|
||||
if not data:
|
||||
return await msg.answer("Not found")
|
||||
|
||||
<b>Пример:</b>
|
||||
/search Steins Gate
|
||||
/char Okabe
|
||||
/vn_detail v17
|
||||
await msg.answer(
|
||||
f"{safe_text(data.get('name'))}\n"
|
||||
f"Original: {safe_text(data.get('original'))}"
|
||||
)
|
||||
|
||||
<b>Ссылка:</b>
|
||||
<a href="https://git.kotac.ru/King-of-the-all-Cookies/ayako/src/branch/main/EXAMPLES.md">
|
||||
Примеры команд
|
||||
</a>
|
||||
"""
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
@dp.message(Command("release"))
|
||||
async def release(msg: types.Message):
|
||||
rid = msg.text.replace("/release", "").strip()
|
||||
if not rid:
|
||||
return await msg.answer("No ID")
|
||||
|
||||
# ======================
|
||||
# STATS
|
||||
# ======================
|
||||
@staticmethod
|
||||
async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
try:
|
||||
stats = await vndb_client.get_stats()
|
||||
data = await vndb.get_release(rid)
|
||||
if not data:
|
||||
return await msg.answer("Not found")
|
||||
|
||||
text = f"""
|
||||
<b>Статистика VNDB</b>
|
||||
await msg.answer(
|
||||
f"{safe_text(data.get('title'))}\n"
|
||||
f"Released: {safe_text(data.get('released'))}"
|
||||
)
|
||||
|
||||
VN: {stats.get('vn', 0)}
|
||||
Characters: {stats.get('chars', 0)}
|
||||
Releases: {stats.get('releases', 0)}
|
||||
Producers: {stats.get('producers', 0)}
|
||||
Staff: {stats.get('staff', 0)}
|
||||
"""
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await update.message.reply_text(f"❌ {ErrorHandler.format_error(e)}")
|
||||
|
||||
# ======================
|
||||
# SCHEMA
|
||||
# ======================
|
||||
@staticmethod
|
||||
async def schema(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
try:
|
||||
await update.message.reply_text("⏳ Загружаю схему...", parse_mode="HTML")
|
||||
|
||||
schema = await vndb_client.get_schema()
|
||||
|
||||
text = "<b>API Schema VNDB</b>\n\n"
|
||||
|
||||
if "db_types" in schema:
|
||||
text += "<b>Типы:</b>\n"
|
||||
for k in list(schema["db_types"].keys())[:5]:
|
||||
text += f"• {k}\n"
|
||||
|
||||
text += "\n<a href='https://api.vndb.org/kana'>Документация API</a>"
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await update.message.reply_text(f"❌ {ErrorHandler.format_error(e)}")
|
||||
|
||||
# ======================
|
||||
# SEARCH VN
|
||||
# ======================
|
||||
@staticmethod
|
||||
async def search_vn(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
try:
|
||||
query = " ".join(context.args)
|
||||
|
||||
if not query:
|
||||
await update.message.reply_text("Введите название")
|
||||
return ConversationHandler.END
|
||||
|
||||
await update.message.reply_text(
|
||||
f"<b>Поиск:</b> {query}",
|
||||
parse_mode="HTML"
|
||||
)
|
||||
|
||||
results = await vndb_client.query_vn(
|
||||
filters=["search", "=", query.strip()],
|
||||
fields=["id", "title"],
|
||||
results=10
|
||||
)
|
||||
|
||||
if not results.get("results"):
|
||||
await update.message.reply_text("Ничего не найдено")
|
||||
return ConversationHandler.END
|
||||
|
||||
text = "<b>Результаты:</b>\n\n"
|
||||
|
||||
for vn in results["results"]:
|
||||
text += f"{vn.get('id')} - {vn.get('title')}\n"
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
|
||||
# images
|
||||
for vn in results["results"]:
|
||||
text += f"{vn.get('id')} - {vn.get('title')}\n"
|
||||
|
||||
# 👇 ДОБАВЬ ССЫЛКУ НА VNDB
|
||||
text += f"https://vndb.org/{vn.get('id')}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await update.message.reply_text(f"❌ {ErrorHandler.format_error(e)}")
|
||||
|
||||
return ConversationHandler.END
|
||||
|
||||
# ======================
|
||||
# CHARACTER
|
||||
# ======================
|
||||
@staticmethod
|
||||
async def search_character(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
try:
|
||||
query = " ".join(context.args)
|
||||
|
||||
if not query:
|
||||
await update.message.reply_text("Введите имя")
|
||||
return ConversationHandler.END
|
||||
|
||||
results = await vndb_client.query_character(
|
||||
filters=["search", "=", query.strip()],
|
||||
fields=["id", "name", "original"],
|
||||
results=10
|
||||
)
|
||||
|
||||
text = "<b>Персонажи:</b>\n\n"
|
||||
|
||||
for c in results.get("results", []):
|
||||
text += f"{c.get('id')} - {c.get('name')} ({c.get('original', '')})\n"
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await update.message.reply_text(f"❌ {ErrorHandler.format_error(e)}")
|
||||
|
||||
return ConversationHandler.END
|
||||
|
||||
# ======================
|
||||
# RELEASE
|
||||
# ======================
|
||||
@staticmethod
|
||||
async def search_release(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
try:
|
||||
query = " ".join(context.args)
|
||||
|
||||
results = await vndb_client.query_release(
|
||||
filters=["search", "=", query.strip()], # ✔️ важно
|
||||
fields=["id", "title"],
|
||||
results=10
|
||||
)
|
||||
|
||||
text = "<b>Релизы:</b>\n\n"
|
||||
|
||||
for r in results.get("results", []):
|
||||
text += f"{r.get('id')} - {r.get('title')}\n"
|
||||
|
||||
await update.message.reply_text(text, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
await update.message.reply_text(f"❌ {ErrorHandler.format_error(e)}")
|
||||
|
||||
return ConversationHandler.END
|
||||
|
||||
|
||||
# ======================
|
||||
# MAIN
|
||||
# ======================
|
||||
def main():
|
||||
Config.validate()
|
||||
|
||||
app = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build()
|
||||
|
||||
app.add_handler(CommandHandler("start", BotHandlers.start))
|
||||
app.add_handler(CommandHandler("help", BotHandlers.help_command))
|
||||
app.add_handler(CommandHandler("stats", BotHandlers.stats))
|
||||
app.add_handler(CommandHandler("schema", BotHandlers.schema))
|
||||
app.add_handler(CommandHandler("search", BotHandlers.search_vn))
|
||||
app.add_handler(CommandHandler("char", BotHandlers.search_character))
|
||||
app.add_handler(CommandHandler("release", BotHandlers.search_release))
|
||||
|
||||
for h in get_detail_handlers():
|
||||
app.add_handler(h)
|
||||
|
||||
logger.info("Bot started")
|
||||
app.run_polling()
|
||||
async def main():
|
||||
await dp.start_polling(bot)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
asyncio.run(main())
|
||||
53
config.py
53
config.py
@@ -1,53 +0,0 @@
|
||||
"""
|
||||
Configuration module for VNDB Telegram Bot
|
||||
Handles environment variables and settings
|
||||
"""
|
||||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class Config:
|
||||
"""Bot configuration class"""
|
||||
|
||||
# Telegram configuration
|
||||
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
# VNDB API configuration
|
||||
VNDB_TOKEN: Optional[str] = os.getenv("VNDB_TOKEN")
|
||||
USE_SANDBOX: bool = os.getenv("USE_SANDBOX", "false").lower() == "true"
|
||||
|
||||
# Logging configuration
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
|
||||
# API limits
|
||||
MAX_RESULTS_PER_PAGE: int = 100
|
||||
DEFAULT_RESULTS_PER_PAGE: int = 10
|
||||
MAX_QUOTES_AT_ONCE: int = 5
|
||||
|
||||
# Timeouts (in seconds)
|
||||
API_TIMEOUT: int = 10
|
||||
BOT_TIMEOUT: int = 30
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> bool:
|
||||
"""Validate that required configuration is present"""
|
||||
if not cls.TELEGRAM_BOT_TOKEN:
|
||||
raise ValueError("TELEGRAM_BOT_TOKEN environment variable is required")
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def to_dict(cls) -> dict:
|
||||
"""Convert config to dictionary"""
|
||||
return {
|
||||
"TELEGRAM_BOT_TOKEN": "***" if cls.TELEGRAM_BOT_TOKEN else "NOT SET",
|
||||
"VNDB_TOKEN": "SET" if cls.VNDB_TOKEN else "NOT SET",
|
||||
"USE_SANDBOX": cls.USE_SANDBOX,
|
||||
"LOG_LEVEL": cls.LOG_LEVEL,
|
||||
"MAX_RESULTS_PER_PAGE": cls.MAX_RESULTS_PER_PAGE,
|
||||
"DEFAULT_RESULTS_PER_PAGE": cls.DEFAULT_RESULTS_PER_PAGE,
|
||||
"API_TIMEOUT": cls.API_TIMEOUT,
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
"""
|
||||
Inline handlers for detailed item viewing (NO IMAGES VERSION - STABLE)
|
||||
"""
|
||||
from telegram import Update
|
||||
from telegram.ext import ContextTypes, CommandHandler
|
||||
from vndb_client import VndbClient
|
||||
from config import Config
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX)
|
||||
|
||||
|
||||
class DetailedHandlers:
|
||||
|
||||
# =========================
|
||||
# VN DETAIL
|
||||
# =========================
|
||||
@staticmethod
|
||||
async def view_vn_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
try:
|
||||
if not context.args:
|
||||
await update.message.reply_text("❌ Пример: /vn_detail v2002")
|
||||
return
|
||||
|
||||
vn_id = context.args[0].strip()
|
||||
|
||||
await update.message.reply_text(f"⏳ Загружаю {vn_id}...")
|
||||
|
||||
results = await vndb_client.query_vn(
|
||||
filters=["id", "=", vn_id],
|
||||
fields=[
|
||||
"id",
|
||||
"title",
|
||||
"original",
|
||||
"released",
|
||||
"rating",
|
||||
"votecount",
|
||||
"description",
|
||||
"length",
|
||||
"developer"
|
||||
],
|
||||
results=1
|
||||
)
|
||||
|
||||
if not results.get("results"):
|
||||
await update.message.reply_text("😞 VN не найдена")
|
||||
return
|
||||
|
||||
vn = results["results"][0]
|
||||
|
||||
text = f"🎮 {vn.get('title','Unknown')} (`{vn_id}`)\n"
|
||||
|
||||
if vn.get("original"):
|
||||
text += f"{vn['original']}\n"
|
||||
|
||||
text += f"Релиз: {vn.get('released','?')}\n"
|
||||
text += f"Рейтинг: {vn.get('rating',0)/10:.1f} ({vn.get('votecount',0)} голосов)\n"
|
||||
|
||||
if vn.get("length"):
|
||||
text += f"Длина: {vn['length']}\n"
|
||||
|
||||
if vn.get("developer"):
|
||||
text += f"Разработчик: {vn['developer']}\n"
|
||||
|
||||
if vn.get("description"):
|
||||
text += f"\nОписание:\n{vn['description'][:300]}...\n"
|
||||
|
||||
text += f"\nhttps://vndb.org/{vn_id}"
|
||||
|
||||
await update.message.reply_text(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"VN detail error: {e}")
|
||||
await update.message.reply_text(f"❌ Ошибка: {e}")
|
||||
|
||||
|
||||
# =========================
|
||||
# CHARACTER DETAIL
|
||||
# =========================
|
||||
@staticmethod
|
||||
async def view_character_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
try:
|
||||
if not context.args:
|
||||
await update.message.reply_text("❌ Пример: /char_detail c6498")
|
||||
return
|
||||
|
||||
char_id = context.args[0].strip()
|
||||
|
||||
await update.message.reply_text(f"⏳ Загружаю {char_id}...")
|
||||
|
||||
results = await vndb_client.query_character(
|
||||
filters=["id", "=", char_id],
|
||||
fields=[
|
||||
"id",
|
||||
"name",
|
||||
"original",
|
||||
"gender",
|
||||
"bloodtype",
|
||||
],
|
||||
results=1
|
||||
)
|
||||
|
||||
if not results.get("results"):
|
||||
await update.message.reply_text("😞 Персонаж не найден")
|
||||
return
|
||||
|
||||
c = results["results"][0]
|
||||
|
||||
text = f"👤 {c.get('name','Unknown')} (`{char_id}`)\n"
|
||||
|
||||
if c.get("original"):
|
||||
text += f"{c['original']}\n"
|
||||
|
||||
if c.get("gender"):
|
||||
text += f"Пол: {c['gender']}\n"
|
||||
|
||||
if c.get("bloodtype"):
|
||||
text += f"Кровь: {c['bloodtype']}\n"
|
||||
|
||||
if c.get("description"):
|
||||
text += f"\nОписание:\n{c['description'][:300]}...\n"
|
||||
|
||||
text += f"\nhttps://vndb.org/{char_id}"
|
||||
|
||||
await update.message.reply_text(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CHAR detail error: {e}")
|
||||
await update.message.reply_text(f"❌ Ошибка: {e}")
|
||||
|
||||
|
||||
# =========================
|
||||
# RELEASE DETAIL
|
||||
# =========================
|
||||
@staticmethod
|
||||
async def view_release_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
||||
try:
|
||||
if not context.args:
|
||||
await update.message.reply_text("❌ Пример: /release_detail r3930")
|
||||
return
|
||||
|
||||
release_id = context.args[0].strip()
|
||||
|
||||
await update.message.reply_text(f"⏳ Загружаю {release_id}...")
|
||||
|
||||
results = await vndb_client.query_release(
|
||||
filters=["id", "=", release_id],
|
||||
fields=[
|
||||
"id",
|
||||
"title",
|
||||
"original",
|
||||
"released",
|
||||
"platform",
|
||||
"type",
|
||||
"language",
|
||||
"description"
|
||||
],
|
||||
results=1
|
||||
)
|
||||
|
||||
if not results.get("results"):
|
||||
await update.message.reply_text("😞 Релиз не найден")
|
||||
return
|
||||
|
||||
r = results["results"][0]
|
||||
|
||||
text = f"🎬 {r.get('title','Unknown')} (`{release_id}`)\n"
|
||||
|
||||
if r.get("original"):
|
||||
text += f"{r['original']}\n"
|
||||
|
||||
text += f"Дата: {r.get('released','?')}\n"
|
||||
text += f"Платформа: {r.get('platform','?')}\n"
|
||||
text += f"Тип: {r.get('type','?')}\n"
|
||||
|
||||
if r.get("language"):
|
||||
text += f"Языки: {', '.join(r['language'])}\n"
|
||||
|
||||
if r.get("description"):
|
||||
text += f"\nОписание:\n{r['description'][:200]}...\n"
|
||||
|
||||
text += f"\nhttps://vndb.org/{release_id}"
|
||||
|
||||
await update.message.reply_text(text)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RELEASE detail error: {e}")
|
||||
await update.message.reply_text(f"❌ Ошибка: {e}")
|
||||
|
||||
|
||||
# =========================
|
||||
# REGISTER
|
||||
# =========================
|
||||
def get_detail_handlers():
|
||||
return [
|
||||
CommandHandler("vn_detail", DetailedHandlers.view_vn_detail),
|
||||
CommandHandler("char_detail", DetailedHandlers.view_character_detail),
|
||||
CommandHandler("release_detail", DetailedHandlers.view_release_detail),
|
||||
]
|
||||
@@ -1,20 +1,8 @@
|
||||
version: '3.8'
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
vndb-bot:
|
||||
bot:
|
||||
build: .
|
||||
container_name: vndb-telegram-bot
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- VNDB_TOKEN=${VNDB_TOKEN:-}
|
||||
- LOG_LEVEL=INFO
|
||||
- USE_SANDBOX=false
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- bot-network
|
||||
|
||||
networks:
|
||||
bot-network:
|
||||
driver: bridge
|
||||
restart: always
|
||||
env_file:
|
||||
- .env
|
||||
@@ -1,6 +1,2 @@
|
||||
python-telegram-bot==21.0
|
||||
python-dotenv==1.0.0
|
||||
aiohttp==3.9.1
|
||||
requests==2.31.0
|
||||
pytest==7.4.0
|
||||
pytest-asyncio==0.21.0
|
||||
aiogram==3.6.0
|
||||
httpx==0.27.0
|
||||
168
test_bot.py
168
test_bot.py
@@ -1,168 +0,0 @@
|
||||
"""
|
||||
Tests for VNDB Telegram Bot
|
||||
"""
|
||||
import asyncio
|
||||
import pytest
|
||||
from vndb_client import VndbClient
|
||||
from config import Config
|
||||
from utils import Formatter, ErrorHandler, QueryBuilder
|
||||
|
||||
|
||||
class TestVndbClient:
|
||||
"""Test VNDB client functionality"""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create VNDB client instance"""
|
||||
return VndbClient()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_stats(self, client):
|
||||
"""Test getting database statistics"""
|
||||
stats = await client.get_stats()
|
||||
assert "vn" in stats
|
||||
assert "chars" in stats
|
||||
assert isinstance(stats["vn"], int)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_schema(self, client):
|
||||
"""Test getting API schema"""
|
||||
schema = await client.get_schema()
|
||||
assert "db_types" in schema
|
||||
assert "fields" in schema
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_vn(self, client):
|
||||
"""Test querying visual novels"""
|
||||
results = await client.query_vn(
|
||||
filters=[["id", "=", "v17"]],
|
||||
fields=["title", "original"]
|
||||
)
|
||||
assert "results" in results
|
||||
assert len(results["results"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_vn_search(self, client):
|
||||
"""Test searching visual novels"""
|
||||
results = await client.query_vn(
|
||||
filters=[["search", "=", "Steins"]],
|
||||
fields=["title", "rating"],
|
||||
results=5
|
||||
)
|
||||
assert "results" in results
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_character(self, client):
|
||||
"""Test querying characters"""
|
||||
results = await client.query_character(
|
||||
fields=["name", "gender"],
|
||||
results=5
|
||||
)
|
||||
assert "results" in results
|
||||
assert len(results["results"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_tag(self, client):
|
||||
"""Test querying tags"""
|
||||
results = await client.query_tag(
|
||||
fields=["name"],
|
||||
results=5
|
||||
)
|
||||
assert "results" in results
|
||||
assert len(results["results"]) > 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_quote(self, client):
|
||||
"""Test querying quotes"""
|
||||
results = await client.query_quote(
|
||||
fields=["quote", "character"],
|
||||
results=2
|
||||
)
|
||||
assert "results" in results
|
||||
|
||||
|
||||
class TestFormatter:
|
||||
"""Test formatter utilities"""
|
||||
|
||||
def test_truncate_text(self):
|
||||
"""Test text truncation"""
|
||||
text = "A" * 300
|
||||
truncated = Formatter.truncate_text(text, 100)
|
||||
assert len(truncated) <= 100
|
||||
assert truncated.endswith("...")
|
||||
|
||||
def test_truncate_text_short(self):
|
||||
"""Test truncation of short text"""
|
||||
text = "Short text"
|
||||
truncated = Formatter.truncate_text(text, 100)
|
||||
assert truncated == text
|
||||
|
||||
def test_format_vn_item(self):
|
||||
"""Test VN item formatting"""
|
||||
vn = {
|
||||
"id": "v17",
|
||||
"title": "Clannad",
|
||||
"original": "クラナド",
|
||||
"released": "2004-04-28",
|
||||
"rating": 85,
|
||||
"votecount": 1000
|
||||
}
|
||||
formatted = Formatter.format_vn_item(vn)
|
||||
assert "v17" in formatted
|
||||
assert "Clannad" in formatted
|
||||
assert "8.5" in formatted
|
||||
|
||||
|
||||
class TestErrorHandler:
|
||||
"""Test error handling utilities"""
|
||||
|
||||
def test_format_error_http_error(self):
|
||||
"""Test formatting HTTP error"""
|
||||
error = Exception("HTTP Error")
|
||||
formatted = ErrorHandler.format_error(error)
|
||||
assert isinstance(formatted, str)
|
||||
assert len(formatted) > 0
|
||||
|
||||
def test_format_error_value_error(self):
|
||||
"""Test formatting ValueError"""
|
||||
error = ValueError("Invalid value")
|
||||
formatted = ErrorHandler.format_error(error)
|
||||
assert "Invalid value" in formatted
|
||||
|
||||
|
||||
class TestQueryBuilder:
|
||||
"""Test query builder utilities"""
|
||||
|
||||
def test_build_search_filter(self):
|
||||
"""Test building search filter"""
|
||||
filter_result = QueryBuilder.build_search_filter("Clannad")
|
||||
assert filter_result == ["search", "=", "Clannad"]
|
||||
|
||||
def test_build_id_filter(self):
|
||||
"""Test building ID filter"""
|
||||
filter_result = QueryBuilder.build_id_filter("v17")
|
||||
assert filter_result == ["id", "=", "v17"]
|
||||
|
||||
def test_build_complex_filter(self):
|
||||
"""Test building complex filter"""
|
||||
filter1 = ["search", "=", "Clannad"]
|
||||
filter2 = ["lang", "=", "en"]
|
||||
complex_filter = QueryBuilder.build_complex_filter(filter1, filter2)
|
||||
assert complex_filter[0] == "and"
|
||||
assert len(complex_filter) == 3
|
||||
|
||||
|
||||
class TestConfig:
|
||||
"""Test configuration"""
|
||||
|
||||
def test_config_dict(self):
|
||||
"""Test configuration as dictionary"""
|
||||
config_dict = Config.to_dict()
|
||||
assert "TELEGRAM_BOT_TOKEN" in config_dict
|
||||
assert "VNDB_TOKEN" in config_dict
|
||||
assert "LOG_LEVEL" in config_dict
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests with pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
353
utils.py
353
utils.py
@@ -1,353 +0,0 @@
|
||||
"""
|
||||
Utility functions for VNDB Telegram Bot
|
||||
"""
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import json
|
||||
from telegram import Update
|
||||
|
||||
|
||||
class Formatter:
|
||||
"""Utility class for formatting API responses"""
|
||||
|
||||
@staticmethod
|
||||
def truncate_text(text: str, max_length: int = 200) -> str:
|
||||
"""Truncate text to max length"""
|
||||
if len(text) > max_length:
|
||||
return text[:max_length-3] + "..."
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_vn_item(vn: Dict[str, Any]) -> str:
|
||||
"""Format a VN item for display"""
|
||||
vn_id = vn.get("id", "Unknown")
|
||||
title = vn.get("title", "Unknown")
|
||||
original = vn.get("original", "")
|
||||
released = vn.get("released", "Unknown")
|
||||
rating = vn.get("rating", 0)
|
||||
votecount = vn.get("votecount", 0)
|
||||
|
||||
text = f"**{title}** (`{vn_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Дата: {released}\n"
|
||||
if rating > 0:
|
||||
text += f"Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n"
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_character_item(char: Dict[str, Any]) -> str:
|
||||
"""Format a character item for display"""
|
||||
char_id = char.get("id", "Unknown")
|
||||
name = char.get("name", "Unknown")
|
||||
original = char.get("original", "")
|
||||
gender = char.get("gender", "Unknown")
|
||||
vns = char.get("vn", [])
|
||||
|
||||
text = f"**{name}** (`{char_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Пол: {gender}\n"
|
||||
if vns:
|
||||
text += f"Появляется в: {len(vns)} VN\n"
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_release_item(release: Dict[str, Any]) -> str:
|
||||
"""Format a release item for display"""
|
||||
release_id = release.get("id", "Unknown")
|
||||
title = release.get("title", "Unknown")
|
||||
original = release.get("original", "")
|
||||
released = release.get("released", "Unknown")
|
||||
platform = release.get("platform", "Unknown")
|
||||
release_type = release.get("type", "Unknown")
|
||||
|
||||
text = f"**{title}** (`{release_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Дата: {released}\n"
|
||||
text += f"Платформа: {platform} | Тип: {release_type}\n"
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_staff_item(staff: Dict[str, Any]) -> str:
|
||||
"""Format a staff member item for display"""
|
||||
staff_id = staff.get("id", "Unknown")
|
||||
name = staff.get("name", "Unknown")
|
||||
original = staff.get("original", "")
|
||||
gender = staff.get("gender", "Unknown")
|
||||
role = staff.get("role", "")
|
||||
|
||||
text = f"**{name}** (`{staff_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Пол: {gender}\n"
|
||||
if role:
|
||||
text += f"Роль: {role}\n"
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_producer_item(producer: Dict[str, Any]) -> str:
|
||||
"""Format a producer item for display"""
|
||||
producer_id = producer.get("id", "Unknown")
|
||||
name = producer.get("name", "Unknown")
|
||||
original = producer.get("original", "")
|
||||
producer_type = producer.get("type", "Unknown")
|
||||
|
||||
text = f"**{name}** (`{producer_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
if producer_type:
|
||||
text += f"Тип: {producer_type}\n"
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_tag_item(tag: Dict[str, Any]) -> str:
|
||||
"""Format a tag item for display"""
|
||||
tag_id = tag.get("id", "Unknown")
|
||||
name = tag.get("name", "Unknown")
|
||||
description = tag.get("description", "")
|
||||
vns = tag.get("vns", 0)
|
||||
|
||||
text = f"**{name}** (`{tag_id}`)\n"
|
||||
if description:
|
||||
truncated = Formatter.truncate_text(description, 100)
|
||||
text += f"_{truncated}_\n"
|
||||
if vns:
|
||||
text += f"ВН: {vns}\n"
|
||||
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def format_trait_item(trait: Dict[str, Any]) -> str:
|
||||
"""Format a trait item for display"""
|
||||
trait_id = trait.get("id", "Unknown")
|
||||
name = trait.get("name", "Unknown")
|
||||
description = trait.get("description", "")
|
||||
chars = trait.get("chars", 0)
|
||||
|
||||
text = f"**{name}** (`{trait_id}`)\n"
|
||||
if description:
|
||||
truncated = Formatter.truncate_text(description, 100)
|
||||
text += f"_{truncated}_\n"
|
||||
if chars:
|
||||
text += f"Персонажей: {chars}\n"
|
||||
|
||||
return text
|
||||
|
||||
|
||||
class ErrorHandler:
|
||||
"""Error handling utilities"""
|
||||
|
||||
@staticmethod
|
||||
def format_error(error: Exception) -> str:
|
||||
"""Format error message for user"""
|
||||
error_type = type(error).__name__
|
||||
error_msg = str(error)
|
||||
|
||||
# Map common errors to user-friendly messages
|
||||
error_messages = {
|
||||
"HTTPStatusError": "Ошибка сервера API. Попробуйте позже.",
|
||||
"ConnectError": "Ошибка подключения. Проверьте интернет соединение.",
|
||||
"Timeout": "Запрос истек. Сервер слишком долго отвечает.",
|
||||
"ValueError": f"Ошибка данных: {error_msg}",
|
||||
"JSONDecodeError": "Ошибка парсинга ответа сервера.",
|
||||
}
|
||||
|
||||
return error_messages.get(error_type, f"❌ {error_msg}")
|
||||
|
||||
|
||||
class PaginationHelper:
|
||||
"""Helper for pagination"""
|
||||
|
||||
@staticmethod
|
||||
def get_page_info(page: int, results_count: int, total: Optional[int] = None) -> str:
|
||||
"""Get pagination info string"""
|
||||
info = f"📄 Страница {page}"
|
||||
|
||||
if total:
|
||||
info += f" | Всего: {total}"
|
||||
|
||||
if results_count > 0:
|
||||
info += f" | Результатов: {results_count}"
|
||||
|
||||
return info
|
||||
|
||||
|
||||
class FieldValidator:
|
||||
"""Validate and clean fields"""
|
||||
|
||||
@staticmethod
|
||||
def validate_fields(fields: List[str], allowed_fields: List[str]) -> List[str]:
|
||||
"""Validate that requested fields are allowed"""
|
||||
return [f for f in fields if f in allowed_fields]
|
||||
|
||||
@staticmethod
|
||||
def clean_field_list(fields_str: str) -> List[str]:
|
||||
"""Parse and clean field list from string"""
|
||||
fields = [f.strip() for f in fields_str.split(",")]
|
||||
return [f for f in fields if f] # Remove empty strings
|
||||
|
||||
|
||||
class QueryBuilder:
|
||||
"""Helper for building API queries"""
|
||||
|
||||
@staticmethod
|
||||
def build_search_filter(search_term: str) -> List[str]:
|
||||
"""Build a search filter"""
|
||||
return ["search", "=", search_term]
|
||||
|
||||
@staticmethod
|
||||
def build_id_filter(vn_id: str) -> List[str]:
|
||||
"""Build an ID filter"""
|
||||
return ["id", "=", vn_id]
|
||||
|
||||
@staticmethod
|
||||
def build_tag_filter(tag_id: str, depth: int = 0) -> Dict[str, Any]:
|
||||
"""Build a tag filter with optional depth"""
|
||||
filter_dict = {
|
||||
"tag": tag_id,
|
||||
}
|
||||
if depth > 0:
|
||||
filter_dict["depth"] = depth
|
||||
return filter_dict
|
||||
|
||||
@staticmethod
|
||||
def build_complex_filter(*filters: List[str]) -> List[Any]:
|
||||
"""Build a complex AND filter from multiple simple filters"""
|
||||
if len(filters) == 1:
|
||||
return filters[0]
|
||||
return ["and"] + list(filters)
|
||||
|
||||
|
||||
class ImageHandler:
|
||||
"""Handle image processing and sending"""
|
||||
|
||||
# VNDB Image CDN
|
||||
VNDB_CDN = "https://t.vndb.org"
|
||||
|
||||
@staticmethod
|
||||
def get_image_url(image_data: Dict[str, Any]) -> Optional[str]:
|
||||
"""
|
||||
Extract image URL from image data
|
||||
|
||||
Args:
|
||||
image_data: Image data from API
|
||||
|
||||
Returns:
|
||||
Full image URL or None
|
||||
"""
|
||||
if not image_data or not isinstance(image_data, dict):
|
||||
return None
|
||||
|
||||
image_path = image_data.get("url")
|
||||
if not image_path:
|
||||
return None
|
||||
|
||||
# Construct full URL
|
||||
if image_path.startswith("http"):
|
||||
return image_path
|
||||
|
||||
return f"{ImageHandler.VNDB_CDN}{image_path}"
|
||||
|
||||
@staticmethod
|
||||
def format_item_with_image(
|
||||
item_type: str,
|
||||
item: Dict[str, Any],
|
||||
) -> tuple[str, Optional[str]]:
|
||||
"""
|
||||
Format item with image information
|
||||
|
||||
Args:
|
||||
item_type: Type of item (vn, character, release, etc.)
|
||||
item: Item data
|
||||
|
||||
Returns:
|
||||
Tuple of (text, image_url)
|
||||
"""
|
||||
text = ""
|
||||
image_url = None
|
||||
|
||||
if item_type == "vn":
|
||||
item_id = item.get("id", "Unknown")
|
||||
title = item.get("title", "Unknown")
|
||||
original = item.get("original", "")
|
||||
released = item.get("released", "Unknown")
|
||||
rating = item.get("rating", 0)
|
||||
votecount = item.get("votecount", 0)
|
||||
|
||||
text = f"**{title}** (`{item_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Дата релиза: {released}\n"
|
||||
if rating > 0:
|
||||
text += f"Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n"
|
||||
|
||||
elif item_type == "character":
|
||||
item_id = item.get("id", "Unknown")
|
||||
name = item.get("name", "Unknown")
|
||||
original = item.get("original", "")
|
||||
gender = item.get("gender", "Unknown")
|
||||
|
||||
text = f"**{name}** (`{item_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Пол: {gender}\n"
|
||||
|
||||
elif item_type == "release":
|
||||
item_id = item.get("id", "Unknown")
|
||||
title = item.get("title", "Unknown")
|
||||
original = item.get("original", "")
|
||||
released = item.get("released", "Unknown")
|
||||
platform = item.get("platform", "Unknown")
|
||||
|
||||
text = f"**{title}** (`{item_id}`)\n"
|
||||
if original:
|
||||
text += f"Оригинал: {original}\n"
|
||||
text += f"Дата: {released}\n"
|
||||
text += f"Платформа: {platform}\n"
|
||||
|
||||
# Try to get image
|
||||
image_data = item.get("image")
|
||||
if image_data:
|
||||
image_url = ImageHandler.get_image_url(image_data)
|
||||
|
||||
return text, image_url
|
||||
|
||||
@staticmethod
|
||||
async def send_item_with_photo(
|
||||
update: Update,
|
||||
item_type: str,
|
||||
item: Dict[str, Any],
|
||||
emoji: str = "📦",
|
||||
) -> None:
|
||||
"""
|
||||
Send item with photo if available
|
||||
|
||||
Args:
|
||||
update: Telegram update
|
||||
item_type: Type of item
|
||||
item: Item data
|
||||
emoji: Emoji to use in caption
|
||||
"""
|
||||
text, image_url = ImageHandler.format_item_with_image(item_type, item)
|
||||
|
||||
if image_url:
|
||||
try:
|
||||
title = item.get("title") or item.get("name", "Item")
|
||||
caption = f"{emoji} {title}"
|
||||
|
||||
await update.message.reply_photo(
|
||||
photo=image_url,
|
||||
caption=caption,
|
||||
parse_mode="Markdown"
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Could not send photo: {e}")
|
||||
await update.message.reply_text(text, parse_mode="Markdown")
|
||||
50
vndb.py
Normal file
50
vndb.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import httpx
|
||||
|
||||
|
||||
class VNDBClient:
|
||||
def __init__(self, base_url):
|
||||
self.base = base_url.rstrip("/")
|
||||
self.client = httpx.AsyncClient(timeout=10)
|
||||
|
||||
async def _post(self, endpoint, payload):
|
||||
try:
|
||||
r = await self.client.post(f"{self.base}/{endpoint}", json=payload)
|
||||
|
||||
if r.status_code != 200:
|
||||
return None
|
||||
|
||||
data = r.json()
|
||||
return data.get("results", [])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def search_vn(self, query):
|
||||
return await self._post("vn", {
|
||||
"filters": ["search", "=", query],
|
||||
"fields": "id,title,original,released,rating,votecount",
|
||||
"results": 5
|
||||
})
|
||||
|
||||
async def get_vn(self, vid):
|
||||
res = await self._post("vn", {
|
||||
"filters": ["id", "=", vid],
|
||||
"fields": "id,title,original,released,rating,votecount",
|
||||
"results": 1
|
||||
})
|
||||
return res[0] if res else None
|
||||
|
||||
async def get_char(self, cid):
|
||||
res = await self._post("character", {
|
||||
"filters": ["id", "=", cid],
|
||||
"fields": "id,name,original",
|
||||
"results": 1
|
||||
})
|
||||
return res[0] if res else None
|
||||
|
||||
async def get_release(self, rid):
|
||||
res = await self._post("release", {
|
||||
"filters": ["id", "=", rid],
|
||||
"fields": "id,title,released",
|
||||
"results": 1
|
||||
})
|
||||
return res[0] if res else None
|
||||
488
vndb_client.py
488
vndb_client.py
@@ -1,488 +0,0 @@
|
||||
"""
|
||||
VNDB API Client
|
||||
Handles all interactions with the VNDB API
|
||||
"""
|
||||
import httpx
|
||||
import json
|
||||
from typing import Dict, List, Any, Optional
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class VndbEndpoint(Enum):
|
||||
"""VNDB API Endpoints"""
|
||||
SCHEMA = "/schema"
|
||||
STATS = "/stats"
|
||||
USER = "/user"
|
||||
AUTHINFO = "/authinfo"
|
||||
VN = "/vn"
|
||||
RELEASE = "/release"
|
||||
CHARACTER = "/character"
|
||||
STAFF = "/staff"
|
||||
PRODUCER = "/producer"
|
||||
TAG = "/tag"
|
||||
TRAIT = "/trait"
|
||||
QUOTE = "/quote"
|
||||
ULIST = "/ulist"
|
||||
RLIST = "/rlist"
|
||||
ULIST_LABELS = "/ulist_labels"
|
||||
|
||||
|
||||
class VndbClient:
|
||||
"""Client for interacting with VNDB API"""
|
||||
|
||||
BASE_URL = "https://api.vndb.org/kana"
|
||||
SANDBOX_URL = "https://beta.vndb.org/api/kana"
|
||||
|
||||
def __init__(self, token: Optional[str] = None, use_sandbox: bool = False):
|
||||
"""
|
||||
Initialize VNDB client
|
||||
|
||||
Args:
|
||||
token: Optional API token for authenticated requests
|
||||
use_sandbox: Whether to use sandbox endpoint
|
||||
"""
|
||||
self.token = token
|
||||
self.base_url = self.SANDBOX_URL if use_sandbox else self.BASE_URL
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if token:
|
||||
self.headers["Authorization"] = f"Token {token}"
|
||||
|
||||
def _strip_image_fields(self, fields: Optional[List[str]]) -> List[str]:
|
||||
if not fields:
|
||||
return []
|
||||
|
||||
return [f for f in fields if "image" not in f]
|
||||
|
||||
def _safe_filters(self, filters):
|
||||
if not filters:
|
||||
return ["id", ">", 0]
|
||||
return filters
|
||||
|
||||
def _normalize_fields(self, fields: Optional[List[str]]) -> str:
|
||||
if not fields:
|
||||
return "id"
|
||||
|
||||
clean = [f for f in fields if "image" not in f]
|
||||
return ",".join(clean)
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
method: str = "GET",
|
||||
data: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
url = f"{self.base_url}{endpoint}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=15) as client:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=self.headers,
|
||||
json=data if data else None, # 🔥 ВАЖНО: json= вместо content=
|
||||
)
|
||||
|
||||
if response.status_code >= 400:
|
||||
print("VNDB ERROR:", response.status_code, response.text)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
# Simple Requests
|
||||
|
||||
async def get_schema(self) -> Dict[str, Any]:
|
||||
"""Get API schema with metadata"""
|
||||
return await self._request(VndbEndpoint.SCHEMA.value)
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get database statistics"""
|
||||
return await self._request(VndbEndpoint.STATS.value)
|
||||
|
||||
async def get_user(self, queries: List[str], fields: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Lookup users by ID or username
|
||||
|
||||
Args:
|
||||
queries: List of user IDs or usernames to lookup
|
||||
fields: List of fields to retrieve (lengthvotes, lengthvotes_sum)
|
||||
|
||||
Returns:
|
||||
User information
|
||||
"""
|
||||
params = {"q": queries}
|
||||
if fields:
|
||||
params["fields"] = fields
|
||||
|
||||
# Build query string
|
||||
query_parts = [f"q={q}" for q in queries]
|
||||
if fields:
|
||||
query_parts.append(f"fields={','.join(fields)}")
|
||||
query_string = "&".join(query_parts)
|
||||
|
||||
url = f"{self.base_url}{VndbEndpoint.USER.value}?{query_string}"
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_authinfo(self) -> Dict[str, Any]:
|
||||
"""Get authenticated user information"""
|
||||
if not self.token:
|
||||
raise ValueError("Token required for authinfo")
|
||||
return await self._request(VndbEndpoint.AUTHINFO.value)
|
||||
|
||||
# Database Querying
|
||||
|
||||
async def query_vn(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
user: Optional[str] = None,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": ",".join(fields) if fields else "id,title",
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
if user:
|
||||
data["user"] = user
|
||||
|
||||
return await self._request(VndbEndpoint.VN.value, "POST", data)
|
||||
|
||||
async def query_release(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
user: Optional[str] = None,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query releases"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": ",".join(fields) if fields else "id,title",
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
if user:
|
||||
data["user"] = user
|
||||
|
||||
return await self._request(VndbEndpoint.RELEASE.value, "POST", data)
|
||||
|
||||
async def query_character(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": ",".join(fields) if fields else "id,name,original",
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
return await self._request(VndbEndpoint.CHARACTER.value, "POST", data)
|
||||
|
||||
async def query_staff(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query staff"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
return await self._request(VndbEndpoint.STAFF.value, "POST", data)
|
||||
|
||||
async def query_producer(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query producers"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
return await self._request(VndbEndpoint.PRODUCER.value, "POST", data)
|
||||
|
||||
async def query_tag(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query tags"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
return await self._request(VndbEndpoint.TAG.value, "POST", data)
|
||||
|
||||
async def query_trait(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query traits"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
return await self._request(VndbEndpoint.TRAIT.value, "POST", data)
|
||||
|
||||
async def query_quote(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
count: bool = False,
|
||||
compact_filters: bool = False,
|
||||
normalized_filters: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query quotes"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
"compact_filters": compact_filters,
|
||||
"normalized_filters": normalized_filters,
|
||||
}
|
||||
|
||||
return await self._request(VndbEndpoint.QUOTE.value, "POST", data)
|
||||
|
||||
# List Management
|
||||
|
||||
async def query_ulist(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
user: Optional[str] = None,
|
||||
count: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query user visual novel list"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
}
|
||||
if user:
|
||||
data["user"] = user
|
||||
|
||||
return await self._request(VndbEndpoint.ULIST.value, "POST", data)
|
||||
|
||||
async def query_rlist(
|
||||
self,
|
||||
filters: Optional[List[Any]] = None,
|
||||
fields: Optional[List[str]] = None,
|
||||
sort: str = "id",
|
||||
reverse: bool = False,
|
||||
results: int = 10,
|
||||
page: int = 1,
|
||||
user: Optional[str] = None,
|
||||
count: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Query user release list"""
|
||||
data = {
|
||||
"filters": filters or [],
|
||||
"fields": self._normalize_fields(fields),
|
||||
"sort": sort,
|
||||
"reverse": reverse,
|
||||
"results": results,
|
||||
"page": page,
|
||||
"count": count,
|
||||
}
|
||||
if user:
|
||||
data["user"] = user
|
||||
|
||||
return await self._request(VndbEndpoint.RLIST.value, "POST", data)
|
||||
|
||||
async def get_ulist_labels(self, user: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Get user list labels"""
|
||||
url = f"{self.base_url}{VndbEndpoint.ULIST_LABELS.value}"
|
||||
if user:
|
||||
url += f"?user={user}"
|
||||
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def add_to_ulist(
|
||||
self,
|
||||
vn_id: str,
|
||||
status: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
labels: Optional[List[str]] = None,
|
||||
voted: Optional[int] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add or update visual novel in user list"""
|
||||
if not self.token:
|
||||
raise ValueError("Token required for list operations")
|
||||
|
||||
data = {"id": vn_id}
|
||||
if status:
|
||||
data["status"] = status
|
||||
if notes:
|
||||
data["notes"] = notes
|
||||
if labels:
|
||||
data["labels"] = labels
|
||||
if voted is not None:
|
||||
data["voted"] = voted
|
||||
|
||||
return await self._request(
|
||||
f"{VndbEndpoint.ULIST.value}/{vn_id}",
|
||||
"PATCH",
|
||||
data
|
||||
)
|
||||
|
||||
async def add_to_rlist(
|
||||
self,
|
||||
release_id: str,
|
||||
status: Optional[str] = None,
|
||||
notes: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Add or update release in user list"""
|
||||
if not self.token:
|
||||
raise ValueError("Token required for list operations")
|
||||
|
||||
data = {"id": release_id}
|
||||
if status:
|
||||
data["status"] = status
|
||||
if notes:
|
||||
data["notes"] = notes
|
||||
|
||||
return await self._request(
|
||||
f"{VndbEndpoint.RLIST.value}/{release_id}",
|
||||
"PATCH",
|
||||
data
|
||||
)
|
||||
|
||||
async def remove_from_ulist(self, vn_id: str) -> None:
|
||||
"""Remove visual novel from user list"""
|
||||
if not self.token:
|
||||
raise ValueError("Token required for list operations")
|
||||
|
||||
await self._request(f"{VndbEndpoint.ULIST.value}/{vn_id}", "DELETE")
|
||||
|
||||
async def remove_from_rlist(self, release_id: str) -> None:
|
||||
"""Remove release from user list"""
|
||||
if not self.token:
|
||||
raise ValueError("Token required for list operations")
|
||||
|
||||
await self._request(f"{VndbEndpoint.RLIST.value}/{release_id}", "DELETE")
|
||||
Reference in New Issue
Block a user