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): """Тест клавиатуры для постов"""