# -*- coding: utf-8 -*- """ Обработчик входящих XMPP сообщений (личных и групповых). Использует RAGClient для отправки запросов на RAG-сервер. ИСТОРИЯ НЕ ПЕРЕДАЁТСЯ – сервер хранит её самостоятельно. """ import logging import re import asyncio from core.utils.layout_converter import convert_layout from core.commands.registry import suggest_command logger = logging.getLogger(__name__) class MessageHandler: """ Хендлер, привязанный к Bot. Вызывается при событии "message". """ def __init__(self, bot): self.bot = bot async def _wait_for_queue(self): """Ожидает создания очереди индексации (защита от гонки).""" while self.bot.indexing_queue is None: logger.debug("Очередь индексации ещё не создана, ожидание...") await asyncio.sleep(0.5) async def handle(self, msg): """ Основная точка входа для всех сообщений. """ # Определяем тип: личный чат (chat/normal) или групповой (groupchat) if msg['type'] not in ('chat', 'normal', 'groupchat'): return user_jid = msg['from'].bare # Не обрабатываем сообщения от самого бота if msg['type'] == 'groupchat': if msg['from'].resource == self.bot.boundjid.user: return else: if msg['from'] == self.bot.boundjid: return # Извлекаем room_jid для групповых чатов (None для личных) room_jid = msg['from'].bare if msg['type'] == 'groupchat' else None # Проверка OMEMO (сквозное шифрование не поддерживается) if self.bot.is_message_encrypted(msg): reply = msg.reply( "🔒 Я получил зашифрованное сообщение (OMEMO), но не могу его расшифровать. " "Пожалуйста, отключите сквозное шифрование." ) if reply: reply.send() return # Извлечение тела сообщения и URL файла (из OOB) raw_body = msg['body'].strip() if msg['body'] else "" file_url = msg['oob']['url'] if msg['oob'] else None # Если OOB отсутствует, пытаемся извлечь URL из тела сообщения if not file_url and raw_body: url_match = re.search(r'https?://[^\s<>"\'\)\]]+', raw_body) if url_match: file_url = url_match.group(0) logger.info(f"Извлечена ссылка из тела сообщения (fallback OOB): {file_url}") if not raw_body and not file_url: return # Для групповых чатов – проверяем, упомянут ли бот if msg['type'] == 'groupchat': is_command = raw_body.startswith(('!', '/')) has_file = file_url is not None if not (is_command or has_file): if not self._is_mentioned(msg, raw_body): return # Исправление раскладки клавиатуры (русская ↔ английская) body = convert_layout(raw_body) if body != raw_body: reply = msg.reply(f"🔄 Исправлено: {body}") if reply: reply.send() # Метрика: инкремент счётчика сообщений self.bot.metrics_handler.increment("messages_total") logger.info(f"Сообщение от {user_jid} (room={room_jid}): {body[:50]}...") # ---- ИСТОРИЯ БОЛЬШЕ НЕ СОХРАНЯЕТСЯ ЛОКАЛЬНО ---- # Вся история хранится на сервере. Клиент не занимается этим. # Обработка команд (начинаются с ! или /) if body.startswith(('!', '/')): await self._handle_command(msg, body, user_jid, room_jid) return # Режим обучения (текст без файла) – только в личных чатах mode_info = self.bot.learning_mode.get(user_jid) if mode_info and not file_url and room_jid is None: if isinstance(mode_info, tuple): mode, saved_room = mode_info else: mode = mode_info saved_room = None await self._handle_learning_text(msg, user_jid, mode, body, room_jid) return # Обработка файла (если есть URL) is_command = body.startswith(('!', '/')) is_surgical_keyword = any(kw in body.lower() for kw in self.bot.config.surgical_keywords) if file_url and not (is_command or is_surgical_keyword): await self.bot.file_handler.handle_file(msg, file_url, user_jid, room_jid) return # ---- RAG-ЗАПРОС (БЕЗ ИСТОРИИ) ---- # История НЕ ПЕРЕДАЁТСЯ в запросе. Сервер сам её получит из БД. key = (user_jid, room_jid) if room_jid is not None else user_jid result = await self.bot.rag_client.query( query=body, user_jid=user_jid, room_jid=room_jid, prompts=self.bot.prompts, last_file_path=self.bot._last_file_path.get(key), last_file_text=self.bot.last_file_texts.get(user_jid) ) answer = result.get('answer', '⚠️ Не удалось получить ответ.') # Если в ответе есть файловые теги, обрабатываем их if self.bot.config.surgical_replace and any(tag in answer for tag in ("[SURGICAL_REPLACE]", "[REWRITE]", "[FILE]")): await self.bot.handle_ai_file_request(msg, answer, user_jid, room_jid, body=body) else: reply = msg.reply(answer) if reply: reply.send() else: logger.error("Не удалось отправить ответ RAG (без тегов)") # ---- ИСТОРИЯ БОЛЬШЕ НЕ СОХРАНЯЕТСЯ ЛОКАЛЬНО ---- # await self.bot.db.add_history(...) # УБРАНО! # ========================================================== # Вспомогательные методы # ========================================================== def _is_mentioned(self, msg, body: str) -> bool: """ Проверяет, упомянут ли бот в групповом чате. """ my_nick = self.bot.plugin['xep_0045'].our_nicks.get(msg['from'].bare, self.bot.boundjid.user) mention_jid = self.bot.boundjid.user.lower() mention_word = self.bot.config.mention_keyword.lower() return (mention_jid in body.lower() or my_nick.lower() in body.lower() or mention_word in body.lower()) or body.startswith(('!', '/')) async def _handle_command(self, msg, body: str, user_jid: str, room_jid: str): """Обрабатывает команды (начинаются с ! или /).""" cmd_name = body.split()[0].lower() if cmd_name in self.bot.commands: await self.bot.commands[cmd_name].execute(msg, body, user_jid, room_jid) else: suggestion = suggest_command(cmd_name, self.bot._command_names) if suggestion: reply = msg.reply( f"❓ Неизвестная команда. Может быть, вы имели в виду `{suggestion}`? " f"Напишите `!help` для списка команд." ) if reply: reply.send() else: reply = msg.reply("❓ Неизвестная команда. Напишите `!help` для списка команд.") if reply: reply.send() async def _handle_learning_text(self, msg, user_jid: str, mode: str, body: str, room_jid: str): """ Обрабатывает текстовые сообщения в режиме обучения (только в личных чатах). Поддерживает ссылки (URL) с флагами --recursive, --depth, --max. """ is_global = (mode == 'global') title = None # Парсим --title "Название" (если есть) match = re.search(r'--title "([^"]+)"', body) if match: title = match.group(1) body = re.sub(r'--title "[^"]+"', '', body).strip() # Проверяем, является ли тело ссылкой if body.startswith(('http://', 'https://')): parts = body.split(maxsplit=1) url = parts[0] flags_str = parts[1] if len(parts) > 1 else "" recursive = '--recursive' in flags_str or '-r' in flags_str depth = 1 max_pages = self.bot.config.ws_max_pages depth_match = re.search(r'--depth\s+(\d+)', flags_str) if depth_match: depth = int(depth_match.group(1)) max_match = re.search(r'--max\s+(\d+)', flags_str) if max_match: max_pages = int(max_match.group(1)) target_room = None if mode == 'room' and room_jid is not None: target_room = room_jid # Создаём задачу для фонового индексатора task = { "type": "url", "user_jid": user_jid, "room_jid": target_room, "url": url, "title": title, "is_global": is_global, "recursive": recursive, "depth": depth, "max_pages": max_pages, } await self._wait_for_queue() await self.bot.indexing_queue.put(task) scope = "глобальную" if is_global else ("комнатную" if target_room else "личную") reply = msg.reply(f"🌐 Ссылка '{url}' принята в обработку для {scope} БЗ.") if reply: reply.send() return # Если не ссылка – сообщаем, что текст не индексируется reply = msg.reply("В режиме обучения можно отправлять файлы или ссылки. Текст не индексируется.") if reply: reply.send()