diff --git a/database/async_db.py b/database/async_db.py
index 09ae147..f36a324 100644
--- a/database/async_db.py
+++ b/database/async_db.py
@@ -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()
diff --git a/database/repositories/__init__.py b/database/repositories/__init__.py
index 3b57f50..9062023 100644
--- a/database/repositories/__init__.py
+++ b/database/repositories/__init__.py
@@ -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",
]
diff --git a/database/repositories/bot_settings_repository.py b/database/repositories/bot_settings_repository.py
new file mode 100644
index 0000000..7e0bc21
--- /dev/null
+++ b/database/repositories/bot_settings_repository.py
@@ -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
diff --git a/database/repository_factory.py b/database/repository_factory.py
index d218f21..728e759 100644
--- a/database/repository_factory.py
+++ b/database/repository_factory.py
@@ -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):
"""Проверяет целостность базы данных."""
diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py
index c5cd1de..2db9b50 100644
--- a/helper_bot/handlers/admin/admin_handlers.py
+++ b/helper_bot/handlers/admin/admin_handlers.py
@@ -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 (
+ "⚙️ Авто-модерация постов\n\n"
+ f"🤖 Авто-публикация: {publish_status}\n"
+ f" Порог: RAG score ≥ {publish_threshold}\n\n"
+ f"🚫 Авто-отклонение: {decline_status}\n"
+ f" Порог: RAG score ≤ {decline_threshold}"
+ )
+
+
+@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(
+ "📈 Изменение порога авто-публикации\n\n"
+ "Введите новое значение порога (от 0.0 до 1.0).\n"
+ "Посты с RAG score ≥ этого значения будут автоматически публиковаться.\n\n"
+ "Текущее рекомендуемое значение: 0.8",
+ 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(
+ "📉 Изменение порога авто-отклонения\n\n"
+ "Введите новое значение порога (от 0.0 до 1.0).\n"
+ "Посты с RAG score ≤ этого значения будут автоматически отклоняться.\n\n"
+ "Текущее рекомендуемое значение: 0.4",
+ 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"✅ Порог авто-публикации изменен на {value}",
+ 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"✅ Порог авто-отклонения изменен на {value}",
+ 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)}")
+
+
# ============================================================================
# ХЕНДЛЕРЫ ПРОЦЕССА БАНА
# ============================================================================
diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py
index 3f47aa2..f7ffb7c 100644
--- a/helper_bot/handlers/private/private_handlers.py
+++ b/helper_bot/handlers/private/private_handlers.py
@@ -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()
diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py
index 3a6f1fa..8035503 100644
--- a/helper_bot/handlers/private/services.py
+++ b/helper_bot/handlers/private/services.py
@@ -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} {action_title}\n\n"
+ f"👤 Автор: {safe_name} (@{safe_username}) | ID: {author_id}\n"
+ f"📊 RAG Score: {rag_score:.2f}\n\n"
+ f"📝 Текст поста:\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}")
diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py
index ed605ad..40f26bd 100644
--- a/helper_bot/keyboards/keyboards.py
+++ b/helper_bot/keyboards/keyboards.py
@@ -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(
diff --git a/scripts/create_bot_settings_table.py b/scripts/create_bot_settings_table.py
new file mode 100644
index 0000000..a13c033
--- /dev/null
+++ b/scripts/create_bot_settings_table.py
@@ -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))
diff --git a/tests/test_auto_moderation_service.py b/tests/test_auto_moderation_service.py
new file mode 100644
index 0000000..c9e6b7e
--- /dev/null
+++ b/tests/test_auto_moderation_service.py
@@ -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"]
diff --git a/tests/test_bot_settings_repository.py b/tests/test_bot_settings_repository.py
new file mode 100644
index 0000000..bd0af31
--- /dev/null
+++ b/tests/test_bot_settings_repository.py
@@ -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)
diff --git a/tests/test_keyboards_and_filters.py b/tests/test_keyboards_and_filters.py
index 48de3c1..74a5cb9 100644
--- a/tests/test_keyboards_and_filters.py
+++ b/tests/test_keyboards_and_filters.py
@@ -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):
"""Тест клавиатуры для постов"""