Add new file

This commit is contained in:
Markov Andrey
2026-06-30 09:14:43 +00:00
parent da204344ef
commit 6054f4395c

View File

@@ -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()