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):