From fc0517c01160acf25f11c53e6d1f1ff454228c46 Mon Sep 17 00:00:00 2001 From: Andrey Date: Thu, 4 Sep 2025 00:46:45 +0300 Subject: [PATCH] Enhance bot functionality with new features and improvements - Added a new `/status` endpoint in `server_prometheus.py` to provide process status information, including uptime and resource usage metrics. - Implemented a PID manager in `run_helper.py` to track the bot's process, improving monitoring capabilities. - Introduced a method to delete audio moderation records in `audio_repository.py`, enhancing database management. - Updated voice message handling in callback handlers to ensure proper deletion of audio moderation records. - Improved error handling and logging in various services, ensuring better tracking of media processing and file downloads. - Refactored media handling functions to streamline operations and improve code readability. - Enhanced metrics tracking for file downloads and media processing, providing better insights into bot performance. --- Dockerfile.bot | 2 +- database/repositories/audio_repository.py | 6 + .../handlers/callback/callback_handlers.py | 6 + helper_bot/handlers/callback/services.py | 6 + helper_bot/handlers/private/services.py | 22 +- helper_bot/handlers/voice/services.py | 18 +- helper_bot/middlewares/metrics_middleware.py | 25 +- helper_bot/server_prometheus.py | 92 ++++++ helper_bot/utils/helper_func.py | 293 ++++++++++++++--- helper_bot/utils/messages.py | 2 +- helper_bot/utils/metrics.py | 99 ++++++ run_helper.py | 37 +++ tests/test_audio_file_service.py | 266 ++++++++++++++++ tests/test_audio_repository.py | 14 + tests/test_callback_handlers.py | 297 +++++++++++++++++ tests/test_improved_media_processing.py | 301 ++++++++++++++++++ tests/test_utils.py | 19 +- 17 files changed, 1421 insertions(+), 84 deletions(-) create mode 100644 tests/test_audio_file_service.py create mode 100644 tests/test_callback_handlers.py create mode 100644 tests/test_improved_media_processing.py diff --git a/Dockerfile.bot b/Dockerfile.bot index 17ec6bf..eaa67a0 100644 --- a/Dockerfile.bot +++ b/Dockerfile.bot @@ -43,7 +43,7 @@ RUN chown -R 1001:1001 /opt/venv # Create app directory and set permissions WORKDIR /app -RUN mkdir -p /app/database /app/logs && \ +RUN mkdir -p /app/database /app/logs /app/voice_users && \ chown -R 1001:1001 /app # Copy application code diff --git a/database/repositories/audio_repository.py b/database/repositories/audio_repository.py index 32d0071..bc854cd 100644 --- a/database/repositories/audio_repository.py +++ b/database/repositories/audio_repository.py @@ -208,3 +208,9 @@ class AudioRepository(DatabaseConnection): self.logger.info(f"Получен user_id {user_id} для message_id {message_id}") return user_id return None + + async def delete_audio_moderate_record(self, message_id: int) -> None: + """Удаляет запись из таблицы audio_moderate по message_id.""" + query = "DELETE FROM audio_moderate WHERE message_id = ?" + await self._execute_query(query, (message_id,)) + self.logger.info(f"Удалена запись из audio_moderate для message_id {message_id}") \ No newline at end of file diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index f9e81a8..6ee0283 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -273,6 +273,9 @@ async def save_voice_message( message_id=call.message.message_id ) + # Удаляем запись из таблицы audio_moderate + await bot_db.delete_audio_moderate_record(call.message.message_id) + await call.answer(text='Сохранено!', cache_time=3) except Exception as e: @@ -296,6 +299,9 @@ async def delete_voice_message( message_id=call.message.message_id ) + # Удаляем запись из таблицы audio_moderate + await bot_db.delete_audio_moderate_record(call.message.message_id) + await call.answer(text='Удалено!', cache_time=3) except Exception as e: diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index bb69930..fd3a155 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -331,6 +331,12 @@ class BanService: self.group_for_posts = settings['Telegram']['group_for_posts'] self.important_logs = settings['Telegram']['important_logs'] + def _get_bot(self, message) -> Bot: + """Получает бота из контекста сообщения или использует переданного""" + if self.bot: + return self.bot + return message.bot + @track_time("ban_user_from_post", "ban_service") @track_errors("ban_service", "ban_user_from_post") async def ban_user_from_post(self, call: CallbackQuery) -> None: diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index bc09fb5..cefb566 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -180,7 +180,9 @@ class PostService: created_at=int(datetime.now().timestamp()) ) await self.db.add_post(post) - await add_in_db_media(sent_message, self.db) + success = await add_in_db_media(sent_message, self.db) + if not success: + logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") @track_time("handle_video_post", "post_service") @track_errors("post_service", "handle_video_post") @@ -202,7 +204,9 @@ class PostService: created_at=int(datetime.now().timestamp()) ) await self.db.add_post(post) - await add_in_db_media(sent_message, self.db) + success = await add_in_db_media(sent_message, self.db) + if not success: + logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") @track_time("handle_video_note_post", "post_service") @track_errors("post_service", "handle_video_note_post") @@ -220,7 +224,9 @@ class PostService: created_at=int(datetime.now().timestamp()) ) await self.db.add_post(post) - await add_in_db_media(sent_message, self.db) + success = await add_in_db_media(sent_message, self.db) + if not success: + logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") @track_time("handle_audio_post", "post_service") @track_errors("post_service", "handle_audio_post") @@ -242,7 +248,9 @@ class PostService: created_at=int(datetime.now().timestamp()) ) await self.db.add_post(post) - await add_in_db_media(sent_message, self.db) + success = await add_in_db_media(sent_message, self.db) + if not success: + logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") @track_time("handle_voice_post", "post_service") @track_errors("post_service", "handle_voice_post") @@ -260,7 +268,9 @@ class PostService: created_at=int(datetime.now().timestamp()) ) await self.db.add_post(post) - await add_in_db_media(sent_message, self.db) + success = await add_in_db_media(sent_message, self.db) + if not success: + logger.warning(f"handle_photo_post: Не удалось сохранить медиа для поста {sent_message.message_id}") @track_time("handle_media_group_post", "post_service") @track_errors("post_service", "handle_media_group_post") @@ -283,7 +293,7 @@ class PostService: # Отправляем медиагруппу в группу для модерации media_group = await prepare_media_group_from_middlewares(album, post_caption) media_group_message_id = await send_media_group_message_to_private_chat( - self.settings.group_for_posts, message, media_group, self.db + self.settings.group_for_posts, message, media_group, self.db, main_post.message_id ) await asyncio.sleep(0.2) diff --git a/helper_bot/handlers/voice/services.py b/helper_bot/handlers/voice/services.py index 3c14634..516f755 100644 --- a/helper_bot/handlers/voice/services.py +++ b/helper_bot/handlers/voice/services.py @@ -299,10 +299,19 @@ class AudioFileService: logger.info(f"Получена информация о файле: {file_info.file_path}") downloaded_file = await bot.download_file(file_path=file_info.file_path) - logger.info(f"Файл скачан, размер: {len(downloaded_file.read()) if downloaded_file else 'None'} bytes") - # Сбрасываем позицию в файле - downloaded_file.seek(0) + # Проверяем что файл успешно скачан + if not downloaded_file: + logger.error("Не удалось скачать файл") + raise FileOperationError("Не удалось скачать файл") + + # Получаем размер файла без изменения позиции + current_pos = downloaded_file.tell() + downloaded_file.seek(0, 2) # Переходим в конец файла + file_size = downloaded_file.tell() + downloaded_file.seek(current_pos) # Возвращаемся в исходную позицию + + logger.info(f"Файл скачан, размер: {file_size} bytes") # Создаем директорию если она не существует import os @@ -312,6 +321,9 @@ class AudioFileService: file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg' logger.info(f"Сохраняем файл по пути: {file_path}") + # Сбрасываем позицию в файле перед сохранением + downloaded_file.seek(0) + # Сохраняем файл with open(file_path, 'wb') as new_file: new_file.write(downloaded_file.read()) diff --git a/helper_bot/middlewares/metrics_middleware.py b/helper_bot/middlewares/metrics_middleware.py index 574419b..2cda059 100644 --- a/helper_bot/middlewares/metrics_middleware.py +++ b/helper_bot/middlewares/metrics_middleware.py @@ -155,30 +155,27 @@ class MetricsMiddleware(BaseMiddleware): bdf = get_global_instance() bot_db = bdf.get_db() - await bot_db.connect() - + # Используем правильные методы AsyncBotDB для выполнения запросов # Простой подсчет всех пользователей в базе - total_users_query = "SELECT COUNT(DISTINCT user_id) FROM our_users" - await bot_db.cursor.execute(total_users_query) - total_users_result = await bot_db.cursor.fetchone() - total_users = total_users_result[0] if total_users_result else 1 - daily_users_query = "SELECT COUNT(DISTINCT user_id) as active_users FROM our_users WHERE date_changed > datetime('now', '-1 day')" - await bot_db.cursor.execute(daily_users_query) - daily_users_result = await bot_db.cursor.fetchone() - daily_users = daily_users_result[0] if daily_users_result else 1 + total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users" + total_users_result = await bot_db.fetch_one(total_users_query) + total_users = total_users_result['total'] if total_users_result else 1 - await bot_db.close() + # Подсчет активных за день пользователей (date_changed - это Unix timestamp) + daily_users_query = "SELECT COUNT(DISTINCT user_id) as daily FROM our_users WHERE date_changed > (strftime('%s', 'now', '-1 day'))" + daily_users_result = await bot_db.fetch_one(daily_users_query) + daily_users = daily_users_result['daily'] if daily_users_result else 1 + # Устанавливаем метрики с правильными лейблами metrics.set_active_users(daily_users, "daily") - metrics.set_active_users(total_users, "total") + metrics.set_total_users(total_users) self.logger.info(f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)") - except Exception as e: self.logger.error(f"❌ Failed to update users metric: {e}") # Устанавливаем 1 как fallback metrics.set_active_users(1, "daily") - metrics.set_active_users(1, "total") + metrics.set_total_users(1) async def _record_comprehensive_message_metrics(self, message: Message) -> Dict[str, Any]: """Record comprehensive message metrics.""" diff --git a/helper_bot/server_prometheus.py b/helper_bot/server_prometheus.py index 339a008..52bf192 100644 --- a/helper_bot/server_prometheus.py +++ b/helper_bot/server_prometheus.py @@ -31,6 +31,7 @@ class MetricsServer: # Настраиваем роуты self.app.router.add_get('/metrics', self.metrics_handler) self.app.router.add_get('/health', self.health_handler) + self.app.router.add_get('/status', self.status_handler) async def metrics_handler(self, request: web.Request) -> web.Response: """Handle /metrics endpoint for Prometheus scraping.""" @@ -102,6 +103,96 @@ class MetricsServer: status=500 ) + async def status_handler(self, request: web.Request) -> web.Response: + """Handle /status endpoint for process status information.""" + try: + import os + import time + import psutil + + # Получаем PID текущего процесса + current_pid = os.getpid() + + try: + # Получаем информацию о процессе + process = psutil.Process(current_pid) + create_time = process.create_time() + uptime_seconds = time.time() - create_time + + # Логируем для диагностики + import datetime + create_time_str = datetime.datetime.fromtimestamp(create_time).strftime('%Y-%m-%d %H:%M:%S') + current_time_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + logger.info(f"Process PID {current_pid}: created at {create_time_str}, current time {current_time_str}, uptime {uptime_seconds:.1f}s") + + # Форматируем uptime + if uptime_seconds < 60: + uptime_str = f"{int(uptime_seconds)}с" + elif uptime_seconds < 3600: + minutes = int(uptime_seconds // 60) + uptime_str = f"{minutes}м" + elif uptime_seconds < 86400: + hours = int(uptime_seconds // 3600) + minutes = int((uptime_seconds % 3600) // 60) + uptime_str = f"{hours}ч {minutes}м" + else: + days = int(uptime_seconds // 86400) + hours = int((uptime_seconds % 86400) // 3600) + uptime_str = f"{days}д {hours}ч" + + # Проверяем, что процесс активен + if process.is_running(): + status = "running" + else: + status = "stopped" + + # Формируем ответ + response_data = { + "status": status, + "pid": current_pid, + "uptime": uptime_str, + "memory_usage_mb": round(process.memory_info().rss / 1024 / 1024, 2), + "cpu_percent": process.cpu_percent(), + "timestamp": time.time() + } + + import json + return web.Response( + text=json.dumps(response_data, ensure_ascii=False), + content_type='application/json', + status=200 + ) + + except psutil.NoSuchProcess: + # Процесс не найден + response_data = { + "status": "not_found", + "error": "Process not found", + "timestamp": time.time() + } + + import json + return web.Response( + text=json.dumps(response_data, ensure_ascii=False), + content_type='application/json', + status=404 + ) + + except Exception as e: + logger.error(f"Status check failed: {e}") + import json + response_data = { + "status": "error", + "error": str(e), + "timestamp": time.time() + } + + return web.Response( + text=json.dumps(response_data, ensure_ascii=False), + content_type='application/json', + status=500 + ) + async def start(self) -> None: """Start the HTTP server.""" try: @@ -115,6 +206,7 @@ class MetricsServer: logger.info("Available endpoints:") logger.info(f" - /metrics - Prometheus metrics") logger.info(f" - /health - Health check") + logger.info(f" - /status - Process status") except Exception as e: logger.error(f"Failed to start metrics server: {e}") diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index a12a5ff..5372066 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -1,9 +1,10 @@ import html import os import random +import time from datetime import datetime, timedelta from time import sleep -from typing import List, Dict, Any, Optional, TYPE_CHECKING +from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union try: import emoji as _emoji_lib @@ -115,31 +116,79 @@ def get_text_message(post_text: str, first_name: str, username: str = None): return f'Пост из ТГ:\n{safe_post_text}\n\nАвтор поста: {author_info}' -async def download_file(message: types.Message, file_id: str): +async def download_file(message: types.Message, file_id: str, content_type: str = None) -> Optional[str]: """ - Скачивает файл по file_id из Telegram. + Скачивает файл по file_id из Telegram и сохраняет в соответствующую папку. Args: message: сообщение - file_id: File ID фотографии - filename: Имя файла, под которым будет сохранено фото + file_id: File ID файла + content_type: тип контента (photo, video, audio, voice, video_note) Returns: Путь к сохраненному файлу, если файл был скачан успешно, иначе None """ + start_time = time.time() + try: - os.makedirs("files", exist_ok=True) - os.makedirs("files/photos", exist_ok=True) - os.makedirs("files/videos", exist_ok=True) - os.makedirs("files/music", exist_ok=True) - os.makedirs("files/voice", exist_ok=True) - os.makedirs("files/video_notes", exist_ok=True) + # Валидация параметров + if not file_id or not message or not message.bot: + logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют") + return None + + # Определяем папку по типу контента + type_folders = { + 'photo': 'photos', + 'video': 'videos', + 'audio': 'music', + 'voice': 'voice', + 'video_note': 'video_notes' + } + + folder = type_folders.get(content_type, 'other') + base_path = "files" + full_folder_path = os.path.join(base_path, folder) + + # Создаем необходимые папки + os.makedirs(base_path, exist_ok=True) + os.makedirs(full_folder_path, exist_ok=True) + + logger.debug(f"download_file: Начинаю скачивание файла {file_id} типа {content_type} в папку {folder}") + + # Получаем информацию о файле file = await message.bot.get_file(file_id) - file_path = os.path.join("files", file.file_path) + if not file or not file.file_path: + logger.error(f"download_file: Не удалось получить информацию о файле {file_id}") + return None + + # Генерируем уникальное имя файла + original_filename = os.path.basename(file.file_path) + file_extension = os.path.splitext(original_filename)[1] or '.bin' + safe_filename = f"{file_id}{file_extension}" + file_path = os.path.join(full_folder_path, safe_filename) + + # Скачиваем файл await message.bot.download_file(file_path=file.file_path, destination=file_path) + + # Проверяем, что файл действительно скачался + if not os.path.exists(file_path): + logger.error(f"download_file: Файл не был скачан - {file_path}") + return None + + file_size = os.path.getsize(file_path) + download_time = time.time() - start_time + + logger.info(f"download_file: Файл успешно скачан - {file_path}, размер: {file_size} байт, время: {download_time:.2f}с") + + # Записываем метрики + metrics.record_file_download(content_type or 'unknown', file_size, download_time) + return file_path + except Exception as e: - logger.error(f"Ошибка скачивания фотографии: {e}") + download_time = time.time() - start_time + logger.error(f"download_file: Ошибка скачивания файла {file_id}: {e}, время: {download_time:.2f}с") + metrics.record_file_download_error(content_type or 'unknown', str(e)) return None @@ -195,33 +244,123 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''): return media_group -async def add_in_db_media_mediagroup(sent_message, bot_db): +async def add_in_db_media_mediagroup(sent_message: List[types.Message], bot_db: Any, main_post_id: Optional[int] = None) -> bool: """ Добавляет контент медиа-группы в базу данных Args: sent_message: sent_message объект из Telegram API bot_db: Экземпляр базы данных + main_post_id: ID основного поста медиагруппы (если не указан, используется последний message_id) Returns: - None + bool: True если весь контент успешно добавлен, False в случае ошибки """ - post_id = sent_message[-1].message_id # ID поста (первое сообщение в медиа-группе) - for i, message in enumerate(sent_message): - if message.photo: - file_id = message.photo[-1].file_id - file_path = await download_file(message, file_id=file_id) - await bot_db.add_post_content(post_id, message.message_id, file_path, 'photo') - elif message.video: - file_id = message.video.file_id - file_path = await download_file(message, file_id=file_id) - await bot_db.add_post_content(post_id, message.message_id, file_path, 'video') + start_time = time.time() + + try: + # Валидация параметров + if not sent_message or not bot_db or not isinstance(sent_message, list): + logger.error("add_in_db_media_mediagroup: Неверные параметры - sent_message, bot_db или sent_message не является списком") + return False + + if len(sent_message) == 0: + logger.warning("add_in_db_media_mediagroup: Пустая медиагруппа") + return False + + # Используем переданный main_post_id или ID последнего сообщения + post_id = main_post_id or sent_message[-1].message_id + logger.debug(f"add_in_db_media_mediagroup: Обрабатываю медиагруппу из {len(sent_message)} сообщений, post_id: {post_id}") + + processed_count = 0 + failed_count = 0 + + for i, message in enumerate(sent_message): + try: + content_type = None + file_id = None + + # Определяем тип контента и file_id + if message.photo: + content_type = 'photo' + file_id = message.photo[-1].file_id + elif message.video: + content_type = 'video' + file_id = message.video.file_id + elif message.audio: + content_type = 'audio' + file_id = message.audio.file_id + elif message.voice: + content_type = 'voice' + file_id = message.voice.file_id + elif message.video_note: + content_type = 'video_note' + file_id = message.video_note.file_id + else: + logger.warning(f"add_in_db_media_mediagroup: Неподдерживаемый тип контента в сообщении {i+1}/{len(sent_message)}") + failed_count += 1 + continue + + if not file_id: + logger.error(f"add_in_db_media_mediagroup: file_id отсутствует в сообщении {i+1}/{len(sent_message)}") + failed_count += 1 + continue + + logger.debug(f"add_in_db_media_mediagroup: Обрабатываю {content_type} в сообщении {i+1}/{len(sent_message)}") + + # Скачиваем файл + file_path = await download_file(message, file_id=file_id, content_type=content_type) + if not file_path: + logger.error(f"add_in_db_media_mediagroup: Не удалось скачать файл {file_id} в сообщении {i+1}/{len(sent_message)}") + failed_count += 1 + continue + + # Добавляем в базу данных + success = await bot_db.add_post_content(post_id, message.message_id, file_path, content_type) + if not success: + logger.error(f"add_in_db_media_mediagroup: Не удалось добавить контент в БД для сообщения {i+1}/{len(sent_message)}") + # Удаляем скачанный файл при ошибке БД + try: + os.remove(file_path) + logger.debug(f"add_in_db_media_mediagroup: Удален файл {file_path} после ошибки БД") + except Exception as e: + logger.warning(f"add_in_db_media_mediagroup: Не удалось удалить файл {file_path}: {e}") + failed_count += 1 + continue + + processed_count += 1 + logger.debug(f"add_in_db_media_mediagroup: Успешно обработано сообщение {i+1}/{len(sent_message)}") + + except Exception as e: + logger.error(f"add_in_db_media_mediagroup: Ошибка обработки сообщения {i+1}/{len(sent_message)}: {e}") + failed_count += 1 + continue + + processing_time = time.time() - start_time + + if processed_count == 0: + logger.error(f"add_in_db_media_mediagroup: Не удалось обработать ни одного сообщения из медиагруппы {post_id}") + metrics.record_media_processing('media_group', processing_time, False) + return False + + if failed_count > 0: + logger.warning(f"add_in_db_media_mediagroup: Обработано {processed_count}/{len(sent_message)} сообщений медиагруппы {post_id}, ошибок: {failed_count}") else: - # Если нет фото, видео или аудио, или другой контент, пропускаем сообщение - continue + logger.info(f"add_in_db_media_mediagroup: Успешно обработана медиагруппа {post_id} - {processed_count} сообщений, время: {processing_time:.2f}с") + + # Записываем метрики + metrics.record_media_processing('media_group', processing_time, failed_count == 0) + + return failed_count == 0 + + except Exception as e: + processing_time = time.time() - start_time + logger.error(f"add_in_db_media_mediagroup: Критическая ошибка обработки медиагруппы: {e}, время: {processing_time:.2f}с") + metrics.record_media_processing('media_group', processing_time, False) + return False -async def add_in_db_media(sent_message, bot_db): +async def add_in_db_media(sent_message: types.Message, bot_db: Any) -> bool: """ Добавляет контент одиночного сообщения в базу данных @@ -230,33 +369,81 @@ async def add_in_db_media(sent_message, bot_db): bot_db: Экземпляр базы данных Returns: - None + bool: True если контент успешно добавлен, False в случае ошибки """ - post_id = sent_message.message_id # ID поста (это же сообщение) - if sent_message.photo: - file_id = sent_message.photo[-1].file_id - file_path = await download_file(sent_message, file_id=file_id) - await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'photo') - elif sent_message.video: - file_id = sent_message.video.file_id - file_path = await download_file(sent_message, file_id=file_id) - await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'video') - elif sent_message.voice: - file_id = sent_message.voice.file_id - file_path = await download_file(sent_message, file_id=file_id) - await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'voice') - elif sent_message.audio: - file_id = sent_message.audio.file_id - file_path = await download_file(sent_message, file_id=file_id) - await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'audio') - elif sent_message.video_note: - file_id = sent_message.video_note.file_id - file_path = await download_file(sent_message, file_id=file_id) - await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'video_note') + start_time = time.time() + + try: + # Валидация параметров + if not sent_message or not bot_db: + logger.error("add_in_db_media: Неверные параметры - sent_message или bot_db отсутствуют") + return False + + post_id = sent_message.message_id # ID поста (это же сообщение) + content_type = None + file_id = None + + # Определяем тип контента и file_id + if sent_message.photo: + content_type = 'photo' + file_id = sent_message.photo[-1].file_id + elif sent_message.video: + content_type = 'video' + file_id = sent_message.video.file_id + elif sent_message.voice: + content_type = 'voice' + file_id = sent_message.voice.file_id + elif sent_message.audio: + content_type = 'audio' + file_id = sent_message.audio.file_id + elif sent_message.video_note: + content_type = 'video_note' + file_id = sent_message.video_note.file_id + else: + logger.warning(f"add_in_db_media: Неподдерживаемый тип контента для сообщения {post_id}") + return False + + if not file_id: + logger.error(f"add_in_db_media: file_id отсутствует для сообщения {post_id}") + return False + + logger.debug(f"add_in_db_media: Обрабатываю {content_type} для сообщения {post_id}") + + # Скачиваем файл + file_path = await download_file(sent_message, file_id=file_id, content_type=content_type) + if not file_path: + logger.error(f"add_in_db_media: Не удалось скачать файл {file_id} для сообщения {post_id}") + return False + + # Добавляем в базу данных + success = await bot_db.add_post_content(post_id, sent_message.message_id, file_path, content_type) + if not success: + logger.error(f"add_in_db_media: Не удалось добавить контент в БД для сообщения {post_id}") + # Удаляем скачанный файл при ошибке БД + try: + os.remove(file_path) + logger.debug(f"add_in_db_media: Удален файл {file_path} после ошибки БД") + except Exception as e: + logger.warning(f"add_in_db_media: Не удалось удалить файл {file_path}: {e}") + return False + + processing_time = time.time() - start_time + logger.info(f"add_in_db_media: Контент успешно добавлен для сообщения {post_id}, тип: {content_type}, время: {processing_time:.2f}с") + + # Записываем метрики + metrics.record_media_processing(content_type, processing_time, True) + + return True + + except Exception as e: + processing_time = time.time() - start_time + logger.error(f"add_in_db_media: Ошибка обработки медиа для сообщения {post_id}: {e}, время: {processing_time:.2f}с") + metrics.record_media_processing(content_type or 'unknown', processing_time, False) + return False async def send_media_group_message_to_private_chat(chat_id: int, message: types.Message, - media_group: List, bot_db): + media_group: List, bot_db: Any, main_post_id: Optional[int] = None) -> int: sent_message = await message.bot.send_media_group( chat_id=chat_id, media=media_group, @@ -268,7 +455,9 @@ async def send_media_group_message_to_private_chat(chat_id: int, message: types. created_at=int(datetime.now().timestamp()) ) await bot_db.add_post(post) - await add_in_db_media_mediagroup(sent_message, bot_db) + success = await add_in_db_media_mediagroup(sent_message, bot_db, main_post_id) + if not success: + logger.warning(f"send_media_group_message_to_private_chat: Не удалось сохранить медиа для медиагруппы {sent_message[-1].message_id}") message_id = sent_message[-1].message_id return message_id diff --git a/helper_bot/utils/messages.py b/helper_bot/utils/messages.py index 771a25a..57bab43 100644 --- a/helper_bot/utils/messages.py +++ b/helper_bot/utils/messages.py @@ -52,7 +52,7 @@ constants = { 'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. Твой эмоджи - {emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)", 'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help", 'FINAL_MESSAGE': "Ну всё, достаточно инструкций. записывайся! Микрофон твой - 🎤", - 'HELP_MESSAGE': "Скорее всего ответы на твои вопросы есть здесь, ознакомься: https://telegra.ph/Instrukciya-k-botu-Golosa-Bijsk-10-11-2\nЕсли это не поможет, пиши в личку: @Kerrad1", + 'HELP_MESSAGE': "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами", 'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌", 'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗", 'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится", diff --git a/helper_bot/utils/metrics.py b/helper_bot/utils/metrics.py index b0f08e1..9f68310 100644 --- a/helper_bot/utils/metrics.py +++ b/helper_bot/utils/metrics.py @@ -52,6 +52,13 @@ class BotMetrics: registry=self.registry ) + # Total users gauge (отдельная метрика) + self.total_users = Gauge( + 'total_users', + 'Total number of users in database', + registry=self.registry + ) + # Database query metrics self.db_query_duration_seconds = Histogram( 'db_query_duration_seconds', @@ -110,6 +117,46 @@ class BotMetrics: ['activity_type', 'user_type', 'chat_type'], registry=self.registry ) + + # File download metrics + self.file_downloads_total = Counter( + 'file_downloads_total', + 'Total number of file downloads', + ['content_type', 'status'], + registry=self.registry + ) + + self.file_download_duration_seconds = Histogram( + 'file_download_duration_seconds', + 'Time spent downloading files', + ['content_type'], + buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0], + registry=self.registry + ) + + self.file_download_size_bytes = Histogram( + 'file_download_size_bytes', + 'Size of downloaded files in bytes', + ['content_type'], + buckets=[1024, 10240, 102400, 1048576, 10485760, 104857600, 1073741824], + registry=self.registry + ) + + # Media processing metrics + self.media_processing_total = Counter( + 'media_processing_total', + 'Total number of media processing operations', + ['content_type', 'status'], + registry=self.registry + ) + + self.media_processing_duration_seconds = Histogram( + 'media_processing_duration_seconds', + 'Time spent processing media', + ['content_type'], + buckets=[0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0], + registry=self.registry + ) def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown", status: str = "success"): """Record a bot command execution.""" @@ -140,6 +187,10 @@ class BotMetrics: """Set the number of active users for a specific type.""" self.active_users.labels(user_type=user_type).set(count) + def set_total_users(self, count: int): + """Set the total number of users in database.""" + self.total_users.set(count) + def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"): """Record database query duration.""" self.db_query_duration_seconds.labels( @@ -168,6 +219,54 @@ class BotMetrics: status=status ).observe(duration) + def record_file_download(self, content_type: str, file_size: int, duration: float): + """Record file download metrics.""" + self.file_downloads_total.labels( + content_type=content_type, + status="success" + ).inc() + + self.file_download_duration_seconds.labels( + content_type=content_type + ).observe(duration) + + self.file_download_size_bytes.labels( + content_type=content_type + ).observe(file_size) + + def record_file_download_error(self, content_type: str, error_message: str): + """Record file download error metrics.""" + self.file_downloads_total.labels( + content_type=content_type, + status="error" + ).inc() + + self.errors_total.labels( + error_type="file_download_error", + handler_type="media_processing", + method_name="download_file" + ).inc() + + def record_media_processing(self, content_type: str, duration: float, success: bool): + """Record media processing metrics.""" + status = "success" if success else "error" + + self.media_processing_total.labels( + content_type=content_type, + status=status + ).inc() + + self.media_processing_duration_seconds.labels( + content_type=content_type + ).observe(duration) + + if not success: + self.errors_total.labels( + error_type="media_processing_error", + handler_type="media_processing", + method_name="add_in_db_media" + ).inc() + def get_metrics(self) -> bytes: """Generate metrics in Prometheus format.""" return generate_latest(self.registry) diff --git a/run_helper.py b/run_helper.py index de05dd7..56d4edb 100644 --- a/run_helper.py +++ b/run_helper.py @@ -13,9 +13,42 @@ from helper_bot.utils.base_dependency_factory import get_global_instance from helper_bot.utils.auto_unban_scheduler import get_auto_unban_scheduler from logs.custom_logger import logger +# Импортируем PID менеджер из инфраструктуры (если доступен) +import sys +import os + +def get_pid_manager(): + """Получение PID менеджера из инфраструктуры проекта""" + try: + # Пытаемся импортировать из инфраструктуры проекта + infra_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'infra', 'monitoring') + if infra_path not in sys.path: + sys.path.insert(0, infra_path) + + from pid_manager import get_bot_pid_manager + return get_bot_pid_manager + + except ImportError: + # В изолированном запуске PID менеджер не нужен + logger.info("PID менеджер недоступен (изолированный запуск), PID файл не создается") + return None + +# Получаем функцию создания PID менеджера +get_bot_pid_manager = get_pid_manager() + async def main(): """Основная функция запуска""" + # Создаем PID менеджер для отслеживания процесса (если доступен) + pid_manager = None + if get_bot_pid_manager: + pid_manager = get_bot_pid_manager("helper_bot") + if not pid_manager.create_pid_file(): + logger.error("Не удалось создать PID файл, завершаем работу") + return + else: + logger.info("PID менеджер недоступен, запуск без PID файла") + bdf = get_global_instance() # Создаем бота для автоматического разбана @@ -78,6 +111,10 @@ async def main(): # Отменяем задачу бота bot_task.cancel() + # Очищаем PID файл (если PID менеджер доступен) + if pid_manager: + pid_manager.cleanup_pid_file() + # Ждем завершения задачи бота и получаем результат main bot try: results = await asyncio.gather(bot_task, return_exceptions=True) diff --git a/tests/test_audio_file_service.py b/tests/test_audio_file_service.py new file mode 100644 index 0000000..761295a --- /dev/null +++ b/tests/test_audio_file_service.py @@ -0,0 +1,266 @@ +import pytest +from unittest.mock import Mock, AsyncMock, patch, MagicMock +from datetime import datetime +import time + +from helper_bot.handlers.voice.services import AudioFileService +from helper_bot.handlers.voice.exceptions import FileOperationError, DatabaseError + + +@pytest.fixture +def mock_bot_db(): + """Мок для базы данных""" + mock_db = Mock() + mock_db.get_user_audio_records_count = AsyncMock(return_value=0) + mock_db.get_path_for_audio_record = AsyncMock(return_value=None) + mock_db.add_audio_record_simple = AsyncMock() + return mock_db + +@pytest.fixture +def audio_service(mock_bot_db): + """Экземпляр AudioFileService для тестов""" + return AudioFileService(mock_bot_db) + +@pytest.fixture +def sample_datetime(): + """Тестовая дата""" + return datetime(2025, 1, 15, 14, 30, 0) + +@pytest.fixture +def mock_bot(): + """Мок для бота""" + bot = Mock() + bot.get_file = AsyncMock() + bot.download_file = AsyncMock() + return bot + +@pytest.fixture +def mock_message(): + """Мок для сообщения""" + message = Mock() + message.voice = Mock() + message.voice.file_id = "test_file_id" + return message + +@pytest.fixture +def mock_file_info(): + """Мок для информации о файле""" + file_info = Mock() + file_info.file_path = "voice/test_file_id.ogg" + return file_info + + +class TestGenerateFileName: + """Тесты для метода generate_file_name""" + + @pytest.mark.asyncio + async def test_generate_file_name_first_record(self, audio_service, mock_bot_db): + """Тест генерации имени файла для первой записи пользователя""" + mock_bot_db.get_user_audio_records_count.return_value = 0 + + result = await audio_service.generate_file_name(12345) + + assert result == "message_from_12345_number_1" + mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345) + + @pytest.mark.asyncio + async def test_generate_file_name_existing_records(self, audio_service, mock_bot_db): + """Тест генерации имени файла для существующих записей""" + mock_bot_db.get_user_audio_records_count.return_value = 3 + mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_3" + + result = await audio_service.generate_file_name(12345) + + assert result == "message_from_12345_number_4" + mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345) + mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345) + + @pytest.mark.asyncio + async def test_generate_file_name_no_last_record(self, audio_service, mock_bot_db): + """Тест генерации имени файла когда нет последней записи""" + mock_bot_db.get_user_audio_records_count.return_value = 2 + mock_bot_db.get_path_for_audio_record.return_value = None + + result = await audio_service.generate_file_name(12345) + + assert result == "message_from_12345_number_3" + + @pytest.mark.asyncio + async def test_generate_file_name_invalid_last_record_format(self, audio_service, mock_bot_db): + """Тест генерации имени файла с некорректным форматом последней записи""" + mock_bot_db.get_user_audio_records_count.return_value = 2 + mock_bot_db.get_path_for_audio_record.return_value = "invalid_format" + + result = await audio_service.generate_file_name(12345) + + assert result == "message_from_12345_number_3" + + @pytest.mark.asyncio + async def test_generate_file_name_exception_handling(self, audio_service, mock_bot_db): + """Тест обработки исключений при генерации имени файла""" + mock_bot_db.get_user_audio_records_count.side_effect = Exception("Database error") + + with pytest.raises(FileOperationError) as exc_info: + await audio_service.generate_file_name(12345) + + assert "Не удалось сгенерировать имя файла" in str(exc_info.value) + + +class TestSaveAudioFile: + """Тесты для метода save_audio_file""" + + @pytest.mark.asyncio + async def test_save_audio_file_success(self, audio_service, mock_bot_db, sample_datetime): + """Тест успешного сохранения аудио файла""" + file_name = "test_audio.ogg" + user_id = 12345 + file_id = "test_file_id" + + await audio_service.save_audio_file(file_name, user_id, sample_datetime, file_id) + + mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, user_id, sample_datetime) + + @pytest.mark.asyncio + async def test_save_audio_file_with_string_date(self, audio_service, mock_bot_db): + """Тест сохранения аудио файла со строковой датой""" + file_name = "test_audio.ogg" + user_id = 12345 + date_string = "2025-01-15 14:30:00" + file_id = "test_file_id" + + await audio_service.save_audio_file(file_name, user_id, date_string, file_id) + + mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, user_id, date_string) + + @pytest.mark.asyncio + async def test_save_audio_file_exception_handling(self, audio_service, mock_bot_db, sample_datetime): + """Тест обработки исключений при сохранении аудио файла""" + mock_bot_db.add_audio_record_simple.side_effect = Exception("Database error") + + with pytest.raises(DatabaseError) as exc_info: + await audio_service.save_audio_file("test.ogg", 12345, sample_datetime, "file_id") + + assert "Не удалось сохранить аудио файл в БД" in str(exc_info.value) + + +class TestDownloadAndSaveAudio: + """Тесты для метода download_and_save_audio""" + + @pytest.mark.asyncio + async def test_download_and_save_audio_success(self, audio_service, mock_bot, mock_message, mock_file_info): + """Тест успешного скачивания и сохранения аудио""" + mock_bot.get_file.return_value = mock_file_info + + # Мокаем скачанный файл + mock_downloaded_file = Mock() + mock_downloaded_file.tell.return_value = 0 + mock_downloaded_file.seek = Mock() + mock_downloaded_file.read.return_value = b"audio_data" + mock_bot.download_file.return_value = mock_downloaded_file + + with patch('builtins.open', mock_open()) as mock_file: + with patch('os.makedirs'): + await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") + + mock_bot.get_file.assert_called_once_with(file_id="test_file_id") + mock_bot.download_file.assert_called_once_with(file_path="voice/test_file_id.ogg") + mock_file.assert_called_once() + + @pytest.mark.asyncio + async def test_download_and_save_audio_no_message(self, audio_service, mock_bot): + """Тест скачивания когда сообщение отсутствует""" + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, None, "test_audio") + + assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_download_and_save_audio_no_voice(self, audio_service, mock_bot): + """Тест скачивания когда у сообщения нет voice атрибута""" + message = Mock() + message.voice = None + + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, message, "test_audio") + + assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_download_and_save_audio_download_failed(self, audio_service, mock_bot, mock_message, mock_file_info): + """Тест скачивания когда загрузка не удалась""" + mock_bot.get_file.return_value = mock_file_info + mock_bot.download_file.return_value = None + + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") + + assert "Не удалось скачать файл" in str(exc_info.value) + + @pytest.mark.asyncio + async def test_download_and_save_audio_exception_handling(self, audio_service, mock_bot, mock_message): + """Тест обработки исключений при скачивании""" + mock_bot.get_file.side_effect = Exception("Network error") + + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") + + assert "Не удалось скачать и сохранить аудио" in str(exc_info.value) + + +def mock_open(): + """Мок для функции open""" + from unittest.mock import mock_open as _mock_open + return _mock_open() + + +class TestAudioFileServiceIntegration: + """Интеграционные тесты для AudioFileService""" + + @pytest.mark.asyncio + async def test_full_audio_processing_workflow(self, mock_bot_db): + """Тест полного рабочего процесса обработки аудио""" + service = AudioFileService(mock_bot_db) + + # Настраиваем моки + mock_bot_db.get_user_audio_records_count.return_value = 1 + mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_1" + mock_bot_db.add_audio_record_simple = AsyncMock() + + # Тестируем генерацию имени файла + file_name = await service.generate_file_name(12345) + assert file_name == "message_from_12345_number_2" + + # Тестируем сохранение в БД + test_date = datetime.now() + await service.save_audio_file(file_name, 12345, test_date, "test_file_id") + + # Проверяем вызовы + mock_bot_db.get_user_audio_records_count.assert_called_once_with(user_id=12345) + mock_bot_db.get_path_for_audio_record.assert_called_once_with(user_id=12345) + mock_bot_db.add_audio_record_simple.assert_called_once_with(file_name, 12345, test_date) + + @pytest.mark.asyncio + async def test_file_name_generation_sequence(self, mock_bot_db): + """Тест последовательности генерации имен файлов""" + service = AudioFileService(mock_bot_db) + + # Первая запись + mock_bot_db.get_user_audio_records_count.return_value = 0 + file_name_1 = await service.generate_file_name(12345) + assert file_name_1 == "message_from_12345_number_1" + + # Вторая запись + mock_bot_db.get_user_audio_records_count.return_value = 1 + mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_1" + file_name_2 = await service.generate_file_name(12345) + assert file_name_2 == "message_from_12345_number_2" + + # Третья запись + mock_bot_db.get_user_audio_records_count.return_value = 2 + mock_bot_db.get_path_for_audio_record.return_value = "message_from_12345_number_2" + file_name_3 = await service.generate_file_name(12345) + assert file_name_3 == "message_from_12345_number_3" + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/test_audio_repository.py b/tests/test_audio_repository.py index dcc6a72..56f6bcb 100644 --- a/tests/test_audio_repository.py +++ b/tests/test_audio_repository.py @@ -331,6 +331,20 @@ class TestAudioRepository: assert result is None + @pytest.mark.asyncio + async def test_delete_audio_moderate_record(self, audio_repository): + """Тест удаления записи из таблицы audio_moderate""" + message_id = 12345 + + await audio_repository.delete_audio_moderate_record(message_id) + + audio_repository._execute_query.assert_called_once_with( + "DELETE FROM audio_moderate WHERE message_id = ?", (message_id,) + ) + audio_repository.logger.info.assert_called_once_with( + f"Удалена запись из audio_moderate для message_id {message_id}" + ) + @pytest.mark.asyncio async def test_add_audio_record_logging(self, audio_repository, sample_audio_message): """Тест логирования при добавлении аудио записи""" diff --git a/tests/test_callback_handlers.py b/tests/test_callback_handlers.py new file mode 100644 index 0000000..5831aa1 --- /dev/null +++ b/tests/test_callback_handlers.py @@ -0,0 +1,297 @@ +import pytest +from unittest.mock import Mock, AsyncMock, patch, MagicMock +from datetime import datetime +import time + +from helper_bot.handlers.callback.callback_handlers import ( + save_voice_message, + delete_voice_message +) +from helper_bot.handlers.voice.constants import CALLBACK_SAVE, CALLBACK_DELETE + + +@pytest.fixture +def mock_call(): + """Мок для CallbackQuery""" + call = Mock() + call.message = Mock() + call.message.message_id = 12345 + call.message.voice = Mock() + call.message.voice.file_id = "test_file_id_123" + call.bot = Mock() + call.bot.delete_message = AsyncMock() + call.answer = AsyncMock() + return call + +@pytest.fixture +def mock_bot_db(): + """Мок для базы данных""" + mock_db = Mock() + mock_db.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=67890) + mock_db.delete_audio_moderate_record = AsyncMock() + return mock_db + +@pytest.fixture +def mock_settings(): + """Мок для настроек""" + return { + 'Telegram': { + 'group_for_posts': 'test_group_id' + } + } + +@pytest.fixture +def mock_audio_service(): + """Мок для AudioFileService""" + mock_service = Mock() + mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1") + mock_service.save_audio_file = AsyncMock() + mock_service.download_and_save_audio = AsyncMock() + return mock_service + + +class TestSaveVoiceMessage: + """Тесты для функции save_voice_message""" + + @pytest.mark.asyncio + async def test_save_voice_message_success(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): + """Тест успешного сохранения голосового сообщения""" + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: + mock_service_class.return_value = mock_audio_service + + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что все методы вызваны + mock_bot_db.get_user_id_by_message_id_for_voice_bot.assert_called_once_with(12345) + mock_audio_service.generate_file_name.assert_called_once_with(67890) + mock_audio_service.save_audio_file.assert_called_once() + mock_audio_service.download_and_save_audio.assert_called_once_with( + mock_call.bot, mock_call.message, "message_from_67890_number_1" + ) + + # Проверяем удаление сообщения из чата + mock_call.bot.delete_message.assert_called_once_with( + chat_id='test_group_id', + message_id=12345 + ) + + # Проверяем удаление записи из audio_moderate + mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345) + + # Проверяем ответ пользователю + mock_call.answer.assert_called_once_with(text='Сохранено!', cache_time=3) + + @pytest.mark.asyncio + async def test_save_voice_message_with_correct_parameters(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): + """Тест сохранения с правильными параметрами""" + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: + mock_service_class.return_value = mock_audio_service + + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем параметры save_audio_file + save_call_args = mock_audio_service.save_audio_file.call_args + assert save_call_args[0][0] == "message_from_67890_number_1" # file_name + assert save_call_args[0][1] == 67890 # user_id + assert isinstance(save_call_args[0][2], datetime) # date_added + assert save_call_args[0][3] == "test_file_id_123" # file_id + + @pytest.mark.asyncio + async def test_save_voice_message_exception_handling(self, mock_call, mock_bot_db, mock_settings): + """Тест обработки исключений при сохранении""" + mock_bot_db.get_user_id_by_message_id_for_voice_bot.side_effect = Exception("Database error") + + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что при ошибке отправляется соответствующий ответ + mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) + + @pytest.mark.asyncio + async def test_save_voice_message_audio_service_exception(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): + """Тест обработки исключений в AudioFileService""" + mock_audio_service.save_audio_file.side_effect = Exception("Save error") + + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: + mock_service_class.return_value = mock_audio_service + + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что при ошибке отправляется соответствующий ответ + mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) + + @pytest.mark.asyncio + async def test_save_voice_message_download_exception(self, mock_call, mock_bot_db, mock_settings, mock_audio_service): + """Тест обработки исключений при скачивании файла""" + mock_audio_service.download_and_save_audio.side_effect = Exception("Download error") + + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: + mock_service_class.return_value = mock_audio_service + + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что при ошибке отправляется соответствующий ответ + mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) + + +class TestDeleteVoiceMessage: + """Тесты для функции delete_voice_message""" + + @pytest.mark.asyncio + async def test_delete_voice_message_success(self, mock_call, mock_bot_db, mock_settings): + """Тест успешного удаления голосового сообщения""" + await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем удаление сообщения из чата + mock_call.bot.delete_message.assert_called_once_with( + chat_id='test_group_id', + message_id=12345 + ) + + # Проверяем удаление записи из audio_moderate + mock_bot_db.delete_audio_moderate_record.assert_called_once_with(12345) + + # Проверяем ответ пользователю + mock_call.answer.assert_called_once_with(text='Удалено!', cache_time=3) + + @pytest.mark.asyncio + async def test_delete_voice_message_exception_handling(self, mock_call, mock_bot_db, mock_settings): + """Тест обработки исключений при удалении""" + mock_call.bot.delete_message.side_effect = Exception("Delete error") + + await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что при ошибке отправляется соответствующий ответ + mock_call.answer.assert_called_once_with(text='Ошибка при удалении!', cache_time=3) + + @pytest.mark.asyncio + async def test_delete_voice_message_database_exception(self, mock_call, mock_bot_db, mock_settings): + """Тест обработки исключений в базе данных при удалении""" + mock_bot_db.delete_audio_moderate_record.side_effect = Exception("Database error") + + await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что при ошибке отправляется соответствующий ответ + mock_call.answer.assert_called_once_with(text='Ошибка при удалении!', cache_time=3) + + +class TestCallbackHandlersIntegration: + """Интеграционные тесты для callback handlers""" + + @pytest.mark.asyncio + async def test_save_voice_message_full_workflow(self, mock_call, mock_bot_db, mock_settings): + """Тест полного рабочего процесса сохранения""" + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: + mock_service = Mock() + mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1") + mock_service.save_audio_file = AsyncMock() + mock_service.download_and_save_audio = AsyncMock() + mock_service_class.return_value = mock_service + + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем последовательность вызовов + assert mock_bot_db.get_user_id_by_message_id_for_voice_bot.called + assert mock_service.generate_file_name.called + assert mock_service.save_audio_file.called + assert mock_service.download_and_save_audio.called + assert mock_call.bot.delete_message.called + assert mock_bot_db.delete_audio_moderate_record.called + assert mock_call.answer.called + + @pytest.mark.asyncio + async def test_delete_voice_message_full_workflow(self, mock_call, mock_bot_db, mock_settings): + """Тест полного рабочего процесса удаления""" + await delete_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем последовательность вызовов + assert mock_call.bot.delete_message.called + assert mock_bot_db.delete_audio_moderate_record.called + assert mock_call.answer.called + + @pytest.mark.asyncio + async def test_audio_moderate_cleanup_consistency(self, mock_call, mock_bot_db, mock_settings): + """Тест консистентности очистки audio_moderate""" + # Тестируем, что в обоих случаях (сохранение и удаление) + # вызывается delete_audio_moderate_record + + # Создаем отдельные моки для каждого теста + mock_bot_db_save = Mock() + mock_bot_db_save.get_user_id_by_message_id_for_voice_bot = AsyncMock(return_value=67890) + mock_bot_db_save.delete_audio_moderate_record = AsyncMock() + + mock_bot_db_delete = Mock() + mock_bot_db_delete.delete_audio_moderate_record = AsyncMock() + + # Тест для сохранения + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService') as mock_service_class: + mock_service = Mock() + mock_service.generate_file_name = AsyncMock(return_value="message_from_67890_number_1") + mock_service.save_audio_file = AsyncMock() + mock_service.download_and_save_audio = AsyncMock() + mock_service_class.return_value = mock_service + + await save_voice_message(mock_call, bot_db=mock_bot_db_save, settings=mock_settings) + save_calls = mock_bot_db_save.delete_audio_moderate_record.call_count + + # Тест для удаления + await delete_voice_message(mock_call, bot_db=mock_bot_db_delete, settings=mock_settings) + delete_calls = mock_bot_db_delete.delete_audio_moderate_record.call_count + + # Проверяем, что в обоих случаях вызывается очистка + assert save_calls == 1 + assert delete_calls == 1 + + +class TestCallbackHandlersEdgeCases: + """Тесты граничных случаев для callback handlers""" + + @pytest.mark.asyncio + async def test_save_voice_message_no_voice_attribute(self, mock_bot_db, mock_settings): + """Тест сохранения когда у сообщения нет voice атрибута""" + call = Mock() + call.message = Mock() + call.message.message_id = 12345 + call.message.voice = None # Нет голосового сообщения + call.bot = Mock() + call.bot.delete_message = AsyncMock() + call.answer = AsyncMock() + + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'): + await save_voice_message(call, bot_db=mock_bot_db, settings=mock_settings) + + # Должна быть ошибка + call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) + + @pytest.mark.asyncio + async def test_save_voice_message_user_not_found(self, mock_call, mock_bot_db, mock_settings): + """Тест сохранения когда пользователь не найден""" + mock_bot_db.get_user_id_by_message_id_for_voice_bot.return_value = None + + with patch('helper_bot.handlers.callback.callback_handlers.AudioFileService'): + await save_voice_message(mock_call, bot_db=mock_bot_db, settings=mock_settings) + + # Должна быть ошибка + mock_call.answer.assert_called_once_with(text='Ошибка при сохранении!', cache_time=3) + + @pytest.mark.asyncio + async def test_delete_voice_message_with_different_message_id(self, mock_bot_db, mock_settings): + """Тест удаления с другим message_id""" + call = Mock() + call.message = Mock() + call.message.message_id = 99999 # Другой ID + call.bot = Mock() + call.bot.delete_message = AsyncMock() + call.answer = AsyncMock() + + await delete_voice_message(call, bot_db=mock_bot_db, settings=mock_settings) + + # Проверяем, что используется правильный message_id + call.bot.delete_message.assert_called_once_with( + chat_id='test_group_id', + message_id=99999 + ) + mock_bot_db.delete_audio_moderate_record.assert_called_once_with(99999) + + +if __name__ == '__main__': + pytest.main([__file__]) diff --git a/tests/test_improved_media_processing.py b/tests/test_improved_media_processing.py new file mode 100644 index 0000000..dba6442 --- /dev/null +++ b/tests/test_improved_media_processing.py @@ -0,0 +1,301 @@ +""" +Тесты для улучшенных методов обработки медиа +""" + +import pytest +import os +import tempfile +from unittest.mock import Mock, AsyncMock, patch, MagicMock +from aiogram import types + +from helper_bot.utils.helper_func import ( + download_file, + add_in_db_media, + add_in_db_media_mediagroup, + send_media_group_message_to_private_chat +) + + +class TestDownloadFile: + """Тесты для функции download_file""" + + @pytest.mark.asyncio + async def test_download_file_success_photo(self): + """Тест успешного скачивания фото""" + # Создаем временную директорию + with tempfile.TemporaryDirectory() as temp_dir: + with patch('helper_bot.utils.helper_func.os.makedirs'), \ + patch('helper_bot.utils.helper_func.os.path.exists', return_value=True), \ + patch('helper_bot.utils.helper_func.os.path.getsize', return_value=1024), \ + patch('helper_bot.utils.helper_func.os.path.basename', return_value='photo.jpg'), \ + patch('helper_bot.utils.helper_func.os.path.splitext', return_value=('photo', '.jpg')): + + # Мокаем сообщение и бота + mock_message = Mock() + mock_message.bot = Mock() + mock_file = Mock() + mock_file.file_path = 'photos/photo.jpg' + mock_message.bot.get_file = AsyncMock(return_value=mock_file) + mock_message.bot.download_file = AsyncMock() + + # Вызываем функцию + result = await download_file(mock_message, 'test_file_id', 'photo') + + # Проверяем результат + assert result is not None + assert 'files/photos/test_file_id.jpg' in result + mock_message.bot.get_file.assert_called_once_with('test_file_id') + mock_message.bot.download_file.assert_called_once() + + @pytest.mark.asyncio + async def test_download_file_invalid_parameters(self): + """Тест с неверными параметрами""" + result = await download_file(None, 'test_file_id', 'photo') + assert result is None + + mock_message = Mock() + mock_message.bot = None + result = await download_file(mock_message, 'test_file_id', 'photo') + assert result is None + + @pytest.mark.asyncio + async def test_download_file_error(self): + """Тест обработки ошибки при скачивании""" + mock_message = Mock() + mock_message.bot = Mock() + mock_message.bot.get_file = AsyncMock(side_effect=Exception("Network error")) + + result = await download_file(mock_message, 'test_file_id', 'photo') + assert result is None + + +class TestAddInDbMedia: + """Тесты для функции add_in_db_media""" + + @pytest.mark.asyncio + async def test_add_in_db_media_success_photo(self): + """Тест успешного добавления фото в БД""" + # Мокаем сообщение + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = [Mock()] + mock_message.photo[-1].file_id = 'photo_123' + mock_message.video = None + mock_message.voice = None + mock_message.audio = None + mock_message.video_note = None + + # Мокаем БД + mock_db = AsyncMock() + mock_db.add_post_content = AsyncMock(return_value=True) + + with patch('helper_bot.utils.helper_func.download_file', return_value='files/photos/photo_123.jpg'): + result = await add_in_db_media(mock_message, mock_db) + + assert result is True + mock_db.add_post_content.assert_called_once_with(123, 123, 'files/photos/photo_123.jpg', 'photo') + + @pytest.mark.asyncio + async def test_add_in_db_media_download_fails(self): + """Тест когда скачивание файла не удается""" + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = [Mock()] + mock_message.photo[-1].file_id = 'photo_123' + mock_message.video = None + mock_message.voice = None + mock_message.audio = None + mock_message.video_note = None + + mock_db = AsyncMock() + + with patch('helper_bot.utils.helper_func.download_file', return_value=None): + result = await add_in_db_media(mock_message, mock_db) + + assert result is False + mock_db.add_post_content.assert_not_called() + + @pytest.mark.asyncio + async def test_add_in_db_media_db_fails(self): + """Тест когда добавление в БД не удается""" + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = [Mock()] + mock_message.photo[-1].file_id = 'photo_123' + mock_message.video = None + mock_message.voice = None + mock_message.audio = None + mock_message.video_note = None + + mock_db = AsyncMock() + mock_db.add_post_content = AsyncMock(return_value=False) + + with patch('helper_bot.utils.helper_func.download_file', return_value='files/photos/photo_123.jpg'), \ + patch('helper_bot.utils.helper_func.os.remove'): + + result = await add_in_db_media(mock_message, mock_db) + + assert result is False + mock_db.add_post_content.assert_called_once() + + @pytest.mark.asyncio + async def test_add_in_db_media_unsupported_content(self): + """Тест с неподдерживаемым типом контента""" + mock_message = Mock() + mock_message.message_id = 123 + mock_message.photo = None + mock_message.video = None + mock_message.voice = None + mock_message.audio = None + mock_message.video_note = None + + mock_db = AsyncMock() + + result = await add_in_db_media(mock_message, mock_db) + + assert result is False + mock_db.add_post_content.assert_not_called() + + +class TestAddInDbMediaMediagroup: + """Тесты для функции add_in_db_media_mediagroup""" + + @pytest.mark.asyncio + async def test_add_in_db_media_mediagroup_success(self): + """Тест успешного добавления медиагруппы в БД""" + # Создаем моки сообщений + mock_message1 = Mock() + mock_message1.message_id = 1 + mock_message1.photo = [Mock()] + mock_message1.photo[-1].file_id = 'photo_1' + mock_message1.video = None + mock_message1.voice = None + mock_message1.audio = None + mock_message1.video_note = None + + mock_message2 = Mock() + mock_message2.message_id = 2 + mock_message2.photo = None + mock_message2.video = Mock() + mock_message2.video.file_id = 'video_1' + mock_message2.voice = None + mock_message2.audio = None + mock_message2.video_note = None + + sent_messages = [mock_message1, mock_message2] + + # Мокаем БД + mock_db = AsyncMock() + mock_db.add_post_content = AsyncMock(return_value=True) + + with patch('helper_bot.utils.helper_func.download_file', return_value='files/test.jpg'): + result = await add_in_db_media_mediagroup(sent_messages, mock_db, main_post_id=100) + + assert result is True + assert mock_db.add_post_content.call_count == 2 + + @pytest.mark.asyncio + async def test_add_in_db_media_mediagroup_empty_list(self): + """Тест с пустым списком сообщений""" + mock_db = AsyncMock() + + result = await add_in_db_media_mediagroup([], mock_db) + + assert result is False + mock_db.add_post_content.assert_not_called() + + @pytest.mark.asyncio + async def test_add_in_db_media_mediagroup_partial_failure(self): + """Тест когда часть сообщений обрабатывается успешно""" + # Создаем моки сообщений + mock_message1 = Mock() + mock_message1.message_id = 1 + mock_message1.photo = [Mock()] + mock_message1.photo[-1].file_id = 'photo_1' + mock_message1.video = None + mock_message1.voice = None + mock_message1.audio = None + mock_message1.video_note = None + + mock_message2 = Mock() + mock_message2.message_id = 2 + mock_message2.photo = None + mock_message2.video = None + mock_message2.voice = None + mock_message2.audio = None + mock_message2.video_note = None # Неподдерживаемый тип + + sent_messages = [mock_message1, mock_message2] + + # Мокаем БД + mock_db = AsyncMock() + mock_db.add_post_content = AsyncMock(return_value=True) + + with patch('helper_bot.utils.helper_func.download_file', return_value='files/test.jpg'): + result = await add_in_db_media_mediagroup(sent_messages, mock_db) + + # Должен вернуть False, так как есть ошибки (второе сообщение не поддерживается) + assert result is False + assert mock_db.add_post_content.call_count == 1 + + +class TestSendMediaGroupMessageToPrivateChat: + """Тесты для функции send_media_group_message_to_private_chat""" + + @pytest.mark.asyncio + async def test_send_media_group_message_success(self): + """Тест успешной отправки медиагруппы""" + # Мокаем сообщение + mock_message = Mock() + mock_message.from_user.id = 123 + mock_message.bot = Mock() + + # Мокаем отправленное сообщение + mock_sent_message = Mock() + mock_sent_message.message_id = 456 + mock_sent_message.caption = "Test caption" + mock_message.bot.send_media_group = AsyncMock(return_value=[mock_sent_message]) + + # Мокаем БД + mock_db = AsyncMock() + mock_db.add_post = AsyncMock() + + with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=True): + result = await send_media_group_message_to_private_chat( + 100, mock_message, [], mock_db, main_post_id=789 + ) + + assert result == 456 + mock_message.bot.send_media_group.assert_called_once() + mock_db.add_post.assert_called_once() + + @pytest.mark.asyncio + async def test_send_media_group_message_media_processing_fails(self): + """Тест когда обработка медиа не удается""" + # Мокаем сообщение + mock_message = Mock() + mock_message.from_user.id = 123 + mock_message.bot = Mock() + + # Мокаем отправленное сообщение + mock_sent_message = Mock() + mock_sent_message.message_id = 456 + mock_sent_message.caption = "Test caption" + mock_message.bot.send_media_group = AsyncMock(return_value=[mock_sent_message]) + + # Мокаем БД + mock_db = AsyncMock() + mock_db.add_post = AsyncMock() + + with patch('helper_bot.utils.helper_func.add_in_db_media_mediagroup', return_value=False): + result = await send_media_group_message_to_private_chat( + 100, mock_message, [], mock_db, main_post_id=789 + ) + + assert result == 456 # Функция все равно возвращает message_id + mock_message.bot.send_media_group.assert_called_once() + mock_db.add_post.assert_called_once() + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/test_utils.py b/tests/test_utils.py index 5b485f2..04011b3 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -288,15 +288,20 @@ class TestDownloadFile: # Мокаем download_file mock_message.bot.download_file = AsyncMock() - # Мокаем os.makedirs + # Мокаем os.makedirs и другие зависимости with patch('os.makedirs') as mock_makedirs: with patch('os.path.join', return_value="files/photos/file_123.jpg"): - result = await download_file(mock_message, "file_id_123") - - assert result == "files/photos/file_123.jpg" - mock_makedirs.assert_called() - mock_message.bot.get_file.assert_called_once_with("file_id_123") - mock_message.bot.download_file.assert_called_once() + with patch('os.path.exists', return_value=True): + with patch('os.path.getsize', return_value=1024): + with patch('os.path.basename', return_value='file_123.jpg'): + with patch('os.path.splitext', return_value=('file_123', '.jpg')): + with patch('helper_bot.utils.helper_func.metrics') as mock_metrics: + result = await download_file(mock_message, "file_id_123", "photo") + + assert result == "files/photos/file_123.jpg" + mock_makedirs.assert_called() + mock_message.bot.get_file.assert_called_once_with("file_id_123") + mock_message.bot.download_file.assert_called_once() @pytest.mark.asyncio async def test_download_file_exception(self):