import os import logging import re import httpx import random import random from aiogram import Bot, Dispatcher, types from aiogram.filters import Command, CommandObject from aiogram.client.default import DefaultBotProperties from aiogram.enums import ParseMode TOKEN = os.getenv("TELEGRAM_TOKEN") API_URL = os.getenv("VNDB_API_URL", "https://api.vndb.org/kana") logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML)) dp = Dispatcher() # --- UTILS --- def clean_text(text: str, limit: int = 900) -> str: if not text: return "No description available." # Удаляем все теги [b], [i], [url] и т.д. text = re.sub(r'\[.*?\]', '', text) text = text.replace('"', "'") if len(text) > limit: return text[:limit].rsplit(' ', 1)[0] + "..." return text def safe_list_to_str(data) -> str: if not data: return "N/A" if isinstance(data, list): processed = [] for x in data: if isinstance(x, str): processed.append(x.upper()) elif isinstance(x, dict): # Ищем любое человекочитаемое поле val = x.get('lang') or x.get('name') or x.get('title') or x.get('label') if val: processed.append(str(val).upper()) return ", ".join(processed) if processed else "N/A" return str(data).upper() async def fetch_vndb(endpoint: str, filters: list, fields: str, sort: str = "id", results: int = 10): payload = {"filters": filters, "fields": fields, "sort": sort, "results": results} async with httpx.AsyncClient(timeout=15.0) as client: try: response = await client.post(f"{API_URL}/{endpoint}", json=payload) if response.status_code == 200: return response.json().get("results", []) logger.error(f"VNDB Error {response.status_code}: {response.text}") return None except Exception as e: logger.error(f"Error: {e}") return None async def send_result(message: types.Message, text: str, image_url: str = None): try: if image_url: await message.answer_photo(photo=image_url, caption=text[:1024]) else: await message.answer(text) except Exception as e: await message.answer(text) def find_exact_match(results, query, attr): """Ищет 100% совпадение по имени/названию в результатах.""" for item in results: if item.get(attr, "").lower() == query.lower(): return item return None # --- HANDLERS --- @dp.message(Command("vn")) async def handle_vn(message: types.Message, command: CommandObject): if not command.args: return await message.answer("Usage: /vn ") is_id = command.args.startswith('v') and command.args[1:].isdigit() filt = ["id", "=", command.args] if is_id else ["search", "=", command.args] fields = "id, title, alttitle, released, rating, votecount, description, languages, platforms, developers{name}, image{url}, relations{title, id, relation}" res = await fetch_vndb("vn", filt, fields) if not res: return await message.answer("❌ Not found.") # Логика умного поиска target = res[0] if not is_id and len(res) > 1: exact = find_exact_match(res, command.args, 'title') if exact: target = exact else: out = [f"• {i['title']} — {i['id']}" for i in res[:10]] return await message.answer("🔍 Select VN:\n" + "\n".join(out)) img = target.get('image', {}).get('url') if target.get('image') else None rel_str = "\n".join([f"• {r['title']} ({r['id']}) - {r['relation']}" for r in target.get('relations', [])[:3]]) text = ( f"TITLE: {target['title']}\n" f"ORIGINAL: {target.get('alttitle') or 'N/A'}\n" f"DEVELOPER: {safe_list_to_str(target.get('developers'))}\n" f"RATING: {target['rating']/10 if target.get('rating') else 'N/A'} ({target.get('votecount', 0)} votes)\n" f"LANGS: {safe_list_to_str(target.get('languages'))}\n" f"PLATFORMS: {safe_list_to_str(target.get('platforms'))}\n\n" f"RELATIONS:\n{rel_str or 'None'}\n\n" f"DESC:\n{clean_text(target.get('description'), 450)}\n\n" f"VNDB: https://vndb.org/{target['id']}" ) await send_result(message, text, img) @dp.message(Command("random")) async def handle_random(message: types.Message): """Выбирает случайную новеллу из 50 популярных с высоким рейтингом.""" # Фильтр: рейтинг > 75, популярность высокая res = await fetch_vndb("vn", ["rating", ">=", 75], "id, title, image{url}, rating, description", sort="votecount", results=50) if not res: return await message.answer("❌ API error") v = random.choice(res) text = ( f"🎲 RANDOM VN PICK\n\n" f"TITLE: {v['title']}\n" f"RATING: {v['rating']/10} ⭐\n\n" f"DESCRIPTION:\n{clean_text(v.get('description'), 300)}\n\n" f"VNDB: https://vndb.org/{v['id']}" ) await send_result(message, text, v.get('image', {}).get('url')) @dp.message(Command("top")) async def handle_top(message: types.Message): """Выводит топ-10 новелл по рейтингу.""" res = await fetch_vndb("vn", ["votecount", ">", 1000], "id, title, rating", sort="rating", results=10) if not res: return await message.answer("❌ API error") out = ["🏆 TOP RATED VISUAL NOVELS\n"] for i, v in enumerate(res, 1): out.append(f"{i}. {v['title']} — {v['rating']/10} ({v['id']})") await message.answer("\n".join(out)) @dp.message(Command("char")) async def handle_char(message: types.Message, command: CommandObject): if not command.args: return await message.answer("Usage: /char ") is_id = command.args.startswith('c') and command.args[1:].isdigit() filt = ["id", "=", command.args] if is_id else ["search", "=", command.args] fields = "id, name, original, description, gender, age, blood_type, image{url}" res = await fetch_vndb("character", filt, fields) if not res: return await message.answer("❌ Not found.") target = res[0] if not is_id and len(res) > 1: exact = find_exact_match(res, command.args, 'name') if exact: target = exact else: out = [f"• {i['name']} — {i['id']}" for i in res[:10]] return await message.answer("👤 Select Character:\n" + "\n".join(out)) img = target.get('image', {}).get('url') if target.get('image') else None raw_gender = target.get('gender') if isinstance(raw_gender, list): raw_gender = raw_gender[0] if raw_gender else None text = ( f"NAME: {target['name']}\n" f"ORIGINAL: {target.get('original') or 'N/A'}\n" f"GENDER: {str(raw_gender).upper()}\n" f"AGE: {target.get('age') or 'N/A'} | BLOOD: {target.get('blood_type') or 'N/A'}\n\n" f"DESCRIPTION:\n{clean_text(target.get('description'), 800)}\n\n" f"VNDB: https://vndb.org/{target['id']}" ) await send_result(message, text, img) @dp.message(Command("release")) async def handle_release(message: types.Message, command: CommandObject): if not command.args: return await message.answer("Usage: /release ") is_id = command.args.startswith('r') and command.args[1:].isdigit() filt = ["id", "=", command.args] if is_id else ["search", "=", command.args] fields = "id, title, alttitle, released, languages{lang}, platforms, extlinks{url, label}, images{url}" res = await fetch_vndb("release", filt, fields) if not res: return await message.answer("❌ Not found.") target = res[0] if not is_id and len(res) > 1: exact = find_exact_match(res, command.args, 'title') if exact: target = exact else: out = [f"• {i['title']} — {i['id']}" for i in res[:10]] return await message.answer("💿 Select Release:\n" + "\n".join(out)) img_list = target.get('images', []) img = img_list[0].get('url') if img_list else None links = "\n".join([f"• {l['label']}" for l in target.get('extlinks', [])[:8]]) text = ( f"RELEASE: {target['title']}\n" f"ORIGINAL: {target.get('alttitle') or 'N/A'}\n\n" f"DATE: {target.get('released', 'N/A')}\n" f"LANGS: {safe_list_to_str(target.get('languages'))}\n" f"PLATFORMS: {safe_list_to_str(target.get('platforms'))}\n\n" f"LINKS / STORES:\n{links or 'N/A'}\n\n" f"VNDB: https://vndb.org/{target['id']}" ) await send_result(message, text, img) @dp.message(Command("random")) async def handle_random(message: types.Message): res = await fetch_vndb("vn", ["rating", ">=", 80], "id, title, image{url}, rating, description", sort="votecount", results=100) if not res: return await message.answer("❌ API error") v = random.choice(res) text = f"🎲 RANDOM VN\n\n{v['title']}\nRating: {v['rating']/10} ⭐\n\n{clean_text(v.get('description'), 400)}\n\nhttps://vndb.org/{v['id']}" await send_result(message, text, v.get('image', {}).get('url')) @dp.message(Command("top")) async def handle_top(message: types.Message): res = await fetch_vndb("vn", ["votecount", ">", 2000], "id, title, rating", sort="rating", results=10) out = ["🏆 TOP 10 VISUAL NOVELS"] for i, v in enumerate(res, 1): out.append(f"{i}. {v['title']} — {v['rating']/10}") await message.answer("\n".join(out)) @dp.message(Command("start", "help", "search")) async def cmd_start(message: types.Message): await message.answer("🤖 VNDB Professional\n/vn, /char, /release, /random, /top") if __name__ == "__main__": dp.run_polling(bot)