Refactor VNDB Telegram Bot

- Remove config.py and integrate environment variable handling directly in docker-compose.yml.
- Delete detailed_handlers.py and replace with simplified command handlers.
- Update requirements.txt to use aiogram and httpx instead of python-telegram-bot and requests.
- Remove test_bot.py and utils.py, consolidating functionality into new VNDBClient class.
- Implement VNDBClient for API interactions, simplifying query methods for visual novels, characters, and releases.
- Clean up docker-compose.yml for improved readability and maintainability.
This commit is contained in:
2026-05-01 18:04:13 +03:00
parent fd0a403f37
commit 88bba02983
19 changed files with 142 additions and 6647 deletions

View File

@@ -1,13 +1,2 @@
# Telegram Bot Token (обязательно)
# Получите у @BotFather в Telegram
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
# VNDB API Token (опционально)
# Получите на https://vndb.org/u/tokens
VNDB_TOKEN=your_vndb_api_token_here
# Опции логирования
LOG_LEVEL=INFO
# Использовать sandbox API (для тестирования)
USE_SANDBOX=false
TELEGRAM_TOKEN=your_token_here
VNDB_API_URL=https://api.vndb.org/kana

3411
API.mhtml

File diff suppressed because it is too large Load Diff

View File

@@ -1,142 +0,0 @@
# CHANGELOG
## [1.0.0] - 2026-05-01
### Added
- ✨ Полная поддержка VNDB API v2 (Kana)
- 🎮 Поиск визуальных новелл по названию, языку, платформе, тегам, рейтингу
- 👥 Поиск персонажей по имени, полу, черт характера
- 🎬 Поиск релизов по названию, платформе, типу
- 👨‍💼 Поиск сотрудников (сценаристы, художники, композиторы)
- 🏢 Поиск продюсеров и издателей
- 🏷️ Просмотр популярных тегов
- ✨ Просмотр черт характера персонажей
- 💬 Получение случайных цитат
- 📊 Статистика базы данных VNDB
- 📋 Информация о схеме API
- 🔐 Поддержка авторизации с помощью API токена
### Features
- ⚡ Асинхронные запросы для быстрого отклика
- 🔄 Обработка ошибок с пользовательскими сообщениями
- 📝 Подробное логирование для отладки
- 🎨 Красивое форматирование ответов с поддержкой Markdown
- 📦 Полная поддержка Docker и Docker Compose
- 🧪 Unit тесты для основных функций
- ⚙️ Гибкая конфигурация через переменные окружения
### Architecture
- `bot.py` - Основной модуль бота с обработчиками команд
- `vndb_client.py` - VNDB API клиент с поддержкой всех endpoints
- `config.py` - Управление конфигурацией
- `utils.py` - Утилиты для форматирования и обработки ошибок
- `advanced_features.py` - Продвинутые функции (кэширование, rate limiting, сессии)
- `test_bot.py` - Unit тесты
- `requirements.txt` - Зависимости Python
- `Dockerfile` и `docker-compose.yml` - Контейнеризация
### Documentation
- 📚 README.md с полной документацией
- 📖 INSTALLATION.md с пошаговыми инструкциями установки
- 🔍 Inline документация в исходном коде
- 📝 Примеры использования для каждой команды
### Commands
- `/start` - Начало работы с ботом
- `/help` - Справка по всем командам
- `/search <название>` - Поиск визуальных новелл
- `/char <имя>` - Поиск персонажей
- `/release <название>` - Поиск релизов
- `/staff <имя>` - Поиск сотрудников
- `/producer <название>` - Поиск продюсеров
- `/tag` - Список тегов
- `/trait` - Список черт характера
- `/quote [число]` - Случайные цитаты
- `/stats` - Статистика базы данных
- `/schema` - Информация о схеме API
- `/authinfo` - Информация об авторизации
### Technical Details
- Python 3.8+
- python-telegram-bot 21.0
- httpx для асинхронных HTTP запросов
- Асинхронная обработка с asyncio
- Полная поддержка rate limiting API VNDB (200 запросов за 5 минут)
### Known Limitations
- Максимум 100 результатов на странице (ограничение API)
- Функции управления списками требуют валидный API токен
- Время выполнения одного запроса не должно превышать 3 секунды (ограничение API)
### Future Plans
- 🔄 Добавить пагинацию результатов с кнопками навигации
- 💾 Добавить кэширование популярных запросов
- 📊 Добавить статистику использования бота
- 🌍 Поддержка разных языков интерфейса
- ⚙️ Админ панель для управления ботом
- 🔔 Уведомления о новых релизах избранных ВН
- 📱 Поддержка inline режима для использования в других чатах
---
## Version History
### v1.0.0 (Current)
- Initial release with full VNDB API v2 support
---
## Dependencies
### Core
- python-telegram-bot==21.0 - Telegram Bot API
- python-dotenv==1.0.0 - Environment variables
- aiohttp==3.9.1 - Async HTTP client
- requests==2.31.0 - HTTP library
### Testing
- pytest==7.4.0 - Testing framework
- pytest-asyncio==0.21.0 - Async support for pytest
### Development (Optional)
- black - Code formatter
- pylint - Code linter
- mypy - Type checker
---
## Contributing
Если вы хотите улучшить бот:
1. Форкните репозиторий
2. Создайте ветку для вашей функции
3. Коммитьте изменения
4. Отправьте Pull Request
---
## License
Данные, полученные через VNDB API, подлежат [Data License VNDB](https://vndb.org/d17#4).
---
## Support
Если у вас есть вопросы или проблемы:
- Проверьте документацию (README.md, INSTALLATION.md)
- Проверьте файлы логов
- Убедитесь, что API доступен
- Проверьте правильность конфигурации
---
## Acknowledgments
- VNDB (Visual Novel Database) за отличную базу данных и API
- python-telegram-bot за удобную библиотеку
- Python асинхронное сообщество
---
**Последнее обновление**: 1 мая 2026

View File

@@ -1,20 +1,10 @@
FROM python:3.11-slim
FROM python:3.12-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application files
COPY . .
# Copy environment file (or use docker secrets/environment variables)
# COPY .env .
# Create non-root user for security
RUN useradd -m -u 1000 botuser && chown -R botuser:botuser /app
USER botuser
# Run the bot
CMD ["python", "bot.py"]
CMD ["python", "bot.py"]

View File

@@ -1,343 +0,0 @@
# Примеры использования VNDB Telegram Bot
## Быстрый старт
### 1. Начало работы
Напишите боту:
```
/start
```
Бот вернёт приветственное сообщение с основной информацией.
### 2. Справка
Для просмотра всех доступных команд:
```
/help
```
## Примеры основных команд
### Поиск визуальных новелл
#### Простой поиск
```
/search Steins Gate
```
**Результат:**
- Список из 10 найденных ВН с основной информацией
- Рейтинг и количество голосов
- Дата выпуска
- Автоматическая отправка обложек первых 3 результатов 📸
#### Различные запросы
```
/search Clannad
/search Fate
/search Genshin
/search Tsukihime
```
### Поиск персонажей
#### Простой поиск
```
/char Okabe Rintaro
```
**Результат:**
- Список найденных персонажей
- Информация о поле персонажа
- В каких ВН появляется персонаж
- Аватары первых 3 персонажей 🖼️
#### Другие примеры
```
/char Nagisa
/char Sakura
/char Saber
/char Rem
```
### Поиск релизов
#### Поиск по платформе
```
/release Windows
/release Switch
/release PlayStation
```
#### Поиск по названию
```
/release Steins Gate Windows
/release Clannad PS2
```
## Детальный просмотр с картинками
### Просмотр ВН
Сначала найдите ВН:
```
/search Steins Gate
```
Видите в результатах: `v17` - это ID Steins;Gate
Затем просмотрите детали:
```
/vn_detail v17
```
**Получите:**
- Высокое качество обложки
- Полная информация:
- Название: Steins;Gate
- Оригинальное название: シュタインズ・ゲート
- Дата выпуска: 2009-09-15
- Рейтинг и голоса
- Длительность
- Разработчик
- Подробное описание
- Прямая ссылка на VNDB
### Просмотр персонажа
Найдите персонажа:
```
/char Okabe
```
Видите: `c4 - Okabe Rintaro`
Посмотрите детали:
```
/char_detail c4
```
**Получите:**
- Аватар персонажа высокого качества
- Информация:
- Имя и оригинальное имя
- Пол
- Группа крови
- Связанные ВН
- Биография
### Просмотр релиза
Найдите релиз:
```
/release Steins Gate Windows
```
Видите ID релиза в результатах
Посмотрите детали:
```
/release_detail r12345
```
## Информационные команды
### Статистика базы данных
```
/stats
```
**Результат:**
- Количество ВН
- Количество персонажей
- Количество релизов
- Количество продюсеров
- Количество сотрудников
- Количество тегов
- Количество черт характера
### Популярные теги
```
/tag
```
**Результат:**
- Список 15 самых популярных тегов
- Категории и описания
### Черты характера персонажей
```
/trait
```
**Результат:**
- Список 15 самых распространённых черт
- Количество персонажей с каждой чертой
### Случайные цитаты
```
/quote
```
Получить одну случайную цитату
```
/quote 3
```
Получить 3 случайные цитаты (максимум 5)
## Расширенные примеры
### Изучение конкретной ВН
```bash
# 1. Поиск ВН
/search Clannad
# 2. Просмотр полной информации (видите обложку и описание)
/vn_detail v4
# 3. Если интересует персонаж из Clannad
/char Nagisa
# 4. Просмотр информации о персонаже
/char_detail c82
# 5. Поиск выпусков для разных платформ
/release Clannad Windows
# 6. Просмотр конкретного релиза
/release_detail r500
```
### Исследование визуальных новелл от одного разработчика
```bash
# 1. Поиск ВН от Key (фамилия разработчика)
/search Key
# 2. Посмотрите детали найденных ВН
/vn_detail v17 # Steins;Gate
/vn_detail v4 # Clannad
/vn_detail v1 # Air
# 3. Изучите персонажей из каждой ВН
/char Nagisa # из Clannad
/char Okabe # из Steins;Gate
```
### Поиск информации о персонаже
```bash
# 1. Найти персонажа по имени
/char Saber
# 2. Посмотреть его подробную информацию
/char_detail c1234
# 3. Найти его родственные ВН в списке
# (информация включена в /char_detail)
```
## Советы и трюки
### Совет 1: Комбинирование команд
Эффективный способ использования:
```bash
/search [название] # Найдите элемент
/[type]_detail [id] # Посмотрите детали
```
### Совет 2: Использование ID
После поиска вы видите ID в скобках:
```
1. **Steins;Gate** (v17)
```
Скопируйте ID и используйте:
```
/vn_detail v17
```
### Совет 3: Максимум информации за раз
Команды подробного просмотра дают максимум информации:
- Картинку (если есть)
- Все основные данные
- Описание
- Связанные элементы
- Ссылку на оригинальный сайт
### Совет 4: Работа с изображениями
- **Быстрый поиск**: используйте `/search`, получите автоматические картинки
- **Качество**: используйте `/vn_detail` и т.д. для лучшего качества картинок
- **Нет картинки**: если элемента нет в VNDB, текстовая информация всё равно будет доступна
## Обработка ошибок
### Если ничего не найдено
```
/search очень_редкоеазвание
# Результат: 😞 Ничего не найдено
```
Попробуйте:
- Изменить орфографию
- Использовать часть названия
- Попробовать русское или английское название
### Если элемент не существует
```
/vn_detail v999999
# Результат: 😞 ВН с ID v999999 не найдена
```
Убедитесь, что:
- ID правильный
- ID на самом сайте VNDB имеет этот вид
### Если картинка не загружается
Текстовая информация по-прежнему будет отправлена. Вы можете:
- Попробовать ещё раз
- Посетить https://vndb.org напрямую для просмотра картинок
- Сообщить об ошибке, если проблема повторяется
## Полезные команды для разных целей
### Исследование ВН
```bash
/stats # Сколько всего ВН
/search [имя] # Найти конкретную ВН
/vn_detail [id] # Полная информация о ВН
```
### Изучение персонажей
```bash
/char [имя] # Найти персонажа
/char_detail [id] # Полная информация о персонаже
/trait # Посмотреть черты характера
```
### Поиск релизов
```bash
/release [название] # Найти релиз
/release_detail [id] # Информация о релизе
/search [ВН] # Найти ВН и её релизы
```
### Просмотр категорий
```bash
/tag # Теги и категории
/trait # Черты характера
/schema # Полная информация о полях API
```
---
**Нужна помощь?** Напишите `/help` для справки по командам

177
IMAGES.md
View File

@@ -1,177 +0,0 @@
# Работа с изображениями в VNDB Telegram Bot
## Поддерживаемые изображения
Бот может отправлять изображения для:
- **Визуальные новеллы** - обложки и постеры
- **Персонажи** - аватары и официальные картинки
- **Релизы** - коробки и обложки физических изданий
## Использование изображений
### 1. Автоматическая отправка при поиске
При поиске бот автоматически отправляет изображения для первых 3 результатов:
```
/search Steins Gate
```
Бот вернёт:
- Текстовую информацию со списком найденных ВН
- Обложки первых 3 ВН
### 2. Детальный просмотр с полной информацией
Для получения полной информации с изображением используйте команды подробного просмотра:
#### Просмотр визуальной новеллы
```
/vn_detail v17
```
Отправит:
- Обложку ВН (высокое качество)
- Полная информация:
- Название и оригинальное название
- Дата выпуска
- Рейтинг и количество голосов
- Длительность
- Разработчик
- Описание
- Ссылка на VNDB
#### Просмотр персонажа
```
/char_detail c1
```
Отправит:
- Аватар персонажа
- Информация:
- Имя и оригинальное имя
- Пол
- Группа крови
- Список ВН, в которых появляется персонаж
- Описание
#### Просмотр релиза
```
/release_detail r1
```
Отправит:
- Картинку релиза
- Информация:
- Название
- Дата выпуска
- Платформа
- Тип (оригинальный, фан-перевод и т.д.)
- Языки
- Издание
- ВН, к которой относится
- Описание
## Примеры
### Поиск и просмотр ВН
```
# Найти ВН по названию
/search Clannad
# Просмотреть детали найденной ВН
/vn_detail v4
```
### Поиск и просмотр персонажа
```
# Найти персонажа
/char Nagisa
# Просмотреть детали персонажа
/char_detail c82
```
### Поиск и просмотр релиза
```
# Найти релиз
/release Clannad Windows
# Просмотреть детали релиза
/release_detail r500
```
## 🔗 Как найти ID элемента
### ID визуальной новеллы
- На сайте VNDB URL выглядит как: `https://vndb.org/v17`
- ID: `v17`
### ID персонажа
- На сайте VNDB URL: `https://vndb.org/c1`
- ID: `c1`
### ID релиза
- На сайте VNDB URL: `https://vndb.org/r123`
- ID: `r123`
### ID посредством поиска
При поиске бот показывает ID в формате:
```
1. **Steins;Gate**
ID: v17
```
Скопируйте ID и используйте в команде подробного просмотра:
```
/vn_detail v17
```
## Обработка ошибок при изображениях
Если изображение недоступно или бот не может его отправить:
- Бот вернёт текстовую информацию без изображения
- Информация будет полной и точной
- Вы по-прежнему можете посетить VNDB напрямую для просмотра изображения
## Источник изображений
Все изображения загружаются с официального CDN VNDB:
- **URL база**: `https://t.vndb.org`
- **Качество**: Оптимальное для отображения в Telegram
## Советы
1. **Для быстрого поиска**: используйте `/search`, `/char`, `/release`
- Автоматически получите первые изображения
- Быстрый просмотр информации
2. **Для подробной информации**: используйте `/vn_detail`, `/char_detail`, `/release_detail`
- Полная информация о элементе
- Высокое качество изображения
- Ссылка на официальный VNDB
3. **Сочетание команд**: сначала найдите элемент, потом просмотрите детали
```
/search Steins Gate
# Видите v17 в результатах
/vn_detail v17
# Получаете полную информацию с обложкой
```
## Примечания
- Не все элементы имеют изображения в VNDB
- Если изображение отсутствует, бот отправит только текстовую информацию
- Качество изображений зависит от наличия и разрешения в базе данных VNDB
- Бот поддерживает максимум до 3 изображений при поиске (для экономии трафика и скорости)
---
Для других вопросов см. `/help` или README.md

View File

@@ -1,343 +0,0 @@
# Руководство по установке VNDB Telegram Bot
## Быстрый старт (5 минут)
### Требования
- Python 3.8 или выше
- pip (идёт с Python)
- Интернет соединение
### Шаг 1: Получение токенов
#### Telegram Bot Token
1. Откройте Telegram и найдите @BotFather
2. Напишите `/newbot`
3. Ответьте на вопросы о названии и username вашего бота
4. Вы получите токен (выглядит как `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)
#### VNDB API Token (опционально)
1. Создайте аккаунт на https://vndb.org (если ещё нет)
2. Перейдите на https://vndb.org/u/tokens
3. Нажмите "Create new token"
4. Скопируйте полученный токен
### Шаг 2: Установка бота
```bash
# Скачайте проект
git clone <repo_url>
cd vntgbot
# Создайте виртуальное окружение (рекомендуется)
python -m venv venv
# Активируйте виртуальное окружение
# На Windows:
venv\Scripts\activate
# На macOS/Linux:
source venv/bin/activate
# Установите зависимости
pip install -r requirements.txt
```
### Шаг 3: Настройка переменных окружения
Создайте файл `.env` в корневой папке проекта:
```
TELEGRAM_BOT_TOKEN=ваш_токен_тг
VNDB_TOKEN=ваш_токен_vndb
```
Заменив аш_токен_тг` и аш_токен_vndb` на реальные токены.
### Шаг 4: Запуск бота
```bash
python bot.py
```
Если все настроено правильно, вы должны увидеть сообщение о том, что бот запущен.
---
## Подробная установка
### Linux/macOS
```bash
# 1. Скачайте исходный код
git clone <repo_url>
cd vntgbot
# 2. Создайте виртуальное окружение
python3 -m venv venv
source venv/bin/activate
# 3. Обновите pip
pip install --upgrade pip
# 4. Установите зависимости
pip install -r requirements.txt
# 5. Создайте .env файл
cp .env.example .env
# Отредактируйте .env и добавьте токены
nano .env
# 6. Запустите бота
python bot.py
```
### Windows (PowerShell)
```powershell
# 1. Скачайте исходный код
git clone <repo_url>
cd vntgbot
# 2. Создайте виртуальное окружение
python -m venv venv
.\venv\Scripts\Activate.ps1
# 3. Обновите pip
python -m pip install --upgrade pip
# 4. Установите зависимости
pip install -r requirements.txt
# 5. Создайте .env файл
Copy-Item .env.example .env
# Отредактируйте .env с помощью notepad или вашего любимого редактора
notepad .env
# 6. Запустите бота
python bot.py
```
### Windows (CMD)
```cmd
# 1. Скачайте исходный код
git clone <repo_url>
cd vntgbot
# 2. Создайте виртуальное окружение
python -m venv venv
venv\Scripts\activate.bat
# 3. Обновите pip
python -m pip install --upgrade pip
# 4. Установите зависимости
pip install -r requirements.txt
# 5. Создайте .env файл
copy .env.example .env
REM Отредактируйте .env
# 6. Запустите бота
python bot.py
```
---
## Развертывание с помощью Docker
### Требования
- Docker
- Docker Compose (опционально)
### С использованием Docker Compose (рекомендуется)
```bash
# 1. Создайте .env файл в корневой папке
echo "TELEGRAM_BOT_TOKEN=ваш_токен" > .env
echo "VNDB_TOKEN=ваш_токен" >> .env
# 2. Запустите бота
docker-compose up -d
# 3. Проверьте логи
docker-compose logs -f vndb-bot
# 4. Остановите бота
docker-compose down
```
### С использованием Docker напрямую
```bash
# 1. Создайте образ
docker build -t vndb-bot .
# 2. Запустите контейнер
docker run -d \
--name vndb-bot \
-e TELEGRAM_BOT_TOKEN=ваш_токен \
-e VNDB_TOKEN=ваш_токен \
vndb-bot
# 3. Проверьте логи
docker logs vndb-bot
# 4. Остановите контейнер
docker stop vndb-bot
docker rm vndb-bot
```
---
## Проверка установки
После запуска бота:
1. Откройте Telegram
2. Найдите вашего бота по username
3. Напишите `/start`
4. Вы должны увидеть приветственное сообщение
Если всё работает, попробуйте команду:
```
/stats
```
---
## Решение проблем
### Ошибка: "TELEGRAM_BOT_TOKEN not set"
- Проверьте, что файл `.env` существует в корневой папке
- Убедитесь, что в `.env` правильно указан токен
- Убедитесь, что токен не содержит кавычки или лишних пробелов
### Ошибка: "Connection refused"
- Проверьте интернет соединение
- Убедитесь, что сервер VNDB API доступен (https://api.vndb.org/kana)
### Бот не отвечает на сообщения
- Проверьте, что бот имеет права администратора в чате (если используется в групповом чате)
- Убедитесь, что в настройках бота включены приватные сообщения
- Перезагрузите бота: остановите его и запустите снова
### Медленные ответы
- Это может быть из-за ограничений API VNDB
- Попробуйте уменьшить количество запрашиваемых полей
- Убедитесь, что ваше интернет соединение стабильно
### Ошибка импорта модулей
```bash
# Убедитесь, что виртуальное окружение активировано и зависимости установлены
pip install -r requirements.txt --upgrade
```
### SSL Certificate Error
```bash
# На некоторых системах может потребоваться:
pip install certifi
# Или использовать нестабильное соединение (не рекомендуется):
# Добавьте в bot.py перед запуском: import ssl; ssl._create_default_https_context = ssl._create_unverified_context
```
---
## Конфигурация
### Переменные окружения
| Переменная | Обязательна | По умолчанию | Описание |
|-----------|----------|----------|-----------|
| TELEGRAM_BOT_TOKEN | Да | - | Токен Telegram бота |
| VNDB_TOKEN | Нет | - | Токен VNDB API для авторизации |
| LOG_LEVEL | Нет | INFO | Уровень логирования (DEBUG, INFO, WARNING, ERROR) |
| USE_SANDBOX | Нет | false | Использовать sandbox API для тестирования |
### Файл config.py
Вы можете отредактировать `config.py` для изменения других параметров:
```python
MAX_RESULTS_PER_PAGE = 100 # Максимум результатов на странице
DEFAULT_RESULTS_PER_PAGE = 10 # По умолчанию результатов на странице
MAX_QUOTES_AT_ONCE = 5 # Максимум цитат за раз
API_TIMEOUT = 10 # Timeout для API запросов (в секундах)
BOT_TIMEOUT = 30 # Timeout для бота (в секундах)
```
---
## Проверка зависимостей
```bash
# Проверьте, что все зависимости установлены правильно
pip check
# Обновите зависимости
pip install -r requirements.txt --upgrade
```
---
## Выполнение тестов
```bash
# Установите зависимости для тестирования (они уже в requirements.txt)
# Запустите тесты
pytest test_bot.py -v
# С покрытием
pytest test_bot.py --cov=. --cov-report=html
```
---
## Автозагрузка при запуске системы
### Linux/macOS (systemd)
Создайте файл `/etc/systemd/system/vndb-bot.service`:
```ini
[Unit]
Description=VNDB Telegram Bot
After=network.target
[Service]
Type=simple
User=your_username
WorkingDirectory=/path/to/vntgbot
Environment="PATH=/path/to/vntgbot/venv/bin"
ExecStart=/path/to/vntgbot/venv/bin/python bot.py
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Затем:
```bash
sudo systemctl daemon-reload
sudo systemctl enable vndb-bot
sudo systemctl start vndb-bot
```
### Windows (Планировщик задач)
1. Откройте Планировщик задач
2. Создайте новую задачу
3. Установите триггер "При запуске"
4. Установите действие: запустить `python.exe` с аргументом `C:\path\to\bot.py`
5. Сохраните задачу
---
## Нужна помощь?
- 📖 Прочитайте [README.md](README.md)
- 🐛 Проверьте [логи](#логирование)
- 📝 Посмотрите примеры команд в `/help`
- 🌐 Посетите https://api.vndb.org/kana для справки по API
Удачи! 🚀

73
LICENSE
View File

@@ -1,73 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
(a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
Copyright 2026 King-of-the-all-Cookies
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

256
README.md
View File

@@ -1,256 +0,0 @@
# VNDB Telegram Bot
Полнофункциональный Telegram бот для работы с базой данных [VNDB](https://vndb.org/) (Visual Novel Database).
## Возможности
Бот поддерживает **все методы** VNDB API v2:
### Поиск и запросы
- **Визуальные новеллы** - полный поиск по названию, языку, платформе, тегам, рейтингу и дате выпуска
- **Персонажи** - поиск по имени, полу, роли, чертам характера
- **Релизы** - поиск по названию, платформе, типу, дате выпуска
- **Сотрудники** - поиск сценаристов, художников, композиторов и других
- **Продюсеры** - поиск издателей и разработчиков
- **Теги** - просмотр популярных тегов и категорий
- **Черты характера** - список черт персонажей
- **Цитаты** - получение случайных цитат из ВН
### Изображения
- **Обложки ВН** - автоматическая отправка обложек при поиске визуальных новелл
- **Аватары персонажей** - картинки персонажей при поиске
- **Картинки релизов** - изображения для каждого релиза
- **Подробный просмотр** - команды `/vn_detail`, `/char_detail`, `/release_detail` для полной информации с высоким качеством изображений
### Управление списками (требует токена)
- Добавление ВН в личный список
- Обновление статуса просмотра
- Добавление заметок и оценок
- Управление меткамиме
### Информация
- Статистика базы данных
- Информация о схеме API
- Информация об авторизации
## Установка
### Требования
- Python 3.8+
- pip
### Шаг 1: Клонирование репозитория
```bash
git clone <repo_url>
cd vntgbot
```
### Шаг 2: Установка зависимостей
```bash
pip install -r requirements.txt
```
### Шаг 3: Настройка переменных окружения
Создайте файл `.env` в корневой папке проекта:
```env
# Обязательно
TELEGRAM_BOT_TOKEN=your_telegram_bot_token_here
# Опционально (для функций авторизации)
VNDB_TOKEN=your_vndb_api_token_here
```
#### Получение токена Telegram Bot
1. Напишите боту [@BotFather](https://t.me/botfather) в Telegram
2. Используйте команду `/newbot`
3. Следуйте инструкциям и получите токен
#### Получение VNDB API токена
1. Создайте аккаунт на [VNDB.org](https://vndb.org)
2. Перейдите на https://vndb.org/u/tokens
3. Создайте новый токен
4. Скопируйте его в переменную `VNDB_TOKEN`
### Шаг 4: Запуск бота
```bash
python bot.py
```
## Использование
### Команды
#### Поиск
- `/search <название>` - Поиск визуальных новелл
- `/char <имя>` - Поиск персонажей
- `/release <название>` - Поиск релизов
- `/staff <имя>` - Поиск сотрудников
- `/producer <название>` - Поиск продюсеров
#### Информация
- `/tag` - Список популярных тегов
- `/trait` - Список черт характера
- `/quote [число]` - Случайные цитаты (макс. 5)
- `/stats` - Статистика базы данных
- `/schema` - Информация о схеме API
- `/authinfo` - Информация об авторизации (если токен установлен)
#### Просмотр с картинками
- `/vn_detail <ID>` - Полная информация о ВН с обложкой (_Пример: /vn_detail v17_)
- `/char_detail <ID>` - Информация о персонаже с аватаром (_Пример: /char_detail c1_)
- `/release_detail <ID>` - Информация о релизе с картинкой (_Пример: /release_detail r1_)
#### Справка
- `/start` - Приветствие и основная информация
- `/help` - Подробная справка по всем командам
### Примеры использования
```
/search Steins Gate
/char Okabe Rintaro
/release Windows
/staff Yoko Taro
/producer Key
/quote 3
/vn_detail v17
/char_detail c25
/release_detail r1
```
## Структура проекта
```
vntgbot/
├── bot.py # Основной файл бота с обработчиками команд
├── vndb_client.py # VNDB API клиент
├── requirements.txt # Зависимости Python
├── .env # Переменные окружения (не отслеживается в git)
├── .gitignore # Файлы для игнорирования
└── README.md # Этот файл
```
## API Endpoints
Бот поддерживает следующие endpoint'ы VNDB API:
### Простые запросы
- `GET /schema` - Информация о схеме API
- `GET /stats` - Статистика базы данных
- `GET /user` - Информация о пользователе
- `GET /authinfo` - Информация об авторизации
### Запросы к базе данных
- `POST /vn` - Запрос визуальных новелл
- `POST /release` - Запрос релизов
- `POST /character` - Запрос персонажей
- `POST /staff` - Запрос сотрудников
- `POST /producer` - Запрос продюсеров
- `POST /tag` - Запрос тегов
- `POST /trait` - Запрос черт характера
- `POST /quote` - Запрос цитат
### Управление списками
- `POST /ulist` - Запрос списка ВН пользователя
- `POST /rlist` - Запрос списка релизов пользователя
- `PATCH /ulist/<id>` - Обновление записи в списке ВН
- `PATCH /rlist/<id>` - Обновление записи в списке релизов
- `DELETE /ulist/<id>` - Удаление из списка ВН
- `DELETE /rlist/<id>` - Удаление из списка релизов
- `GET /ulist_labels` - Получение меток списка
## Фильтры и опции
### Поддерживаемые параметры запросов:
- **filters** - Условия фильтрации
- **fields** - Выбираемые поля
- **sort** - Сортировка (id, title, released, rating, votecount)
- **reverse** - Обратный порядок сортировки
- **results** - Количество результатов (макс. 100)
- **page** - Номер страницы для пагинации
- **count** - Включить общее количество результатов
- **user** - ID пользователя для фильтров специфичных для пользователя
## Ограничения API
VNDB API имеет следующие ограничения:
- **200 запросов** за 5 минут
- **1 секунда** общего времени выполнения в минуту
- **3 секунды** максимального времени для одного запроса
Бот учитывает эти ограничения и использует асинхронные запросы для оптимальной производительности.
## Обработка ошибок
Бот обрабатывает следующие типы ошибок:
- Сетевые ошибки (HTTP, timeout)
- Ошибки парсинга JSON
- Ошибки авторизации (401)
- Ошибки валидации данных
Все ошибки логируются и сообщаются пользователю в удобном формате.
## Расширение функционала
Для добавления новых команд:
1. Добавьте метод в класс `BotHandlers`
2. Зарегистрируйте обработчик в функции `main()`
3. (Опционально) Расширьте `VndbClient` новыми методами API
Пример:
```python
@staticmethod
async def my_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""Description"""
try:
# Your code here
pass
except Exception as e:
logger.error(f"Error: {e}")
await update.message.reply_text(f"❌ Error: {str(e)}")
# In main():
application.add_handler(CommandHandler("mycommand", BotHandlers.my_command))
```
## Логирование
Бот использует встроенный модуль `logging` для отслеживания операций. Логи выводятся в консоль с уровнем INFO.
Для изменения уровня логирования отредактируйте `bot.py`:
```python
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=logging.DEBUG # Измените на DEBUG для более подробных логов
)
```
## Документация VNDB API
Полная документация VNDB API доступна по адресу: https://api.vndb.org/kana
## Лицензирование
Данные, полученные через VNDB API, подлежат [Data License VNDB](https://vndb.org/d17#4).
## Благодарности
- [VNDB](https://vndb.org/) за отличную базу данных и API
- [python-telegram-bot](https://github.com/python-telegram-bot/python-telegram-bot) за удобную библиотеку
## Поддержка
Если у вас есть вопросы или проблемы:
1. Проверьте файл логов
2. Убедитесь, что токены установлены правильно
3. Проверьте интернет соединение и статус API VNDB
## Автор
Создано для удобного доступа к VNDB из Telegram.

View File

@@ -1,325 +0,0 @@
"""
Advanced features for VNDB Telegram Bot
Includes pagination, caching, and rate limiting
"""
import asyncio
import time
from typing import Dict, List, Any, Optional, Callable
from collections import defaultdict
from functools import wraps
import logging
logger = logging.getLogger(__name__)
class RateLimiter:
"""Rate limiter for API requests"""
def __init__(self, max_requests: int = 200, window_seconds: int = 300):
"""
Initialize rate limiter
Args:
max_requests: Maximum requests allowed in window
window_seconds: Time window in seconds (default 5 minutes = 300 seconds)
"""
self.max_requests = max_requests
self.window_seconds = window_seconds
self.requests = []
def is_allowed(self) -> bool:
"""Check if a request is allowed"""
now = time.time()
# Remove old requests outside the window
self.requests = [req_time for req_time in self.requests
if now - req_time < self.window_seconds]
# Check if we can make another request
if len(self.requests) < self.max_requests:
self.requests.append(now)
return True
return False
def wait_if_needed(self) -> None:
"""Wait if rate limit is reached"""
if not self.is_allowed():
if self.requests:
oldest = self.requests[0]
wait_time = self.window_seconds - (time.time() - oldest)
if wait_time > 0:
logger.warning(f"Rate limit reached, waiting {wait_time:.1f}s")
time.sleep(wait_time)
self.is_allowed()
class SimpleCache:
"""Simple in-memory cache for API responses"""
def __init__(self, ttl_seconds: int = 300):
"""
Initialize cache
Args:
ttl_seconds: Time to live for cached items
"""
self.ttl_seconds = ttl_seconds
self.cache: Dict[str, tuple] = {}
def _make_key(self, endpoint: str, params: Dict[str, Any]) -> str:
"""Create cache key from endpoint and parameters"""
params_str = str(sorted(params.items()))
return f"{endpoint}:{params_str}"
def get(self, endpoint: str, params: Dict[str, Any]) -> Optional[Any]:
"""Get item from cache"""
key = self._make_key(endpoint, params)
if key not in self.cache:
return None
value, timestamp = self.cache[key]
# Check if expired
if time.time() - timestamp > self.ttl_seconds:
del self.cache[key]
return None
logger.debug(f"Cache hit for {key}")
return value
def set(self, endpoint: str, params: Dict[str, Any], value: Any) -> None:
"""Set item in cache"""
key = self._make_key(endpoint, params)
self.cache[key] = (value, time.time())
logger.debug(f"Cached {key}")
def clear(self) -> None:
"""Clear all cache"""
self.cache.clear()
def stats(self) -> Dict[str, int]:
"""Get cache statistics"""
now = time.time()
expired = sum(
1 for _, (_, timestamp) in self.cache.items()
if now - timestamp > self.ttl_seconds
)
return {
"total_items": len(self.cache),
"expired_items": expired,
}
class Paginator:
"""Handle pagination for search results"""
def __init__(self, items: List[Dict[str, Any]], items_per_page: int = 5):
"""
Initialize paginator
Args:
items: List of items to paginate
items_per_page: Number of items per page
"""
self.items = items
self.items_per_page = items_per_page
self.current_page = 1
@property
def total_pages(self) -> int:
"""Get total number of pages"""
return (len(self.items) + self.items_per_page - 1) // self.items_per_page
@property
def current_items(self) -> List[Dict[str, Any]]:
"""Get items for current page"""
start = (self.current_page - 1) * self.items_per_page
end = start + self.items_per_page
return self.items[start:end]
def next_page(self) -> bool:
"""Go to next page"""
if self.current_page < self.total_pages:
self.current_page += 1
return True
return False
def prev_page(self) -> bool:
"""Go to previous page"""
if self.current_page > 1:
self.current_page -= 1
return True
return False
def goto_page(self, page: int) -> bool:
"""Go to specific page"""
if 1 <= page <= self.total_pages:
self.current_page = page
return True
return False
def page_info(self) -> str:
"""Get page information string"""
return f"Страница {self.current_page}/{self.total_pages}"
class UserSession:
"""Manage user session data"""
def __init__(self, user_id: int):
"""Initialize session"""
self.user_id = user_id
self.data: Dict[str, Any] = {}
self.created_at = time.time()
self.last_activity = time.time()
def set(self, key: str, value: Any) -> None:
"""Set session data"""
self.data[key] = value
self.last_activity = time.time()
def get(self, key: str, default: Any = None) -> Any:
"""Get session data"""
self.last_activity = time.time()
return self.data.get(key, default)
def update_activity(self) -> None:
"""Update last activity time"""
self.last_activity = time.time()
def is_idle(self, timeout_seconds: int = 1800) -> bool:
"""Check if session is idle"""
return time.time() - self.last_activity > timeout_seconds
def clear(self) -> None:
"""Clear session data"""
self.data.clear()
class SessionManager:
"""Manage user sessions"""
def __init__(self, idle_timeout_seconds: int = 1800):
"""
Initialize session manager
Args:
idle_timeout_seconds: Timeout for idle sessions
"""
self.sessions: Dict[int, UserSession] = {}
self.idle_timeout = idle_timeout_seconds
def get_session(self, user_id: int) -> UserSession:
"""Get or create user session"""
if user_id not in self.sessions:
self.sessions[user_id] = UserSession(user_id)
else:
self.sessions[user_id].update_activity()
return self.sessions[user_id]
def cleanup_idle_sessions(self) -> int:
"""Remove idle sessions"""
user_ids_to_remove = [
user_id for user_id, session in self.sessions.items()
if session.is_idle(self.idle_timeout)
]
for user_id in user_ids_to_remove:
del self.sessions[user_id]
logger.info(f"Cleaned up {len(user_ids_to_remove)} idle sessions")
return len(user_ids_to_remove)
def stats(self) -> Dict[str, Any]:
"""Get session statistics"""
return {
"active_sessions": len(self.sessions),
"total_users": len(self.sessions),
}
class RequestLogger:
"""Log API requests for debugging"""
def __init__(self):
"""Initialize request logger"""
self.requests: List[Dict[str, Any]] = []
self.max_history = 100
def log_request(
self,
endpoint: str,
method: str,
params: Optional[Dict[str, Any]] = None,
response_time: float = 0,
status_code: int = 0,
error: Optional[str] = None,
) -> None:
"""Log an API request"""
request_log = {
"timestamp": time.time(),
"endpoint": endpoint,
"method": method,
"params": params,
"response_time": response_time,
"status_code": status_code,
"error": error,
}
self.requests.append(request_log)
# Keep only recent requests
if len(self.requests) > self.max_history:
self.requests = self.requests[-self.max_history:]
def get_stats(self) -> Dict[str, Any]:
"""Get request statistics"""
if not self.requests:
return {"requests_logged": 0}
total_time = sum(r["response_time"] for r in self.requests)
avg_time = total_time / len(self.requests) if self.requests else 0
errors = sum(1 for r in self.requests if r["error"])
return {
"requests_logged": len(self.requests),
"total_time": total_time,
"average_time": avg_time,
"errors": errors,
"success_rate": (len(self.requests) - errors) / len(self.requests) * 100,
}
def rate_limit(limiter: RateLimiter):
"""Decorator for rate limiting"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(*args, **kwargs):
limiter.wait_if_needed()
return await func(*args, **kwargs)
return wrapper
return decorator
def with_cache(cache: SimpleCache, ttl: int = 300):
"""Decorator for caching"""
def decorator(func: Callable) -> Callable:
@wraps(func)
async def wrapper(self, *args, endpoint: str = "", **kwargs):
# Try to get from cache
cached = cache.get(endpoint, kwargs)
if cached:
return cached
# Call function
result = await func(self, *args, endpoint=endpoint, **kwargs)
# Cache result
cache.set(endpoint, kwargs, result)
return result
return wrapper
return decorator

348
bot.py
View File

@@ -1,297 +1,111 @@
import logging
from typing import Optional
import os
import asyncio
from aiogram import Bot, Dispatcher, types
from aiogram.filters import Command
from vndb import VNDBClient
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import (
Application,
CommandHandler,
MessageHandler,
CallbackQueryHandler,
ConversationHandler,
ContextTypes,
filters,
)
TOKEN = os.getenv("TELEGRAM_TOKEN")
from vndb_client import VndbClient
from config import Config
from utils import Formatter, ErrorHandler, QueryBuilder
from detailed_handlers import get_detail_handlers
bot = Bot(token=TOKEN)
dp = Dispatcher()
vndb = VNDBClient(os.getenv("VNDB_API_URL"))
# ======================
# LOGGING
# ======================
logging.basicConfig(
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
level=Config.LOG_LEVEL
)
logger = logging.getLogger(__name__)
def safe_text(x):
if not x:
return ""
return str(x)
# ======================
# STATES
# ======================
SEARCH_VN, SELECT_VN, VN_DETAILS = range(3)
SEARCH_CHARACTER, SELECT_CHARACTER = range(2)
SEARCH_RELEASE, SELECT_RELEASE = range(2)
SEARCH_STAFF, SELECT_STAFF = range(2)
@dp.message(Command("start"))
async def start(msg: types.Message):
await msg.answer(
"VNDB Bot ready.\n"
"Commands:\n"
"/search <vn>\n"
"/vn <id>\n"
"/char <id>\n"
"/release <id>"
)
# ======================
# CLIENT
# ======================
vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX)
@dp.message(Command("help"))
async def help(msg: types.Message):
await start(msg)
# ======================
# HELPERS
# ======================
HTML_HELP_HEADER = "<b>VNDB Telegram Bot</b>\n\n"
@dp.message(Command("search"))
async def search(msg: types.Message):
query = msg.text.replace("/search", "").strip()
if not query:
return await msg.answer("Empty query")
result = await vndb.search_vn(query)
# ======================
# HANDLERS
# ======================
class BotHandlers:
if not result:
return await msg.answer("Not found")
@staticmethod
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = """
<b>Добро пожаловать в VNDB Telegram Бот!</b>
text = "\n\n".join(
f"{i['id']}{i.get('title') or i.get('name')}"
for i in result
)
await msg.answer(text[:4000])
Этот бот позволяет искать визуальные новеллы, персонажей и релизы из VNDB.
<b>Команды:</b>
/search - поиск VN
/char - персонажи
/release - релизы
/staff - сотрудники
/producer - продюсеры
/tag - теги
/trait - черты
/quote - цитаты
/help - помощь
"""
@dp.message(Command("vn"))
async def vn(msg: types.Message):
vid = msg.text.replace("/vn", "").strip()
if not vid:
return await msg.answer("No ID")
await update.message.reply_text(text, parse_mode="HTML")
data = await vndb.get_vn(vid)
if not data:
return await msg.answer("Not found")
# ======================
# HELP
# ======================
@staticmethod
async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = """
<b>Справка по командам</b>
await msg.answer(
f"{safe_text(data.get('title'))}\n"
f"Original: {safe_text(data.get('original'))}\n"
f"Released: {safe_text(data.get('released'))}\n"
f"Rating: {safe_text(data.get('rating'))}\n"
f"Votes: {safe_text(data.get('votecount'))}"
)
<b>Поиск:</b>
/search &lt;название&gt;
/char &lt;имя&gt;
/release &lt;название&gt;
/staff &lt;имя&gt;
/producer &lt;имя&gt;
<b>Информация:</b>
/tag - популярные теги
/trait - черты
/quote &lt;число&gt; - цитаты
/stats - статистика
/schema - API
@dp.message(Command("char"))
async def char(msg: types.Message):
cid = msg.text.replace("/char", "").strip()
if not cid:
return await msg.answer("No ID")
<b>Детальный просмотр:</b>
/vn_detail &lt;id&gt;
/char_detail &lt;id&gt;
/release_detail &lt;id&gt;
data = await vndb.get_char(cid)
if not data:
return await msg.answer("Not found")
<b>Пример:</b>
/search Steins Gate
/char Okabe
/vn_detail v17
await msg.answer(
f"{safe_text(data.get('name'))}\n"
f"Original: {safe_text(data.get('original'))}"
)
<b>Ссылка:</b>
<a href="https://git.kotac.ru/King-of-the-all-Cookies/ayako/src/branch/main/EXAMPLES.md">
Примеры команд
</a>
"""
await update.message.reply_text(text, parse_mode="HTML")
@dp.message(Command("release"))
async def release(msg: types.Message):
rid = msg.text.replace("/release", "").strip()
if not rid:
return await msg.answer("No ID")
# ======================
# STATS
# ======================
@staticmethod
async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
try:
stats = await vndb_client.get_stats()
data = await vndb.get_release(rid)
if not data:
return await msg.answer("Not found")
text = f"""
<b>Статистика VNDB</b>
await msg.answer(
f"{safe_text(data.get('title'))}\n"
f"Released: {safe_text(data.get('released'))}"
)
VN: {stats.get('vn', 0)}
Characters: {stats.get('chars', 0)}
Releases: {stats.get('releases', 0)}
Producers: {stats.get('producers', 0)}
Staff: {stats.get('staff', 0)}
"""
await update.message.reply_text(text, parse_mode="HTML")
except Exception as e:
logger.error(e)
await update.message.reply_text(f"{ErrorHandler.format_error(e)}")
# ======================
# SCHEMA
# ======================
@staticmethod
async def schema(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
try:
await update.message.reply_text("⏳ Загружаю схему...", parse_mode="HTML")
schema = await vndb_client.get_schema()
text = "<b>API Schema VNDB</b>\n\n"
if "db_types" in schema:
text += "<b>Типы:</b>\n"
for k in list(schema["db_types"].keys())[:5]:
text += f"{k}\n"
text += "\n<a href='https://api.vndb.org/kana'>Документация API</a>"
await update.message.reply_text(text, parse_mode="HTML")
except Exception as e:
logger.error(e)
await update.message.reply_text(f"{ErrorHandler.format_error(e)}")
# ======================
# SEARCH VN
# ======================
@staticmethod
async def search_vn(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
query = " ".join(context.args)
if not query:
await update.message.reply_text("Введите название")
return ConversationHandler.END
await update.message.reply_text(
f"<b>Поиск:</b> {query}",
parse_mode="HTML"
)
results = await vndb_client.query_vn(
filters=["search", "=", query.strip()],
fields=["id", "title"],
results=10
)
if not results.get("results"):
await update.message.reply_text("Ничего не найдено")
return ConversationHandler.END
text = "<b>Результаты:</b>\n\n"
for vn in results["results"]:
text += f"{vn.get('id')} - {vn.get('title')}\n"
await update.message.reply_text(text, parse_mode="HTML")
# images
for vn in results["results"]:
text += f"{vn.get('id')} - {vn.get('title')}\n"
# 👇 ДОБАВЬ ССЫЛКУ НА VNDB
text += f"https://vndb.org/{vn.get('id')}\n\n"
except Exception as e:
logger.error(e)
await update.message.reply_text(f"{ErrorHandler.format_error(e)}")
return ConversationHandler.END
# ======================
# CHARACTER
# ======================
@staticmethod
async def search_character(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
query = " ".join(context.args)
if not query:
await update.message.reply_text("Введите имя")
return ConversationHandler.END
results = await vndb_client.query_character(
filters=["search", "=", query.strip()],
fields=["id", "name", "original"],
results=10
)
text = "<b>Персонажи:</b>\n\n"
for c in results.get("results", []):
text += f"{c.get('id')} - {c.get('name')} ({c.get('original', '')})\n"
await update.message.reply_text(text, parse_mode="HTML")
except Exception as e:
logger.error(e)
await update.message.reply_text(f"{ErrorHandler.format_error(e)}")
return ConversationHandler.END
# ======================
# RELEASE
# ======================
@staticmethod
async def search_release(update: Update, context: ContextTypes.DEFAULT_TYPE):
try:
query = " ".join(context.args)
results = await vndb_client.query_release(
filters=["search", "=", query.strip()], # ✔️ важно
fields=["id", "title"],
results=10
)
text = "<b>Релизы:</b>\n\n"
for r in results.get("results", []):
text += f"{r.get('id')} - {r.get('title')}\n"
await update.message.reply_text(text, parse_mode="HTML")
except Exception as e:
logger.error(e)
await update.message.reply_text(f"{ErrorHandler.format_error(e)}")
return ConversationHandler.END
# ======================
# MAIN
# ======================
def main():
Config.validate()
app = Application.builder().token(Config.TELEGRAM_BOT_TOKEN).build()
app.add_handler(CommandHandler("start", BotHandlers.start))
app.add_handler(CommandHandler("help", BotHandlers.help_command))
app.add_handler(CommandHandler("stats", BotHandlers.stats))
app.add_handler(CommandHandler("schema", BotHandlers.schema))
app.add_handler(CommandHandler("search", BotHandlers.search_vn))
app.add_handler(CommandHandler("char", BotHandlers.search_character))
app.add_handler(CommandHandler("release", BotHandlers.search_release))
for h in get_detail_handlers():
app.add_handler(h)
logger.info("Bot started")
app.run_polling()
async def main():
await dp.start_polling(bot)
if __name__ == "__main__":
main()
asyncio.run(main())

View File

@@ -1,53 +0,0 @@
"""
Configuration module for VNDB Telegram Bot
Handles environment variables and settings
"""
import os
from typing import Optional
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
class Config:
"""Bot configuration class"""
# Telegram configuration
TELEGRAM_BOT_TOKEN: str = os.getenv("TELEGRAM_BOT_TOKEN", "")
# VNDB API configuration
VNDB_TOKEN: Optional[str] = os.getenv("VNDB_TOKEN")
USE_SANDBOX: bool = os.getenv("USE_SANDBOX", "false").lower() == "true"
# Logging configuration
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
# API limits
MAX_RESULTS_PER_PAGE: int = 100
DEFAULT_RESULTS_PER_PAGE: int = 10
MAX_QUOTES_AT_ONCE: int = 5
# Timeouts (in seconds)
API_TIMEOUT: int = 10
BOT_TIMEOUT: int = 30
@classmethod
def validate(cls) -> bool:
"""Validate that required configuration is present"""
if not cls.TELEGRAM_BOT_TOKEN:
raise ValueError("TELEGRAM_BOT_TOKEN environment variable is required")
return True
@classmethod
def to_dict(cls) -> dict:
"""Convert config to dictionary"""
return {
"TELEGRAM_BOT_TOKEN": "***" if cls.TELEGRAM_BOT_TOKEN else "NOT SET",
"VNDB_TOKEN": "SET" if cls.VNDB_TOKEN else "NOT SET",
"USE_SANDBOX": cls.USE_SANDBOX,
"LOG_LEVEL": cls.LOG_LEVEL,
"MAX_RESULTS_PER_PAGE": cls.MAX_RESULTS_PER_PAGE,
"DEFAULT_RESULTS_PER_PAGE": cls.DEFAULT_RESULTS_PER_PAGE,
"API_TIMEOUT": cls.API_TIMEOUT,
}

View File

@@ -1,200 +0,0 @@
"""
Inline handlers for detailed item viewing (NO IMAGES VERSION - STABLE)
"""
from telegram import Update
from telegram.ext import ContextTypes, CommandHandler
from vndb_client import VndbClient
from config import Config
import logging
logger = logging.getLogger(__name__)
vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX)
class DetailedHandlers:
# =========================
# VN DETAIL
# =========================
@staticmethod
async def view_vn_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
try:
if not context.args:
await update.message.reply_text("❌ Пример: /vn_detail v2002")
return
vn_id = context.args[0].strip()
await update.message.reply_text(f"⏳ Загружаю {vn_id}...")
results = await vndb_client.query_vn(
filters=["id", "=", vn_id],
fields=[
"id",
"title",
"original",
"released",
"rating",
"votecount",
"description",
"length",
"developer"
],
results=1
)
if not results.get("results"):
await update.message.reply_text("😞 VN не найдена")
return
vn = results["results"][0]
text = f"🎮 {vn.get('title','Unknown')} (`{vn_id}`)\n"
if vn.get("original"):
text += f"{vn['original']}\n"
text += f"Релиз: {vn.get('released','?')}\n"
text += f"Рейтинг: {vn.get('rating',0)/10:.1f} ({vn.get('votecount',0)} голосов)\n"
if vn.get("length"):
text += f"Длина: {vn['length']}\n"
if vn.get("developer"):
text += f"Разработчик: {vn['developer']}\n"
if vn.get("description"):
text += f"\nОписание:\n{vn['description'][:300]}...\n"
text += f"\nhttps://vndb.org/{vn_id}"
await update.message.reply_text(text)
except Exception as e:
logger.error(f"VN detail error: {e}")
await update.message.reply_text(f"❌ Ошибка: {e}")
# =========================
# CHARACTER DETAIL
# =========================
@staticmethod
async def view_character_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
try:
if not context.args:
await update.message.reply_text("❌ Пример: /char_detail c6498")
return
char_id = context.args[0].strip()
await update.message.reply_text(f"⏳ Загружаю {char_id}...")
results = await vndb_client.query_character(
filters=["id", "=", char_id],
fields=[
"id",
"name",
"original",
"gender",
"bloodtype",
],
results=1
)
if not results.get("results"):
await update.message.reply_text("😞 Персонаж не найден")
return
c = results["results"][0]
text = f"👤 {c.get('name','Unknown')} (`{char_id}`)\n"
if c.get("original"):
text += f"{c['original']}\n"
if c.get("gender"):
text += f"Пол: {c['gender']}\n"
if c.get("bloodtype"):
text += f"Кровь: {c['bloodtype']}\n"
if c.get("description"):
text += f"\nОписание:\n{c['description'][:300]}...\n"
text += f"\nhttps://vndb.org/{char_id}"
await update.message.reply_text(text)
except Exception as e:
logger.error(f"CHAR detail error: {e}")
await update.message.reply_text(f"❌ Ошибка: {e}")
# =========================
# RELEASE DETAIL
# =========================
@staticmethod
async def view_release_detail(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
try:
if not context.args:
await update.message.reply_text("❌ Пример: /release_detail r3930")
return
release_id = context.args[0].strip()
await update.message.reply_text(f"⏳ Загружаю {release_id}...")
results = await vndb_client.query_release(
filters=["id", "=", release_id],
fields=[
"id",
"title",
"original",
"released",
"platform",
"type",
"language",
"description"
],
results=1
)
if not results.get("results"):
await update.message.reply_text("😞 Релиз не найден")
return
r = results["results"][0]
text = f"🎬 {r.get('title','Unknown')} (`{release_id}`)\n"
if r.get("original"):
text += f"{r['original']}\n"
text += f"Дата: {r.get('released','?')}\n"
text += f"Платформа: {r.get('platform','?')}\n"
text += f"Тип: {r.get('type','?')}\n"
if r.get("language"):
text += f"Языки: {', '.join(r['language'])}\n"
if r.get("description"):
text += f"\nОписание:\n{r['description'][:200]}...\n"
text += f"\nhttps://vndb.org/{release_id}"
await update.message.reply_text(text)
except Exception as e:
logger.error(f"RELEASE detail error: {e}")
await update.message.reply_text(f"❌ Ошибка: {e}")
# =========================
# REGISTER
# =========================
def get_detail_handlers():
return [
CommandHandler("vn_detail", DetailedHandlers.view_vn_detail),
CommandHandler("char_detail", DetailedHandlers.view_character_detail),
CommandHandler("release_detail", DetailedHandlers.view_release_detail),
]

View File

@@ -1,20 +1,8 @@
version: '3.8'
version: "3.9"
services:
vndb-bot:
bot:
build: .
container_name: vndb-telegram-bot
restart: unless-stopped
environment:
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- VNDB_TOKEN=${VNDB_TOKEN:-}
- LOG_LEVEL=INFO
- USE_SANDBOX=false
volumes:
- ./logs:/app/logs
networks:
- bot-network
networks:
bot-network:
driver: bridge
restart: always
env_file:
- .env

View File

@@ -1,6 +1,2 @@
python-telegram-bot==21.0
python-dotenv==1.0.0
aiohttp==3.9.1
requests==2.31.0
pytest==7.4.0
pytest-asyncio==0.21.0
aiogram==3.6.0
httpx==0.27.0

View File

@@ -1,168 +0,0 @@
"""
Tests for VNDB Telegram Bot
"""
import asyncio
import pytest
from vndb_client import VndbClient
from config import Config
from utils import Formatter, ErrorHandler, QueryBuilder
class TestVndbClient:
"""Test VNDB client functionality"""
@pytest.fixture
def client(self):
"""Create VNDB client instance"""
return VndbClient()
@pytest.mark.asyncio
async def test_get_stats(self, client):
"""Test getting database statistics"""
stats = await client.get_stats()
assert "vn" in stats
assert "chars" in stats
assert isinstance(stats["vn"], int)
@pytest.mark.asyncio
async def test_get_schema(self, client):
"""Test getting API schema"""
schema = await client.get_schema()
assert "db_types" in schema
assert "fields" in schema
@pytest.mark.asyncio
async def test_query_vn(self, client):
"""Test querying visual novels"""
results = await client.query_vn(
filters=[["id", "=", "v17"]],
fields=["title", "original"]
)
assert "results" in results
assert len(results["results"]) > 0
@pytest.mark.asyncio
async def test_query_vn_search(self, client):
"""Test searching visual novels"""
results = await client.query_vn(
filters=[["search", "=", "Steins"]],
fields=["title", "rating"],
results=5
)
assert "results" in results
@pytest.mark.asyncio
async def test_query_character(self, client):
"""Test querying characters"""
results = await client.query_character(
fields=["name", "gender"],
results=5
)
assert "results" in results
assert len(results["results"]) > 0
@pytest.mark.asyncio
async def test_query_tag(self, client):
"""Test querying tags"""
results = await client.query_tag(
fields=["name"],
results=5
)
assert "results" in results
assert len(results["results"]) > 0
@pytest.mark.asyncio
async def test_query_quote(self, client):
"""Test querying quotes"""
results = await client.query_quote(
fields=["quote", "character"],
results=2
)
assert "results" in results
class TestFormatter:
"""Test formatter utilities"""
def test_truncate_text(self):
"""Test text truncation"""
text = "A" * 300
truncated = Formatter.truncate_text(text, 100)
assert len(truncated) <= 100
assert truncated.endswith("...")
def test_truncate_text_short(self):
"""Test truncation of short text"""
text = "Short text"
truncated = Formatter.truncate_text(text, 100)
assert truncated == text
def test_format_vn_item(self):
"""Test VN item formatting"""
vn = {
"id": "v17",
"title": "Clannad",
"original": "クラナド",
"released": "2004-04-28",
"rating": 85,
"votecount": 1000
}
formatted = Formatter.format_vn_item(vn)
assert "v17" in formatted
assert "Clannad" in formatted
assert "8.5" in formatted
class TestErrorHandler:
"""Test error handling utilities"""
def test_format_error_http_error(self):
"""Test formatting HTTP error"""
error = Exception("HTTP Error")
formatted = ErrorHandler.format_error(error)
assert isinstance(formatted, str)
assert len(formatted) > 0
def test_format_error_value_error(self):
"""Test formatting ValueError"""
error = ValueError("Invalid value")
formatted = ErrorHandler.format_error(error)
assert "Invalid value" in formatted
class TestQueryBuilder:
"""Test query builder utilities"""
def test_build_search_filter(self):
"""Test building search filter"""
filter_result = QueryBuilder.build_search_filter("Clannad")
assert filter_result == ["search", "=", "Clannad"]
def test_build_id_filter(self):
"""Test building ID filter"""
filter_result = QueryBuilder.build_id_filter("v17")
assert filter_result == ["id", "=", "v17"]
def test_build_complex_filter(self):
"""Test building complex filter"""
filter1 = ["search", "=", "Clannad"]
filter2 = ["lang", "=", "en"]
complex_filter = QueryBuilder.build_complex_filter(filter1, filter2)
assert complex_filter[0] == "and"
assert len(complex_filter) == 3
class TestConfig:
"""Test configuration"""
def test_config_dict(self):
"""Test configuration as dictionary"""
config_dict = Config.to_dict()
assert "TELEGRAM_BOT_TOKEN" in config_dict
assert "VNDB_TOKEN" in config_dict
assert "LOG_LEVEL" in config_dict
if __name__ == "__main__":
# Run tests with pytest
pytest.main([__file__, "-v"])

353
utils.py
View File

@@ -1,353 +0,0 @@
"""
Utility functions for VNDB Telegram Bot
"""
from typing import Dict, List, Any, Optional, Tuple
import json
from telegram import Update
class Formatter:
"""Utility class for formatting API responses"""
@staticmethod
def truncate_text(text: str, max_length: int = 200) -> str:
"""Truncate text to max length"""
if len(text) > max_length:
return text[:max_length-3] + "..."
return text
@staticmethod
def format_vn_item(vn: Dict[str, Any]) -> str:
"""Format a VN item for display"""
vn_id = vn.get("id", "Unknown")
title = vn.get("title", "Unknown")
original = vn.get("original", "")
released = vn.get("released", "Unknown")
rating = vn.get("rating", 0)
votecount = vn.get("votecount", 0)
text = f"**{title}** (`{vn_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Дата: {released}\n"
if rating > 0:
text += f"Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n"
return text
@staticmethod
def format_character_item(char: Dict[str, Any]) -> str:
"""Format a character item for display"""
char_id = char.get("id", "Unknown")
name = char.get("name", "Unknown")
original = char.get("original", "")
gender = char.get("gender", "Unknown")
vns = char.get("vn", [])
text = f"**{name}** (`{char_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Пол: {gender}\n"
if vns:
text += f"Появляется в: {len(vns)} VN\n"
return text
@staticmethod
def format_release_item(release: Dict[str, Any]) -> str:
"""Format a release item for display"""
release_id = release.get("id", "Unknown")
title = release.get("title", "Unknown")
original = release.get("original", "")
released = release.get("released", "Unknown")
platform = release.get("platform", "Unknown")
release_type = release.get("type", "Unknown")
text = f"**{title}** (`{release_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Дата: {released}\n"
text += f"Платформа: {platform} | Тип: {release_type}\n"
return text
@staticmethod
def format_staff_item(staff: Dict[str, Any]) -> str:
"""Format a staff member item for display"""
staff_id = staff.get("id", "Unknown")
name = staff.get("name", "Unknown")
original = staff.get("original", "")
gender = staff.get("gender", "Unknown")
role = staff.get("role", "")
text = f"**{name}** (`{staff_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Пол: {gender}\n"
if role:
text += f"Роль: {role}\n"
return text
@staticmethod
def format_producer_item(producer: Dict[str, Any]) -> str:
"""Format a producer item for display"""
producer_id = producer.get("id", "Unknown")
name = producer.get("name", "Unknown")
original = producer.get("original", "")
producer_type = producer.get("type", "Unknown")
text = f"**{name}** (`{producer_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
if producer_type:
text += f"Тип: {producer_type}\n"
return text
@staticmethod
def format_tag_item(tag: Dict[str, Any]) -> str:
"""Format a tag item for display"""
tag_id = tag.get("id", "Unknown")
name = tag.get("name", "Unknown")
description = tag.get("description", "")
vns = tag.get("vns", 0)
text = f"**{name}** (`{tag_id}`)\n"
if description:
truncated = Formatter.truncate_text(description, 100)
text += f"_{truncated}_\n"
if vns:
text += f"ВН: {vns}\n"
return text
@staticmethod
def format_trait_item(trait: Dict[str, Any]) -> str:
"""Format a trait item for display"""
trait_id = trait.get("id", "Unknown")
name = trait.get("name", "Unknown")
description = trait.get("description", "")
chars = trait.get("chars", 0)
text = f"**{name}** (`{trait_id}`)\n"
if description:
truncated = Formatter.truncate_text(description, 100)
text += f"_{truncated}_\n"
if chars:
text += f"Персонажей: {chars}\n"
return text
class ErrorHandler:
"""Error handling utilities"""
@staticmethod
def format_error(error: Exception) -> str:
"""Format error message for user"""
error_type = type(error).__name__
error_msg = str(error)
# Map common errors to user-friendly messages
error_messages = {
"HTTPStatusError": "Ошибка сервера API. Попробуйте позже.",
"ConnectError": "Ошибка подключения. Проверьте интернет соединение.",
"Timeout": "Запрос истек. Сервер слишком долго отвечает.",
"ValueError": f"Ошибка данных: {error_msg}",
"JSONDecodeError": "Ошибка парсинга ответа сервера.",
}
return error_messages.get(error_type, f"{error_msg}")
class PaginationHelper:
"""Helper for pagination"""
@staticmethod
def get_page_info(page: int, results_count: int, total: Optional[int] = None) -> str:
"""Get pagination info string"""
info = f"📄 Страница {page}"
if total:
info += f" | Всего: {total}"
if results_count > 0:
info += f" | Результатов: {results_count}"
return info
class FieldValidator:
"""Validate and clean fields"""
@staticmethod
def validate_fields(fields: List[str], allowed_fields: List[str]) -> List[str]:
"""Validate that requested fields are allowed"""
return [f for f in fields if f in allowed_fields]
@staticmethod
def clean_field_list(fields_str: str) -> List[str]:
"""Parse and clean field list from string"""
fields = [f.strip() for f in fields_str.split(",")]
return [f for f in fields if f] # Remove empty strings
class QueryBuilder:
"""Helper for building API queries"""
@staticmethod
def build_search_filter(search_term: str) -> List[str]:
"""Build a search filter"""
return ["search", "=", search_term]
@staticmethod
def build_id_filter(vn_id: str) -> List[str]:
"""Build an ID filter"""
return ["id", "=", vn_id]
@staticmethod
def build_tag_filter(tag_id: str, depth: int = 0) -> Dict[str, Any]:
"""Build a tag filter with optional depth"""
filter_dict = {
"tag": tag_id,
}
if depth > 0:
filter_dict["depth"] = depth
return filter_dict
@staticmethod
def build_complex_filter(*filters: List[str]) -> List[Any]:
"""Build a complex AND filter from multiple simple filters"""
if len(filters) == 1:
return filters[0]
return ["and"] + list(filters)
class ImageHandler:
"""Handle image processing and sending"""
# VNDB Image CDN
VNDB_CDN = "https://t.vndb.org"
@staticmethod
def get_image_url(image_data: Dict[str, Any]) -> Optional[str]:
"""
Extract image URL from image data
Args:
image_data: Image data from API
Returns:
Full image URL or None
"""
if not image_data or not isinstance(image_data, dict):
return None
image_path = image_data.get("url")
if not image_path:
return None
# Construct full URL
if image_path.startswith("http"):
return image_path
return f"{ImageHandler.VNDB_CDN}{image_path}"
@staticmethod
def format_item_with_image(
item_type: str,
item: Dict[str, Any],
) -> tuple[str, Optional[str]]:
"""
Format item with image information
Args:
item_type: Type of item (vn, character, release, etc.)
item: Item data
Returns:
Tuple of (text, image_url)
"""
text = ""
image_url = None
if item_type == "vn":
item_id = item.get("id", "Unknown")
title = item.get("title", "Unknown")
original = item.get("original", "")
released = item.get("released", "Unknown")
rating = item.get("rating", 0)
votecount = item.get("votecount", 0)
text = f"**{title}** (`{item_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Дата релиза: {released}\n"
if rating > 0:
text += f"Рейтинг: {rating/10:.1f}/10 ({votecount} голосов)\n"
elif item_type == "character":
item_id = item.get("id", "Unknown")
name = item.get("name", "Unknown")
original = item.get("original", "")
gender = item.get("gender", "Unknown")
text = f"**{name}** (`{item_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Пол: {gender}\n"
elif item_type == "release":
item_id = item.get("id", "Unknown")
title = item.get("title", "Unknown")
original = item.get("original", "")
released = item.get("released", "Unknown")
platform = item.get("platform", "Unknown")
text = f"**{title}** (`{item_id}`)\n"
if original:
text += f"Оригинал: {original}\n"
text += f"Дата: {released}\n"
text += f"Платформа: {platform}\n"
# Try to get image
image_data = item.get("image")
if image_data:
image_url = ImageHandler.get_image_url(image_data)
return text, image_url
@staticmethod
async def send_item_with_photo(
update: Update,
item_type: str,
item: Dict[str, Any],
emoji: str = "📦",
) -> None:
"""
Send item with photo if available
Args:
update: Telegram update
item_type: Type of item
item: Item data
emoji: Emoji to use in caption
"""
text, image_url = ImageHandler.format_item_with_image(item_type, item)
if image_url:
try:
title = item.get("title") or item.get("name", "Item")
caption = f"{emoji} {title}"
await update.message.reply_photo(
photo=image_url,
caption=caption,
parse_mode="Markdown"
)
except Exception as e:
import logging
logger = logging.getLogger(__name__)
logger.warning(f"Could not send photo: {e}")
await update.message.reply_text(text, parse_mode="Markdown")

50
vndb.py Normal file
View File

@@ -0,0 +1,50 @@
import httpx
class VNDBClient:
def __init__(self, base_url):
self.base = base_url.rstrip("/")
self.client = httpx.AsyncClient(timeout=10)
async def _post(self, endpoint, payload):
try:
r = await self.client.post(f"{self.base}/{endpoint}", json=payload)
if r.status_code != 200:
return None
data = r.json()
return data.get("results", [])
except Exception:
return None
async def search_vn(self, query):
return await self._post("vn", {
"filters": ["search", "=", query],
"fields": "id,title,original,released,rating,votecount",
"results": 5
})
async def get_vn(self, vid):
res = await self._post("vn", {
"filters": ["id", "=", vid],
"fields": "id,title,original,released,rating,votecount",
"results": 1
})
return res[0] if res else None
async def get_char(self, cid):
res = await self._post("character", {
"filters": ["id", "=", cid],
"fields": "id,name,original",
"results": 1
})
return res[0] if res else None
async def get_release(self, rid):
res = await self._post("release", {
"filters": ["id", "=", rid],
"fields": "id,title,released",
"results": 1
})
return res[0] if res else None

View File

@@ -1,488 +0,0 @@
"""
VNDB API Client
Handles all interactions with the VNDB API
"""
import httpx
import json
from typing import Dict, List, Any, Optional
from enum import Enum
class VndbEndpoint(Enum):
"""VNDB API Endpoints"""
SCHEMA = "/schema"
STATS = "/stats"
USER = "/user"
AUTHINFO = "/authinfo"
VN = "/vn"
RELEASE = "/release"
CHARACTER = "/character"
STAFF = "/staff"
PRODUCER = "/producer"
TAG = "/tag"
TRAIT = "/trait"
QUOTE = "/quote"
ULIST = "/ulist"
RLIST = "/rlist"
ULIST_LABELS = "/ulist_labels"
class VndbClient:
"""Client for interacting with VNDB API"""
BASE_URL = "https://api.vndb.org/kana"
SANDBOX_URL = "https://beta.vndb.org/api/kana"
def __init__(self, token: Optional[str] = None, use_sandbox: bool = False):
"""
Initialize VNDB client
Args:
token: Optional API token for authenticated requests
use_sandbox: Whether to use sandbox endpoint
"""
self.token = token
self.base_url = self.SANDBOX_URL if use_sandbox else self.BASE_URL
self.headers = {
"Content-Type": "application/json",
}
if token:
self.headers["Authorization"] = f"Token {token}"
def _strip_image_fields(self, fields: Optional[List[str]]) -> List[str]:
if not fields:
return []
return [f for f in fields if "image" not in f]
def _safe_filters(self, filters):
if not filters:
return ["id", ">", 0]
return filters
def _normalize_fields(self, fields: Optional[List[str]]) -> str:
if not fields:
return "id"
clean = [f for f in fields if "image" not in f]
return ",".join(clean)
async def _request(
self,
endpoint: str,
method: str = "GET",
data: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
url = f"{self.base_url}{endpoint}"
async with httpx.AsyncClient(timeout=15) as client:
response = await client.request(
method,
url,
headers=self.headers,
json=data if data else None, # 🔥 ВАЖНО: json= вместо content=
)
if response.status_code >= 400:
print("VNDB ERROR:", response.status_code, response.text)
response.raise_for_status()
return response.json()
# Simple Requests
async def get_schema(self) -> Dict[str, Any]:
"""Get API schema with metadata"""
return await self._request(VndbEndpoint.SCHEMA.value)
async def get_stats(self) -> Dict[str, Any]:
"""Get database statistics"""
return await self._request(VndbEndpoint.STATS.value)
async def get_user(self, queries: List[str], fields: Optional[List[str]] = None) -> Dict[str, Any]:
"""
Lookup users by ID or username
Args:
queries: List of user IDs or usernames to lookup
fields: List of fields to retrieve (lengthvotes, lengthvotes_sum)
Returns:
User information
"""
params = {"q": queries}
if fields:
params["fields"] = fields
# Build query string
query_parts = [f"q={q}" for q in queries]
if fields:
query_parts.append(f"fields={','.join(fields)}")
query_string = "&".join(query_parts)
url = f"{self.base_url}{VndbEndpoint.USER.value}?{query_string}"
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
async def get_authinfo(self) -> Dict[str, Any]:
"""Get authenticated user information"""
if not self.token:
raise ValueError("Token required for authinfo")
return await self._request(VndbEndpoint.AUTHINFO.value)
# Database Querying
async def query_vn(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
user: Optional[str] = None,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
data = {
"filters": filters or [],
"fields": ",".join(fields) if fields else "id,title",
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
if user:
data["user"] = user
return await self._request(VndbEndpoint.VN.value, "POST", data)
async def query_release(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
user: Optional[str] = None,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
"""Query releases"""
data = {
"filters": filters or [],
"fields": ",".join(fields) if fields else "id,title",
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
if user:
data["user"] = user
return await self._request(VndbEndpoint.RELEASE.value, "POST", data)
async def query_character(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
data = {
"filters": filters or [],
"fields": ",".join(fields) if fields else "id,name,original",
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
return await self._request(VndbEndpoint.CHARACTER.value, "POST", data)
async def query_staff(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
"""Query staff"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
return await self._request(VndbEndpoint.STAFF.value, "POST", data)
async def query_producer(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
"""Query producers"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
return await self._request(VndbEndpoint.PRODUCER.value, "POST", data)
async def query_tag(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
"""Query tags"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
return await self._request(VndbEndpoint.TAG.value, "POST", data)
async def query_trait(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
"""Query traits"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
return await self._request(VndbEndpoint.TRAIT.value, "POST", data)
async def query_quote(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
count: bool = False,
compact_filters: bool = False,
normalized_filters: bool = False,
) -> Dict[str, Any]:
"""Query quotes"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
"compact_filters": compact_filters,
"normalized_filters": normalized_filters,
}
return await self._request(VndbEndpoint.QUOTE.value, "POST", data)
# List Management
async def query_ulist(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
user: Optional[str] = None,
count: bool = False,
) -> Dict[str, Any]:
"""Query user visual novel list"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
}
if user:
data["user"] = user
return await self._request(VndbEndpoint.ULIST.value, "POST", data)
async def query_rlist(
self,
filters: Optional[List[Any]] = None,
fields: Optional[List[str]] = None,
sort: str = "id",
reverse: bool = False,
results: int = 10,
page: int = 1,
user: Optional[str] = None,
count: bool = False,
) -> Dict[str, Any]:
"""Query user release list"""
data = {
"filters": filters or [],
"fields": self._normalize_fields(fields),
"sort": sort,
"reverse": reverse,
"results": results,
"page": page,
"count": count,
}
if user:
data["user"] = user
return await self._request(VndbEndpoint.RLIST.value, "POST", data)
async def get_ulist_labels(self, user: Optional[str] = None) -> Dict[str, Any]:
"""Get user list labels"""
url = f"{self.base_url}{VndbEndpoint.ULIST_LABELS.value}"
if user:
url += f"?user={user}"
async with httpx.AsyncClient(timeout=10) as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
async def add_to_ulist(
self,
vn_id: str,
status: Optional[str] = None,
notes: Optional[str] = None,
labels: Optional[List[str]] = None,
voted: Optional[int] = None,
) -> Dict[str, Any]:
"""Add or update visual novel in user list"""
if not self.token:
raise ValueError("Token required for list operations")
data = {"id": vn_id}
if status:
data["status"] = status
if notes:
data["notes"] = notes
if labels:
data["labels"] = labels
if voted is not None:
data["voted"] = voted
return await self._request(
f"{VndbEndpoint.ULIST.value}/{vn_id}",
"PATCH",
data
)
async def add_to_rlist(
self,
release_id: str,
status: Optional[str] = None,
notes: Optional[str] = None,
) -> Dict[str, Any]:
"""Add or update release in user list"""
if not self.token:
raise ValueError("Token required for list operations")
data = {"id": release_id}
if status:
data["status"] = status
if notes:
data["notes"] = notes
return await self._request(
f"{VndbEndpoint.RLIST.value}/{release_id}",
"PATCH",
data
)
async def remove_from_ulist(self, vn_id: str) -> None:
"""Remove visual novel from user list"""
if not self.token:
raise ValueError("Token required for list operations")
await self._request(f"{VndbEndpoint.ULIST.value}/{vn_id}", "DELETE")
async def remove_from_rlist(self, release_id: str) -> None:
"""Remove release from user list"""
if not self.token:
raise ValueError("Token required for list operations")
await self._request(f"{VndbEndpoint.RLIST.value}/{release_id}", "DELETE")