Add new file
This commit is contained in:
241
core/handlers/message_handler.py
Normal file
241
core/handlers/message_handler.py
Normal 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()
|
||||||
Reference in New Issue
Block a user