Добавлены методы для работы с настройками авто-модерации, включая получение и установку значений, а также переключение состояний авто-публикации и авто-отклонения. Обновлены соответствующие репозитории и обработчики для интеграции новых функций в админ-панели.
Some checks are pending
CI pipeline / Test & Code Quality (push) Waiting to run
Some checks are pending
CI pipeline / Test & Code Quality (push) Waiting to run
This commit is contained in:
@@ -572,3 +572,32 @@ class AsyncBotDB:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing query: {e}")
|
||||
return None
|
||||
|
||||
# Методы для работы с настройками бота
|
||||
async def get_auto_moderation_settings(self) -> Dict[str, Any]:
|
||||
"""Получает все настройки авто-модерации."""
|
||||
return await self.factory.bot_settings.get_auto_moderation_settings()
|
||||
|
||||
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
|
||||
"""Получает булево значение настройки."""
|
||||
return await self.factory.bot_settings.get_bool_setting(key, default)
|
||||
|
||||
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
|
||||
"""Получает числовое значение настройки."""
|
||||
return await self.factory.bot_settings.get_float_setting(key, default)
|
||||
|
||||
async def set_setting(self, key: str, value: str) -> None:
|
||||
"""Устанавливает значение настройки."""
|
||||
await self.factory.bot_settings.set_setting(key, value)
|
||||
|
||||
async def set_float_setting(self, key: str, value: float) -> None:
|
||||
"""Устанавливает числовое значение настройки."""
|
||||
await self.factory.bot_settings.set_float_setting(key, value)
|
||||
|
||||
async def toggle_auto_publish(self) -> bool:
|
||||
"""Переключает состояние авто-публикации."""
|
||||
return await self.factory.bot_settings.toggle_auto_publish()
|
||||
|
||||
async def toggle_auto_decline(self) -> bool:
|
||||
"""Переключает состояние авто-отклонения."""
|
||||
return await self.factory.bot_settings.toggle_auto_decline()
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
- admin_repository: работа с администраторами
|
||||
- audio_repository: работа с аудио
|
||||
- migration_repository: работа с миграциями БД
|
||||
- bot_settings_repository: работа с настройками бота
|
||||
"""
|
||||
|
||||
from .admin_repository import AdminRepository
|
||||
from .audio_repository import AudioRepository
|
||||
from .blacklist_history_repository import BlacklistHistoryRepository
|
||||
from .blacklist_repository import BlacklistRepository
|
||||
from .bot_settings_repository import BotSettingsRepository
|
||||
from .message_repository import MessageRepository
|
||||
from .migration_repository import MigrationRepository
|
||||
from .post_repository import PostRepository
|
||||
@@ -30,4 +32,5 @@ __all__ = [
|
||||
"AdminRepository",
|
||||
"AudioRepository",
|
||||
"MigrationRepository",
|
||||
"BotSettingsRepository",
|
||||
]
|
||||
|
||||
160
database/repositories/bot_settings_repository.py
Normal file
160
database/repositories/bot_settings_repository.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Репозиторий для работы с настройками бота."""
|
||||
|
||||
from typing import Dict, Optional
|
||||
|
||||
from database.base import DatabaseConnection
|
||||
|
||||
|
||||
class BotSettingsRepository(DatabaseConnection):
|
||||
"""Репозиторий для управления настройками бота в таблице bot_settings."""
|
||||
|
||||
async def create_table(self) -> None:
|
||||
"""Создает таблицу bot_settings, если она не существует."""
|
||||
query = """
|
||||
CREATE TABLE IF NOT EXISTS bot_settings (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
)
|
||||
"""
|
||||
await self._execute_query(query)
|
||||
self.logger.info("Таблица bot_settings создана или уже существует")
|
||||
|
||||
async def get_setting(self, key: str) -> Optional[str]:
|
||||
"""
|
||||
Получает значение настройки по ключу.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
|
||||
Returns:
|
||||
Значение настройки или None, если не найдено
|
||||
"""
|
||||
query = "SELECT value FROM bot_settings WHERE key = ?"
|
||||
rows = await self._execute_query_with_result(query, (key,))
|
||||
if rows and len(rows) > 0:
|
||||
return rows[0][0]
|
||||
return None
|
||||
|
||||
async def set_setting(self, key: str, value: str) -> None:
|
||||
"""
|
||||
Устанавливает значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Значение настройки
|
||||
"""
|
||||
query = """
|
||||
INSERT INTO bot_settings (key, value, updated_at)
|
||||
VALUES (?, ?, strftime('%s', 'now'))
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value,
|
||||
updated_at = strftime('%s', 'now')
|
||||
"""
|
||||
await self._execute_query(query, (key, value))
|
||||
self.logger.debug(f"Настройка {key} установлена: {value}")
|
||||
|
||||
async def get_bool_setting(self, key: str, default: bool = False) -> bool:
|
||||
"""
|
||||
Получает булево значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
True если значение 'true', иначе False
|
||||
"""
|
||||
value = await self.get_setting(key)
|
||||
if value is None:
|
||||
return default
|
||||
return value.lower() == "true"
|
||||
|
||||
async def set_bool_setting(self, key: str, value: bool) -> None:
|
||||
"""
|
||||
Устанавливает булево значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Булево значение
|
||||
"""
|
||||
await self.set_setting(key, "true" if value else "false")
|
||||
|
||||
async def get_float_setting(self, key: str, default: float = 0.0) -> float:
|
||||
"""
|
||||
Получает числовое значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
default: Значение по умолчанию
|
||||
|
||||
Returns:
|
||||
Числовое значение или default
|
||||
"""
|
||||
value = await self.get_setting(key)
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except ValueError:
|
||||
self.logger.warning(
|
||||
f"Невозможно преобразовать значение '{value}' в float для ключа '{key}'"
|
||||
)
|
||||
return default
|
||||
|
||||
async def set_float_setting(self, key: str, value: float) -> None:
|
||||
"""
|
||||
Устанавливает числовое значение настройки.
|
||||
|
||||
Args:
|
||||
key: Ключ настройки
|
||||
value: Числовое значение
|
||||
"""
|
||||
await self.set_setting(key, str(value))
|
||||
|
||||
async def get_auto_moderation_settings(self) -> Dict[str, any]:
|
||||
"""
|
||||
Получает все настройки авто-модерации.
|
||||
|
||||
Returns:
|
||||
Словарь с настройками авто-модерации
|
||||
"""
|
||||
return {
|
||||
"auto_publish_enabled": await self.get_bool_setting(
|
||||
"auto_publish_enabled", False
|
||||
),
|
||||
"auto_decline_enabled": await self.get_bool_setting(
|
||||
"auto_decline_enabled", False
|
||||
),
|
||||
"auto_publish_threshold": await self.get_float_setting(
|
||||
"auto_publish_threshold", 0.8
|
||||
),
|
||||
"auto_decline_threshold": await self.get_float_setting(
|
||||
"auto_decline_threshold", 0.4
|
||||
),
|
||||
}
|
||||
|
||||
async def toggle_auto_publish(self) -> bool:
|
||||
"""
|
||||
Переключает состояние авто-публикации.
|
||||
|
||||
Returns:
|
||||
Новое состояние (True/False)
|
||||
"""
|
||||
current = await self.get_bool_setting("auto_publish_enabled", False)
|
||||
new_value = not current
|
||||
await self.set_bool_setting("auto_publish_enabled", new_value)
|
||||
return new_value
|
||||
|
||||
async def toggle_auto_decline(self) -> bool:
|
||||
"""
|
||||
Переключает состояние авто-отклонения.
|
||||
|
||||
Returns:
|
||||
Новое состояние (True/False)
|
||||
"""
|
||||
current = await self.get_bool_setting("auto_decline_enabled", False)
|
||||
new_value = not current
|
||||
await self.set_bool_setting("auto_decline_enabled", new_value)
|
||||
return new_value
|
||||
@@ -6,6 +6,7 @@ from database.repositories.blacklist_history_repository import (
|
||||
BlacklistHistoryRepository,
|
||||
)
|
||||
from database.repositories.blacklist_repository import BlacklistRepository
|
||||
from database.repositories.bot_settings_repository import BotSettingsRepository
|
||||
from database.repositories.message_repository import MessageRepository
|
||||
from database.repositories.migration_repository import MigrationRepository
|
||||
from database.repositories.post_repository import PostRepository
|
||||
@@ -25,6 +26,7 @@ class RepositoryFactory:
|
||||
self._admin_repo: Optional[AdminRepository] = None
|
||||
self._audio_repo: Optional[AudioRepository] = None
|
||||
self._migration_repo: Optional[MigrationRepository] = None
|
||||
self._bot_settings_repo: Optional[BotSettingsRepository] = None
|
||||
|
||||
@property
|
||||
def users(self) -> UserRepository:
|
||||
@@ -82,6 +84,13 @@ class RepositoryFactory:
|
||||
self._migration_repo = MigrationRepository(self.db_path)
|
||||
return self._migration_repo
|
||||
|
||||
@property
|
||||
def bot_settings(self) -> BotSettingsRepository:
|
||||
"""Возвращает репозиторий настроек бота."""
|
||||
if self._bot_settings_repo is None:
|
||||
self._bot_settings_repo = BotSettingsRepository(self.db_path)
|
||||
return self._bot_settings_repo
|
||||
|
||||
async def create_all_tables(self):
|
||||
"""Создает все таблицы в базе данных."""
|
||||
await self.migrations.create_table() # Сначала создаем таблицу миграций
|
||||
@@ -92,6 +101,7 @@ class RepositoryFactory:
|
||||
await self.posts.create_tables()
|
||||
await self.admins.create_tables()
|
||||
await self.audio.create_tables()
|
||||
await self.bot_settings.create_table()
|
||||
|
||||
async def check_database_integrity(self):
|
||||
"""Проверяет целостность базы данных."""
|
||||
|
||||
@@ -21,6 +21,7 @@ from helper_bot.keyboards.keyboards import (
|
||||
create_keyboard_for_ban_days,
|
||||
create_keyboard_for_ban_reason,
|
||||
create_keyboard_with_pagination,
|
||||
get_auto_moderation_keyboard,
|
||||
get_reply_keyboard_admin,
|
||||
)
|
||||
from helper_bot.utils.base_dependency_factory import get_global_instance
|
||||
@@ -250,6 +251,268 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs):
|
||||
await message.answer(f"❌ Ошибка получения статистики: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ХЕНДЛЕРЫ АВТО-МОДЕРАЦИИ
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("ADMIN"),
|
||||
F.text == "⚙️ Авто-модерация",
|
||||
)
|
||||
@track_time("auto_moderation_menu", "admin_handlers")
|
||||
@track_errors("admin_handlers", "auto_moderation_menu")
|
||||
async def auto_moderation_menu(
|
||||
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Меню управления авто-модерацией"""
|
||||
try:
|
||||
logger.info(
|
||||
f"Открытие меню авто-модерации пользователем: {message.from_user.full_name}"
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка открытия меню авто-модерации: {e}")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
def _format_auto_moderation_status(settings: dict) -> str:
|
||||
"""Форматирует текст статуса авто-модерации."""
|
||||
auto_publish = settings.get("auto_publish_enabled", False)
|
||||
auto_decline = settings.get("auto_decline_enabled", False)
|
||||
publish_threshold = settings.get("auto_publish_threshold", 0.8)
|
||||
decline_threshold = settings.get("auto_decline_threshold", 0.4)
|
||||
|
||||
publish_status = "✅ Включена" if auto_publish else "❌ Выключена"
|
||||
decline_status = "✅ Включено" if auto_decline else "❌ Выключено"
|
||||
|
||||
return (
|
||||
"⚙️ <b>Авто-модерация постов</b>\n\n"
|
||||
f"🤖 <b>Авто-публикация:</b> {publish_status}\n"
|
||||
f" Порог: RAG score ≥ <b>{publish_threshold}</b>\n\n"
|
||||
f"🚫 <b>Авто-отклонение:</b> {decline_status}\n"
|
||||
f" Порог: RAG score ≤ <b>{decline_threshold}</b>"
|
||||
)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_toggle_publish")
|
||||
@track_time("toggle_auto_publish", "admin_handlers")
|
||||
@track_errors("admin_handlers", "toggle_auto_publish")
|
||||
async def toggle_auto_publish(call: types.CallbackQuery, bot_db: MagicData("bot_db")):
|
||||
"""Переключение авто-публикации"""
|
||||
try:
|
||||
new_state = await bot_db.toggle_auto_publish()
|
||||
logger.info(
|
||||
f"Авто-публикация {'включена' if new_state else 'выключена'} "
|
||||
f"пользователем {call.from_user.full_name}"
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await call.answer(
|
||||
f"Авто-публикация {'включена ✅' if new_state else 'выключена ❌'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения авто-публикации: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_toggle_decline")
|
||||
@track_time("toggle_auto_decline", "admin_handlers")
|
||||
@track_errors("admin_handlers", "toggle_auto_decline")
|
||||
async def toggle_auto_decline(call: types.CallbackQuery, bot_db: MagicData("bot_db")):
|
||||
"""Переключение авто-отклонения"""
|
||||
try:
|
||||
new_state = await bot_db.toggle_auto_decline()
|
||||
logger.info(
|
||||
f"Авто-отклонение {'включено' if new_state else 'выключено'} "
|
||||
f"пользователем {call.from_user.full_name}"
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
await call.answer(
|
||||
f"Авто-отклонение {'включено ✅' if new_state else 'выключено ❌'}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка переключения авто-отклонения: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_refresh")
|
||||
@track_time("refresh_auto_moderation", "admin_handlers")
|
||||
@track_errors("admin_handlers", "refresh_auto_moderation")
|
||||
async def refresh_auto_moderation(
|
||||
call: types.CallbackQuery, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Обновление статуса авто-модерации"""
|
||||
try:
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
|
||||
try:
|
||||
await call.message.edit_text(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
except Exception as edit_error:
|
||||
if "message is not modified" in str(edit_error):
|
||||
pass # Сообщение не изменилось - это нормально
|
||||
else:
|
||||
raise
|
||||
await call.answer("🔄 Обновлено")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обновления статуса авто-модерации: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_threshold_publish")
|
||||
@track_time("change_publish_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "change_publish_threshold")
|
||||
async def change_publish_threshold(
|
||||
call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Начало изменения порога авто-публикации"""
|
||||
try:
|
||||
await state.set_state("AWAIT_PUBLISH_THRESHOLD")
|
||||
await call.message.answer(
|
||||
"📈 <b>Изменение порога авто-публикации</b>\n\n"
|
||||
"Введите новое значение порога (от 0.0 до 1.0).\n"
|
||||
"Посты с RAG score ≥ этого значения будут автоматически публиковаться.\n\n"
|
||||
"Текущее рекомендуемое значение: <b>0.8</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await call.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка начала изменения порога публикации: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.callback_query(F.data == "auto_mod_threshold_decline")
|
||||
@track_time("change_decline_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "change_decline_threshold")
|
||||
async def change_decline_threshold(
|
||||
call: types.CallbackQuery, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Начало изменения порога авто-отклонения"""
|
||||
try:
|
||||
await state.set_state("AWAIT_DECLINE_THRESHOLD")
|
||||
await call.message.answer(
|
||||
"📉 <b>Изменение порога авто-отклонения</b>\n\n"
|
||||
"Введите новое значение порога (от 0.0 до 1.0).\n"
|
||||
"Посты с RAG score ≤ этого значения будут автоматически отклоняться.\n\n"
|
||||
"Текущее рекомендуемое значение: <b>0.4</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
await call.answer()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка начала изменения порога отклонения: {e}")
|
||||
await call.answer(f"❌ Ошибка: {str(e)}", show_alert=True)
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("AWAIT_PUBLISH_THRESHOLD"),
|
||||
)
|
||||
@track_time("process_publish_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "process_publish_threshold")
|
||||
async def process_publish_threshold(
|
||||
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Обработка нового порога авто-публикации"""
|
||||
try:
|
||||
value = float(message.text.strip().replace(",", "."))
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError("Значение должно быть от 0.0 до 1.0")
|
||||
|
||||
await bot_db.set_float_setting("auto_publish_threshold", value)
|
||||
logger.info(
|
||||
f"Порог авто-публикации изменен на {value} "
|
||||
f"пользователем {message.from_user.full_name}"
|
||||
)
|
||||
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(
|
||||
f"✅ Порог авто-публикации изменен на <b>{value}</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(
|
||||
f"❌ Неверное значение: {e}\n"
|
||||
"Введите число от 0.0 до 1.0 (например: 0.8)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения порога публикации: {e}")
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
@admin_router.message(
|
||||
ChatTypeFilter(chat_type=["private"]),
|
||||
StateFilter("AWAIT_DECLINE_THRESHOLD"),
|
||||
)
|
||||
@track_time("process_decline_threshold", "admin_handlers")
|
||||
@track_errors("admin_handlers", "process_decline_threshold")
|
||||
async def process_decline_threshold(
|
||||
message: types.Message, state: FSMContext, bot_db: MagicData("bot_db")
|
||||
):
|
||||
"""Обработка нового порога авто-отклонения"""
|
||||
try:
|
||||
value = float(message.text.strip().replace(",", "."))
|
||||
if not 0.0 <= value <= 1.0:
|
||||
raise ValueError("Значение должно быть от 0.0 до 1.0")
|
||||
|
||||
await bot_db.set_float_setting("auto_decline_threshold", value)
|
||||
logger.info(
|
||||
f"Порог авто-отклонения изменен на {value} "
|
||||
f"пользователем {message.from_user.full_name}"
|
||||
)
|
||||
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(
|
||||
f"✅ Порог авто-отклонения изменен на <b>{value}</b>",
|
||||
parse_mode="HTML",
|
||||
)
|
||||
|
||||
settings = await bot_db.get_auto_moderation_settings()
|
||||
text = _format_auto_moderation_status(settings)
|
||||
keyboard = get_auto_moderation_keyboard(settings)
|
||||
await message.answer(text, reply_markup=keyboard, parse_mode="HTML")
|
||||
|
||||
except ValueError as e:
|
||||
await message.answer(
|
||||
f"❌ Неверное значение: {e}\n"
|
||||
"Введите число от 0.0 до 1.0 (например: 0.4)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка изменения порога отклонения: {e}")
|
||||
await state.set_state("ADMIN")
|
||||
await message.answer(f"❌ Ошибка: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
|
||||
# ============================================================================
|
||||
|
||||
@@ -31,7 +31,13 @@ from helper_bot.utils.metrics import db_query_time, track_errors, track_time
|
||||
# Local imports - modular components
|
||||
from .constants import BUTTON_TEXTS, ERROR_MESSAGES, FSM_STATES
|
||||
from .decorators import error_handler
|
||||
from .services import BotSettings, PostService, StickerService, UserService
|
||||
from .services import (
|
||||
AutoModerationService,
|
||||
BotSettings,
|
||||
PostService,
|
||||
StickerService,
|
||||
UserService,
|
||||
)
|
||||
|
||||
# Expose sleep for tests (tests patch helper_bot.handlers.private.private_handlers.sleep)
|
||||
sleep = asyncio.sleep
|
||||
@@ -50,7 +56,12 @@ class PrivateHandlers:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.user_service = UserService(db, settings)
|
||||
self.post_service = PostService(db, settings, s3_storage, scoring_manager)
|
||||
self.auto_moderation_service = AutoModerationService(
|
||||
db, settings, scoring_manager, s3_storage
|
||||
)
|
||||
self.post_service = PostService(
|
||||
db, settings, s3_storage, scoring_manager, self.auto_moderation_service
|
||||
)
|
||||
self.sticker_service = StickerService(settings)
|
||||
|
||||
self.router = Router()
|
||||
|
||||
@@ -22,6 +22,7 @@ from helper_bot.utils.helper_func import (
|
||||
check_username_and_full_name,
|
||||
determine_anonymity,
|
||||
get_first_name,
|
||||
get_publish_text,
|
||||
get_text_message,
|
||||
prepare_media_group_from_middlewares,
|
||||
send_audio_message,
|
||||
@@ -252,11 +253,13 @@ class PostService:
|
||||
settings: BotSettings,
|
||||
s3_storage=None,
|
||||
scoring_manager=None,
|
||||
auto_moderation_service: "AutoModerationService" = None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.s3_storage = s3_storage
|
||||
self.scoring_manager = scoring_manager
|
||||
self.auto_moderation = auto_moderation_service
|
||||
|
||||
async def _save_media_background(
|
||||
self, sent_message: types.Message, bot_db: Any, s3_storage
|
||||
@@ -379,6 +382,200 @@ class PostService:
|
||||
error_message = "Не удалось рассчитать скоры"
|
||||
return None, None, None, None, None, error_message
|
||||
|
||||
@track_time("_handle_auto_action", "post_service")
|
||||
@track_errors("post_service", "_handle_auto_action")
|
||||
async def _handle_auto_action(
|
||||
self,
|
||||
auto_action: str,
|
||||
message: types.Message,
|
||||
content_type: str,
|
||||
original_raw_text: str,
|
||||
first_name: str,
|
||||
is_anonymous: bool,
|
||||
rag_score: float,
|
||||
ml_scores_json: str = None,
|
||||
album: Union[list, None] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Обрабатывает автоматическое действие (публикация или отклонение).
|
||||
|
||||
Args:
|
||||
auto_action: 'publish' или 'decline'
|
||||
message: Сообщение пользователя
|
||||
content_type: Тип контента
|
||||
original_raw_text: Оригинальный текст поста
|
||||
first_name: Имя автора
|
||||
is_anonymous: Флаг анонимности
|
||||
rag_score: Скор RAG модели
|
||||
ml_scores_json: JSON со скорами для БД
|
||||
album: Медиагруппа (если есть)
|
||||
"""
|
||||
author_id = message.from_user.id
|
||||
author_name = message.from_user.full_name or first_name
|
||||
author_username = message.from_user.username or ""
|
||||
|
||||
try:
|
||||
if auto_action == "publish":
|
||||
await self._auto_publish(
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
original_raw_text=original_raw_text,
|
||||
first_name=first_name,
|
||||
is_anonymous=is_anonymous,
|
||||
rag_score=rag_score,
|
||||
ml_scores_json=ml_scores_json,
|
||||
album=album,
|
||||
)
|
||||
else: # decline
|
||||
await self._auto_decline(message=message, author_id=author_id)
|
||||
|
||||
# Логируем действие
|
||||
if self.auto_moderation:
|
||||
await self.auto_moderation.log_auto_action(
|
||||
bot=message.bot,
|
||||
action=auto_action,
|
||||
author_id=author_id,
|
||||
author_name=author_name,
|
||||
author_username=author_username,
|
||||
rag_score=rag_score,
|
||||
post_text=original_raw_text,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"PostService: Ошибка авто-{auto_action} для message_id={message.message_id}: {e}"
|
||||
)
|
||||
raise
|
||||
|
||||
@track_time("_auto_publish", "post_service")
|
||||
@track_errors("post_service", "_auto_publish")
|
||||
async def _auto_publish(
|
||||
self,
|
||||
message: types.Message,
|
||||
content_type: str,
|
||||
original_raw_text: str,
|
||||
first_name: str,
|
||||
is_anonymous: bool,
|
||||
rag_score: float,
|
||||
ml_scores_json: str = None,
|
||||
album: Union[list, None] = None,
|
||||
) -> None:
|
||||
"""Автоматически публикует пост в канал."""
|
||||
author_id = message.from_user.id
|
||||
username = message.from_user.username
|
||||
|
||||
# Формируем текст для публикации (без скоров и разметки)
|
||||
formatted_text = get_publish_text(
|
||||
original_raw_text, first_name, username, is_anonymous
|
||||
)
|
||||
|
||||
sent_message = None
|
||||
|
||||
# Публикуем в зависимости от типа контента
|
||||
if content_type == "text":
|
||||
sent_message = await message.bot.send_message(
|
||||
chat_id=self.settings.main_public,
|
||||
text=formatted_text,
|
||||
)
|
||||
elif content_type == "photo":
|
||||
sent_message = await message.bot.send_photo(
|
||||
chat_id=self.settings.main_public,
|
||||
photo=message.photo[-1].file_id,
|
||||
caption=formatted_text,
|
||||
)
|
||||
elif content_type == "video":
|
||||
sent_message = await message.bot.send_video(
|
||||
chat_id=self.settings.main_public,
|
||||
video=message.video.file_id,
|
||||
caption=formatted_text,
|
||||
)
|
||||
elif content_type == "audio":
|
||||
sent_message = await message.bot.send_audio(
|
||||
chat_id=self.settings.main_public,
|
||||
audio=message.audio.file_id,
|
||||
caption=formatted_text,
|
||||
)
|
||||
elif content_type == "voice":
|
||||
sent_message = await message.bot.send_voice(
|
||||
chat_id=self.settings.main_public,
|
||||
voice=message.voice.file_id,
|
||||
)
|
||||
elif content_type == "video_note":
|
||||
sent_message = await message.bot.send_video_note(
|
||||
chat_id=self.settings.main_public,
|
||||
video_note=message.video_note.file_id,
|
||||
)
|
||||
elif content_type == "media_group" and album:
|
||||
# TODO: Реализовать авто-публикацию медиагрупп при необходимости
|
||||
logger.warning(
|
||||
"PostService: Авто-публикация медиагрупп пока не поддерживается"
|
||||
)
|
||||
return
|
||||
|
||||
if sent_message:
|
||||
# Сохраняем пост в БД со статусом approved
|
||||
post = TelegramPost(
|
||||
message_id=sent_message.message_id,
|
||||
text=original_raw_text,
|
||||
author_id=author_id,
|
||||
created_at=int(datetime.now().timestamp()),
|
||||
is_anonymous=is_anonymous,
|
||||
status="approved",
|
||||
)
|
||||
await self.db.add_post(post)
|
||||
|
||||
# Сохраняем скоры если есть
|
||||
if ml_scores_json:
|
||||
asyncio.create_task(
|
||||
self._save_scores_background(sent_message.message_id, ml_scores_json)
|
||||
)
|
||||
|
||||
# Индексируем пост в RAG
|
||||
if self.scoring_manager and original_raw_text and original_raw_text.strip():
|
||||
asyncio.create_task(
|
||||
self._add_submitted_post_background(
|
||||
original_raw_text, sent_message.message_id, rag_score
|
||||
)
|
||||
)
|
||||
|
||||
# Уведомляем автора
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
chat_id=author_id,
|
||||
text="Твой пост был выложен🥰",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}")
|
||||
|
||||
logger.info(
|
||||
f"PostService: Пост авто-опубликован в {self.settings.main_public}, "
|
||||
f"author_id={author_id}, rag_score={rag_score:.2f}"
|
||||
)
|
||||
|
||||
@track_time("_auto_decline", "post_service")
|
||||
@track_errors("post_service", "_auto_decline")
|
||||
async def _auto_decline(self, message: types.Message, author_id: int) -> None:
|
||||
"""Автоматически отклоняет пост."""
|
||||
# Обучаем RAG на отклоненном посте
|
||||
if self.scoring_manager:
|
||||
original_text = message.text or message.caption or ""
|
||||
if original_text and original_text.strip():
|
||||
try:
|
||||
await self.scoring_manager.on_post_declined(original_text)
|
||||
except Exception as e:
|
||||
logger.warning(f"PostService: Ошибка обучения RAG на отклоненном посте: {e}")
|
||||
|
||||
# Уведомляем автора
|
||||
try:
|
||||
await message.bot.send_message(
|
||||
chat_id=author_id,
|
||||
text="Твой пост был отклонен😔",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"PostService: Не удалось уведомить автора {author_id}: {e}")
|
||||
|
||||
logger.info(f"PostService: Пост авто-отклонен, author_id={author_id}")
|
||||
|
||||
@track_time("_process_post_background", "post_service")
|
||||
@track_errors("post_service", "_process_post_background")
|
||||
async def _process_post_background(
|
||||
@@ -485,6 +682,33 @@ class PostService:
|
||||
# Определяем анонимность по исходному тексту (без сообщения об ошибке)
|
||||
is_anonymous = determine_anonymity(original_raw_text)
|
||||
|
||||
# Проверяем авто-модерацию
|
||||
logger.debug(
|
||||
f"PostService: Проверка авто-модерации - "
|
||||
f"auto_moderation={self.auto_moderation is not None}, "
|
||||
f"rag_score={rag_score}"
|
||||
)
|
||||
if self.auto_moderation and rag_score is not None:
|
||||
auto_action = await self.auto_moderation.check_auto_action(rag_score)
|
||||
logger.info(
|
||||
f"PostService: Авто-модерация решение - "
|
||||
f"rag_score={rag_score:.2f}, action={auto_action}"
|
||||
)
|
||||
|
||||
if auto_action in ("publish", "decline"):
|
||||
await self._handle_auto_action(
|
||||
auto_action=auto_action,
|
||||
message=message,
|
||||
content_type=content_type,
|
||||
original_raw_text=original_raw_text,
|
||||
first_name=first_name,
|
||||
is_anonymous=is_anonymous,
|
||||
rag_score=rag_score,
|
||||
ml_scores_json=ml_scores_json,
|
||||
album=album,
|
||||
)
|
||||
return
|
||||
|
||||
markup = get_reply_keyboard_for_post()
|
||||
sent_message = None
|
||||
|
||||
@@ -1128,3 +1352,128 @@ class StickerService:
|
||||
random_stick_bye = random.choice(name_stick_bye)
|
||||
random_stick_bye = FSInputFile(path=random_stick_bye)
|
||||
await message.answer_sticker(random_stick_bye)
|
||||
|
||||
|
||||
class AutoModerationService:
|
||||
"""
|
||||
Сервис автоматической модерации постов на основе RAG score.
|
||||
|
||||
Автоматически публикует посты с высоким скором и отклоняет с низким.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: DatabaseProtocol,
|
||||
settings: BotSettings,
|
||||
scoring_manager=None,
|
||||
s3_storage=None,
|
||||
) -> None:
|
||||
self.db = db
|
||||
self.settings = settings
|
||||
self.scoring_manager = scoring_manager
|
||||
self.s3_storage = s3_storage
|
||||
|
||||
@track_time("check_auto_action", "auto_moderation_service")
|
||||
async def check_auto_action(self, rag_score: float) -> str:
|
||||
"""
|
||||
Проверяет, требуется ли автоматическое действие.
|
||||
|
||||
Args:
|
||||
rag_score: Скор от RAG модели (0.0 - 1.0)
|
||||
|
||||
Returns:
|
||||
'publish' - автопубликация
|
||||
'decline' - автоотклонение
|
||||
'manual' - ручная модерация
|
||||
"""
|
||||
if rag_score is None:
|
||||
return "manual"
|
||||
|
||||
settings = await self.db.get_auto_moderation_settings()
|
||||
|
||||
auto_publish_enabled = settings.get("auto_publish_enabled", False)
|
||||
auto_decline_enabled = settings.get("auto_decline_enabled", False)
|
||||
auto_publish_threshold = settings.get("auto_publish_threshold", 0.8)
|
||||
auto_decline_threshold = settings.get("auto_decline_threshold", 0.4)
|
||||
|
||||
logger.info(
|
||||
f"AutoModeration: Настройки из БД - "
|
||||
f"publish_enabled={auto_publish_enabled}, decline_enabled={auto_decline_enabled}, "
|
||||
f"publish_threshold={auto_publish_threshold}, decline_threshold={auto_decline_threshold}, "
|
||||
f"rag_score={rag_score:.2f}"
|
||||
)
|
||||
|
||||
if auto_publish_enabled and rag_score >= auto_publish_threshold:
|
||||
logger.info(
|
||||
f"AutoModeration: score {rag_score:.2f} >= {auto_publish_threshold} → auto_publish"
|
||||
)
|
||||
return "publish"
|
||||
|
||||
if auto_decline_enabled and rag_score <= auto_decline_threshold:
|
||||
logger.info(
|
||||
f"AutoModeration: score {rag_score:.2f} <= {auto_decline_threshold} → auto_decline"
|
||||
)
|
||||
return "decline"
|
||||
|
||||
return "manual"
|
||||
|
||||
@track_time("log_auto_action", "auto_moderation_service")
|
||||
async def log_auto_action(
|
||||
self,
|
||||
bot,
|
||||
action: str,
|
||||
author_id: int,
|
||||
author_name: str,
|
||||
author_username: str,
|
||||
rag_score: float,
|
||||
post_text: str,
|
||||
) -> None:
|
||||
"""
|
||||
Отправляет лог автоматического действия в IMPORTANT_LOGS.
|
||||
|
||||
Args:
|
||||
bot: Экземпляр бота для отправки сообщений
|
||||
action: Тип действия ('publish' или 'decline')
|
||||
author_id: ID автора поста
|
||||
author_name: Имя автора
|
||||
author_username: Username автора
|
||||
rag_score: Скор модели
|
||||
post_text: Текст поста
|
||||
"""
|
||||
try:
|
||||
safe_name = html.escape(author_name or "Без имени")
|
||||
safe_username = html.escape(author_username or "нет")
|
||||
|
||||
truncated_text = post_text[:200] if post_text else ""
|
||||
if len(post_text or "") > 200:
|
||||
truncated_text += "..."
|
||||
safe_text = html.escape(truncated_text)
|
||||
|
||||
if action == "publish":
|
||||
emoji = "🤖"
|
||||
action_title = "АВТО-ПУБЛИКАЦИЯ"
|
||||
action_result = "✅ Пост автоматически опубликован"
|
||||
else:
|
||||
emoji = "🚫"
|
||||
action_title = "АВТО-ОТКЛОНЕНИЕ"
|
||||
action_result = "❌ Пост автоматически отклонён"
|
||||
|
||||
message_text = (
|
||||
f"{emoji} <b>{action_title}</b>\n\n"
|
||||
f"👤 <b>Автор:</b> {safe_name} (@{safe_username}) | ID: {author_id}\n"
|
||||
f"📊 <b>RAG Score:</b> {rag_score:.2f}\n\n"
|
||||
f"📝 <b>Текст поста:</b>\n"
|
||||
f'"{safe_text}"\n\n'
|
||||
f"{action_result}"
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=self.settings.important_logs,
|
||||
text=message_text,
|
||||
parse_mode="HTML",
|
||||
)
|
||||
logger.info(
|
||||
f"AutoModeration: Лог отправлен в IMPORTANT_LOGS ({action})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"AutoModeration: Ошибка отправки лога: {e}")
|
||||
|
||||
@@ -46,11 +46,64 @@ def get_reply_keyboard_admin():
|
||||
types.KeyboardButton(text="Разбан (список)"),
|
||||
types.KeyboardButton(text="📊 ML Статистика"),
|
||||
)
|
||||
builder.row(types.KeyboardButton(text="⚙️ Авто-модерация"))
|
||||
builder.row(types.KeyboardButton(text="Вернуться в бота"))
|
||||
markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True)
|
||||
return markup
|
||||
|
||||
|
||||
def get_auto_moderation_keyboard(settings: dict) -> types.InlineKeyboardMarkup:
|
||||
"""
|
||||
Создает inline клавиатуру для управления авто-модерацией.
|
||||
|
||||
Args:
|
||||
settings: Словарь с текущими настройками авто-модерации
|
||||
|
||||
Returns:
|
||||
InlineKeyboardMarkup с кнопками управления
|
||||
"""
|
||||
builder = InlineKeyboardBuilder()
|
||||
|
||||
auto_publish = settings.get("auto_publish_enabled", False)
|
||||
auto_decline = settings.get("auto_decline_enabled", False)
|
||||
publish_threshold = settings.get("auto_publish_threshold", 0.8)
|
||||
decline_threshold = settings.get("auto_decline_threshold", 0.4)
|
||||
|
||||
publish_status = "✅" if auto_publish else "❌"
|
||||
decline_status = "✅" if auto_decline else "❌"
|
||||
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{publish_status} Авто-публикация (≥{publish_threshold})",
|
||||
callback_data="auto_mod_toggle_publish",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text=f"{decline_status} Авто-отклонение (≤{decline_threshold})",
|
||||
callback_data="auto_mod_toggle_decline",
|
||||
)
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="📈 Изменить порог публикации",
|
||||
callback_data="auto_mod_threshold_publish",
|
||||
),
|
||||
types.InlineKeyboardButton(
|
||||
text="📉 Изменить порог отклонения",
|
||||
callback_data="auto_mod_threshold_decline",
|
||||
),
|
||||
)
|
||||
builder.row(
|
||||
types.InlineKeyboardButton(
|
||||
text="🔄 Обновить",
|
||||
callback_data="auto_mod_refresh",
|
||||
)
|
||||
)
|
||||
|
||||
return builder.as_markup()
|
||||
|
||||
|
||||
@track_time("create_keyboard_with_pagination", "keyboard_service")
|
||||
@track_errors("keyboard_service", "create_keyboard_with_pagination")
|
||||
def create_keyboard_with_pagination(
|
||||
|
||||
117
scripts/create_bot_settings_table.py
Normal file
117
scripts/create_bot_settings_table.py
Normal file
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Миграция: Создание таблицы bot_settings для хранения настроек бота.
|
||||
|
||||
Создает таблицу с ключ-значение для хранения:
|
||||
- auto_publish_enabled: Включена ли авто-публикация (default: false)
|
||||
- auto_decline_enabled: Включено ли авто-отклонение (default: false)
|
||||
- auto_publish_threshold: Порог для авто-публикации (default: 0.8)
|
||||
- auto_decline_threshold: Порог для авто-отклонения (default: 0.4)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
project_root = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(project_root))
|
||||
|
||||
import aiosqlite
|
||||
|
||||
try:
|
||||
from logs.custom_logger import logger
|
||||
except ImportError:
|
||||
import logging
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DB_PATH = "database/tg-bot-database.db"
|
||||
|
||||
DEFAULT_SETTINGS = [
|
||||
("auto_publish_enabled", "false"),
|
||||
("auto_decline_enabled", "false"),
|
||||
("auto_publish_threshold", "0.8"),
|
||||
("auto_decline_threshold", "0.4"),
|
||||
]
|
||||
|
||||
|
||||
async def table_exists(conn: aiosqlite.Connection, table_name: str) -> bool:
|
||||
"""Проверяет существование таблицы."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?",
|
||||
(table_name,),
|
||||
)
|
||||
result = await cursor.fetchone()
|
||||
return result is not None
|
||||
|
||||
|
||||
async def main(db_path: str) -> None:
|
||||
"""
|
||||
Основная функция миграции.
|
||||
|
||||
Создает таблицу bot_settings и добавляет дефолтные настройки.
|
||||
Миграция идемпотентна - можно запускать повторно без ошибок.
|
||||
"""
|
||||
db_path = os.path.abspath(db_path)
|
||||
|
||||
if not os.path.exists(db_path):
|
||||
logger.error(f"База данных не найдена: {db_path}")
|
||||
return
|
||||
|
||||
async with aiosqlite.connect(db_path) as conn:
|
||||
await conn.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
if not await table_exists(conn, "bot_settings"):
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE bot_settings (
|
||||
id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
value TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
||||
)
|
||||
"""
|
||||
)
|
||||
logger.info("Таблица bot_settings создана")
|
||||
|
||||
for key, value in DEFAULT_SETTINGS:
|
||||
await conn.execute(
|
||||
"INSERT INTO bot_settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
logger.info(f"Добавлена настройка: {key} = {value}")
|
||||
else:
|
||||
logger.info("Таблица bot_settings уже существует")
|
||||
|
||||
for key, value in DEFAULT_SETTINGS:
|
||||
cursor = await conn.execute(
|
||||
"SELECT COUNT(*) FROM bot_settings WHERE key = ?", (key,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row[0] == 0:
|
||||
await conn.execute(
|
||||
"INSERT INTO bot_settings (key, value) VALUES (?, ?)",
|
||||
(key, value),
|
||||
)
|
||||
logger.info(f"Добавлена отсутствующая настройка: {key} = {value}")
|
||||
|
||||
await conn.commit()
|
||||
logger.info("Миграция create_bot_settings_table завершена успешно")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Создание таблицы bot_settings для настроек авто-модерации"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--db",
|
||||
default=os.environ.get("DATABASE_PATH", DEFAULT_DB_PATH),
|
||||
help="Путь к БД",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
asyncio.run(main(args.db))
|
||||
222
tests/test_auto_moderation_service.py
Normal file
222
tests/test_auto_moderation_service.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Тесты для AutoModerationService."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from helper_bot.handlers.private.services import AutoModerationService, BotSettings
|
||||
|
||||
|
||||
class TestAutoModerationService:
|
||||
"""Тесты для сервиса авто-модерации."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_db(self):
|
||||
"""Создает мок базы данных."""
|
||||
db = MagicMock()
|
||||
db.get_auto_moderation_settings = AsyncMock()
|
||||
return db
|
||||
|
||||
@pytest.fixture
|
||||
def settings(self):
|
||||
"""Создает настройки бота."""
|
||||
return BotSettings(
|
||||
group_for_posts="-123",
|
||||
group_for_message="-456",
|
||||
main_public="@test_channel",
|
||||
group_for_logs="-789",
|
||||
important_logs="-999",
|
||||
preview_link="false",
|
||||
logs="false",
|
||||
test="false",
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def service(self, mock_db, settings):
|
||||
"""Создает экземпляр сервиса."""
|
||||
return AutoModerationService(mock_db, settings)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_manual_when_score_is_none(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает manual когда score равен None."""
|
||||
result = await service.check_auto_action(None)
|
||||
assert result == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_publish_when_score_above_threshold(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает publish когда score выше порога."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": True,
|
||||
"auto_decline_enabled": False,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.9)
|
||||
|
||||
assert result == "publish"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_decline_when_score_below_threshold(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает decline когда score ниже порога."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": False,
|
||||
"auto_decline_enabled": True,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.3)
|
||||
|
||||
assert result == "decline"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_manual_when_disabled(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает manual когда авто-действия отключены."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": False,
|
||||
"auto_decline_enabled": False,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.9)
|
||||
|
||||
assert result == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_returns_manual_when_score_in_middle(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает manual когда score между порогами."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": True,
|
||||
"auto_decline_enabled": True,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.6)
|
||||
|
||||
assert result == "manual"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_publish_at_exact_threshold(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает publish когда score равен порогу."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": True,
|
||||
"auto_decline_enabled": False,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.8)
|
||||
|
||||
assert result == "publish"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_auto_action_decline_at_exact_threshold(
|
||||
self, service, mock_db
|
||||
):
|
||||
"""Тест: возвращает decline когда score равен порогу."""
|
||||
mock_db.get_auto_moderation_settings.return_value = {
|
||||
"auto_publish_enabled": False,
|
||||
"auto_decline_enabled": True,
|
||||
"auto_publish_threshold": 0.8,
|
||||
"auto_decline_threshold": 0.4,
|
||||
}
|
||||
|
||||
result = await service.check_auto_action(0.4)
|
||||
|
||||
assert result == "decline"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_publish(self, service, settings):
|
||||
"""Тест отправки лога для авто-публикации."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock()
|
||||
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="publish",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.85,
|
||||
post_text="Test post text",
|
||||
)
|
||||
|
||||
mock_bot.send_message.assert_called_once()
|
||||
call_kwargs = mock_bot.send_message.call_args[1]
|
||||
assert call_kwargs["chat_id"] == settings.important_logs
|
||||
assert "АВТО-ПУБЛИКАЦИЯ" in call_kwargs["text"]
|
||||
assert "Test User" in call_kwargs["text"]
|
||||
assert "0.85" in call_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_decline(self, service, settings):
|
||||
"""Тест отправки лога для авто-отклонения."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock()
|
||||
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="decline",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.25,
|
||||
post_text="Test post text",
|
||||
)
|
||||
|
||||
mock_bot.send_message.assert_called_once()
|
||||
call_kwargs = mock_bot.send_message.call_args[1]
|
||||
assert "АВТО-ОТКЛОНЕНИЕ" in call_kwargs["text"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_handles_exception(self, service):
|
||||
"""Тест обработки исключения при отправке лога."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock(side_effect=Exception("Network error"))
|
||||
|
||||
# Не должно выбрасывать исключение
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="publish",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.85,
|
||||
post_text="Test post text",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_log_auto_action_truncates_long_text(self, service):
|
||||
"""Тест обрезки длинного текста в логе."""
|
||||
mock_bot = MagicMock()
|
||||
mock_bot.send_message = AsyncMock()
|
||||
|
||||
long_text = "a" * 300
|
||||
|
||||
await service.log_auto_action(
|
||||
bot=mock_bot,
|
||||
action="publish",
|
||||
author_id=12345,
|
||||
author_name="Test User",
|
||||
author_username="testuser",
|
||||
rag_score=0.85,
|
||||
post_text=long_text,
|
||||
)
|
||||
|
||||
call_kwargs = mock_bot.send_message.call_args[1]
|
||||
# Текст должен быть обрезан до 200 символов + "..."
|
||||
assert "..." in call_kwargs["text"]
|
||||
161
tests/test_bot_settings_repository.py
Normal file
161
tests/test_bot_settings_repository.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Тесты для BotSettingsRepository."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from database.repositories.bot_settings_repository import BotSettingsRepository
|
||||
|
||||
|
||||
class TestBotSettingsRepository:
|
||||
"""Тесты для репозитория настроек бота."""
|
||||
|
||||
@pytest.fixture
|
||||
def repository(self):
|
||||
"""Создает экземпляр репозитория с замоканным путем к БД."""
|
||||
return BotSettingsRepository("test.db")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_setting_returns_value(self, repository):
|
||||
"""Тест получения настройки по ключу."""
|
||||
with patch.object(
|
||||
repository, "_execute_query_with_result", new_callable=AsyncMock
|
||||
) as mock_query:
|
||||
mock_query.return_value = [("true",)]
|
||||
|
||||
result = await repository.get_setting("auto_publish_enabled")
|
||||
|
||||
assert result == "true"
|
||||
mock_query.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_setting_returns_none_when_not_found(self, repository):
|
||||
"""Тест получения несуществующей настройки."""
|
||||
with patch.object(
|
||||
repository, "_execute_query_with_result", new_callable=AsyncMock
|
||||
) as mock_query:
|
||||
mock_query.return_value = []
|
||||
|
||||
result = await repository.get_setting("nonexistent_key")
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_setting(self, repository):
|
||||
"""Тест установки настройки."""
|
||||
with patch.object(
|
||||
repository, "_execute_query", new_callable=AsyncMock
|
||||
) as mock_query:
|
||||
await repository.set_setting("auto_publish_enabled", "true")
|
||||
|
||||
mock_query.assert_called_once()
|
||||
call_args = mock_query.call_args[0]
|
||||
assert "auto_publish_enabled" in str(call_args)
|
||||
assert "true" in str(call_args)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bool_setting_true(self, repository):
|
||||
"""Тест получения булевой настройки со значением true."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "true"
|
||||
|
||||
result = await repository.get_bool_setting("auto_publish_enabled")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bool_setting_false(self, repository):
|
||||
"""Тест получения булевой настройки со значением false."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "false"
|
||||
|
||||
result = await repository.get_bool_setting("auto_publish_enabled")
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_bool_setting_default(self, repository):
|
||||
"""Тест получения булевой настройки с дефолтным значением."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = None
|
||||
|
||||
result = await repository.get_bool_setting("auto_publish_enabled", True)
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_float_setting(self, repository):
|
||||
"""Тест получения числовой настройки."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "0.8"
|
||||
|
||||
result = await repository.get_float_setting("auto_publish_threshold")
|
||||
|
||||
assert result == 0.8
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_float_setting_invalid_value(self, repository):
|
||||
"""Тест получения числовой настройки с некорректным значением."""
|
||||
with patch.object(
|
||||
repository, "get_setting", new_callable=AsyncMock
|
||||
) as mock_get:
|
||||
mock_get.return_value = "invalid"
|
||||
|
||||
result = await repository.get_float_setting("auto_publish_threshold", 0.5)
|
||||
|
||||
assert result == 0.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_auto_moderation_settings(self, repository):
|
||||
"""Тест получения всех настроек авто-модерации."""
|
||||
with patch.object(
|
||||
repository, "get_bool_setting", new_callable=AsyncMock
|
||||
) as mock_bool, patch.object(
|
||||
repository, "get_float_setting", new_callable=AsyncMock
|
||||
) as mock_float:
|
||||
mock_bool.side_effect = [True, False]
|
||||
mock_float.side_effect = [0.8, 0.4]
|
||||
|
||||
result = await repository.get_auto_moderation_settings()
|
||||
|
||||
assert result["auto_publish_enabled"] is True
|
||||
assert result["auto_decline_enabled"] is False
|
||||
assert result["auto_publish_threshold"] == 0.8
|
||||
assert result["auto_decline_threshold"] == 0.4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_auto_publish(self, repository):
|
||||
"""Тест переключения авто-публикации."""
|
||||
with patch.object(
|
||||
repository, "get_bool_setting", new_callable=AsyncMock
|
||||
) as mock_get, patch.object(
|
||||
repository, "set_bool_setting", new_callable=AsyncMock
|
||||
) as mock_set:
|
||||
mock_get.return_value = False
|
||||
|
||||
result = await repository.toggle_auto_publish()
|
||||
|
||||
assert result is True
|
||||
mock_set.assert_called_once_with("auto_publish_enabled", True)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_auto_decline(self, repository):
|
||||
"""Тест переключения авто-отклонения."""
|
||||
with patch.object(
|
||||
repository, "get_bool_setting", new_callable=AsyncMock
|
||||
) as mock_get, patch.object(
|
||||
repository, "set_bool_setting", new_callable=AsyncMock
|
||||
) as mock_set:
|
||||
mock_get.return_value = True
|
||||
|
||||
result = await repository.toggle_auto_decline()
|
||||
|
||||
assert result is False
|
||||
mock_set.assert_called_once_with("auto_decline_enabled", False)
|
||||
@@ -115,7 +115,7 @@ class TestKeyboards:
|
||||
|
||||
assert isinstance(keyboard, ReplyKeyboardMarkup)
|
||||
assert keyboard.keyboard is not None
|
||||
assert len(keyboard.keyboard) == 3 # Три строки
|
||||
assert len(keyboard.keyboard) == 4 # Четыре строки
|
||||
|
||||
# Проверяем первую строку (3 кнопки)
|
||||
first_row = keyboard.keyboard[0]
|
||||
@@ -130,10 +130,15 @@ class TestKeyboards:
|
||||
assert second_row[0].text == "Разбан (список)"
|
||||
assert second_row[1].text == "📊 ML Статистика"
|
||||
|
||||
# Проверяем третью строку (1 кнопка)
|
||||
# Проверяем третью строку (1 кнопка - авто-модерация)
|
||||
third_row = keyboard.keyboard[2]
|
||||
assert len(third_row) == 1
|
||||
assert third_row[0].text == "Вернуться в бота"
|
||||
assert third_row[0].text == "⚙️ Авто-модерация"
|
||||
|
||||
# Проверяем четвертую строку (1 кнопка)
|
||||
fourth_row = keyboard.keyboard[3]
|
||||
assert len(fourth_row) == 1
|
||||
assert fourth_row[0].text == "Вернуться в бота"
|
||||
|
||||
def test_get_reply_keyboard_for_post(self):
|
||||
"""Тест клавиатуры для постов"""
|
||||
|
||||
Reference in New Issue
Block a user