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