diff --git a/database/async_db.py b/database/async_db.py index 39bdf94..f36a324 100644 --- a/database/async_db.py +++ b/database/async_db.py @@ -279,6 +279,34 @@ class AsyncBotDB: """Получает тексты отклоненных постов для обучения RAG.""" return await self.factory.posts.get_declined_posts_texts(limit) + async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]: + """ + Получает статистику постов пользователя. + + Returns: + Tuple (approved_count, declined_count, suggest_count) + """ + return await self.factory.posts.get_user_posts_stats(user_id) + + async def get_last_post_by_author(self, user_id: int) -> Optional[str]: + """Получает текст последнего поста пользователя.""" + return await self.factory.posts.get_last_post_by_author(user_id) + + async def get_user_ban_count(self, user_id: int) -> int: + """Получает количество банов пользователя за все время.""" + return await self.factory.blacklist_history.get_ban_count(user_id) + + async def get_last_ban_info( + self, user_id: int + ) -> Optional[Tuple[int, str, Optional[int]]]: + """ + Получает информацию о последнем бане пользователя. + + Returns: + Tuple (date_ban, reason, date_unban) или None + """ + return await self.factory.blacklist_history.get_last_ban_info(user_id) + # Методы для работы с черным списком async def set_user_blacklist( self, @@ -361,7 +389,8 @@ class AsyncBotDB: """Возвращает список пользователей в черном списке с учетом смещения и ограничения.""" users = await self.factory.blacklist.get_all_users(offset, limit) return [ - (user.user_id, user.message_for_user, user.date_to_unban) for user in users + (user.user_id, user.message_for_user, user.date_to_unban, user.created_at) + for user in users ] async def get_blacklist_users_by_id(self, user_id: int) -> Optional[tuple]: @@ -543,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/blacklist_history_repository.py b/database/repositories/blacklist_history_repository.py index 54685ea..6ee809d 100644 --- a/database/repositories/blacklist_history_repository.py +++ b/database/repositories/blacklist_history_repository.py @@ -1,4 +1,4 @@ -from typing import Optional +from typing import Optional, Tuple from database.base import DatabaseConnection from database.models import BlacklistHistoryRecord @@ -120,3 +120,55 @@ class BlacklistHistoryRepository(DatabaseConnection): f"Ошибка обновления даты разбана в истории для user_id={user_id}: {str(e)}" ) return False + + async def get_ban_count(self, user_id: int) -> int: + """ + Получает количество банов пользователя за все время. + + Args: + user_id: ID пользователя + + Returns: + Количество банов + """ + query = "SELECT COUNT(*) FROM blacklist_history WHERE user_id = ?" + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + count = row[0] if row else 0 + self.logger.info(f"Количество банов для user_id={user_id}: {count}") + return count + + async def get_last_ban_info( + self, user_id: int + ) -> Optional[Tuple[int, str, Optional[int]]]: + """ + Получает информацию о последнем бане пользователя. + + Args: + user_id: ID пользователя + + Returns: + Tuple (date_ban, reason, date_unban) или None, если банов не было + """ + query = """ + SELECT date_ban, reason, date_unban FROM blacklist_history + WHERE user_id = ? + ORDER BY date_ban DESC + LIMIT 1 + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + date_ban = row[0] + reason = row[1] + date_unban = row[2] + self.logger.info( + f"Последний бан для user_id={user_id}: " + f"date_ban={date_ban}, reason={reason}, date_unban={date_unban}" + ) + return (date_ban, reason, date_unban) + + self.logger.info(f"Банов для user_id={user_id} не найдено") + return None diff --git a/database/repositories/blacklist_repository.py b/database/repositories/blacklist_repository.py index f8d275e..97d2957 100644 --- a/database/repositories/blacklist_repository.py +++ b/database/repositories/blacklist_repository.py @@ -87,13 +87,14 @@ class BlacklistRepository(DatabaseConnection): async def get_all_users( self, offset: int = 0, limit: int = 10 ) -> List[BlacklistUser]: - """Возвращает список пользователей в черном списке.""" + """Возвращает список пользователей в черном списке, отсортированных по дате бана (новые первые).""" query = """ SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist - LIMIT ?, ? + ORDER BY created_at DESC + LIMIT ? OFFSET ? """ - rows = await self._execute_query_with_result(query, (offset, limit)) + rows = await self._execute_query_with_result(query, (limit, offset)) users = [] for row in rows: @@ -113,10 +114,11 @@ class BlacklistRepository(DatabaseConnection): return users async def get_all_users_no_limit(self) -> List[BlacklistUser]: - """Возвращает список всех пользователей в черном списке без лимитов.""" + """Возвращает список всех пользователей в черном списке без лимитов, отсортированных по дате бана (новые первые).""" query = """ SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist + ORDER BY created_at DESC """ rows = await self._execute_query_with_result(query) 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/repositories/post_repository.py b/database/repositories/post_repository.py index 37cdea0..f645e67 100644 --- a/database/repositories/post_repository.py +++ b/database/repositories/post_repository.py @@ -545,3 +545,67 @@ class PostRepository(DatabaseConnection): texts = [row[0] for row in rows if row[0]] self.logger.info(f"Получено {len(texts)} отклоненных постов для обучения") return texts + + async def get_user_posts_stats(self, user_id: int) -> Tuple[int, int, int]: + """ + Получает статистику постов пользователя. + + Args: + user_id: ID пользователя + + Returns: + Tuple (approved_count, declined_count, suggest_count) + """ + query = """ + SELECT + SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, + SUM(CASE WHEN status = 'declined' THEN 1 ELSE 0 END) as declined, + SUM(CASE WHEN status = 'suggest' THEN 1 ELSE 0 END) as suggest + FROM post_from_telegram_suggest + WHERE author_id = ? AND text != '^' + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + approved = row[0] or 0 + declined = row[1] or 0 + suggest = row[2] or 0 + self.logger.info( + f"Статистика постов для user_id={user_id}: " + f"approved={approved}, declined={declined}, suggest={suggest}" + ) + return (approved, declined, suggest) + + return (0, 0, 0) + + async def get_last_post_by_author(self, user_id: int) -> Optional[str]: + """ + Получает текст последнего поста пользователя. + + Args: + user_id: ID пользователя + + Returns: + Текст последнего поста или None, если постов нет + """ + query = """ + SELECT text FROM post_from_telegram_suggest + WHERE author_id = ? AND text IS NOT NULL AND text != '' AND text != '^' + ORDER BY created_at DESC + LIMIT 1 + """ + rows = await self._execute_query_with_result(query, (user_id,)) + row = rows[0] if rows else None + + if row: + text = row[0] + self.logger.info( + f"Последний пост для user_id={user_id}: '{text[:50]}...'" + if len(text) > 50 + else f"Последний пост для user_id={user_id}: '{text}'" + ) + return text + + self.logger.info(f"Постов для user_id={user_id} не найдено") + return None 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 66d0519..862d088 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 @@ -138,7 +139,9 @@ async def get_banned_users( keyboard = create_keyboard_with_pagination( 1, len(buttons_list), buttons_list, "unlock" ) - await message.answer(text=message_text, reply_markup=keyboard) + await message.answer( + text=message_text, reply_markup=keyboard, parse_mode="HTML" + ) else: await message.answer( text="В списке заблокированных пользователей никого нет" @@ -216,9 +219,11 @@ async def get_ml_stats(message: types.Message, state: FSMContext, **kwargs): # Fallback на синхронные данные (если API недоступен) lines.append(f" • API URL: {rag.get('api_url', 'N/A')}") if "enabled" in rag: - lines.append( - f" • Статус: {'✅ Включен' if rag.get('enabled') else '❌ Отключен'}" - ) + if rag.get("enabled"): + lines.append(f" • Статус: ⚠️ Включен, но API не отвечает") + lines.append(f" • Проверьте доступность сервиса и API ключ") + else: + lines.append(f" • Статус: ❌ Отключен") lines.append("") @@ -244,6 +249,266 @@ 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/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index d156184..f652ceb 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -255,6 +255,8 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs logger.info(f"Переход на страницу {page_number}") + items_per_page = 9 + if call.message.text == "Список пользователей которые последними обращались к боту": list_users = await bot_db.get_last_users(30) keyboard = create_keyboard_with_pagination( @@ -266,11 +268,13 @@ async def change_page(call: CallbackQuery, bot_db: MagicData("bot_db"), **kwargs reply_markup=keyboard, ) else: - message_user = await get_banned_users_list(int(page_number) * 7 - 7, bot_db) + offset = (page_number - 1) * items_per_page + message_user = await get_banned_users_list(offset, bot_db) await call.bot.edit_message_text( chat_id=call.message.chat.id, message_id=call.message.message_id, text=message_user, + parse_mode="HTML", ) buttons = await get_banned_users_buttons(bot_db) diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index f9aff0d..910d852 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -8,7 +8,7 @@ from aiogram.types import CallbackQuery from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason from helper_bot.utils.helper_func import ( delete_user_blacklist, - get_text_message, + get_publish_text, send_audio_message, send_media_group_to_channel, send_photo_message, @@ -137,7 +137,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -188,7 +188,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -247,7 +247,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -340,7 +340,7 @@ class PostPublishService: raise PostNotFoundError(f"Пользователь {author_id} не найден в базе данных") # Формируем финальный текст с учетом is_anonymous - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -452,7 +452,7 @@ class PostPublishService: f"Пользователь {author_id} не найден в базе данных" ) - formatted_text = get_text_message( + formatted_text = get_publish_text( raw_text, user.first_name, user.username, is_anonymous ) @@ -838,7 +838,7 @@ class BanService: await self.db.set_user_blacklist( user_id=author_id, user_name=None, - message_for_user="Спам", + message_for_user="Последний пост", date_to_unban=date_to_unban, ban_author=ban_author_id, ) diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index c8d0f8b..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() @@ -291,12 +302,33 @@ class PrivateHandlers: """Handle messages in admin chat states""" # User service operations with metrics await self.user_service.update_user_activity(message.from_user.id) - await message.forward(chat_id=self.settings.group_for_message) + + # Формируем обогащённое сообщение для админов + user_id = message.from_user.id + full_name = message.from_user.full_name + username = message.from_user.username + message_text = message.text or "" + + enriched_message = await self.user_service.format_user_message_for_admins( + user_id=user_id, + full_name=full_name, + username=username, + message_text=message_text, + ) + + # Отправляем обогащённое сообщение вместо forward + sent_message = await message.bot.send_message( + chat_id=self.settings.group_for_message, + text=enriched_message, + parse_mode="HTML", + ) current_date = datetime.now() date = int(current_date.timestamp()) + + # Сохраняем message_id из результата send_message await self.db.add_message( - message.text, message.from_user.id, message.message_id + 1, date + message.text, message.from_user.id, sent_message.message_id, date ) question = messages.get_message(get_first_name(message), "QUESTION") diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index b369484..13004ac 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, @@ -156,6 +157,96 @@ class UserService: username = message.from_user.username or "Без никнейма" return html.escape(full_name), html.escape(username) + async def format_user_message_for_admins( + self, user_id: int, full_name: str, username: str, message_text: str + ) -> str: + """ + Форматирует сообщение пользователя для отправки админам с обогащёнными данными. + + Args: + user_id: ID пользователя + full_name: Полное имя пользователя + username: Username пользователя (может быть None) + message_text: Текст сообщения пользователя + + Returns: + Отформатированное сообщение для админов + """ + safe_full_name = ( + html.escape(full_name) if full_name else "Неизвестный пользователь" + ) + safe_username = html.escape(username) if username else None + safe_message_text = html.escape(message_text) if message_text else "" + + # Формируем строку с информацией об авторе + if safe_username: + author_info = f"{safe_full_name} (@{safe_username})" + else: + author_info = f"{safe_full_name} (Ник не указан)" + + # Получаем статистику постов + approved, declined, suggest = await self.db.get_user_posts_stats(user_id) + total_posts = approved + declined + suggest + + # Получаем последний пост + last_post = await self.db.get_last_post_by_author(user_id) + if last_post: + if len(last_post) > 80: + last_post_display = f'"{html.escape(last_post[:80])}..."' + else: + last_post_display = f'"{html.escape(last_post)}"' + else: + last_post_display = "Нет постов" + + # Получаем дату регистрации + user_info = await self.db.get_user_by_id(user_id) + if user_info and user_info.date_added: + date_added = datetime.fromtimestamp(user_info.date_added).strftime( + "%d.%m.%Y" + ) + else: + date_added = "Неизвестно" + + # Получаем информацию о банах + ban_count = await self.db.get_user_ban_count(user_id) + ban_section = "" + if ban_count > 0: + last_ban = await self.db.get_last_ban_info(user_id) + if last_ban: + date_ban, reason, date_unban = last_ban + ban_date_str = datetime.fromtimestamp(date_ban).strftime("%d.%m.%Y") + reason_display = html.escape(reason) if reason else "Не указана" + + if date_unban: + unban_date_str = datetime.fromtimestamp(date_unban).strftime( + "%d.%m.%Y %H:%M" + ) + last_ban_info = ( + f" Последний: {ban_date_str}, причина «{reason_display}», " + f"истёк {unban_date_str}" + ) + else: + last_ban_info = ( + f" Последний: {ban_date_str}, причина «{reason_display}», " + f"активен" + ) + + ban_section = f"\n\n🚫 Банов: {ban_count}\n{last_ban_info}" + + # Формируем итоговое сообщение + formatted_message = ( + f"👤 От: {author_info} | ID: {user_id}\n\n" + f"📊 Постов в базе: {total_posts}\n" + f"📝 Последний пост: {last_post_display}\n" + f"📅 В боте с: {date_added}" + f"{ban_section}\n\n" + f"---\n" + f"Сообщение пользователя:\n\n" + f"{safe_message_text}" + ) + + return formatted_message + class PostService: """Service for post-related operations""" @@ -166,11 +257,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 @@ -236,6 +329,16 @@ class PostService: f"PostService: Ошибка сохранения скоров для {message_id}: {e}" ) + async def _add_submitted_post_background( + self, text: str, post_id: int, rag_score: float = None + ) -> None: + """Индексирует пост в RAG submitted collection в фоне.""" + try: + if self.scoring_manager: + await self.scoring_manager.add_submitted_post(text, post_id, rag_score) + except Exception as e: + logger.warning(f"PostService: Ошибка добавления поста в submitted: {e}") + async def _get_scores_with_error_handling(self, text: str) -> tuple: """ Получает скоры для текста поста с обработкой ошибок. @@ -281,6 +384,206 @@ 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( @@ -321,6 +624,37 @@ class PostService: error_message, ) = await self._get_scores_with_error_handling(original_raw_text) + # Проверяем похожие посты (до добавления текущего в submitted) + similar_warning = "" + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + try: + similar_result = await self.scoring_manager.find_similar_posts( + original_raw_text, threshold=0.9, hours=24 + ) + if similar_result and similar_result.similar_count > 0: + # Формируем предупреждение с текстом похожего поста + similar_text = "" + if similar_result.similar_posts: + first_similar = similar_result.similar_posts[0] + if first_similar.text: + truncated_text = first_similar.text[:150] + if len(first_similar.text) > 150: + truncated_text += "..." + similar_text = f'\nТекст поста:\n"{html.escape(truncated_text)}"' + + similar_warning = ( + f"\n\n⚠️ Похожий пост за последние 24ч " + f"(совпадение {similar_result.max_similarity:.0%})" + f"{similar_text}" + ) + logger.info( + f"PostService: Найден похожий пост для message_id={message.message_id}, " + f"similar_count={similar_result.similar_count}, " + f"max_similarity={similar_result.max_similarity:.2%}" + ) + except Exception as e: + logger.warning(f"PostService: Ошибка поиска похожих постов: {e}") + # Формируем текст для поста (с сообщением об ошибке если есть) text_for_post = original_raw_text if error_message: @@ -347,13 +681,42 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) + # Добавляем предупреждение о похожем посте + if similar_warning: + post_text += similar_warning # Определяем анонимность по исходному тексту (без сообщения об ошибке) 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 @@ -401,8 +764,11 @@ class PostService: markup, ) elif content_type == "media_group": + # Добавляем предупреждение о похожем посте в caption медиагруппы + if similar_warning: + post_text += similar_warning # Для медиагруппы используем специальную обработку - # Передаем ml_scores_json для сохранения в БД + # Передаем ml_scores_json и rag_score для сохранения в БД await self._process_media_group_background( message, album, @@ -411,6 +777,7 @@ class PostService: is_anonymous, original_raw_text, ml_scores_json, + rag_score, ) return else: @@ -448,6 +815,14 @@ class PostService: ) ) + # Индексируем пост в RAG submitted collection (после успешной отправки) + 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 + ) + ) + except Exception as e: logger.error( f"PostService: Критическая ошибка в _process_post_background для {content_type}: {e}" @@ -462,6 +837,7 @@ class PostService: is_anonymous: bool, original_raw_text: str, ml_scores_json: str = None, + rag_score: float = None, ) -> None: """Обрабатывает медиагруппу в фоне""" try: @@ -495,6 +871,14 @@ class PostService: self._save_scores_background(main_post_id, ml_scores_json) ) + # Индексируем пост в RAG submitted collection + if self.scoring_manager and original_raw_text and original_raw_text.strip(): + asyncio.create_task( + self._add_submitted_post_background( + original_raw_text, main_post_id, rag_score + ) + ) + for msg_id in media_group_message_ids: await self.db.add_message_link(main_post_id, msg_id) @@ -552,8 +936,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -611,8 +994,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -677,8 +1059,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -770,8 +1151,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) markup = get_reply_keyboard_for_post() @@ -869,8 +1249,7 @@ class PostService: message.from_user.username, deepseek_score=deepseek_score, rag_score=rag_score, - rag_confidence=rag_confidence, - rag_score_pos_only=rag_score_pos_only, + user_id=message.from_user.id, ) is_anonymous = determine_anonymity(raw_caption) @@ -981,3 +1360,126 @@ 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/helper_bot/services/scoring/rag_client.py b/helper_bot/services/scoring/rag_client.py index 513a929..da31e1e 100644 --- a/helper_bot/services/scoring/rag_client.py +++ b/helper_bot/services/scoring/rag_client.py @@ -4,7 +4,8 @@ HTTP клиент для взаимодействия с внешним RAG се Использует REST API для получения скоров и отправки примеров. """ -from typing import Any, Dict, Optional +from dataclasses import dataclass +from typing import Any, Dict, List, Optional import httpx @@ -15,6 +16,30 @@ from .base import ScoringResult from .exceptions import InsufficientExamplesError, ScoringError, TextTooShortError +@dataclass +class SimilarPost: + """Данные о похожем посте.""" + + similarity: float + created_at: int + post_id: Optional[int] + text: str + rag_score: Optional[float] + + +@dataclass +class SimilarPostsResult: + """Результат поиска похожих постов.""" + + similar_count: int + similar_posts: List[SimilarPost] + max_similarity: float = 0.0 + + def __post_init__(self): + if self.similar_posts: + self.max_similarity = max(p.similarity for p in self.similar_posts) + + class RagApiClient: """ HTTP клиент для взаимодействия с внешним RAG сервисом. @@ -329,21 +354,39 @@ class RagApiClient: Словарь со статистикой или пустой словарь при ошибке """ if not self._enabled: + logger.debug("RagApiClient: get_stats пропущен - клиент отключен") return {} try: + logger.debug(f"RagApiClient: Запрос статистики от {self.api_url}/stats") response = await self._client.get(f"{self.api_url}/stats") if response.status_code == 200: - return response.json() + data = response.json() + logger.info( + f"RagApiClient: Статистика получена успешно: " + f"model_loaded={data.get('model_loaded')}, " + f"model_name={data.get('model_name')}, " + f"vector_store={data.get('vector_store', {}).get('total_count', 'N/A')} примеров" + ) + return data + elif response.status_code == 401 or response.status_code == 403: + logger.warning( + f"RagApiClient: Ошибка авторизации при получении статистики: " + f"status={response.status_code}, body={response.text[:200]}" + ) + return {} else: logger.warning( - f"RagApiClient: Неожиданный статус при получении статистики: {response.status_code}" + f"RagApiClient: Неожиданный статус при получении статистики: " + f"status={response.status_code}, body={response.text[:200]}" ) return {} except httpx.TimeoutException: - logger.warning(f"RagApiClient: Таймаут при получении статистики") + logger.warning( + f"RagApiClient: Таймаут при получении статистики (timeout={self.timeout}s)" + ) return {} except httpx.RequestError as e: logger.warning( @@ -365,3 +408,138 @@ class RagApiClient: "api_url": self.api_url, "timeout": self.timeout, } + + @track_time("find_similar_posts", "rag_client") + async def find_similar_posts( + self, text: str, threshold: float = 0.9, hours: int = 24 + ) -> Optional[SimilarPostsResult]: + """ + Ищет похожие посты за последние N часов. + + Args: + text: Текст поста для поиска похожих + threshold: Порог схожести (0.0-1.0), по умолчанию 0.9 + hours: За сколько часов искать (1-168), по умолчанию 24 + + Returns: + SimilarPostsResult с информацией о похожих постах или None при ошибке + """ + if not self._enabled: + return None + + if not text or not text.strip(): + return None + + try: + response = await self._client.post( + f"{self.api_url}/similar", + json={"text": text.strip(), "threshold": threshold, "hours": hours}, + ) + + if response.status_code == 200: + data = response.json() + similar_posts = [] + + for post_data in data.get("similar_posts", []): + similar_posts.append( + SimilarPost( + similarity=float(post_data.get("similarity", 0.0)), + created_at=int(post_data.get("created_at", 0)), + post_id=post_data.get("post_id"), + text=post_data.get("text", ""), + rag_score=post_data.get("rag_score"), + ) + ) + + result = SimilarPostsResult( + similar_count=data.get("similar_count", 0), + similar_posts=similar_posts, + ) + + if result.similar_count > 0: + logger.info( + f"RagApiClient: Найдено {result.similar_count} похожих постов " + f"(max_similarity={result.max_similarity:.2%})" + ) + + return result + else: + logger.warning( + f"RagApiClient: Неожиданный статус при поиске похожих постов: " + f"{response.status_code}, body: {response.text}" + ) + return None + + except httpx.TimeoutException: + logger.warning("RagApiClient: Таймаут при поиске похожих постов") + return None + except httpx.RequestError as e: + logger.warning( + f"RagApiClient: Ошибка подключения при поиске похожих постов: {e}" + ) + return None + except Exception as e: + logger.error(f"RagApiClient: Ошибка поиска похожих постов: {e}") + return None + + @track_time("add_submitted_post", "rag_client") + async def add_submitted_post( + self, + text: str, + post_id: Optional[int] = None, + rag_score: Optional[float] = None, + ) -> bool: + """ + Добавляет пост в коллекцию submitted для поиска похожих. + + Args: + text: Текст поста + post_id: ID поста (опционально) + rag_score: RAG скор на момент добавления (опционально) + + Returns: + True если пост успешно добавлен + """ + if not self._enabled: + return False + + if not text or not text.strip(): + return False + + try: + payload = {"text": text.strip()} + if post_id is not None: + payload["post_id"] = post_id + if rag_score is not None: + payload["rag_score"] = rag_score + + response = await self._client.post( + f"{self.api_url}/submitted", + json=payload, + ) + + if response.status_code in (200, 201): + data = response.json() + logger.debug( + f"RagApiClient: Пост добавлен в submitted " + f"(post_id={post_id}, submitted_count={data.get('submitted_count', 'N/A')})" + ) + return True + else: + logger.warning( + f"RagApiClient: Неожиданный статус при добавлении в submitted: " + f"{response.status_code}" + ) + return False + + except httpx.TimeoutException: + logger.warning("RagApiClient: Таймаут при добавлении в submitted") + return False + except httpx.RequestError as e: + logger.warning( + f"RagApiClient: Ошибка подключения при добавлении в submitted: {e}" + ) + return False + except Exception as e: + logger.error(f"RagApiClient: Ошибка добавления в submitted: {e}") + return False diff --git a/helper_bot/services/scoring/scoring_manager.py b/helper_bot/services/scoring/scoring_manager.py index 6761176..f95812b 100644 --- a/helper_bot/services/scoring/scoring_manager.py +++ b/helper_bot/services/scoring/scoring_manager.py @@ -221,3 +221,46 @@ class ScoringManager: stats["deepseek"] = self.deepseek_service.get_stats() return stats + + @track_time("find_similar_posts", "scoring_manager") + async def find_similar_posts( + self, text: str, threshold: float = 0.9, hours: int = 24 + ): + """ + Ищет похожие посты через RAG API. + + Args: + text: Текст для поиска похожих + threshold: Порог схожести (0.0-1.0) + hours: За сколько часов искать + + Returns: + SimilarPostsResult или None + """ + if not self.rag_client or not self.rag_client.is_enabled: + return None + + return await self.rag_client.find_similar_posts(text, threshold, hours) + + @track_time("add_submitted_post", "scoring_manager") + async def add_submitted_post( + self, + text: str, + post_id: Optional[int] = None, + rag_score: Optional[float] = None, + ) -> bool: + """ + Добавляет пост в коллекцию submitted для поиска похожих. + + Args: + text: Текст поста + post_id: ID поста (опционально) + rag_score: RAG скор на момент добавления (опционально) + + Returns: + True если успешно добавлен + """ + if not self.rag_client or not self.rag_client.is_enabled: + return False + + return await self.rag_client.add_submitted_post(text, post_id, rag_score) diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 39aab76..133bd5f 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -138,6 +138,52 @@ def determine_anonymity(post_text: str) -> bool: return False +def get_publish_text( + post_text: str, + first_name: str, + username: str = None, + is_anonymous: Optional[bool] = None, +) -> str: + """ + Форматирует текст для финальной публикации в канал. + Только текст поста + подпись автора или анон. + + Args: + post_text: Текст сообщения + first_name: Имя автора поста + username: Юзернейм автора поста (может быть None) + is_anonymous: Флаг анонимности (True - анонимно, False - не анонимно, None - legacy) + + Returns: + str: Текст для публикации в канал + """ + safe_post_text = post_text or "" + safe_first_name = first_name or "Пользователь" + + # Формируем строку с информацией об авторе + if username: + author_info = f"{safe_first_name} @{username}" + else: + author_info = f"{safe_first_name}" + + # Определяем анонимность и формируем финальный текст + if is_anonymous is not None: + if is_anonymous: + final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + else: + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + else: + # Legacy: определяем по тексту + if "неанон" in post_text.lower() or "не анон" in post_text.lower(): + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + elif "анон" in post_text.lower(): + final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + else: + final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + + return final_text + + def get_text_message( post_text: str, first_name: str, @@ -147,10 +193,10 @@ def get_text_message( rag_score: Optional[float] = None, rag_confidence: Optional[float] = None, rag_score_pos_only: Optional[float] = None, + user_id: Optional[int] = None, ): """ - Форматирует текст сообщения для публикации в зависимости от наличия ключевых слов "анон" и "неанон" - или переданного параметра is_anonymous. + Форматирует текст сообщения для модерации (с полной информацией об авторе и скорами). Args: post_text: Текст сообщения @@ -161,64 +207,69 @@ def get_text_message( rag_score: Скор от RAG/ruBERT neg/pos (0.0-1.0, опционально) rag_confidence: Уверенность RAG модели (0.0-1.0, зависит от количества примеров) rag_score_pos_only: Скор RAG только по положительным примерам (0.0-1.0, опционально) + user_id: ID пользователя Telegram (опционально) Returns: - str: - Сформированный текст сообщения. + str: - Сформированный текст сообщения для модерации. """ # Экранируем post_text для безопасного использования в HTML safe_post_text = html.escape(str(post_text)) if post_text else "" # Экранируем username для безопасного использования в HTML safe_username = html.escape(username) if username else None + safe_first_name = html.escape(first_name) if first_name else "Пользователь" - # Формируем строку с информацией об авторе + # Формируем шапку с информацией об авторе if safe_username: - author_info = f"{first_name} @{safe_username}" + header = f"👤 От: {safe_first_name} (@{safe_username})" else: - author_info = f"{first_name} (Ник не указан)" + header = f"👤 От: {safe_first_name} (Ник не указан)" - # Формируем базовый текст - # Если передан is_anonymous, используем его, иначе определяем по тексту (legacy) + if user_id: + header += f" | ID: {user_id}" + + # Формируем строку с информацией об авторе для подвала + if safe_username: + author_info = f"{safe_first_name} @{safe_username}" + else: + author_info = f"{safe_first_name} (Ник не указан)" + + # Формируем блок с текстом поста + separator = "=" * 32 + post_block = f"{header}\nТекст поста:\n{separator}\n{safe_post_text}" + + # Определяем анонимность и формируем подвал if is_anonymous is not None: if is_anonymous: - final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + post_block += f"\n\nПост опубликован анонимно" else: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" else: # Legacy: определяем по тексту if "неанон" in post_text or "не анон" in post_text: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" elif "анон" in post_text: - final_text = f"{safe_post_text}\n\nПост опубликован анонимно" + post_block += f"\n\nПост опубликован анонимно" else: - final_text = f"{safe_post_text}\n\nАвтор поста: {author_info}" + post_block += f"\n\nАвтор поста: {author_info}" - # Добавляем блок со скорами если есть - if ( - deepseek_score is not None - or rag_score is not None - or rag_score_pos_only is not None - ): - scores_lines = ["\n📊 Уверенность в одобрении:"] + post_block += f"\n{separator}" + + # Добавляем блок со скорами если есть (без RAG pos only и уверенности) + if deepseek_score is not None or rag_score is not None: + scores_lines = ["📊 Уверенность в одобрении:"] if deepseek_score is not None: scores_lines.append(f"DeepSeek: {deepseek_score:.2f}") if rag_score is not None: logger.debug( f"get_text_message: Форматирование rag_score - " f"rag_score={rag_score} (type: {type(rag_score).__name__}), " - f"rag_score_pos_only={rag_score_pos_only} (type: {type(rag_score_pos_only).__name__ if rag_score_pos_only is not None else 'None'}), " - f"rag_confidence={rag_confidence} (type: {type(rag_confidence).__name__ if rag_confidence is not None else 'None'}), " f"formatted_value={rag_score:.2f}" ) - rag_line = f"RAG neg/pos: {rag_score:.2f}" - if rag_confidence is not None: - rag_line += f" (уверенность: {rag_confidence:.0%})" - scores_lines.append(rag_line) - if rag_score_pos_only is not None: - scores_lines.append(f"RAG pos only: {rag_score_pos_only:.2f}") - final_text += "\n" + "\n".join(scores_lines) + scores_lines.append(f"RAG neg/pos: {rag_score:.2f}") + post_block += "\n" + "\n".join(scores_lines) - return final_text + return post_block @track_time("download_file", "helper_func") @@ -854,15 +905,14 @@ async def send_text_message( ): from .rate_limiter import send_with_rate_limit - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - async def _send_message(): if markup is None: - return await message.bot.send_message(chat_id=chat_id, text=safe_post_text) + return await message.bot.send_message( + chat_id=chat_id, text=post_text, parse_mode="HTML" + ) else: return await message.bot.send_message( - chat_id=chat_id, text=safe_post_text, reply_markup=markup + chat_id=chat_id, text=post_text, reply_markup=markup, parse_mode="HTML" ) sent_message = await send_with_rate_limit(_send_message, chat_id) @@ -878,16 +928,17 @@ async def send_photo_message( post_text: str, markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_photo( - chat_id=chat_id, caption=safe_post_text, photo=photo + chat_id=chat_id, caption=post_text, photo=photo, parse_mode="HTML" ) else: sent_message = await message.bot.send_photo( - chat_id=chat_id, caption=safe_post_text, photo=photo, reply_markup=markup + chat_id=chat_id, + caption=post_text, + photo=photo, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -901,16 +952,17 @@ async def send_video_message( post_text: str = "", markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_video( - chat_id=chat_id, caption=safe_post_text, video=video + chat_id=chat_id, caption=post_text, video=video, parse_mode="HTML" ) else: sent_message = await message.bot.send_video( - chat_id=chat_id, caption=safe_post_text, video=video, reply_markup=markup + chat_id=chat_id, + caption=post_text, + video=video, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -943,16 +995,17 @@ async def send_audio_message( post_text: str, markup: types.ReplyKeyboardMarkup = None, ): - # Экранируем post_text для безопасного использования в HTML - safe_post_text = html.escape(str(post_text)) if post_text else "" - if markup is None: sent_message = await message.bot.send_audio( - chat_id=chat_id, caption=safe_post_text, audio=audio + chat_id=chat_id, caption=post_text, audio=audio, parse_mode="HTML" ) else: sent_message = await message.bot.send_audio( - chat_id=chat_id, caption=safe_post_text, audio=audio, reply_markup=markup + chat_id=chat_id, + caption=post_text, + audio=audio, + reply_markup=markup, + parse_mode="HTML", ) return sent_message @@ -1012,11 +1065,14 @@ async def get_banned_users_list(offset: int, bot_db): message - текст сообщения user_ids - лист кортежей [(user_name: user_id)] """ - users = await bot_db.get_banned_users_from_db_with_limits(limit=7, offset=offset) + items_per_page = 9 + users = await bot_db.get_banned_users_from_db_with_limits( + limit=items_per_page, offset=offset + ) message = "Список заблокированных пользователей:\n" for user in users: - user_id, ban_reason, unban_date = user + user_id, ban_reason, unban_date, ban_date = user # Получаем имя пользователя из таблицы users username = await bot_db.get_username(user_id) full_name = await bot_db.get_full_name_by_id(user_id) @@ -1028,41 +1084,42 @@ async def get_banned_users_list(offset: int, bot_db): html.escape(str(ban_reason)) if ban_reason else "Причина не указана" ) - # Форматируем дату разбана в человекочитаемый формат - if unban_date: - try: - # Предполагаем, что unban_date это UNIX timestamp - if isinstance(unban_date, (int, float)): - unban_datetime = datetime.fromtimestamp(unban_date) - safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M") - elif isinstance(unban_date, str): - # Если это строка, попытаемся её обработать - try: - # Попробуем преобразовать строку в timestamp - timestamp = int(unban_date) - unban_datetime = datetime.fromtimestamp(timestamp) - safe_unban_date = unban_datetime.strftime("%d-%m-%Y %H:%M") - except (ValueError, TypeError): - # Если не удалось, показываем как есть - safe_unban_date = html.escape(str(unban_date)) - elif hasattr(unban_date, "strftime"): - # Если это datetime объект - safe_unban_date = unban_date.strftime("%d-%m-%Y %H:%M") - else: - # Для всех остальных случаев - safe_unban_date = html.escape(str(unban_date)) - except (ValueError, TypeError, OSError): - # В случае ошибки показываем исходное значение - safe_unban_date = html.escape(str(unban_date)) - else: - safe_unban_date = "Дата не указана" + # Форматируем дату бана в человекочитаемый формат + safe_ban_date = _format_timestamp_to_date(ban_date) - message += f"**Пользователь:** {safe_user_name}\n" - message += f"**Причина бана:** {safe_ban_reason}\n" - message += f"**Дата разбана:** {safe_unban_date}\n\n" + # Форматируем дату разбана в человекочитаемый формат + safe_unban_date = _format_timestamp_to_date(unban_date, default="Навсегда") + + message += f"Пользователь: {safe_user_name}\n" + message += f"Причина бана: {safe_ban_reason}\n" + message += f"Дата бана: {safe_ban_date}\n" + message += f"Дата разбана: {safe_unban_date}\n\n" return message +def _format_timestamp_to_date(timestamp, default: str = "Дата не указана") -> str: + """Форматирует timestamp в читаемую дату.""" + if not timestamp: + return default + try: + if isinstance(timestamp, (int, float)): + dt = datetime.fromtimestamp(timestamp) + return dt.strftime("%d.%m.%Y %H:%M") + elif isinstance(timestamp, str): + try: + ts = int(timestamp) + dt = datetime.fromtimestamp(ts) + return dt.strftime("%d.%m.%Y %H:%M") + except (ValueError, TypeError): + return html.escape(str(timestamp)) + elif hasattr(timestamp, "strftime"): + return timestamp.strftime("%d.%m.%Y %H:%M") + else: + return html.escape(str(timestamp)) + except (ValueError, TypeError, OSError): + return html.escape(str(timestamp)) + + @track_time("get_banned_users_buttons", "helper_func") @track_errors("helper_func", "get_banned_users_buttons") @db_query_time("get_banned_users_buttons", "users", "select") diff --git a/scripts/create_bot_settings_table.py b/scripts/create_bot_settings_table.py new file mode 100644 index 0000000..4a6c262 --- /dev/null +++ b/scripts/create_bot_settings_table.py @@ -0,0 +1,115 @@ +#!/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..c2dd6ea --- /dev/null +++ b/tests/test_auto_moderation_service.py @@ -0,0 +1,219 @@ +"""Тесты для AutoModerationService.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +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_blacklist_repository.py b/tests/test_blacklist_repository.py index 97caf4f..5b323a7 100644 --- a/tests/test_blacklist_repository.py +++ b/tests/test_blacklist_repository.py @@ -274,9 +274,9 @@ class TestBlacklistRepository: # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) actual_query = " ".join(call_args[0][0].split()) - expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist LIMIT ?, ?" + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC LIMIT ? OFFSET ?" assert actual_query == expected_query - assert call_args[0][1] == (0, 10) + assert call_args[0][1] == (10, 0) # Проверяем логирование blacklist_repository.logger.info.assert_called_once_with( @@ -310,7 +310,7 @@ class TestBlacklistRepository: # Нормализуем SQL запрос (убираем лишние пробелы и переносы строк) actual_query = " ".join(call_args[0][0].split()) - expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist" + expected_query = "SELECT user_id, message_for_user, date_to_unban, created_at, ban_author FROM blacklist ORDER BY created_at DESC" assert actual_query == expected_query # Проверяем, что параметры пустые (без лимитов) assert len(call_args[0]) == 1 # Только SQL запрос, без параметров diff --git a/tests/test_bot_settings_repository.py b/tests/test_bot_settings_repository.py new file mode 100644 index 0000000..47b44dd --- /dev/null +++ b/tests/test_bot_settings_repository.py @@ -0,0 +1,171 @@ +"""Тесты для BotSettingsRepository.""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +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_callback_services.py b/tests/test_callback_services.py index 1bfea92..216f6b9 100644 --- a/tests/test_callback_services.py +++ b/tests/test_callback_services.py @@ -85,7 +85,7 @@ class TestPostPublishService: return call @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_text_success( self, mock_get_text, mock_send_text, service, mock_call_text, mock_db ): @@ -214,7 +214,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_photo_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_photo_success( self, mock_get_text, mock_send_text, mock_send_photo, service, mock_db ): @@ -239,7 +239,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_video_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_video_success( self, mock_get_text, mock_send_text, mock_send_video, service, mock_db ): @@ -285,7 +285,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_audio_message") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_post_audio_success( self, mock_get_text, mock_send_text, mock_send_audio, service, mock_db ): @@ -499,7 +499,7 @@ class TestPostPublishService: @patch("helper_bot.handlers.callback.services.send_media_group_to_channel") @patch("helper_bot.handlers.callback.services.send_text_message") - @patch("helper_bot.handlers.callback.services.get_text_message") + @patch("helper_bot.handlers.callback.services.get_publish_text") async def test_publish_media_group_success( self, mock_get_text, mock_send_text, mock_send_media, service, mock_db ): 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): """Тест клавиатуры для постов""" diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py index d36b4e3..515eb2c 100644 --- a/tests/test_refactored_private_handlers.py +++ b/tests/test_refactored_private_handlers.py @@ -29,6 +29,11 @@ class TestPrivateHandlers: db.add_message = AsyncMock() db.update_helper_message = AsyncMock() db.update_user_activity = AsyncMock() + db.get_user_posts_stats = AsyncMock(return_value=(5, 2, 3)) + db.get_last_post_by_author = AsyncMock(return_value="Last post text") + db.get_user_by_id = AsyncMock(return_value=Mock(date_added=1704067200)) + db.get_user_ban_count = AsyncMock(return_value=0) + db.get_last_ban_info = AsyncMock(return_value=None) return db @pytest.fixture @@ -257,6 +262,7 @@ class TestPrivateHandlers: """resend_message_in_group при PRE_CHAT переводит в START и отправляет question.""" handlers = create_private_handlers(mock_db, mock_settings) mock_state.get_state = AsyncMock(return_value=FSM_STATES["PRE_CHAT"]) + mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100)) with pytest.MonkeyPatch().context() as m: m.setattr( "helper_bot.handlers.private.private_handlers.get_reply_keyboard", @@ -267,9 +273,7 @@ class TestPrivateHandlers: lambda x, y: "Question?", ) await handlers.resend_message_in_group_for_message(mock_message, mock_state) - mock_message.forward.assert_called_once_with( - chat_id=mock_settings.group_for_message - ) + mock_message.bot.send_message.assert_called_once() mock_state.set_state.assert_called_once_with(FSM_STATES["START"]) @pytest.mark.asyncio @@ -279,6 +283,7 @@ class TestPrivateHandlers: """resend_message_in_group при CHAT оставляет в CHAT и отправляет question с leave markup.""" handlers = create_private_handlers(mock_db, mock_settings) mock_state.get_state = AsyncMock(return_value=FSM_STATES["CHAT"]) + mock_message.bot.send_message = AsyncMock(return_value=Mock(message_id=100)) with pytest.MonkeyPatch().context() as m: m.setattr( "helper_bot.handlers.private.private_handlers.get_reply_keyboard_leave_chat", @@ -289,9 +294,7 @@ class TestPrivateHandlers: lambda x, y: "Question?", ) await handlers.resend_message_in_group_for_message(mock_message, mock_state) - mock_message.forward.assert_called_once_with( - chat_id=mock_settings.group_for_message - ) + mock_message.bot.send_message.assert_called_once() mock_message.answer.assert_called() @pytest.mark.asyncio diff --git a/tests/test_utils.py b/tests/test_utils.py index d511122..b57d253 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -665,7 +665,7 @@ class TestSendMessageFunctions: assert result == mock_sent_message mock_message.bot.send_photo.assert_called_once_with( - chat_id=123, caption="Подпись к фото", photo="photo.jpg" + chat_id=123, caption="Подпись к фото", photo="photo.jpg", parse_mode="HTML" ) @pytest.mark.asyncio @@ -684,7 +684,7 @@ class TestSendMessageFunctions: assert result == mock_sent_message mock_message.bot.send_video.assert_called_once_with( - chat_id=123, caption="Подпись к видео", video="video.mp4" + chat_id=123, caption="Подпись к видео", video="video.mp4", parse_mode="HTML" ) @@ -722,8 +722,9 @@ class TestUtilityFunctions: """Тест получения списка заблокированных пользователей""" mock_db = AsyncMock() mock_db.get_banned_users_from_db_with_limits.return_value = [ - (123, "Spam", 1704067200), # user_id, ban_reason, unban_date (timestamp) - (456, "Violation", 1704153600), + # user_id, ban_reason, unban_date (timestamp), ban_date (timestamp) + (123, "Spam", 1704067200, 1703980800), + (456, "Violation", 1704153600, 1704067200), ] mock_db.get_username.return_value = None mock_db.get_full_name_by_id.return_value = "Test User" @@ -734,18 +735,16 @@ class TestUtilityFunctions: assert "Test User" in result assert "Spam" in result assert "Violation" in result + assert "Дата бана:" in result @pytest.mark.asyncio async def test_get_banned_users_list_with_string_timestamp(self): """Тест получения списка заблокированных пользователей со строковым timestamp""" mock_db = AsyncMock() mock_db.get_banned_users_from_db_with_limits.return_value = [ - ( - 123, - "Spam", - "1704067200", - ), # user_id, ban_reason, unban_date (string timestamp) - (456, "Violation", "1704153600"), + # user_id, ban_reason, unban_date (string timestamp), ban_date (string timestamp) + (123, "Spam", "1704067200", "1703980800"), + (456, "Violation", "1704153600", "1704067200"), ] mock_db.get_username.return_value = None mock_db.get_full_name_by_id.return_value = "Test User" @@ -756,6 +755,7 @@ class TestUtilityFunctions: assert "Test User" in result assert "Spam" in result assert "Violation" in result + assert "Дата бана:" in result @pytest.mark.asyncio async def test_get_banned_users_buttons(self):