First commit

This commit is contained in:
2026-05-01 15:13:02 +03:00
parent c2fcedf608
commit b983126e6e
18 changed files with 7142 additions and 147 deletions

13
.env.example Normal file
View File

@@ -0,0 +1,13 @@
# 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

168
.gitignore vendored
View File

@@ -1,13 +1,13 @@
# ---> Python # Environment variables
# Byte-compiled / optimized / DLL files .env
.env.local
.env.*.local
# Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions
*.so *.so
# Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
@@ -21,156 +21,32 @@ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
pip-wheel-metadata/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
# PyInstaller # Virtual environments
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/ venv/
env/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Spyder project settings # IDE
.spyderproject .vscode/
.spyproject .idea/
*.swp
*.swo
*~
.DS_Store
# Rope project settings # Logs
.ropeproject *.log
logs/
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Bot-specific
.cache/
tmp/

3411
API.mhtml Normal file

File diff suppressed because it is too large Load Diff

142
CHANGELOG.md Normal file
View File

@@ -0,0 +1,142 @@
# 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

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files
COPY bot.py .
COPY vndb_client.py .
COPY config.py .
COPY utils.py .
# 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"]

343
EXAMPLES.md Normal file
View File

@@ -0,0 +1,343 @@
# Примеры использования 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 Normal file
View File

@@ -0,0 +1,177 @@
# Работа с изображениями в 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 Normal file
View File

@@ -0,0 +1,343 @@
# Руководство по установке 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
Удачи! 🚀

256
README.md
View File

@@ -1,2 +1,256 @@
# ayako # 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.

325
advanced_features.py Normal file
View File

