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:
Markov Andrey
2026-06-30 10:33:28 +00:00
parent ea0d42de9c
commit 63900feece
45 changed files with 0 additions and 265 deletions

View File

@@ -1,7 +0,0 @@
# -*- coding: utf-8 -*-
"""
Пакет commands команды XMPP-ботов (каждая команда — отдельный класс).
Команды реализуют пользовательский интерфейс:
- info, help, learn, kb, stats, expert, reset, template, create, global_remove.
Регистрация команд осуществляется через registry.py.
"""

View File

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

View File

@@ -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 \"Название шаблона\"` (только в комнате)"
)

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 \"Имя шаблона\"` (только в комнате)"
)