diff --git a/core/handlers/message_handler.py b/core/handlers/message_handler.py new file mode 100644 index 0000000..d5b9667 --- /dev/null +++ b/core/handlers/message_handler.py @@ -0,0 +1,241 @@ +# -*- 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() \ No newline at end of file