@@ -0,0 +1,325 @@
"""
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

699
bot.py Normal file
View File

@@ -0,0 +1,699 @@
"""
VNDB Telegram Bot
Main bot implementation with command handlers
"""
import logging
from typing import Optional
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
ConversationHandler,
ContextTypes,
filters,
)
from vndb_client import VndbClient
from config import Config
from utils import Formatter, ErrorHandler, QueryBuilder
from detailed_handlers import get_detail_handlers
# Setup logging
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=Config.LOG_LEVEL
)
logger = logging.getLogger(__name__)
# States for conversation handlers
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)
# Global VNDB client
vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX)
class BotHandlers:
"""Telegram bot command and message handlers"""
@staticmethod
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Start command handler"""
welcome_text = """
🎮 **Добро пожаловать в VNDB Telegram Бот!**
Этот бот позволяет искать информацию о визуальных новеллах, персонажах, релизах и многом другом из базы данных VNDB.
**Доступные команды:**
/search - Поиск визуальных новелл
/char - Поиск персонажей
/release - Поиск релизов
/staff - Поиск сотрудников
/producer - Поиск продюсеров
/tag - Поиск тегов
/trait - Поиск черт характера
/quote - Поиск цитат
/stats - Статистика базы данных
/schema - Информация о схеме API
/help - Справка по командам
*Используйте /help для получения подробной информации*
"""
await update.message.reply_text(welcome_text, parse_mode="Markdown")
@staticmethod
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Help command handler"""
help_text = """
**📚 Справка по командам VNDB Бота**
**Поиск информации:**
/search <название> - Поиск визуальных новелл по названию
/char <название> - Поиск персонажей по имени
/release <название> - Поиск релизов
/staff <название> - Поиск сотрудников (сценаристы, художники и т.д.)
/producer <название> - Поиск продюсеров
/tag - Список популярных тегов
/trait - Список черт характера
/quote <количество> - Получить случайные цитаты
**Подробный просмотр (с картинками):**
/vn_detail <ID> - Просмотр полной информации о ВН с обложкой
ример: /vn_detail v17_
/char_detail <ID> - Просмотр информации о персонаже с аватаром
ример: /char_detail c1_
/release_detail <ID> - Просмотр информации о релизе с картинкой
ример: /release_detail r1_
**Информация:**
/stats - Показать статистику базы данных VNDB
/schema - Получить информацию о доступных полях API
/authinfo - Информация об авторизации (если настроена)
**Функции пользователя (требуют токена):**
Чтобы использовать функции списка, установите токен в переменной окружения VNDB_TOKEN
**Примеры использования:**
/search Steins Gate
/char Okabe
/release Windows
/vn_detail v17
/char_detail c25
/stats
**Важно:**
- Бот работает в асинхронном режиме
- Результаты ограничены 10 элементами по умолчанию
- При поиске автоматически отправляются картинки (первые 3 результата)
- Для просмотра полной информации с картинкой используйте /vn_detail, /char_detail и т.д.
"""
await update.message.reply_text(help_text, parse_mode="Markdown")
@staticmethod
async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Get database statistics"""
try:
stats = await vndb_client.get_stats()
stats_text = f"""
📊 **Статистика базы данных VNDB:**
🎮 Визуальные новеллы: {stats.get('vn', 0):,}
👥 Персонажи: {stats.get('chars', 0):,}
🎬 Релизы: {stats.get('releases', 0):,}
🏢 Продюсеры: {stats.get('producers', 0):,}
👨‍💼 Сотрудники: {stats.get('staff', 0):,}
🏷️ Теги: {stats.get('tags', 0):,}
✨ Черты характера: {stats.get('traits', 0):,}
"""
await update.message.reply_text(stats_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error getting stats: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
@staticmethod
async def schema(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Get API schema information"""
try:
await update.message.reply_text(
"⏳ Загружаю информацию о схеме... (это может занять некоторое время)",
parse_mode="Markdown"
)
schema = await vndb_client.get_schema()
# Build schema info
schema_text = "📋 **Информация о схеме VNDB API:**\n\n"
# Database types
if "db_types" in schema:
schema_text += "**Типы данных:**\n"
for db_type, info in list(schema["db_types"].items())[:5]:
schema_text += f"{db_type}\n"
schema_text += "\n"
# Search fields
if "fields" in schema:
schema_text += "**Доступные поля для запросов:**\n"
for field_type, fields in list(schema["fields"].items())[:3]:
schema_text += f"{field_type}\n"
schema_text += "\n"
schema_text += "Для полного списка полей и типов посетите: https://api.vndb.org/kana"
await update.message.reply_text(schema_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error getting schema: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
@staticmethod
async def search_vn(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Search for visual novels"""
try:
args = " ".join(context.args) if context.args else ""
if not args:
await update.message.reply_text(
"❌ Пожалуйста, укажите название для поиска\n"
"Пример: /search Steins Gate"
)
return ConversationHandler.END
await update.message.reply_text(f"🔍 Поиск визуальных новелл: **{args}**\n⏳ Загрузка...", parse_mode="Markdown")
# Search for VN
filters = ["search", "=", args]
results = await vndb_client.query_vn(
filters=[filters],
fields=["title", "original", "released", "rating", "votecount", "image{url}"],
results=10
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return ConversationHandler.END
# Format results
response_text = f"**Результаты поиска: {args}**\n\n"
for i, vn in enumerate(results["results"], 1):
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)
response_text += (
f"{i}. **{title}**\n"
f" ID: {vn_id}\n"
)
if original:
response_text += f" Оригинал: {original}\n"
response_text += (
f" Релиз: {released}\n"
f" Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n\n"
)
response_text += f"\n📌 Всего найдено: {len(results['results'])} результатов"
if results.get("more"):
response_text += " (есть еще результаты)"
await update.message.reply_text(response_text, parse_mode="Markdown")
# Send images if available
for vn in results["results"][:3]: # Send images for first 3 results
image = vn.get("image")
if image and isinstance(image, dict):
image_url = image.get("url")
if image_url:
try:
title = vn.get("title", "VN")
await update.message.reply_photo(
photo=f"https://t.vndb.org{image_url}",
caption=f"🎮 {title}",
parse_mode="Markdown"
)
except Exception as e:
logger.warning(f"Could not send image: {e}")
# Store results for detail view
context.user_data["vn_results"] = results["results"]
except Exception as e:
logger.error(f"Error searching VN: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
return ConversationHandler.END
@staticmethod
async def search_character(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Search for characters"""
try:
args = " ".join(context.args) if context.args else ""
if not args:
await update.message.reply_text(
"❌ Пожалуйста, укажите имя персонажа\n"
"Пример: /char Okabe"
)
return ConversationHandler.END
await update.message.reply_text(f"🔍 Поиск персонажей: **{args}**\n⏳ Загрузка...", parse_mode="Markdown")
filters = ["search", "=", args]
results = await vndb_client.query_character(
filters=[filters],
fields=["name", "original", "gender", "vn", "image{url}"],
results=10
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return ConversationHandler.END
response_text = f"**Результаты поиска персонажей: {args}**\n\n"
for i, char in enumerate(results["results"], 1):
char_id = char.get("id", "Unknown")
name = char.get("name", "Unknown")
original = char.get("original", "")
gender = char.get("gender", "Unknown")
vns = char.get("vn", [])
response_text += (
f"{i}. **{name}**\n"
f" ID: {char_id}\n"
)
if original:
response_text += f" Оригинал: {original}\n"
response_text += f" Пол: {gender}\n"
if vns:
response_text += f" Появляется в {len(vns)} VN\n\n"
else:
response_text += "\n"
await update.message.reply_text(response_text, parse_mode="Markdown")
# Send character images if available
for char in results["results"][:3]: # Send images for first 3 results
image = char.get("image")
if image and isinstance(image, dict):
image_url = image.get("url")
if image_url:
try:
name = char.get("name", "Character")
await update.message.reply_photo(
photo=f"https://t.vndb.org{image_url}",
caption=f"👤 {name}",
parse_mode="Markdown"
)
except Exception as e:
logger.warning(f"Could not send character image: {e}")
context.user_data["char_results"] = results["results"]
except Exception as e:
logger.error(f"Error searching characters: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
return ConversationHandler.END
@staticmethod
async def search_release(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Search for releases"""
try:
args = " ".join(context.args) if context.args else ""
if not args:
await update.message.reply_text(
"❌ Пожалуйста, укажите название для поиска\n"
"Пример: /release Windows"
)
return ConversationHandler.END
await update.message.reply_text(f"🔍 Поиск релизов: **{args}**\n⏳ Загрузка...", parse_mode="Markdown")
filters = ["search", "=", args]
results = await vndb_client.query_release(
filters=[filters],
fields=["title", "original", "released", "platform", "type", "image{url}"],
results=10
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return ConversationHandler.END
response_text = f"**Результаты поиска релизов: {args}**\n\n"
for i, release in enumerate(results["results"], 1):
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")
response_text += (
f"{i}. **{title}**\n"
f" ID: {release_id}\n"
)
if original:
response_text += f" Оригинал: {original}\n"
response_text += (
f" Дата: {released}\n"
f" Платформа: {platform}\n"
f" Тип: {release_type}\n\n"
)
await update.message.reply_text(response_text, parse_mode="Markdown")
# Send release images if available
for release in results["results"][:3]: # Send images for first 3 results
image = release.get("image")
if image and isinstance(image, dict):
image_url = image.get("url")
if image_url:
try:
title = release.get("title", "Release")
await update.message.reply_photo(
photo=f"https://t.vndb.org{image_url}",
caption=f"🎬 {title}",
parse_mode="Markdown"
)
except Exception as e:
logger.warning(f"Could not send release image: {e}")
context.user_data["release_results"] = results["results"]
except Exception as e:
logger.error(f"Error searching releases: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
return ConversationHandler.END
@staticmethod
async def search_staff(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Search for staff members"""
try:
args = " ".join(context.args) if context.args else ""
if not args:
await update.message.reply_text(
"❌ Пожалуйста, укажите имя\n"
"Пример: /staff Yoko"
)
return ConversationHandler.END
await update.message.reply_text(f"🔍 Поиск сотрудников: **{args}**\n⏳ Загрузка...", parse_mode="Markdown")
filters = ["search", "=", args]
results = await vndb_client.query_staff(
filters=[filters],
fields=["name", "original", "gender", "role"],
results=10
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return ConversationHandler.END
response_text = f"**Результаты поиска сотрудников: {args}**\n\n"
for i, staff in enumerate(results["results"], 1):
staff_id = staff.get("id", "Unknown")
name = staff.get("name", "Unknown")
original = staff.get("original", "")
gender = staff.get("gender", "Unknown")
response_text += (
f"{i}. **{name}**\n"
f" ID: {staff_id}\n"
)
if original:
response_text += f" Оригинал: {original}\n"
response_text += f" Пол: {gender}\n\n"
await update.message.reply_text(response_text, parse_mode="Markdown")
context.user_data["staff_results"] = results["results"]
except Exception as e:
logger.error(f"Error searching staff: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
return ConversationHandler.END
@staticmethod
async def search_producer(update: Update, context: ContextTypes.DEFAULT_TYPE) -> int:
"""Search for producers"""
try:
args = " ".join(context.args) if context.args else ""
if not args:
await update.message.reply_text(
"❌ Пожалуйста, укажите название\n"
"Пример: /producer Key"
)
return ConversationHandler.END
await update.message.reply_text(f"🔍 Поиск продюсеров: **{args}**\n⏳ Загрузка...", parse_mode="Markdown")
filters = ["search", "=", args]
results = await vndb_client.query_producer(
filters=[filters],
fields=["name", "original", "type"],
results=10
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return ConversationHandler.END
response_text = f"**Результаты поиска продюсеров: {args}**\n\n"
for i, producer in enumerate(results["results"], 1):
producer_id = producer.get("id", "Unknown")
name = producer.get("name", "Unknown")
original = producer.get("original", "")
producer_type = producer.get("type", "Unknown")
response_text += (
f"{i}. **{name}**\n"
f" ID: {producer_id}\n"
)
if original:
response_text += f" Оригинал: {original}\n"
response_text += f" Тип: {producer_type}\n\n"
await update.message.reply_text(response_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error searching producers: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
return ConversationHandler.END
@staticmethod
async def list_tags(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""List popular tags"""
try:
await update.message.reply_text("⏳ Загружаю теги...", parse_mode="Markdown")
results = await vndb_client.query_tag(
fields=["name", "description"],
sort="vns",
reverse=True,
results=15
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return
response_text = "**🏷️ Популярные теги VNDB:**\n\n"
for i, tag in enumerate(results["results"], 1):
tag_id = tag.get("id", "Unknown")
name = tag.get("name", "Unknown")
description = tag.get("description", "")
response_text += f"{i}. **{name}** (`{tag_id}`)\n"
if description and len(description) < 50:
response_text += f" {description}\n"
response_text += "\n"
await update.message.reply_text(response_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error listing tags: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
@staticmethod
async def list_traits(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""List character traits"""
try:
await update.message.reply_text("⏳ Загружаю черты характера...", parse_mode="Markdown")
results = await vndb_client.query_trait(
fields=["name", "description"],
sort="chars",
reverse=True,
results=15
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return
response_text = "**✨ Популярные черты характера:**\n\n"
for i, trait in enumerate(results["results"], 1):
trait_id = trait.get("id", "Unknown")
name = trait.get("name", "Unknown")
description = trait.get("description", "")
response_text += f"{i}. **{name}** (`{trait_id}`)\n"
if description and len(description) < 50:
response_text += f" {description}\n"
response_text += "\n"
await update.message.reply_text(response_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error listing traits: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
@staticmethod
async def get_quote(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Get random quotes"""
try:
count = 1
if context.args and context.args[0].isdigit():
count = min(int(context.args[0]), 5) # Max 5 quotes
await update.message.reply_text(f"⏳ Загружаю {count} цитат...", parse_mode="Markdown")
results = await vndb_client.query_quote(
fields=["character", "quote"],
sort="id",
results=count
)
if not results.get("results"):
await update.message.reply_text("😞 Ничего не найдено")
return
response_text = "**💬 Случайные цитаты:**\n\n"
for quote in results["results"]:
quote_text = quote.get("quote", "")
character = quote.get("character", "Unknown")
if quote_text:
response_text += f"_{quote_text}_\n"
response_text += f"— **{character}**\n\n"
await update.message.reply_text(response_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error getting quotes: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(f"{error_msg}")
@staticmethod
async def authinfo(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Get authentication info"""
try:
token = Config.VNDB_TOKEN
if not token:
await update.message.reply_text(
"❌ Токен VNDB не установлен\n\n"
"Чтобы использовать функции авторизации:\n"
"1. Посетите https://vndb.org/u/tokens\n"
"2. Создайте новый токен\n"
"3. Установите переменную окружения VNDB_TOKEN"
)
return
client_with_token = VndbClient(token=token, use_sandbox=Config.USE_SANDBOX)
auth_info = await client_with_token.get_authinfo()
response_text = f"""
**👤 Информация об авторизации:**
ID: {auth_info.get('id', 'Unknown')}
Пользователь: {auth_info.get('username', 'Unknown')}
**Разрешения:**
"""
permissions = auth_info.get("permissions", [])
if "listread" in permissions:
response_text += "✅ Чтение списка (listread)\n"
if "listwrite" in permissions:
response_text += "✅ Запись в список (listwrite)\n"
if not permissions:
response_text += "❌ Нет разрешений"
await update.message.reply_text(response_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error getting authinfo: {e}")
error_msg = ErrorHandler.format_error(e)
await update.message.reply_text(error_msg)
def main() -> None:
"""Start the bot"""
# Validate configuration
try:
Config.validate()
except ValueError as e:
logger.error(f"Configuration error: {e}")
raise
logger.info(f"Starting bot with config: {Config.to_dict()}")
# Create application
application = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build()
# Add handlers
application.add_handler(CommandHandler("start", BotHandlers.start))
application.add_handler(CommandHandler("help", BotHandlers.help_command))
application.add_handler(CommandHandler("stats", BotHandlers.stats))
application.add_handler(CommandHandler("schema", BotHandlers.schema))
application.add_handler(CommandHandler("search", BotHandlers.search_vn))
application.add_handler(CommandHandler("char", BotHandlers.search_character))
application.add_handler(CommandHandler("release", BotHandlers.search_release))
application.add_handler(CommandHandler("staff", BotHandlers.search_staff))
application.add_handler(CommandHandler("producer", BotHandlers.search_producer))
application.add_handler(CommandHandler("tag", BotHandlers.list_tags))
application.add_handler(CommandHandler("trait", BotHandlers.list_traits))
application.add_handler(CommandHandler("quote", BotHandlers.get_quote))
application.add_handler(CommandHandler("authinfo", BotHandlers.authinfo))
# Add detailed handlers for viewing with images
for handler in get_detail_handlers():
application.add_handler(handler)
# Start the bot
logger.info("Starting bot...")
application.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()

53
config.py Normal file
View File

@@ -0,0 +1,53 @@
"""
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,
}

293
detailed_handlers.py Normal file
View File

@@ -0,0 +1,293 @@
"""
Inline handlers for detailed item viewing with images
"""
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes, CommandHandler, CallbackQueryHandler
from vndb_client import VndbClient
from utils import ImageHandler
from config import Config
import logging
logger = logging.getLogger(__name__)
vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX)
class DetailedHandlers:
"""Handlers for detailed item viewing"""
@staticmethod
async def view_vn_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""View detailed VN information with image"""
try:
if not context.args:
await update.message.reply_text(
"❌ Пожалуйста, укажите ID визуальной новеллы\n"
"Пример: /vn_detail v17"
)
return
vn_id = context.args[0]
await update.message.reply_text(f"⏳ Загружаю информацию о {vn_id}...", parse_mode="Markdown")
# Get detailed VN information
filters = ["id", "=", vn_id]
results = await vndb_client.query_vn(
filters=[filters],
fields=[
"title", "original", "released", "rating", "votecount",
"description", "image{url,dims}", "length", "developer"
]
)
if not results.get("results"):
await update.message.reply_text(f"😞 ВН с ID {vn_id} не найдена")
return
vn = results["results"][0]
# Build detailed text
title = vn.get("title", "Unknown")
original = vn.get("original", "")
released = vn.get("released", "Unknown")
rating = vn.get("rating", 0)
votecount = vn.get("votecount", 0)
description = vn.get("description", "")
length = vn.get("length", "")
developer = vn.get("developer", "")
detail_text = f"""
**🎮 {title}** (`{vn_id}`)
"""
if original:
detail_text += f"Оригинал: {original}\n"
detail_text += f"""
Дата релиза: {released}
Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)
"""
if length:
detail_text += f"Длительность: {length}\n"
if developer:
detail_text += f"Разработчик: {developer}\n"
if description:
# Truncate long descriptions
desc_truncated = description[:300] + "..." if len(description) > 300 else description
detail_text += f"\nОписание:\n{desc_truncated}\n"
detail_text += f"\n[Открыть на VNDB](https://vndb.org/{vn_id})"
# Send with image if available
image_data = vn.get("image")
if image_data:
image_url = ImageHandler.get_image_url(image_data)
if image_url:
try:
await update.message.reply_photo(
photo=image_url,
caption=detail_text,
parse_mode="Markdown"
)
except Exception as e:
logger.warning(f"Could not send VN image: {e}")
await update.message.reply_text(detail_text, parse_mode="Markdown")
else:
await update.message.reply_text(detail_text, parse_mode="Markdown")
else:
await update.message.reply_text(detail_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error viewing VN detail: {e}")
await update.message.reply_text(f"❌ Ошибка при загрузке информации: {str(e)}")
@staticmethod
async def view_character_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""View detailed character information with image"""
try:
if not context.args:
await update.message.reply_text(
"❌ Пожалуйста, укажите ID персонажа\n"
"Пример: /char_detail c1"
)
return
char_id = context.args[0]
await update.message.reply_text(f"⏳ Загружаю информацию о {char_id}...", parse_mode="Markdown")
# Get detailed character information
filters = ["id", "=", char_id]
results = await vndb_client.query_character(
filters=[filters],
fields=[
"name", "original", "gender", "bloodtype", "height", "weight",
"bust", "waist", "hips", "description", "image{url,dims}", "vn"
]
)
if not results.get("results"):
await update.message.reply_text(f"😞 Персонаж с ID {char_id} не найден")
return
char = results["results"][0]
# Build detailed text
name = char.get("name", "Unknown")
original = char.get("original", "")
gender = char.get("gender", "")
bloodtype = char.get("bloodtype", "")
description = char.get("description", "")
vns = char.get("vn", [])
detail_text = f"**👤 {name}** (`{char_id}`)\n"
if original:
detail_text += f"Оригинал: {original}\n"
if gender:
detail_text += f"Пол: {gender}\n"
if bloodtype:
detail_text += f"Группа крови: {bloodtype}\n"
if vns:
detail_text += f"\nПоявляется в:\n"
for vn in vns[:5]: # Show first 5 VNs
vn_id = vn.get("id", "")
if vn_id:
detail_text += f"• [{vn_id}](https://vndb.org/{vn_id})\n"
if description:
desc_truncated = description[:300] + "..." if len(description) > 300 else description
detail_text += f"\nОписание:\n{desc_truncated}\n"
detail_text += f"\n[Открыть на VNDB](https://vndb.org/{char_id})"
# Send with image if available
image_data = char.get("image")
if image_data:
image_url = ImageHandler.get_image_url(image_data)
if image_url:
try:
await update.message.reply_photo(
photo=image_url,
caption=detail_text,
parse_mode="Markdown"
)
except Exception as e:
logger.warning(f"Could not send character image: {e}")
await update.message.reply_text(detail_text, parse_mode="Markdown")
else:
await update.message.reply_text(detail_text, parse_mode="Markdown")
else:
await update.message.reply_text(detail_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error viewing character detail: {e}")
await update.message.reply_text(f"❌ Ошибка при загрузке информации: {str(e)}")
@staticmethod
async def view_release_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""View detailed release information with image"""
try:
if not context.args:
await update.message.reply_text(
"❌ Пожалуйста, укажите ID релиза\n"
"Пример: /release_detail r1"
)
return
release_id = context.args[0]
await update.message.reply_text(f"⏳ Загружаю информацию о {release_id}...", parse_mode="Markdown")
# Get detailed release information
filters = ["id", "=", release_id]
results = await vndb_client.query_release(
filters=[filters],
fields=[
"title", "original", "released", "platform", "type", "language",
"edition", "description", "image{url,dims}", "vn"
]
)
if not results.get("results"):
await update.message.reply_text(f"😞 Релиз с ID {release_id} не найден")
return
release = results["results"][0]
# Build detailed text
title = release.get("title", "Unknown")
original = release.get("original", "")
released = release.get("released", "Unknown")
platform = release.get("platform", "")
rel_type = release.get("type", "")
language = release.get("language", [])
edition = release.get("edition", "")
description = release.get("description", "")
vns = release.get("vn", [])
detail_text = f"**🎬 {title}** (`{release_id}`)\n"
if original:
detail_text += f"Оригинал: {original}\n"
detail_text += f"""
Дата выпуска: {released}
Платформа: {platform}
Тип: {rel_type}
"""
if language:
lang_str = ", ".join(language) if isinstance(language, list) else str(language)
detail_text += f"Языки: {lang_str}\n"
if edition:
detail_text += f"Издание: {edition}\n"
if vns:
detail_text += f"\nЧасть из:\n"
for vn in vns[:3]:
vn_id = vn.get("id", "")
if vn_id:
detail_text += f"• [{vn_id}](https://vndb.org/{vn_id})\n"
if description:
desc_truncated = description[:200] + "..." if len(description) > 200 else description
detail_text += f"\nОписание:\n{desc_truncated}\n"
detail_text += f"\n[Открыть на VNDB](https://vndb.org/{release_id})"
# Send with image if available
image_data = release.get("image")
if image_data:
image_url = ImageHandler.get_image_url(image_data)
if image_url:
try:
await update.message.reply_photo(
photo=image_url,
caption=detail_text,
parse_mode="Markdown"
)
except Exception as e:
logger.warning(f"Could not send release image: {e}")
await update.message.reply_text(detail_text, parse_mode="Markdown")
else:
await update.message.reply_text(detail_text, parse_mode="Markdown")
else:
await update.message.reply_text(detail_text, parse_mode="Markdown")
except Exception as e:
logger.error(f"Error viewing release detail: {e}")
await update.message.reply_text(f"❌ Ошибка при загрузке информации: {str(e)}")
def get_detail_handlers():
"""Get all detail view handlers"""
return [
CommandHandler("vn_detail", DetailedHandlers.view_vn_detail),
CommandHandler("char_detail", DetailedHandlers.view_character_detail),
CommandHandler("release_detail", DetailedHandlers.view_release_detail),
]

20
docker-compose.yml Normal file
View File

@@ -0,0 +1,20 @@
version: '3.8'
services:
vndb-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

6
requirements.txt Normal file
View File

@@ -0,0 +1,6 @@
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

168
test_bot.py Normal file
View File

@@ -0,0 +1,168 @@
"""
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 Normal file
View File

@@ -0,0 +1,353 @@
"""
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")

496
vndb_client.py Normal file
View File

@@ -0,0 +1,496 @@
"""
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}"
async def _request(
self,
endpoint: str,
method: str = "GET",
data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Make HTTP request to VNDB API
Args:
endpoint: API endpoint
method: HTTP method (GET, POST, PATCH, DELETE)
data: Request body data
Returns:
Response JSON
Raises:
httpx.HTTPError: On HTTP errors
json.JSONDecodeError: On invalid JSON response
"""
url = f"{self.base_url}{endpoint}"
async with httpx.AsyncClient(timeout=10) as client:
response = await client.request(
method,
url,
headers=self.headers,
content=json.dumps(data) if data else None,
)
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]:
"""
Query visual novels
Args:
filters: Filter conditions
fields: Fields to retrieve
sort: Sort field (id, title, released, rating, votecount, searchrank)
reverse: Sort in descending order
results: Number of results per page (max 100)
page: Page number starting from 1
count: Include total count
user: User ID for user-specific filters
compact_filters: Include compact filter representation
normalized_filters: Include normalized filter representation
Returns:
Query results
"""
data = {
"filters": filters or [],
"fields": ",".join(fields) if fields else "",
"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 "",
"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]:
"""Query characters"""
data = {
"filters": filters or [],
"fields": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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": ",".join(fields) if fields else "",
"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")