First commit
This commit is contained in:
13
.env.example
Normal file
13
.env.example
Normal 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
168
.gitignore
vendored
@@ -1,13 +1,13 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
@@ -21,156 +21,32 @@ parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# 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/
|
||||
# Virtual environments
|
||||
venv/
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# 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
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Bot-specific
|
||||
.cache/
|
||||
tmp/
|
||||
|
||||
142
CHANGELOG.md
Normal file
142
CHANGELOG.md
Normal 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
23
Dockerfile
Normal 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
343
EXAMPLES.md
Normal 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
177
IMAGES.md
Normal 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
343
INSTALLATION.md
Normal 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
256
README.md
@@ -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
325
advanced_features.py
Normal 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
699
bot.py
Normal 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
53
config.py
Normal 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
293
detailed_handlers.py
Normal 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
20
docker-compose.yml
Normal 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
6
requirements.txt
Normal 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
168
test_bot.py
Normal 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
353
utils.py
Normal 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
496
vndb_client.py
Normal 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")
|
||||
Reference in New Issue
Block a user