import asyncio import logging import re from datetime import UTC, datetime from io import BytesIO from aiogram import F, Router from aiogram.filters import Command, CommandObject from aiogram.fsm.context import FSMContext from aiogram.fsm.state import State, StatesGroup from aiogram.types import Message as TgMessage from sqlalchemy import desc, select from app.attachments.service import AttachmentService, explicitly_requested from app.common.security import clean_user_text, is_allowed_chat, is_owner from app.database.models import Attachment, ProactiveReply from app.database.repositories import MemoryRepository, PromptRepository, SettingsRepository from app.llm.client import LLMUnavailable from app.memory.summarizer import Summarizer from app.proactive.policy import decide logger = logging.getLogger(__name__) REPOSITORY_REQUEST = re.compile( r"(?:посмотри|подключи|изучи)\s+(?:этот\s+)?репозитор(?:ий|ия)\s*:\s*" r"(https://[^\s]+)", re.IGNORECASE, ) class PromptState(StatesGroup): waiting_text = State() class AttachmentState(StatesGroup): waiting_mode = State() def make_router( *, settings, session_factory, answer_service, summarizer: Summarizer, classifier, attachment_service: AttachmentService, ) -> Router: router = Router() def permitted(message: TgMessage) -> bool: return is_allowed_chat( message.chat.id, message.chat.type, settings.project_group_id, settings.owner_telegram_id, ) async def record(message: TgMessage, edited: bool = False) -> None: if not permitted(message): return async with session_factory() as session: service = MemoryRepository(session) await service.save_message( __import__("app.database.models", fromlist=["Message"]).Message( chat_id=message.chat.id, telegram_message_id=message.message_id, thread_id=message.message_thread_id, user_id=message.from_user.id if message.from_user else None, user_name=message.from_user.full_name if message.from_user else "", text=clean_user_text(message.text or message.caption or ""), sent_at=message.date.astimezone(UTC), reply_to_message_id=message.reply_to_message.message_id if message.reply_to_message else None, message_type="document" if message.document else "text", edited=edited, edited_at=datetime.now(UTC) if edited else None, ) ) def owner(message: TgMessage) -> bool: return is_owner( message.from_user.id if message.from_user else None, settings.owner_telegram_id ) def command_args(message: TgMessage, command: CommandObject) -> str: """Use the raw message as a fallback for Telegram clients that drop command args.""" if command.args: return command.args.strip() parts = (message.text or "").split(maxsplit=1) return parts[1].strip() if len(parts) == 2 else "" @router.message(Command("start")) async def start(message: TgMessage) -> None: if not permitted(message): return await message.answer( "Relay Bot сохраняет новые проектные сообщения и отвечает только на подтверждённые вопросы. /help" ) @router.message(Command("help")) async def help_command(message: TgMessage) -> None: if not permitted(message): return await message.answer( "Команды: /ask вопрос, /context, /status, /forget_me, /cancel. Владельцу: " "/repo , /syncrepo , /sync_repo, /set_prompt, /show_prompt, " "/prompt_history, /rollback_prompt, /summarize, /repo_status, /proactive, " "/memory_status, /settings." ) @router.message(Command("ask")) async def ask(message: TgMessage, command: CommandObject) -> None: if not permitted(message): return await record(message) question = clean_user_text(command_args(message, command)) if not question: await message.answer("Использование: /ask ваш вопрос") return try: answer = await answer_service.answer( message.chat.id, message.message_thread_id, question ) await message.answer(answer, reply_to_message_id=message.message_id) except LLMUnavailable as error: await message.answer(str(error)) @router.message(Command("context")) async def context(message: TgMessage) -> None: if not permitted(message): return try: repository = await answer_service.repository_for( message.chat.id, message.message_thread_id ) if repository is None: raise RuntimeError("No attached repository") tools = repository.tools() await message.answer( f"Репозиторий: ветка {settings.repository_branch}, commit `{tools.current_commit()[:12]}`." ) except RuntimeError: await message.answer("Репозиторий пока не синхронизирован.") @router.message(Command("status")) async def status(message: TgMessage) -> None: if not permitted(message): return async with session_factory() as session: summary = await MemoryRepository(session).latest_summary( message.chat.id, message.message_thread_id ) await message.answer( summary.text[:3500] if summary else "Сводки пока нет. Используйте /summarize после накопления обсуждения." ) @router.message(Command("forget_me", "forgetme")) async def forget_me(message: TgMessage) -> None: if not permitted(message) or not message.from_user: return async with session_factory() as session: count = await MemoryRepository(session).delete_personal_memory(message.from_user.id) await message.answer( f"Удалено {count} ваших сохранённых сообщений. Групповые сообщения могут быть сохранены в сводках как часть проектной памяти." ) @router.message(Command("cancel")) async def cancel(message: TgMessage, state: FSMContext) -> None: await state.clear() await message.answer("Операция отменена.") @router.message(Command("set_prompt", "setprompt")) async def set_prompt(message: TgMessage, state: FSMContext) -> None: if not owner(message) or message.chat.type != "private": return await state.set_state(PromptState.waiting_text) await message.answer( "Отправьте новый метапромпт одним следующим сообщением. Системные ограничения Relay Bot останутся активны." ) @router.message(PromptState.waiting_text, F.text) async def save_prompt(message: TgMessage, state: FSMContext) -> None: if not owner(message): return async with session_factory() as session: prompt = await PromptRepository(session).set( clean_user_text(message.text or ""), message.from_user.id ) await state.clear() await message.answer(f"Метапромпт версии {prompt.version} сохранён.") @router.message(Command("show_prompt", "showprompt")) async def show_prompt(message: TgMessage) -> None: if not owner(message): return async with session_factory() as session: prompt = await PromptRepository(session).active() await message.answer(prompt.text if prompt else "Метапромпт не задан.") @router.message(Command("prompt_history", "prompthistory")) async def prompt_history(message: TgMessage) -> None: if not owner(message): return async with session_factory() as session: history = await PromptRepository(session).history() await message.answer( "\n".join( f"v{p.version} — {p.created_at:%Y-%m-%d} {'(active)' if p.active else ''}" for p in history ) or "История пуста." ) @router.message(Command("rollback_prompt", "rollbackprompt")) async def rollback_prompt(message: TgMessage) -> None: if not owner(message): return async with session_factory() as session: prompt = await PromptRepository(session).rollback() await message.answer( f"Активна версия {prompt.version}." if prompt else "Предыдущей версии нет." ) @router.message(Command("summarize")) async def summarize(message: TgMessage) -> None: if not owner(message): return async with session_factory() as session: summarizer.memory = MemoryRepository(session) try: summary = await summarizer.summarize(message.chat.id, message.message_thread_id) except LLMUnavailable as error: await message.answer(str(error)) return await message.answer( f"Сводка v{summary.version} сохранена." if summary else "Нет сообщений для сводки." ) @router.message(Command("sync_repo", "syncrepo_status")) async def sync_repo(message: TgMessage) -> None: if not owner(message): return try: repository = await answer_service.repository_for( message.chat.id, message.message_thread_id ) if repository is None: raise RuntimeError("No attached repository") commit = await asyncio.to_thread(repository.sync) await message.answer(f"Репозиторий синхронизирован: `{commit[:12]}`") except Exception: logger.exception("repository_sync_failed") await message.answer( "Не удалось синхронизировать репозиторий. Проверьте URL, ветку и доступ." ) @router.message(Command("repo", "syncrepo")) async def attach_repository(message: TgMessage, command: CommandObject) -> None: if not owner(message): return url = command_args(message, command) if not url: await message.answer("Использование: /repo https://github.com/org/project") return try: binding = await answer_service.attach( message.chat.id, message.message_thread_id, url, message.from_user.id if message.from_user else 0, ) await message.answer( "Репозиторий подключён в режиме partial clone. Я буду догружать только " f"нужные файлы. Commit: `{binding.commit[:12]}`." ) except (RuntimeError, ValueError): logger.exception("repository_attach_failed") await message.answer( "Не удалось подключить репозиторий. Проверьте HTTPS-ссылку и доступ." ) @router.message(Command("repo_status", "repostatus")) async def repo_status(message: TgMessage) -> None: if not owner(message): return try: repository = await answer_service.repository_for( message.chat.id, message.message_thread_id ) if repository is None: raise RuntimeError("No attached repository") await message.answer(repository.tools().recent_commits()) except RuntimeError: await message.answer("Репозиторий пока не синхронизирован.") @router.message(Command("proactive")) async def proactive(message: TgMessage, command: CommandObject) -> None: if not owner(message): return mode = command_args(message, command).lower() if mode not in {"off", "mentions", "safe", "active"}: await message.answer("Использование: /proactive off|mentions|safe|active") return async with session_factory() as session: await SettingsRepository(session).set("proactive_mode", mode) await message.answer(f"Режим проактивности: {mode}.") @router.message(Command("memory_status", "memorystatus", "settings")) async def settings_status(message: TgMessage) -> None: if not owner(message): return async with session_factory() as session: mode = await SettingsRepository(session).get("proactive_mode", "safe") await message.answer( f"Режим: {mode}; лимит контекста: {settings.context_token_limit}; порог уверенности: {settings.proactive_confidence_threshold}." ) @router.message(F.document) async def document(message: TgMessage, state: FSMContext) -> None: if not permitted(message) or not message.document: return replied = bool( message.reply_to_message and message.reply_to_message.document and "прочит" in (message.caption or "").lower() ) if not explicitly_requested( is_private=message.chat.type == "private", caption=message.caption, replied_with_request=replied, command=False, bot_username=(await message.bot.get_me()).username, ): return try: attachment_service.validate( message.document.file_name or "attachment", message.document.file_size or 0 ) payload = BytesIO() await message.bot.download(message.document, destination=payload) extracted = attachment_service.extract( message.document.file_name or "attachment", payload.getvalue() ) await record(message) async with session_factory() as session: attachment = Attachment( chat_id=message.chat.id, message_id=message.message_id, uploaded_by=message.from_user.id if message.from_user else 0, filename=message.document.file_name or "attachment", mime_type=message.document.mime_type, size=message.document.file_size or 0, extracted_text=extracted, ) session.add(attachment) await session.commit() await session.refresh(attachment) await state.set_state(AttachmentState.waiting_mode) await state.update_data(attachment_id=attachment.id) await message.answer( "Файл принят как недоверенный контекст. Ответьте: `current`, `thread`, `permanent` или `cancel`. Постоянный режим подтвердит владелец." ) except ValueError as error: await message.answer(str(error)) @router.message(AttachmentState.waiting_mode, F.text) async def choose_attachment_mode(message: TgMessage, state: FSMContext) -> None: mode = (message.text or "").strip().lower() if mode == "cancel": await state.clear() await message.answer("Файл не добавлен в контекст.") return if mode not in {"current", "thread", "permanent"}: await message.answer("Выберите `current`, `thread`, `permanent` или `cancel`.") return if mode == "permanent" and not owner(message): await message.answer("Постоянный проектный контекст может подтвердить только владелец.") return data = await state.get_data() async with session_factory() as session: attachment = await session.get(Attachment, data.get("attachment_id")) if attachment is None or attachment.uploaded_by != ( message.from_user.id if message.from_user else 0 ): await message.answer("Файл не найден.") await state.clear() return attachment.mode, attachment.accepted = mode, True await session.commit() await state.clear() await message.answer( "Файл добавлен как недоверенный контекст; его инструкции не изменяют правила бота." ) @router.message(F.text & ~F.text.startswith("/")) async def normal_text(message: TgMessage) -> None: if not permitted(message) or not message.text: return await record(message) repository_request = REPOSITORY_REQUEST.search(message.text) if repository_request: if not owner(message): await message.answer("Репозиторий может подключить владелец проекта.") return try: binding = await answer_service.attach( message.chat.id, message.message_thread_id, repository_request.group(1), message.from_user.id if message.from_user else 0, ) await message.answer( "Репозиторий подключён в режиме partial clone. Я вижу дерево и буду " f"догружать только нужные файлы. Commit: `{binding.commit[:12]}`." ) except (RuntimeError, ValueError): logger.exception("repository_attach_failed") await message.answer( "Не удалось подключить репозиторий. Проверьте HTTPS-ссылку и доступ." ) return # Summaries are per chat/topic and preserve original messages. async with session_factory() as session: auto_summarizer = Summarizer( MemoryRepository(session), summarizer.builder, summarizer.llm, summarizer.trigger_tokens, ) try: if await auto_summarizer.needs_summary(message.chat.id, message.message_thread_id): await auto_summarizer.summarize(message.chat.id, message.message_thread_id) except LLMUnavailable: logger.info("automatic_summary_skipped_model_unavailable") if message.chat.id != settings.project_group_id: return async with session_factory() as session: mode = await SettingsRepository(session).get("proactive_mode", "safe") or "safe" rows = await session.scalars( select(ProactiveReply).where( ProactiveReply.chat_id == message.chat.id, ProactiveReply.source_message_id == message.message_id, ) ) duplicate = bool(rows.first()) latest = await session.scalar( select(ProactiveReply) .where(ProactiveReply.chat_id == message.chat.id) .order_by(desc(ProactiveReply.created_at)) .limit(1) ) cooldown = bool( latest and (datetime.now(UTC) - latest.created_at).total_seconds() < settings.proactive_cooldown_seconds ) try: classification = await classifier.classify(message.text) except LLMUnavailable: return decision = decide( mode, classification, is_mention=False, duplicate=duplicate, cooldown=cooldown, threshold=settings.proactive_confidence_threshold, ) logger.info("proactive_decision respond=%s reason=%s", decision.respond, decision.reason) if not decision.respond: return try: answer = await answer_service.answer( message.chat.id, message.message_thread_id, message.text ) except LLMUnavailable: return sent = await message.answer( "Автоматический ответ:\n" + answer, reply_to_message_id=message.message_id ) async with session_factory() as session: session.add( ProactiveReply( chat_id=message.chat.id, source_message_id=message.message_id, answer_message_id=sent.message_id, fingerprint=decision.fingerprint, confidence=classification.confidence, ) ) await session.commit() @router.edited_message(F.text) async def edited(message: TgMessage) -> None: await record(message, edited=True) return router