diff --git a/bot.py b/bot.py index cc48f98..c0f47ea 100644 --- a/bot.py +++ b/bot.py @@ -1,125 +1,297 @@ -import os -import httpx -from dotenv import load_dotenv +import logging +from typing import Optional -load_dotenv() +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 -class VndbClient: - """ - Minimal safe VNDB client for Telegram bots - """ +# ====================== +# LOGGING +# ====================== +logging.basicConfig( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + level=Config.LOG_LEVEL +) +logger = logging.getLogger(__name__) - def __init__(self): - self.base_url = os.getenv("VNDB_API_URL", "https://api.vndb.org/kana") - self.token = os.getenv("VNDB_TOKEN") - self.headers = { - "Content-Type": "application/json" - } +# ====================== +# 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) - if self.token: - self.headers["Authorization"] = f"Token {self.token}" - # ========================= - # CORE REQUEST - # ========================= - async def _request(self, endpoint: str, payload: dict): - url = f"{self.base_url}{endpoint}" +# ====================== +# CLIENT +# ====================== +vndb_client = VndbClient(use_sandbox=Config.USE_SANDBOX) - async with httpx.AsyncClient(timeout=20) as client: - r = await client.post(url, json=payload, headers=self.headers) - # debug-friendly - if r.status_code >= 400: - print("VNDB ERROR:", r.status_code, r.text) - print("PAYLOAD:", payload) +# ====================== +# HELPERS +# ====================== +HTML_HELP_HEADER = "VNDB Telegram Bot\n\n" - r.raise_for_status() - return r.json() - # ========================= - # SAFE SEARCH WRAPPER - # ========================= - def _safe_search(self, query: str): - if not query: - return None - return ["search", "=", query.strip()] +# ====================== +# HANDLERS +# ====================== +class BotHandlers: - # ========================= - # VN SEARCH - # ========================= - async def search_vn(self, query: str, limit: int = 10): - filters = self._safe_search(query) - if not filters: - return {"results": []} + @staticmethod + async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + text = """ +Добро пожаловать в VNDB Telegram Бот! - return await self._request("/vn", { - "filters": filters, - "fields": "id,title,original,released,rating,votecount", - "results": limit - }) +Этот бот позволяет искать визуальные новеллы, персонажей и релизы из VNDB. - # ========================= - # CHARACTER SEARCH - # ========================= - async def search_character(self, query: str, limit: int = 10): - filters = self._safe_search(query) - if not filters: - return {"results": []} +Команды: +/search - поиск VN +/char - персонажи +/release - релизы +/staff - сотрудники +/producer - продюсеры +/tag - теги +/trait - черты +/quote - цитаты +/help - помощь + """ - return await self._request("/character", { - "filters": filters, - "fields": "id,name,original", - "results": limit - }) + await update.message.reply_text(text, parse_mode="HTML") - # ========================= - # RELEASE SEARCH - # ========================= - async def search_release(self, query: str, limit: int = 10): - filters = self._safe_search(query) - if not filters: - return {"results": []} + # ====================== + # HELP + # ====================== + @staticmethod + async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + text = """ +Справка по командам - return await self._request("/release", { - "filters": filters, - "fields": "id,title,original,released", - "results": limit - }) +Поиск: +/search <название> +/char <имя> +/release <название> +/staff <имя> +/producer <имя> - # ========================= - # DETAIL VN - # ========================= - async def vn_detail(self, vn_id: str): - return await self._request("/vn", { - "filters": ["id", "=", vn_id], - "fields": "id,title,original,released,rating,votecount,description,length,developer", - "results": 1 - }) +Информация: +/tag - популярные теги +/trait - черты +/quote <число> - цитаты +/stats - статистика +/schema - API - # ========================= - # DETAIL CHARACTER - # ========================= - async def character_detail(self, char_id: str): - return await self._request("/character", { - "filters": ["id", "=", char_id], - "fields": "id,name,original,gender,bloodtype", - "results": 1 - }) +Детальный просмотр: +/vn_detail <id> +/char_detail <id> +/release_detail <id> - # ========================= - # DETAIL RELEASE - # ========================= - async def release_detail(self, rel_id: str): - return await self._request("/release", { - "filters": ["id", "=", rel_id], - "fields": "id,title,original,released,platform,type,language,description", - "results": 1 - }) +Пример: +/search Steins Gate +/char Okabe +/vn_detail v17 - # ========================= +Ссылка: + +Примеры команд + + """ + + await update.message.reply_text(text, parse_mode="HTML") + + # ====================== # STATS - # ========================= - async def stats(self): - return await self._request("/stats", {}) \ No newline at end of file + # ====================== + @staticmethod + async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + try: + stats = await vndb_client.get_stats() + + text = f""" +Статистика VNDB + +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 = "API Schema VNDB\n\n" + + if "db_types" in schema: + text += "Типы:\n" + for k in list(schema["db_types"].keys())[:5]: + text += f"• {k}\n" + + text += "\nДокументация API" + + 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"Поиск: {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 = "Результаты:\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 = "Персонажи:\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 = "Релизы:\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() + + +if __name__ == "__main__": + main() \ No newline at end of file