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 \"Имя шаблона\"` (только в комнате)"
|
||||
)
|
||||
Reference in New Issue
Block a user