Update 86 files
- /rag/commands/expert.py - /core/services/__init__.py - /core/services/embedding_service.py - /core/services/file_service.py - /core/services/giga_client.py - /core/services/kb_service.py - /core/services/postgres_service.py - /core/services/qdrant_service.py - /core/services/reranker_service.py - /core/functions/__init__.py - /core/functions/check_consistency.py - /core/functions/check_spelling.py - /core/functions/critique_answer.py - /core/functions/expand_query.py - /core/functions/extract_metrics.py - /core/functions/file_processor.py - /core/functions/generate_document.py - /core/functions/intent_classify.py - /core/functions/rerank_context.py - /core/functions/summarize_document.py - /core/utils/__init__.py - /core/utils/arg_parser.py - /core/utils/config_loader.py - /core/utils/layout_converter.py - /core/utils/logger.py - /core/utils/text_utils.py - /core/utils/web_utils.py - /bots/commands/__init__.py - /bots/commands/base.py - /bots/commands/create.py - /core/commands/global_remove.py - /core/commands/help.py - /core/commands/info.py - /core/commands/kb.py - /core/commands/learn.py - /core/commands/other.py - /core/commands/registry.py - /core/commands/stats.py - /core/commands/template.py - /core/handlers/__init__.py - /core/handlers/message_handler.py - /core/workers/__init__.py - /core/workers/indexing_worker.py - /core/xmpp/__init__.py - /core/xmpp/client.py - /bots/commands/global_remove.py - /bots/commands/help.py - /bots/commands/info.py - /bots/commands/kb.py - /bots/commands/registry.py - /bots/commands/other.py - /bots/commands/template.py - /bots/commands/learn.py - /bots/commands/stats.py - /bots/handlers/message_handler.py - /bots/handlers/__init__.py - /bots/workers/__init__.py - /bots/workers/indexing_worker.py - /bots/xmpp/__init__.py - /bots/xmpp/client.py - /rag/services/qdrant_service.py - /rag/services/giga_client.py - /rag/services/embedding_service.py - /rag/services/reranker_service.py - /rag/services/__init__.py - /rag/services/file_service.py - /rag/services/postgres_service.py - /rag/services/kb_service.py - /rag/functions/check_spelling.py - /rag/functions/__init__.py - /rag/functions/extract_metrics.py - /rag/functions/generate_document.py - /rag/functions/expand_query.py - /rag/functions/critique_answer.py - /rag/functions/file_processor.py - /rag/functions/check_consistency.py - /rag/functions/summarize_document.py - /rag/functions/rerank_context.py - /rag/functions/intent_classify.py - /rag/utils/__init__.py - /rag/utils/layout_converter.py - /rag/utils/text_utils.py - /rag/utils/arg_parser.py - /rag/utils/config_loader.py - /rag/utils/logger.py - /rag/utils/web_utils.py
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Пакет commands – команды XMPP-ботов (каждая команда — отдельный класс).
|
||||
Команды реализуют пользовательский интерфейс:
|
||||
- info, help, learn, kb, stats, expert, reset, template, create, global_remove.
|
||||
Регистрация команд осуществляется через registry.py.
|
||||
"""
|
||||
@@ -1,77 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Базовый класс для всех команд бота.
|
||||
Реализует:
|
||||
- алиасы (альтернативные имена команд)
|
||||
- проверку прав доступа (admin)
|
||||
- парсинг аргументов (через core.utils.arg_parser)
|
||||
- метод справки get_help()
|
||||
- кэширование результатов (опционально)
|
||||
|
||||
Изменения для групповой работы:
|
||||
- метод execute теперь принимает дополнительный параметр room_jid (None для личных чатов).
|
||||
- Команды, которым нужен контекст комнаты, могут его использовать.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Dict, Any, Tuple, List, Optional
|
||||
import time
|
||||
|
||||
from core.utils.arg_parser import parse_args # путь изменён: arg_parser перенесён в utils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from core.xmpp.client import Bot # обновлённый импорт
|
||||
|
||||
|
||||
class Command(ABC):
|
||||
"""Абстрактный базовый класс команды. Все команды должны наследоваться от него."""
|
||||
|
||||
# Основное имя команды (например "!info")
|
||||
name: str = None
|
||||
# Альтернативные имена (например ["!help", "!справка"])
|
||||
aliases: List[str] = []
|
||||
# Необходимые права: None – любой, "admin" – только администраторы
|
||||
permission: Optional[str] = None
|
||||
# Время жизни кэша в секундах (0 – не кэшировать)
|
||||
cache_ttl: int = 0
|
||||
|
||||
def __init__(self, bot: 'Bot'):
|
||||
self.bot = bot
|
||||
# Кэш: ключ -> (время, результат)
|
||||
self._cache: Dict[str, Tuple[float, Any]] = {}
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Выполнить команду.
|
||||
|
||||
:param msg: объект сообщения slixmpp (для ответа)
|
||||
:param args: полная строка аргументов (включая имя команды)
|
||||
:param user_jid: JID пользователя (без ресурса)
|
||||
:param room_jid: JID комнаты, если команда вызвана в групповом чате; иначе None
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_help(self) -> str:
|
||||
"""Возвращает строку справки для команды. Переопределяется в дочерних классах."""
|
||||
return "Нет описания."
|
||||
|
||||
def check_permission(self, user_jid: str) -> bool:
|
||||
"""Проверяет, имеет ли пользователь право на выполнение команды."""
|
||||
if self.permission is None:
|
||||
return True
|
||||
if self.permission == "admin":
|
||||
return user_jid in self.bot.config.admin_jids
|
||||
return False
|
||||
|
||||
def get_cache_key(self, user_jid: str, args: str) -> str:
|
||||
"""Формирует ключ для кэширования (по умолчанию user_jid)."""
|
||||
return user_jid
|
||||
|
||||
def parse_args(self, args_str: str) -> Tuple[str, Dict[str, Any], List[str]]:
|
||||
"""Разбирает строку аргументов на команду, флаги и позиционные аргументы."""
|
||||
return parse_args(args_str)
|
||||
|
||||
def _cache_result(self, key: str, result: str):
|
||||
"""Сохраняет результат в кэш (вызывается из команды)."""
|
||||
self._cache[key] = (time.time(), result)
|
||||
@@ -1,60 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from core.commands.base import Command
|
||||
from core.functions.generate_document import generate_document_from_template
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CreateCommand(Command):
|
||||
name = "!create"
|
||||
aliases = ["/create"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if not room_jid:
|
||||
reply = msg.reply("⚠️ Команда !create работает только в комнатах.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
clean_args = args.strip()
|
||||
if clean_args.startswith(self.name):
|
||||
clean_args = clean_args[len(self.name):].lstrip()
|
||||
name = clean_args.strip()
|
||||
if name.startswith('"') and name.endswith('"'):
|
||||
name = name[1:-1]
|
||||
|
||||
if not name:
|
||||
reply = msg.reply("Укажите название шаблона: !create \"Название\"")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
template_info = await self.bot.db.get_template(room_jid, name)
|
||||
if not template_info:
|
||||
reply = msg.reply(f"❌ Шаблон '{name}' не найден.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
reply = msg.reply("🔄 Начинаю генерацию документа. Это может занять несколько минут. Я сообщу, когда будет готово.")
|
||||
if reply:
|
||||
reply.send()
|
||||
|
||||
asyncio.create_task(
|
||||
generate_document_from_template(
|
||||
bot=self.bot,
|
||||
msg=msg,
|
||||
room_jid=room_jid,
|
||||
template_path=template_info['file_path'],
|
||||
template_name=name,
|
||||
user_jid=user_jid
|
||||
)
|
||||
)
|
||||
|
||||
def get_help(self) -> str:
|
||||
return (
|
||||
"Генерирует документ по шаблону из базы знаний. "
|
||||
"Использование: `!create \"Название шаблона\"` (только в комнате)"
|
||||
)
|
||||
107
bots/commands/global_remove.py
Normal file
107
bots/commands/global_remove.py
Normal file
@@ -0,0 +1,107 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Команда !global_remove – удаление документа из глобальной базы знаний по его названию.
|
||||
Только для администраторов, работает только в личном чате.
|
||||
|
||||
Изменения для PostgreSQL (asyncpg):
|
||||
- Замена курсоров на прямые запросы conn.fetch / conn.execute.
|
||||
- Использование параметризованных запросов с $1, $2 и ANY($1) для массовых операций.
|
||||
- Удаление синтаксиса, специфичного для MySQL/MariaDB.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GlobalRemoveCommand(Command):
|
||||
"""Удаляет глобальный документ по точному совпадению source_name."""
|
||||
|
||||
name = "!global_remove"
|
||||
aliases = ["/global_remove"]
|
||||
permission = "admin"
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Алгоритм:
|
||||
1. Проверка прав администратора.
|
||||
2. Проверка, что команда вызвана в личном чате (room_jid is None).
|
||||
3. Разбор аргументов, извлечение названия документа (в кавычках или без).
|
||||
4. Поиск всех глобальных документов с таким source_name.
|
||||
5. Удаление найденных документов из Qdrant (по doc_id).
|
||||
6. Удаление записей из таблиц document_access и documents в PostgreSQL.
|
||||
"""
|
||||
# 1. Проверка прав администратора
|
||||
if not self.check_permission(user_jid):
|
||||
reply = msg.reply("⛔ У вас нет прав на удаление глобальных документов.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав для global_remove")
|
||||
return
|
||||
|
||||
# 2. Команда работает только в личном чате
|
||||
if room_jid:
|
||||
reply = msg.reply("⚠️ Команда !global_remove работает только в личном чате.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение об ограничении !global_remove")
|
||||
return
|
||||
|
||||
# 3. Разбор аргументов команды
|
||||
_, flags, pos = self.parse_args(args)
|
||||
if not pos:
|
||||
reply = msg.reply("❌ Укажите название документа для удаления. Пример: `!global_remove \"ГОСТ Р 12345\"`")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о необходимости указать название документа")
|
||||
return
|
||||
|
||||
source_name = " ".join(pos).strip()
|
||||
# Удаляем возможные кавычки, если пользователь заключил название в них
|
||||
if source_name.startswith('"') and source_name.endswith('"'):
|
||||
source_name = source_name[1:-1]
|
||||
|
||||
# 4. Поиск глобальных документов с указанным названием
|
||||
async with self.bot.db.pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT id FROM documents WHERE is_global = TRUE AND source_name = $1",
|
||||
source_name
|
||||
)
|
||||
doc_ids = [row['id'] for row in rows]
|
||||
|
||||
if not doc_ids:
|
||||
reply = msg.reply(f"📭 Глобальный документ с названием '{source_name}' не найден.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о ненайденном глобальном документе")
|
||||
return
|
||||
|
||||
# 5. Удаление из Qdrant (по каждому doc_id)
|
||||
for doc_id in doc_ids:
|
||||
self.bot.qdrant.delete_by_doc_id(doc_id)
|
||||
logger.debug(f"Удалён из Qdrant doc_id={doc_id}")
|
||||
|
||||
# 6. Удаление из PostgreSQL (сначала связанные права доступа, затем сам документ)
|
||||
async with self.bot.db.pool.acquire() as conn:
|
||||
# Удаляем записи о доступе (если есть)
|
||||
await conn.execute("DELETE FROM document_access WHERE doc_id = ANY($1)", doc_ids)
|
||||
# Удаляем сами документы
|
||||
await conn.execute("DELETE FROM documents WHERE id = ANY($1)", doc_ids)
|
||||
|
||||
logger.info(f"Администратор {user_jid} удалил глобальный документ '{source_name}' (doc_ids: {doc_ids})")
|
||||
reply = msg.reply(f"✅ Глобальный документ '{source_name}' удалён из базы знаний.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение удаления глобального документа")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return (
|
||||
"Удаляет глобальный документ по названию (админ, только в личном чате). "
|
||||
"Использование: `!global_remove \"Название документа\"`"
|
||||
)
|
||||
53
bots/commands/help.py
Normal file
53
bots/commands/help.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Команда !help – показывает справку по всем командам или конкретной команде.
|
||||
Алиасы: !справка, /help, /справка.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HelpCommand(Command):
|
||||
name = "!help"
|
||||
aliases = ["!справка", "/help", "/справка"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Если указано имя команды – показывает справку по ней.
|
||||
Иначе – список всех доступных команд.
|
||||
"""
|
||||
_, flags, pos = self.parse_args(args)
|
||||
if pos:
|
||||
cmd_name = pos[0].lower()
|
||||
cmd_obj = self.bot.commands.get(cmd_name)
|
||||
if cmd_obj:
|
||||
help_text = cmd_obj.get_help()
|
||||
reply = msg.reply(help_text)
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить справку по команде")
|
||||
else:
|
||||
reply = msg.reply(f"❓ Команда '{cmd_name}' не найдена. Напишите `!help` для списка команд.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о неизвестной команде")
|
||||
else:
|
||||
# Общая справка
|
||||
lines = ["**Доступные команды:**"]
|
||||
# Сортируем по имени команды
|
||||
for name, cmd in sorted(self.bot.commands.items()):
|
||||
if name == cmd.name: # только основные имена (без алиасов)
|
||||
lines.append(f" • {cmd.name} – {cmd.get_help().splitlines()[0]}")
|
||||
reply = msg.reply("\n".join(lines))
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить общий список команд")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Показывает справку. Использование: `!help` или `!help команда`"
|
||||
51
bots/commands/info.py
Normal file
51
bots/commands/info.py
Normal file
@@ -0,0 +1,51 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Команда !info – показывает краткую справку по всем командам бота.
|
||||
Не зависит от комнаты – всегда одна и та же.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InfoCommand(Command):
|
||||
name = "!info"
|
||||
aliases = ["/info", "!справка_краткая"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Отправляет пользователю краткую справку.
|
||||
Параметр room_jid игнорируется.
|
||||
"""
|
||||
help_text = (
|
||||
"📚 **Управление знаниями:**\n"
|
||||
"!learn [--global] [--title \"Название\"] – включить обучение (следующий файл/текст/ссылка добавятся в БЗ)\n"
|
||||
"!stop_learn – выключить обучение\n"
|
||||
"!clear – очистить ЛИЧНУЮ БЗ (в группе – БЗ комнаты)\n"
|
||||
"!global_clear – очистить ГЛОБАЛЬНУЮ БЗ (админ)\n"
|
||||
"!global_remove \"Название\" – удалить глобальный документ по названию (админ)\n"
|
||||
"!kb – полный список документов в БЗ (личные и глобальные; в группе – документы комнаты)\n"
|
||||
"!reset – сбросить историю диалога\n\n"
|
||||
"💬 **Диалог:**\n"
|
||||
"!stats – статистика работы бота (админ)\n"
|
||||
"!status – статус сервисов (админ)\n"
|
||||
"!clean – очистить временные файлы (админ)\n\n"
|
||||
"🔬 **Экспертные команды:**\n"
|
||||
"!summary – суммаризация последнего документа\n"
|
||||
"!metrics – извлечь KPI из всей базы знаний (кэш 5 мин)\n\n"
|
||||
"🌐 **Обучение по ссылкам (в режиме `!learn`):**\n"
|
||||
"https://example.com [--recursive] [--depth N] [--max M] – рекурсивная загрузка сайта\n\n"
|
||||
"❓ **Справка:**\n"
|
||||
"!help – подробный список команд с синтаксисом\n"
|
||||
"!help команда – справка по конкретной команде"
|
||||
)
|
||||
reply = msg.reply(help_text)
|
||||
if reply:
|
||||
reply.send() # <-- УБРАЛИ await
|
||||
else:
|
||||
logger.error("Не удалось отправить справку info")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Показывает краткую справку. Использование: `!info`"
|
||||
131
bots/commands/kb.py
Normal file
131
bots/commands/kb.py
Normal file
@@ -0,0 +1,131 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Команды управления базой знаний: !clear, !global_clear, !kb.
|
||||
Поддержка комнат:
|
||||
- !clear в комнате очищает комнатную БЗ (требует прав администратора бота).
|
||||
- !kb в комнате показывает документы комнаты + глобальные.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClearCommand(Command):
|
||||
name = "!clear"
|
||||
aliases = ["/clear"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Очищает базу знаний:
|
||||
- В личном чате – личную БЗ пользователя.
|
||||
- В групповом чате – БЗ комнаты (требует прав администратора бота).
|
||||
"""
|
||||
if room_jid:
|
||||
# Для очистки комнатной БЗ нужны права администратора бота
|
||||
if user_jid not in self.bot.config.admin_jids:
|
||||
reply = msg.reply("⛔ Только администраторы бота могут очищать комнатную БЗ.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о правах на очистку комнаты")
|
||||
return
|
||||
await self.bot.kb.clear_room_kb(room_jid)
|
||||
reply = msg.reply(f"🗑 Комнатная БЗ {room_jid} очищена.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение очистки комнатной БЗ")
|
||||
else:
|
||||
await self.bot.kb.clear_user_kb(user_jid)
|
||||
reply = msg.reply("🗑 Ваша ЛИЧНАЯ база знаний очищена.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение очистки личной БЗ")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Очищает вашу личную базу знаний. В групповом чате очищает БЗ комнаты (требует прав администратора бота)."
|
||||
|
||||
|
||||
class GlobalClearCommand(Command):
|
||||
name = "!global_clear"
|
||||
aliases = ["/global_clear"]
|
||||
permission = "admin"
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Очищает глобальную базу знаний (только админ, только в личном чате).
|
||||
В комнате команда не работает.
|
||||
"""
|
||||
if not self.check_permission(user_jid):
|
||||
reply = msg.reply("⛔ Нет прав.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав (kb)")
|
||||
return
|
||||
if room_jid:
|
||||
reply = msg.reply("⚠️ Команда !global_clear работает только в личном чате.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение об ограничении !global_clear")
|
||||
return
|
||||
await self.bot.kb.clear_global_kb()
|
||||
reply = msg.reply("🌐 ГЛОБАЛЬНАЯ база знаний очищена.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение очистки глобальной БЗ")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Очищает глобальную базу знаний (админ, только в личном чате)."
|
||||
|
||||
|
||||
class KbCommand(Command):
|
||||
name = "!kb"
|
||||
aliases = ["/kb"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Показывает список документов в базе знаний:
|
||||
- В личном чате: личные + глобальные.
|
||||
- В групповом чате: документы комнаты + глобальные.
|
||||
"""
|
||||
sources = await self.bot.kb.get_sources_with_type(user_jid, room_jid)
|
||||
if not sources:
|
||||
if room_jid:
|
||||
reply = msg.reply("📭 База знаний этой комнаты пуста.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о пустой комнатной БЗ")
|
||||
else:
|
||||
reply = msg.reply("📭 Ваша личная база знаний пуста.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о пустой личной БЗ")
|
||||
return
|
||||
global_docs = [s['source_name'] for s in sources if s['is_global']]
|
||||
other_docs = [s['source_name'] for s in sources if not s['is_global']]
|
||||
lines = []
|
||||
if global_docs:
|
||||
lines.append("🌍 **Глобальные документы:**")
|
||||
lines.extend(f" • {name}" for name in global_docs)
|
||||
if other_docs:
|
||||
if room_jid:
|
||||
lines.append(f"🏠 **Документы комнаты {room_jid}:**")
|
||||
else:
|
||||
lines.append("👤 **Ваши личные документы:**")
|
||||
lines.extend(f" • {name}" for name in other_docs)
|
||||
reply = msg.reply("\n".join(lines))
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить список документов БЗ")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Показывает список документов в базе знаний (личные/комнатные + глобальные)."
|
||||
128
bots/commands/learn.py
Normal file
128
bots/commands/learn.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Команды управления режимом обучения: !learn, !stop_learn, !global_learn.
|
||||
Поддерживают флаги --global и --title.
|
||||
|
||||
Изменения для групповой работы:
|
||||
- В групповом чате !learn не нужен для файлов (они индексируются автоматически),
|
||||
но может использоваться для ссылок (если пользователь хочет добавить ссылку в БЗ комнаты).
|
||||
- Режим обучения в комнате сохраняется с привязкой к комнате.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LearnCommand(Command):
|
||||
name = "!learn"
|
||||
aliases = ["/learn", "!дуфкт", "/дуфкт", "/учись"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Включает режим обучения. В личном чате – для личной БЗ (или глобальной с флагом --global).
|
||||
В групповом чате – для комнатной БЗ (флаг --global игнорируется).
|
||||
"""
|
||||
_, flags, _ = self.parse_args(args)
|
||||
is_global = 'global' in flags
|
||||
title = flags.get('title')
|
||||
|
||||
# Проверка прав на глобальное обучение (только в личном чате)
|
||||
if is_global and room_jid is None:
|
||||
if user_jid not in self.bot.config.admin_jids:
|
||||
reply = msg.reply("⛔ У вас нет прав на глобальное обучение.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав на глобальное обучение")
|
||||
return
|
||||
|
||||
if room_jid:
|
||||
# В групповом чате – режим обучения для комнаты (только ссылки, файлы и так автоматом)
|
||||
mode = "room"
|
||||
# Сохраняем режим с указанием комнаты
|
||||
self.bot.learning_mode[user_jid] = (mode, room_jid)
|
||||
if title:
|
||||
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для КОМНАТНОЙ БЗ (--title \"{title}\"). При следующей ссылке будет использован заголовок.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение обучения для комнаты с title")
|
||||
else:
|
||||
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для КОМНАТНОЙ БЗ. Теперь можно отправить ссылку – она добавится в БЗ комнаты.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение обучения для комнаты")
|
||||
else:
|
||||
# Личный чат
|
||||
mode = "global" if is_global else "personal"
|
||||
self.bot.learning_mode[user_jid] = (mode, None) # храним кортеж для единообразия
|
||||
if title:
|
||||
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для {'ГЛОБАЛЬНОЙ' if is_global else 'ЛИЧНОЙ'} БЗ (--title \"{title}\").")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение обучения с title")
|
||||
else:
|
||||
reply = msg.reply(f"🎓 Режим обучения ВКЛЮЧЁН для {'ГЛОБАЛЬНОЙ' if is_global else 'ЛИЧНОЙ'} БЗ.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение обучения")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Включает режим обучения (следующий файл/ссылка добавятся в БЗ). В личном чате – личная/глобальная, в групповом – комнатная. Флаги: --global (только личный), --title \"Название\"."
|
||||
|
||||
|
||||
class StopLearnCommand(Command):
|
||||
name = "!stop_learn"
|
||||
aliases = ["/stop_learn", "/стоп", "!стоп", "!stop"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""Выключает режим обучения для пользователя (в любом контексте)."""
|
||||
self.bot.learning_mode.pop(user_jid, None)
|
||||
reply = msg.reply("✅ Режим обучения ВЫКЛЮЧЕН.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение выключения обучения")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Выключает режим обучения."
|
||||
|
||||
|
||||
class GlobalLearnCommand(Command):
|
||||
name = "!global_learn"
|
||||
aliases = ["/global_learn"]
|
||||
permission = "admin"
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Включает глобальное обучение (только в личном чате, для админов).
|
||||
В групповом чате команда не работает.
|
||||
"""
|
||||
if not self.check_permission(user_jid):
|
||||
reply = msg.reply("⛔ Нет прав.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав (learn)")
|
||||
return
|
||||
if room_jid:
|
||||
reply = msg.reply("⚠️ Команда !global_learn работает только в личном чате.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение об ограничении !global_learn")
|
||||
return
|
||||
self.bot.learning_mode[user_jid] = ("global", None)
|
||||
reply = msg.reply("🌐 Режим ГЛОБАЛЬНОГО обучения включён.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение глобального обучения")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Включает режим глобального обучения (админ, только в личном чате)."
|
||||
35
bots/commands/other.py
Normal file
35
bots/commands/other.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Прочие команды: !reset – сброс истории диалога.
|
||||
Сбрасывает историю в зависимости от контекста: в личном чате – личную историю,
|
||||
в групповом – историю комнаты.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ResetCommand(Command):
|
||||
name = "!reset"
|
||||
aliases = ["/reset", "!сброс", "/сброс"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if room_jid:
|
||||
await self.bot.db.clear_room_history(room_jid)
|
||||
reply = msg.reply(f"🧹 История диалога комнаты {room_jid} сброшена.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение сброса истории комнаты")
|
||||
else:
|
||||
await self.bot.db.clear_user_history(user_jid)
|
||||
reply = msg.reply("🧹 Ваша личная история диалога сброшена.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить подтверждение сброса личной истории")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Сбрасывает историю диалога (личную или комнатную)."
|
||||
63
bots/commands/registry.py
Normal file
63
bots/commands/registry.py
Normal file
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Реестр команд – связывает имя команды с объектом-обработчиком.
|
||||
Поддерживает алиасы и подсказки при неизвестной команде.
|
||||
"""
|
||||
|
||||
import difflib
|
||||
from typing import Dict, List
|
||||
|
||||
# Импортируем все классы команд
|
||||
from core.commands.info import InfoCommand
|
||||
from core.commands.help import HelpCommand
|
||||
from core.commands.learn import LearnCommand, StopLearnCommand, GlobalLearnCommand
|
||||
from core.commands.kb import ClearCommand, GlobalClearCommand, KbCommand
|
||||
from core.commands.stats import StatsCommand, StatusCommand, CleanCommand
|
||||
from core.commands.expert import SummaryCommand, MetricsCommand
|
||||
from core.commands.other import ResetCommand
|
||||
from core.commands.global_remove import GlobalRemoveCommand
|
||||
from core.commands.template import TemplateSaveCommand, TemplateListCommand, TemplateDeleteCommand
|
||||
from core.commands.create import CreateCommand
|
||||
|
||||
|
||||
def register_commands(bot) -> Dict[str, object]:
|
||||
"""
|
||||
Создаёт и возвращает словарь: имя_команды -> объект команды.
|
||||
Включает основные имена и все алиасы.
|
||||
"""
|
||||
all_commands = [
|
||||
InfoCommand(bot),
|
||||
HelpCommand(bot),
|
||||
LearnCommand(bot),
|
||||
StopLearnCommand(bot),
|
||||
GlobalLearnCommand(bot),
|
||||
ClearCommand(bot),
|
||||
GlobalClearCommand(bot),
|
||||
KbCommand(bot),
|
||||
StatsCommand(bot),
|
||||
StatusCommand(bot),
|
||||
CleanCommand(bot),
|
||||
ResetCommand(bot),
|
||||
SummaryCommand(bot),
|
||||
MetricsCommand(bot),
|
||||
GlobalRemoveCommand(bot),
|
||||
TemplateSaveCommand(bot),
|
||||
TemplateListCommand(bot),
|
||||
TemplateDeleteCommand(bot),
|
||||
CreateCommand(bot),
|
||||
]
|
||||
cmd_map = {}
|
||||
for cmd in all_commands:
|
||||
if cmd.name:
|
||||
cmd_map[cmd.name.lower()] = cmd
|
||||
for alias in cmd.aliases:
|
||||
cmd_map[alias.lower()] = cmd
|
||||
# Сохраняем список основных имён для подсказок (используется в message_handler)
|
||||
bot._command_names = list(set(cmd.name for cmd in all_commands if cmd.name))
|
||||
return cmd_map
|
||||
|
||||
|
||||
def suggest_command(unknown_cmd: str, known_commands: List[str]) -> str:
|
||||
"""Возвращает ближайшее известное имя команды (или None), используя difflib."""
|
||||
matches = difflib.get_close_matches(unknown_cmd, known_commands, n=1, cutoff=0.6)
|
||||
return matches[0] if matches else None
|
||||
166
bots/commands/stats.py
Normal file
166
bots/commands/stats.py
Normal file
@@ -0,0 +1,166 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Административные команды: !stats, !status, !clean.
|
||||
- !stats – статистика работы бота (сообщения, файлы, AI-запросы, ошибки, uptime).
|
||||
- !status – проверка состояния сервисов (GigaChat, PostgreSQL, Qdrant, диск).
|
||||
- !clean – очистка временной директории.
|
||||
|
||||
Изменения для PostgreSQL:
|
||||
- Замена `conn.ping()` на `conn.execute("SELECT 1")` в команде !status.
|
||||
- Обновлены тексты сообщений (MariaDB → PostgreSQL).
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StatsCommand(Command):
|
||||
"""Показывает статистику работы бота (только для администраторов)."""
|
||||
|
||||
name = "!stats"
|
||||
aliases = ["/stats", "!статистика", "/статистика", "!стат", "/стат"]
|
||||
permission = "admin"
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
"""
|
||||
Выводит:
|
||||
- uptime бота
|
||||
- количество обработанных сообщений
|
||||
- количество AI-запросов
|
||||
- количество обработанных файлов
|
||||
- количество ошибок
|
||||
- количество документов в базе знаний (с учётом контекста)
|
||||
"""
|
||||
if not self.check_permission(user_jid):
|
||||
reply = msg.reply("⛔ Нет прав.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав (stats)")
|
||||
return
|
||||
|
||||
# Время работы бота
|
||||
uptime = datetime.now() - self.bot.start_time
|
||||
|
||||
# Получение метрик из MetricsHandler
|
||||
stats = self.bot.metrics_handler.get()
|
||||
|
||||
# Количество доступных документов в БЗ (личные/комнатные + глобальные)
|
||||
sources = await self.bot.kb.get_available_sources(user_jid, room_jid)
|
||||
|
||||
response = (
|
||||
f"📊 **Статистика бота {self.bot.config.name}**\n"
|
||||
f"⏱ Uptime: {str(uptime).split('.')[0]}\n"
|
||||
f"💬 Сообщений: {stats.get('messages_total', 0)}\n"
|
||||
f"🤖 AI запросов: {stats.get('ai_requests', 0)}\n"
|
||||
f"📁 Файлов обработано: {stats.get('files_processed', 0)}\n"
|
||||
f"❌ Ошибок: {stats.get('errors_total', 0)}\n"
|
||||
f"📚 Документов в БЗ: {len(sources)}"
|
||||
)
|
||||
reply = msg.reply(response)
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить статистику")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Показывает статистику работы бота (админ)."
|
||||
|
||||
|
||||
class StatusCommand(Command):
|
||||
"""Проверяет состояние всех внешних сервисов (административная команда)."""
|
||||
|
||||
name = "!status"
|
||||
aliases = ["/status", "!статус", "/статус"]
|
||||
permission = "admin"
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if not self.check_permission(user_jid):
|
||||
reply = msg.reply("⛔ Нет прав.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав (stats)")
|
||||
return
|
||||
|
||||
# --- GigaChat ---
|
||||
try:
|
||||
# Простой запрос для проверки доступности API
|
||||
await self.bot.giga.chat([], "ping", system_prompt=None, file_id=None, temperature=0.1)
|
||||
ai_status = "✅ GigaChat Online"
|
||||
except Exception as e:
|
||||
ai_status = f"❌ GigaChat Offline: {e}"
|
||||
|
||||
# --- PostgreSQL (asyncpg) ---
|
||||
try:
|
||||
async with self.bot.db.pool.acquire() as conn:
|
||||
# В asyncpg нет метода ping(), используем SELECT 1
|
||||
await conn.execute("SELECT 1")
|
||||
db_status = "✅ PostgreSQL Online"
|
||||
except Exception as e:
|
||||
db_status = f"❌ PostgreSQL Error: {e}"
|
||||
|
||||
# --- Qdrant ---
|
||||
try:
|
||||
self.bot.qdrant.client.get_collection(self.bot.qdrant.collection_name)
|
||||
qd_status = "✅ Qdrant Online"
|
||||
except Exception as e:
|
||||
qd_status = f"❌ Qdrant Error: {e}"
|
||||
|
||||
# --- Дисковое пространство (временная директория) ---
|
||||
total, used, free = shutil.disk_usage(self.bot.config.temp_dir)
|
||||
disk_info = f"💾 Диск: {used // (2**20)}MB / {total // (2**20)}MB"
|
||||
|
||||
full_status = f"🛰 **СТАТУС БОТА**\n{ai_status}\n{db_status}\n{qd_status}\n{disk_info}"
|
||||
reply = msg.reply(full_status)
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить статус")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Проверяет состояние сервисов (GigaChat, PostgreSQL, Qdrant, диск) – админ."
|
||||
|
||||
|
||||
class CleanCommand(Command):
|
||||
"""Очищает временную директорию бота (temp_dir)."""
|
||||
|
||||
name = "!clean"
|
||||
aliases = ["/clean", "!очистка", "/очистка"]
|
||||
permission = "admin"
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if not self.check_permission(user_jid):
|
||||
reply = msg.reply("⛔ Нет прав.")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить сообщение о недостатке прав (stats)")
|
||||
return
|
||||
|
||||
count = 0
|
||||
temp_dir = self.bot.config.temp_dir
|
||||
for f in os.listdir(temp_dir):
|
||||
try:
|
||||
p = os.path.join(temp_dir, f)
|
||||
if os.path.isfile(p):
|
||||
os.unlink(p)
|
||||
elif os.path.isdir(p):
|
||||
shutil.rmtree(p)
|
||||
count += 1
|
||||
except Exception:
|
||||
pass
|
||||
reply = msg.reply(f"🧹 Удалено объектов из temp_dir: {count}")
|
||||
if reply:
|
||||
reply.send() # исправлено
|
||||
else:
|
||||
logger.error("Не удалось отправить отчёт об очистке")
|
||||
|
||||
def get_help(self) -> str:
|
||||
return "Очищает временную директорию бота (админ)."
|
||||
132
bots/commands/template.py
Normal file
132
bots/commands/template.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import os
|
||||
import hashlib
|
||||
import shutil
|
||||
import logging
|
||||
from core.commands.base import Command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateSaveCommand(Command):
|
||||
name = "!template_save"
|
||||
aliases = ["/template_save"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if not room_jid:
|
||||
reply = msg.reply("⚠️ Команда !template_save работает только в комнатах.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
if args.startswith(self.name):
|
||||
args = args[len(self.name):].lstrip()
|
||||
name = args.strip()
|
||||
if name.startswith('"') and name.endswith('"'):
|
||||
name = name[1:-1]
|
||||
if not name:
|
||||
reply = msg.reply("Укажите название шаблона: !template_save \"Название\"")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
key = (user_jid, room_jid) if room_jid is not None else user_jid
|
||||
original_path = self.bot._last_file_path.get(key)
|
||||
if not original_path or not os.path.exists(original_path):
|
||||
reply = msg.reply("❌ Нет загруженного файла. Сначала отправьте DOCX-шаблон.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
if not original_path.lower().endswith('.docx'):
|
||||
reply = msg.reply("❌ Шаблон должен быть в формате .docx.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
with open(original_path, 'rb') as f:
|
||||
file_hash = hashlib.sha256(f.read()).hexdigest()
|
||||
|
||||
templates_dir = self.bot.config.data_dir / "templates"
|
||||
templates_dir.mkdir(parents=True, exist_ok=True)
|
||||
safe_room = room_jid.replace('/', '_').replace('@', '_')
|
||||
dest_name = f"{safe_room}_{name}.docx"
|
||||
dest_path = templates_dir / dest_name
|
||||
shutil.copy2(original_path, dest_path)
|
||||
|
||||
await self.bot.db.save_template(room_jid, name, str(dest_path), file_hash)
|
||||
|
||||
reply = msg.reply(f"✅ Шаблон '{name}' сохранён.")
|
||||
if reply:
|
||||
reply.send()
|
||||
|
||||
def get_help(self) -> str:
|
||||
return (
|
||||
"Сохраняет текущий DOCX-файл как шаблон с указанным именем. "
|
||||
"Использование: `!template_save \"Имя шаблона\"` (только в комнате)"
|
||||
)
|
||||
|
||||
|
||||
class TemplateListCommand(Command):
|
||||
name = "!template_list"
|
||||
aliases = ["/template_list"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if not room_jid:
|
||||
reply = msg.reply("⚠️ Команда !template_list работает только в комнатах.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
if args.startswith(self.name):
|
||||
args = args[len(self.name):].lstrip()
|
||||
templates = await self.bot.db.list_templates(room_jid)
|
||||
if not templates:
|
||||
reply = msg.reply("📭 В этой комнате нет сохранённых шаблонов.")
|
||||
else:
|
||||
lines = ["📋 Шаблоны комнаты:"] + [f" • {t}" for t in templates]
|
||||
reply = msg.reply("\n".join(lines))
|
||||
if reply:
|
||||
reply.send()
|
||||
|
||||
def get_help(self) -> str:
|
||||
return (
|
||||
"Показывает список сохранённых шаблонов в текущей комнате. "
|
||||
"Использование: `!template_list` (только в комнате)"
|
||||
)
|
||||
|
||||
|
||||
class TemplateDeleteCommand(Command):
|
||||
name = "!template_delete"
|
||||
aliases = ["/template_delete"]
|
||||
|
||||
async def execute(self, msg, args: str, user_jid: str, room_jid: str = None):
|
||||
if not room_jid:
|
||||
reply = msg.reply("⚠️ Команда !template_delete работает только в комнатах.")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
if args.startswith(self.name):
|
||||
args = args[len(self.name):].lstrip()
|
||||
name = args.strip()
|
||||
if name.startswith('"') and name.endswith('"'):
|
||||
name = name[1:-1]
|
||||
if not name:
|
||||
reply = msg.reply("Укажите название шаблона: !template_delete \"Название\"")
|
||||
if reply:
|
||||
reply.send()
|
||||
return
|
||||
|
||||
if await self.bot.db.delete_template(room_jid, name):
|
||||
reply = msg.reply(f"🗑 Шаблон '{name}' удалён.")
|
||||
else:
|
||||
reply = msg.reply(f"❌ Шаблон '{name}' не найден.")
|
||||
if reply:
|
||||
reply.send()
|
||||
|
||||
def get_help(self) -> str:
|
||||
return (
|
||||
"Удаляет шаблон по имени. "
|
||||
"Использование: `!template_delete \"Имя шаблона\"` (только в комнате)"
|
||||
)
|
||||
11
bots/handlers/__init__.py
Normal file
11
bots/handlers/__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Пакет handlers – обработчики XMPP-событий для тонких XMPP-клиентов.
|
||||
Содержит:
|
||||
- MetricsHandler – счётчики статистики,
|
||||
- MessageHandler – маршрутизация входящих сообщений,
|
||||
- FileHandler – скачивание файлов,
|
||||
- RoomHandler – управление MUC-комнатами,
|
||||
- SubscriptionHandler – подписки в ростере,
|
||||
- HealthHandler – периодические задачи (очистка, healthcheck).
|
||||
"""
|
||||
241
bots/handlers/message_handler.py
Normal file
241
bots/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()
|
||||
6
bots/workers/__init__.py
Normal file
6
bots/workers/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Пакет workers – фоновые асинхронные воркеры.
|
||||
Содержит:
|
||||
- IndexingWorker – индексация файлов и URL через HTTP-запросы к RAG-серверу.
|
||||
"""
|
||||
324
bots/workers/indexing_worker.py
Normal file
324
bots/workers/indexing_worker.py
Normal file
@@ -0,0 +1,324 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Фоновый воркер (worker) для индексации файлов и URL через RAG-сервер.
|
||||
Читает задачи из asyncio.Queue и выполняет:
|
||||
- Для URL: скачивание, извлечение текста, индексация через HTTP-запрос к серверу.
|
||||
- Для файлов: извлечение текста, обработка изображений/аудио, индексация через HTTP-запрос.
|
||||
|
||||
Все операции с БД и Qdrant выполняются на сервере. Воркер только отправляет данные.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import hashlib
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IndexingWorker:
|
||||
"""
|
||||
Фоновый воркер для индексации.
|
||||
Получает задачи из очереди и выполняет их, отправляя данные на RAG-сервер.
|
||||
"""
|
||||
|
||||
def __init__(self, bot):
|
||||
"""
|
||||
Аргументы:
|
||||
bot: экземпляр Bot (содержит rag_client, config, file_handler и состояния)
|
||||
"""
|
||||
self.bot = bot
|
||||
logger.info("IndexingWorker инициализирован (режим: HTTP-клиент RAG-сервера)")
|
||||
|
||||
async def run(self):
|
||||
"""Бесконечный цикл обработки задач из очереди."""
|
||||
while True:
|
||||
task = await self.bot.indexing_queue.get()
|
||||
try:
|
||||
if task.get("type") == "url":
|
||||
await self._process_url(task)
|
||||
else:
|
||||
await self._process_file(task)
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка в воркере при обработке задачи: {e}")
|
||||
self.bot.metrics_handler.increment("errors_total")
|
||||
finally:
|
||||
self.bot.indexing_queue.task_done()
|
||||
|
||||
# ==========================================================
|
||||
# Обработка URL
|
||||
# ==========================================================
|
||||
|
||||
async def _process_url(self, task):
|
||||
"""
|
||||
Обрабатывает URL-задачу.
|
||||
- Одиночный URL: загружает страницу/PDF, извлекает текст, индексирует через сервер.
|
||||
- Рекурсивный обход: через crawl_url, затем индексирует каждую страницу.
|
||||
"""
|
||||
from core.utils.web_utils import fetch_any_url, crawl_url
|
||||
|
||||
url = task["url"]
|
||||
user_jid = task["user_jid"]
|
||||
room_jid = task.get("room_jid")
|
||||
custom_title = task.get("title")
|
||||
is_global = task.get("is_global", False)
|
||||
recursive = task.get("recursive", False)
|
||||
depth = task.get("depth", 1)
|
||||
max_pages = task.get("max_pages", self.bot.config.ws_max_pages)
|
||||
|
||||
try:
|
||||
if recursive:
|
||||
pages = await crawl_url(url, max_depth=depth, max_pages=max_pages, domain_restrict=True)
|
||||
if not pages:
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"❌ Не удалось загрузить ни одной страницы с {url}",
|
||||
room_jid
|
||||
)
|
||||
return
|
||||
total = 0
|
||||
for page_url, page_title, page_text in pages:
|
||||
if page_text:
|
||||
doc_title = custom_title if custom_title else (page_title if page_title else page_url)
|
||||
# Отправляем на сервер для индексации
|
||||
result = await self.bot.rag_client.index_document(
|
||||
file_name=page_url,
|
||||
file_text=page_text,
|
||||
user_jid=user_jid,
|
||||
room_jid=room_jid,
|
||||
is_global=is_global,
|
||||
title=doc_title,
|
||||
metadata={"url": page_url}
|
||||
)
|
||||
if result.get('doc_id'):
|
||||
total += 1
|
||||
else:
|
||||
logger.error(f"Ошибка индексации {page_url}: {result.get('error')}")
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"✅ Проиндексировано {total} страниц (рекурсивно).",
|
||||
room_jid
|
||||
)
|
||||
else:
|
||||
title, text = await fetch_any_url(url)
|
||||
if text:
|
||||
doc_title = custom_title if custom_title else (title if title else url)
|
||||
result = await self.bot.rag_client.index_document(
|
||||
file_name=url,
|
||||
file_text=text,
|
||||
user_jid=user_jid,
|
||||
room_jid=room_jid,
|
||||
is_global=is_global,
|
||||
title=doc_title,
|
||||
metadata={"url": url}
|
||||
)
|
||||
if result.get('doc_id'):
|
||||
scope = "глобальную" if is_global else ("комнату" if room_jid else "личную")
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"✅ Страница '{doc_title}' добавлена в {scope} БЗ.",
|
||||
room_jid
|
||||
)
|
||||
else:
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"❌ Ошибка индексации {url}: {result.get('error')}",
|
||||
room_jid
|
||||
)
|
||||
else:
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"❌ Не удалось загрузить или извлечь текст из {url}",
|
||||
room_jid
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка индексации URL {url}: {e}")
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"❌ Ошибка при обработке URL: {e}",
|
||||
room_jid
|
||||
)
|
||||
|
||||
# ==========================================================
|
||||
# Обработка файлов
|
||||
# ==========================================================
|
||||
|
||||
async def _process_file(self, task):
|
||||
"""
|
||||
Обрабатывает задачу с файлом:
|
||||
1. Извлекает текст (синхронно в потоке).
|
||||
2. Обрабатывает изображения/аудио (Vision/распознавание) – если есть.
|
||||
3. Сохраняет постоянную копию в data/ (локально, для истории и отладки).
|
||||
4. Отправляет текст и метаданные на RAG-сервер для индексации.
|
||||
5. Удаляет временный файл.
|
||||
"""
|
||||
user_jid = task["user_jid"]
|
||||
room_jid = task.get("room_jid")
|
||||
file_path = task["file_path"]
|
||||
original_fname = task["original_fname"]
|
||||
title = task.get("title")
|
||||
is_global = task.get("is_global", False)
|
||||
mode = task.get("mode")
|
||||
ext = task.get("ext", "").lower()
|
||||
|
||||
try:
|
||||
# --- 1. Извлечение текста (синхронная операция в потоке) ---
|
||||
result = await asyncio.to_thread(self.bot.files.process_any_file, file_path)
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
first, second = result
|
||||
if isinstance(first, list):
|
||||
files_list = first
|
||||
total_count = second
|
||||
else:
|
||||
files_list = [(original_fname, first)] if first else []
|
||||
total_count = second
|
||||
else:
|
||||
files_list = [(original_fname, result)] if result else []
|
||||
total_count = 1 if result else 0
|
||||
|
||||
if total_count == 0:
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"⚠️ Не удалось извлечь текст из {original_fname}.",
|
||||
room_jid
|
||||
)
|
||||
return
|
||||
|
||||
# Сохраняем текст для локального использования (например, !summary)
|
||||
full_text = "\n\n".join([t for _, t in files_list if t])
|
||||
self.bot.last_file_texts[user_jid] = full_text
|
||||
|
||||
# --- 2. Сохранение постоянной копии в data/ (локально) ---
|
||||
# Это нужно для команд !summary и хирургической замены, которые работают с файлом локально.
|
||||
# В будущем можно перенести и их на сервер, но пока оставляем.
|
||||
with open(file_path, 'rb') as f:
|
||||
file_hash = hashlib.sha256(f.read()).hexdigest()
|
||||
short_hash = file_hash[:12]
|
||||
safe_user = user_jid.replace('@', '_at_').replace('/', '_')
|
||||
safe_fname = original_fname.replace('/', '_').replace('\\', '_')
|
||||
dest_filename = f"user_{short_hash}_{safe_user}_{safe_fname}"
|
||||
dest_path = self.bot.config.data_dir / dest_filename
|
||||
|
||||
if not dest_path.exists():
|
||||
shutil.copy2(file_path, dest_path)
|
||||
logger.debug(f"Создана постоянная копия: {dest_path}")
|
||||
else:
|
||||
logger.debug(f"Постоянная копия уже существует: {dest_path}")
|
||||
|
||||
key = (user_jid, room_jid) if room_jid is not None else user_jid
|
||||
self.bot._last_file_path[key] = str(dest_path)
|
||||
|
||||
# --- 3. Специальная обработка: изображения (Vision API) ---
|
||||
# Если это изображение и включена опция vision, отправляем на анализ.
|
||||
# Это можно делать отдельно, не блокируя индексацию.
|
||||
if ext in ('.jpg', '.jpeg', '.png', '.bmp', '.tiff') and self.bot.config.vision:
|
||||
file_id = await self.bot.giga.upload_file(file_path) # giga всё ещё локально? нужно будет перенести и это.
|
||||
# Это временно, пока мы не вынесли всё на сервер. Пока оставим как есть.
|
||||
if file_id:
|
||||
answer = await self.bot.giga.chat(
|
||||
history=[],
|
||||
query="Проанализируй это изображение. Если есть текст – прочитай его.",
|
||||
system_prompt=None,
|
||||
file_id=file_id,
|
||||
temperature=self.bot.config.ai_temperature
|
||||
)
|
||||
await self._send_message(user_jid, answer, room_jid)
|
||||
else:
|
||||
await self._send_message(user_jid, "⚠️ Не удалось передать изображение в GigaChat.", room_jid)
|
||||
# После обработки изображения мы не индексируем его как документ (обычно OCR делает это),
|
||||
# но можно и проиндексировать текст. Здесь мы просто возвращаемся.
|
||||
return
|
||||
|
||||
# --- 4. Специальная обработка: аудио (голос → текст) ---
|
||||
# Аналогично, используем локальный SaluteSpeech, пока не вынесли.
|
||||
if ext in ('.ogg', '.wav', '.mp3', '.amr', '.m4a') and self.bot.config.voice_recognition:
|
||||
first_text = files_list[0][1] if files_list else ""
|
||||
if "--- РАСПОЗНАННЫЙ ГОЛОС ---" in first_text:
|
||||
voice_text = first_text.split("--- РАСПОЗНАННЫЙ ГОЛОС ---")[1].strip()
|
||||
await self._send_message(user_jid, f"🎤 Распознано: {voice_text}", room_jid)
|
||||
# Можно также ответить на голос, но это отдельный сценарий.
|
||||
# Мы не индексируем аудио как документ (разве что текст распознавания).
|
||||
# Если хотим индексировать текст голоса, нужно передать его как документ.
|
||||
# Например, можно создать виртуальный документ с текстом.
|
||||
else:
|
||||
await self._send_message(user_jid, f"⚠️ {first_text}", room_jid)
|
||||
return
|
||||
|
||||
# --- 5. Индексация документов через RAG-сервер ---
|
||||
# Отправляем на сервер каждый извлечённый файл (включая вложенные из архивов)
|
||||
if mode in ('personal', 'global') or (mode == 'room' and room_jid):
|
||||
saved = 0
|
||||
for sub_name, sub_text in files_list:
|
||||
if sub_text:
|
||||
result = await self.bot.rag_client.index_document(
|
||||
file_name=sub_name,
|
||||
file_text=sub_text,
|
||||
user_jid=user_jid,
|
||||
room_jid=room_jid,
|
||||
is_global=is_global,
|
||||
title=title,
|
||||
# file_hash=file_hash, # можно передать, если нужно
|
||||
update_if_exists=True
|
||||
)
|
||||
if result.get('doc_id'):
|
||||
saved += 1
|
||||
else:
|
||||
logger.error(f"Ошибка индексации {sub_name}: {result.get('error')}")
|
||||
if saved:
|
||||
if room_jid:
|
||||
scope = f"комнату {room_jid}"
|
||||
elif is_global:
|
||||
scope = "глобальную"
|
||||
else:
|
||||
scope = "личную"
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"🎓 {saved} файлов добавлено в {scope} БЗ.",
|
||||
room_jid
|
||||
)
|
||||
else:
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"⚠️ Не удалось проиндексировать {original_fname}. Проверьте логи.",
|
||||
room_jid
|
||||
)
|
||||
else:
|
||||
# Если режим обучения не включён (или не совпадает), просто показываем текст
|
||||
preview = full_text[:500] + ("..." if len(full_text) > 500 else "")
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"📄 Файл `{original_fname}` прочитан.\n\n{preview}",
|
||||
room_jid
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception(f"Ошибка при обработке файла {original_fname}: {e}")
|
||||
self.bot.metrics_handler.increment("errors_total")
|
||||
await self._send_message(
|
||||
user_jid,
|
||||
f"❌ Ошибка при обработке файла '{original_fname}'. Проверьте логи.",
|
||||
room_jid
|
||||
)
|
||||
finally:
|
||||
# Удаляем временный файл (постоянная копия уже сохранена)
|
||||
try:
|
||||
if os.path.exists(file_path):
|
||||
os.unlink(file_path)
|
||||
logger.debug(f"Временный файл удалён: {file_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось удалить временный файл {file_path}: {e}")
|
||||
|
||||
# ==========================================================
|
||||
# Вспомогательные методы
|
||||
# ==========================================================
|
||||
|
||||
async def _send_message(self, user_jid: str, body: str, room_jid: str = None):
|
||||
"""
|
||||
Отправляет сообщение пользователю (в личный чат или комнату).
|
||||
Используется для уведомлений о результатах индексации.
|
||||
"""
|
||||
if room_jid:
|
||||
self.bot.send_message(mto=room_jid, mbody=body, mtype='groupchat')
|
||||
else:
|
||||
self.bot.send_message(mto=user_jid, mbody=body, mtype='chat')
|
||||
7
bots/xmpp/__init__.py
Normal file
7
bots/xmpp/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Пакет xmpp – XMPP-клиент для тонких ботов-профилей.
|
||||
Содержит класс Bot, наследующий slixmpp.ClientXMPP.
|
||||
Бот подключается к ejabberd, обрабатывает команды и файлы,
|
||||
и отправляет RAG-запросы через HTTP-клиент (RAGClient) в RAG-сервер.
|
||||
"""
|
||||
180
bots/xmpp/client.py
Normal file
180
bots/xmpp/client.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
XMPP клиент – главный класс Bot.
|
||||
Теперь это тонкий клиент, который только общается с RAG-сервером.
|
||||
Не содержит БД, Qdrant, GigaChat – всё на сервере.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from slixmpp import ClientXMPP
|
||||
|
||||
from core.utils.config_loader import BotConfig
|
||||
from core.services.file_service import FileService
|
||||
from core.handlers.metrics_handler import MetricsHandler
|
||||
from core.rag_client import RAGClient
|
||||
from core.commands.registry import register_commands
|
||||
from core.handlers.message_handler import MessageHandler
|
||||
from core.handlers.file_handler import FileHandler
|
||||
from core.handlers.room_handler import RoomHandler
|
||||
from core.handlers.subscription_handler import SubscriptionHandler
|
||||
from core.handlers.health_handler import HealthHandler
|
||||
from core.workers.indexing_worker import IndexingWorker
|
||||
from core.functions.file_processor import handle_ai_file_request as _handle_ai_file_request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Bot(ClientXMPP):
|
||||
"""
|
||||
Основной класс XMPP бота.
|
||||
Тонкий клиент: вся RAG-логика на сервере.
|
||||
"""
|
||||
|
||||
def __init__(self, config: BotConfig):
|
||||
# Полный JID: user@domain/resource
|
||||
full_jid = f"{config.jid}/{config.xmpp_resource}"
|
||||
super().__init__(full_jid, config.xmpp_password)
|
||||
self.config = config
|
||||
|
||||
# ----- 1. Локальные сервисы (только для файлов и метрик) -----
|
||||
# FileService нужен для извлечения текста из файлов перед отправкой на сервер
|
||||
self.files = FileService(config)
|
||||
# Метрики (счётчики сообщений, ошибок и т.д.)
|
||||
self.metrics_handler = MetricsHandler()
|
||||
|
||||
# ----- 2. Загрузка промптов (остаются локально) -----
|
||||
# Промпты загружаются из файлов и передаются в каждом запросе к серверу.
|
||||
self.ai_prompts = {}
|
||||
prompt_files = {
|
||||
'system': config.system_prompt_file,
|
||||
'synthesis': config.synthesis_prompt_file,
|
||||
'quality': config.quality_prompt_file,
|
||||
'rerank': config.rerank_prompt_file,
|
||||
'expand': config.expand_prompt_file,
|
||||
'intent': config.intent_prompt_file,
|
||||
'metrics': config.metrics_prompt_file,
|
||||
'summary': config.summary_prompt_file,
|
||||
'consistency': config.consistency_prompt_file,
|
||||
'critique': config.critique_prompt_file,
|
||||
'spellcheck': config.spellcheck_prompt_file,
|
||||
'generate_document': config.generate_document_prompt_file,
|
||||
}
|
||||
for name, path in prompt_files.items():
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
self.ai_prompts[name] = f.read()
|
||||
logger.debug(f"Загружен промпт {name} из {path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось загрузить промпт {name} из {path}: {e}")
|
||||
self.ai_prompts[name] = ""
|
||||
# Сохраняем промпты как атрибут для передачи в запросах
|
||||
self.prompts = self.ai_prompts
|
||||
|
||||
# ----- 3. HTTP-клиент для RAG-сервера -----
|
||||
# Адрес сервера берём из конфига (bot.conf) или используем значение по умолчанию
|
||||
rag_server_url = getattr(config, 'rag_server_url', 'http://localhost:8080')
|
||||
self.rag_client = RAGClient(server_url=rag_server_url)
|
||||
|
||||
# ----- 4. Команды и состояния -----
|
||||
# Регистрируем все команды бота (из core/commands/)
|
||||
self.commands = register_commands(self)
|
||||
self._command_names = [cmd.name for cmd in self.commands.values() if cmd.name]
|
||||
|
||||
# Временные состояния (хранятся в памяти)
|
||||
self.learning_mode = {} # user_jid -> (mode, room_jid)
|
||||
self.last_file_texts = {} # user_jid -> текст последнего файла
|
||||
self._last_file_path = {} # key -> путь к последнему файлу
|
||||
self.indexing_queue = None # очередь для фоновой индексации
|
||||
self.rooms = [] # комнаты, в которых бот состоит (загружается позже)
|
||||
|
||||
# ----- 5. Хендлеры -----
|
||||
self.message_handler = MessageHandler(self)
|
||||
self.file_handler = FileHandler(self)
|
||||
self.room_handler = RoomHandler(self)
|
||||
self.subscription_handler = SubscriptionHandler(self)
|
||||
self.health_handler = HealthHandler(self)
|
||||
self.indexing_worker = IndexingWorker(self)
|
||||
|
||||
# ----- 6. XMPP плагины -----
|
||||
self.register_plugin('xep_0030') # Service Discovery
|
||||
self.register_plugin('xep_0199') # Ping
|
||||
self.register_plugin('xep_0045') # Multi-User Chat
|
||||
self.register_plugin('xep_0054') # VCard
|
||||
self.register_plugin('xep_0172') # User Nickname
|
||||
self.register_plugin('xep_0066') # Out of Band Data
|
||||
self.register_plugin('xep_0363') # HTTP File Upload
|
||||
|
||||
# ----- 7. Обработчики событий slixmpp -----
|
||||
self.roster_ready = asyncio.Event()
|
||||
self.add_event_handler("session_start", self.session_start)
|
||||
self.add_event_handler("message", self.message_handler.handle)
|
||||
self.add_event_handler("groupchat_invite", self.room_handler.accept_invite)
|
||||
self.add_event_handler("roster_subscription_request", self.subscription_handler.handle_subscription)
|
||||
self.add_event_handler('roster_update', self._on_roster_ready)
|
||||
self.add_event_handler("muc::kicked", self.room_handler.on_kicked)
|
||||
self.add_event_handler("muc::banned", self.room_handler.on_banned)
|
||||
|
||||
# Время старта бота (для команды !stats)
|
||||
self.start_time = datetime.now()
|
||||
|
||||
def _on_roster_ready(self, *args):
|
||||
"""Вызывается, когда ростер загружен."""
|
||||
self.roster_ready.set()
|
||||
|
||||
def is_message_encrypted(self, msg) -> bool:
|
||||
"""Проверяет, зашифровано ли сообщение (OMEMO). Заглушка."""
|
||||
return False
|
||||
|
||||
async def handle_ai_file_request(self, msg, ai_response: str, user_jid: str, room_jid: str = None, body: str = None):
|
||||
"""
|
||||
Обрабатывает теги [SURGICAL_REPLACE], [REWRITE], [FILE] из ответа AI.
|
||||
"""
|
||||
await _handle_ai_file_request(self, msg, ai_response, user_jid, room_jid, body=body)
|
||||
|
||||
async def session_start(self, event):
|
||||
"""
|
||||
Обработчик успешного подключения и авторизации.
|
||||
"""
|
||||
# Устанавливаем статус присутствия
|
||||
self.send_presence(pshow='chat', pstatus=self.config.status_message)
|
||||
self.get_roster()
|
||||
logger.info(f"Бот {self.config.name} запущен, JID: {self.boundjid}")
|
||||
|
||||
# Ждём загрузки ростера (максимум 10 секунд)
|
||||
try:
|
||||
await asyncio.wait_for(self.roster_ready.wait(), timeout=10.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Таймаут ожидания ростера")
|
||||
|
||||
# Отправляем запросы на подписку администраторам
|
||||
for admin_jid in self.config.admin_jids:
|
||||
if admin_jid in self.roster:
|
||||
item = self.roster[admin_jid]
|
||||
if item and item.get('subscription') in ('both', 'from'):
|
||||
continue
|
||||
self.send_presence(pto=admin_jid, ptype='subscribe')
|
||||
logger.info(f"Отправлен запрос подписки для администратора {admin_jid}")
|
||||
|
||||
# Создаём очередь индексации и запускаем фоновый воркер
|
||||
self.indexing_queue = asyncio.Queue()
|
||||
asyncio.create_task(self.indexing_worker.run())
|
||||
|
||||
# Загружаем комнаты из конфига или БД. Пока оставляем пустым.
|
||||
# Бот будет входить в комнаты только по приглашениям.
|
||||
self.rooms = []
|
||||
|
||||
# Входим в комнаты (если есть)
|
||||
for room in self.rooms:
|
||||
try:
|
||||
await self.plugin['xep_0045'].join_muc(room, self.boundjid.user)
|
||||
logger.info(f"Вошли в комнату {room}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка входа в комнату {room}: {e}")
|
||||
|
||||
# Запускаем фоновые задачи:
|
||||
# - Переподключение к комнатам
|
||||
# - Очистка временных файлов
|
||||
asyncio.create_task(self.room_handler.rejoin_loop())
|
||||
asyncio.create_task(self.health_handler.cleanup_loop())
|
||||
Reference in New Issue
Block a user