"""Aiogram router with LLM chat mode, image generation and user commands.""" from __future__ import annotations import logging from typing import Any import aiohttp from aiogram import F, Router from aiogram.filters import Command, CommandStart from aiogram.types import BufferedInputFile, Message from app.config import Settings from app.llm import LLMChat from app.memory import SQLiteMemory router = Router(name="main") log = logging.getLogger(__name__) IMAGE_ERROR_TEXT = "не удалось сгенерировать, попробуй ещё раз" WELCOME_TEXT = ( "👋 Привет! Я русскоязычный AI-ассистент с памятью диалога.\n\n" "Что умею:\n" "• отвечаю на вопросы через LLM;\n" "• помню последние сообщения в рамках вашего чата;\n" "• могу кратко пересказать последний разговор;\n" "• генерирую изображения по /image <описание>;\n" "• по команде очищаю историю.\n\n" "Напишите сообщение или откройте /help." ) HELP_TEXT = ( "🧭 Доступные команды:\n\n" "/start — приветствие и кратко о возможностях\n" "/help — список команд\n" "/image <описание> — сгенерировать квадратное изображение\n" "/summary — краткое саммари последнего разговора\n" "/reset — удалить вашу историю сообщений\n" "/ping — проверка, что бот отвечает\n" "/config — текущая техническая конфигурация без секретов" ) def _user_id(message: Message) -> int: return message.from_user.id if message.from_user else message.chat.id def _command_args(message: Message) -> str: text = message.text or "" parts = text.split(maxsplit=1) return parts[1].strip() if len(parts) > 1 else "" async def _generate_image_bytes(settings: Settings, prompt: str) -> bytes: """Generate an image with DesignAPI and download the returned URL.""" base_url = settings.designapi_base_url.rstrip("/") endpoint = f"{base_url}/v1/images/generations" headers = { "Authorization": f"Bearer {settings.designapi_api_key}", "Content-Type": "application/json", } payload: dict[str, Any] = { "model": "nano-banana-pro", "prompt": prompt, "n": 1, "aspect_ratio": "1:1", } timeout = aiohttp.ClientTimeout(total=120, connect=15, sock_read=90) async with aiohttp.ClientSession(timeout=timeout) as session: async with session.post(endpoint, json=payload, headers=headers) as response: response.raise_for_status() body = await response.json(content_type=None) image_url = body["data"][0]["url"] if not isinstance(image_url, str) or not image_url: raise ValueError("Image API response does not contain data[0].url") async with session.get(image_url) as response: response.raise_for_status() image_bytes = await response.read() if not image_bytes: raise ValueError("Downloaded image is empty") return image_bytes @router.message(CommandStart()) async def cmd_start(message: Message) -> None: await message.answer(WELCOME_TEXT) @router.message(Command("help")) async def cmd_help(message: Message) -> None: await message.answer(HELP_TEXT) @router.message(Command("ping")) async def cmd_ping(message: Message) -> None: await message.answer("pong") @router.message(Command("config")) async def cmd_config(message: Message, settings: Settings) -> None: await message.answer( "⚙️ Конфигурация загружена.\n" f"DesignAPI base URL: {settings.designapi_base_url}\n" f"Model: {settings.designapi_model}\n" f"max_tokens: {settings.llm_max_tokens}\n" f"DB: {settings.db_path}\n" "DesignAPI API key: задан (скрыт)" ) @router.message(Command("image")) async def cmd_image(message: Message, settings: Settings) -> None: prompt = _command_args(message) if not prompt: await message.answer("Напиши описание после команды: /image кот в космосе") return try: await message.bot.send_chat_action(message.chat.id, "upload_photo") image_bytes = await _generate_image_bytes(settings, prompt) photo = BufferedInputFile(image_bytes, filename="generated.png") await message.answer_photo(photo=photo, caption=f"🖼️ {prompt[:900]}") except (aiohttp.ClientError, TimeoutError, KeyError, IndexError, TypeError, ValueError): log.exception("Failed to generate image for prompt=%r", prompt) await message.answer(IMAGE_ERROR_TEXT) except Exception: log.exception("Unexpected image generation error for prompt=%r", prompt) await message.answer(IMAGE_ERROR_TEXT) @router.message(Command("reset")) async def cmd_reset(message: Message, memory: SQLiteMemory) -> None: memory.reset(_user_id(message)) await message.answer("🧹 История ваших сообщений удалена. Можем начать заново.") @router.message(Command("summary")) async def cmd_summary(message: Message, llm_chat: LLMChat) -> None: try: await message.bot.send_chat_action(message.chat.id, "typing") summary = await llm_chat.summary(_user_id(message)) except ValueError as exc: await message.answer(str(exc)) return except Exception: log.exception("Failed to build conversation summary") await message.answer( "⚠️ Не удалось подготовить саммари: сервис модели временно недоступен. " "Попробуйте ещё раз позже." ) return await message.answer(summary[:4096]) @router.message(F.text) async def chat(message: Message, llm_chat: LLMChat) -> None: text = (message.text or "").strip() if not text: return try: await message.bot.send_chat_action(message.chat.id, "typing") answer = await llm_chat.answer(_user_id(message), text) except Exception: log.exception("Failed to answer chat message") await message.answer("⚠️ Не удалось получить ответ от модели. Попробуйте ещё раз позже.") return await message.answer(answer[:4096])