Dev 8 #10

Merged
KerradKerridi merged 15 commits from dev-8 into master 2025-09-03 22:00:36 +00:00
17 changed files with 1421 additions and 84 deletions
Showing only changes of commit fc0517c011 - Show all commits

View File

@@ -43,7 +43,7 @@ RUN chown -R 1001:1001 /opt/venv
# Create app directory and set permissions # Create app directory and set permissions
WORKDIR /app WORKDIR /app
RUN mkdir -p /app/database /app/logs && \ RUN mkdir -p /app/database /app/logs /app/voice_users && \
chown -R 1001:1001 /app chown -R 1001:1001 /app
# Copy application code # Copy application code

View File

@@ -208,3 +208,9 @@ class AudioRepository(DatabaseConnection):
self.logger.info(f"Получен user_id {user_id} для message_id {message_id}") self.logger.info(f"Получен user_id {user_id} для message_id {message_id}")
return user_id return user_id
return None 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}")

View File

@@ -273,6 +273,9 @@ async def save_voice_message(
message_id=call.message.message_id 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) await call.answer(text='Сохранено!', cache_time=3)
except Exception as e: except Exception as e:
@@ -296,6 +299,9 @@ async def delete_voice_message(
message_id=call.message.message_id 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) await call.answer(text='Удалено!', cache_time=3)
except Exception as e: except Exception as e:

View File

@@ -331,6 +331,12 @@ class BanService:
self.group_for_posts = settings['Telegram']['group_for_posts'] self.group_for_posts = settings['Telegram']['group_for_posts']
self.important_logs = settings['Telegram']['important_logs'] 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_time("ban_user_from_post", "ban_service")
@track_errors("ban_service", "ban_user_from_post") @track_errors("ban_service", "ban_user_from_post")
async def ban_user_from_post(self, call: CallbackQuery) -> None: async def ban_user_from_post(self, call: CallbackQuery) -> None:

View File

@@ -180,7 +180,9 @@ class PostService:
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await self.db.add_post(post) 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_time("handle_video_post", "post_service")
@track_errors("post_service", "handle_video_post") @track_errors("post_service", "handle_video_post")
@@ -202,7 +204,9 @@ class PostService:
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await self.db.add_post(post) 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_time("handle_video_note_post", "post_service")
@track_errors("post_service", "handle_video_note_post") @track_errors("post_service", "handle_video_note_post")
@@ -220,7 +224,9 @@ class PostService:
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await self.db.add_post(post) 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_time("handle_audio_post", "post_service")
@track_errors("post_service", "handle_audio_post") @track_errors("post_service", "handle_audio_post")
@@ -242,7 +248,9 @@ class PostService:
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await self.db.add_post(post) 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_time("handle_voice_post", "post_service")
@track_errors("post_service", "handle_voice_post") @track_errors("post_service", "handle_voice_post")
@@ -260,7 +268,9 @@ class PostService:
created_at=int(datetime.now().timestamp()) created_at=int(datetime.now().timestamp())
) )
await self.db.add_post(post) 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_time("handle_media_group_post", "post_service")
@track_errors("post_service", "handle_media_group_post") @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 = await prepare_media_group_from_middlewares(album, post_caption)
media_group_message_id = await send_media_group_message_to_private_chat( 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) await asyncio.sleep(0.2)

View File

@@ -299,10 +299,19 @@ class AudioFileService:
logger.info(f"Получена информация о файле: {file_info.file_path}") logger.info(f"Получена информация о файле: {file_info.file_path}")
downloaded_file = await bot.download_file(file_path=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 import os
@@ -312,6 +321,9 @@ class AudioFileService:
file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg' file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg'
logger.info(f"Сохраняем файл по пути: {file_path}") logger.info(f"Сохраняем файл по пути: {file_path}")
# Сбрасываем позицию в файле перед сохранением
downloaded_file.seek(0)
# Сохраняем файл # Сохраняем файл
with open(file_path, 'wb') as new_file: with open(file_path, 'wb') as new_file:
new_file.write(downloaded_file.read()) new_file.write(downloaded_file.read())

View File

@@ -155,30 +155,27 @@ class MetricsMiddleware(BaseMiddleware):
bdf = get_global_instance() bdf = get_global_instance()
bot_db = bdf.get_db() bot_db = bdf.get_db()
await bot_db.connect() # Используем правильные методы AsyncBotDB для выполнения запросов
# Простой подсчет всех пользователей в базе # Простой подсчет всех пользователей в базе
total_users_query = "SELECT COUNT(DISTINCT user_id) FROM our_users" total_users_query = "SELECT COUNT(DISTINCT user_id) as total FROM our_users"
await bot_db.cursor.execute(total_users_query) total_users_result = await bot_db.fetch_one(total_users_query)
total_users_result = await bot_db.cursor.fetchone() total_users = total_users_result['total'] if total_users_result else 1
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
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(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)") self.logger.info(f"📊 Active users metric updated: {daily_users} (daily), {total_users} (total)")
except Exception as e: except Exception as e:
self.logger.error(f"❌ Failed to update users metric: {e}") self.logger.error(f"❌ Failed to update users metric: {e}")
# Устанавливаем 1 как fallback # Устанавливаем 1 как fallback
metrics.set_active_users(1, "daily") 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]: async def _record_comprehensive_message_metrics(self, message: Message) -> Dict[str, Any]:
"""Record comprehensive message metrics.""" """Record comprehensive message metrics."""

View File

@@ -31,6 +31,7 @@ class MetricsServer:
# Настраиваем роуты # Настраиваем роуты
self.app.router.add_get('/metrics', self.metrics_handler) self.app.router.add_get('/metrics', self.metrics_handler)
self.app.router.add_get('/health', self.health_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: async def metrics_handler(self, request: web.Request) -> web.Response:
"""Handle /metrics endpoint for Prometheus scraping.""" """Handle /metrics endpoint for Prometheus scraping."""
@@ -102,6 +103,96 @@ class MetricsServer:
status=500 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: async def start(self) -> None:
"""Start the HTTP server.""" """Start the HTTP server."""
try: try:
@@ -115,6 +206,7 @@ class MetricsServer:
logger.info("Available endpoints:") logger.info("Available endpoints:")
logger.info(f" - /metrics - Prometheus metrics") logger.info(f" - /metrics - Prometheus metrics")
logger.info(f" - /health - Health check") logger.info(f" - /health - Health check")
logger.info(f" - /status - Process status")
except Exception as e: except Exception as e:
logger.error(f"Failed to start metrics server: {e}") logger.error(f"Failed to start metrics server: {e}")

View File

@@ -1,9 +1,10 @@
import html import html
import os import os
import random import random
import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from time import sleep from time import sleep
from typing import List, Dict, Any, Optional, TYPE_CHECKING from typing import List, Dict, Any, Optional, TYPE_CHECKING, Union
try: try:
import emoji as _emoji_lib 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}' 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: Args:
message: сообщение message: сообщение
file_id: File ID фотографии file_id: File ID файла
filename: Имя файла, под которым будет сохранено фото content_type: тип контента (photo, video, audio, voice, video_note)
Returns: Returns:
Путь к сохраненному файлу, если файл был скачан успешно, иначе None Путь к сохраненному файлу, если файл был скачан успешно, иначе None
""" """
start_time = time.time()
try: try:
os.makedirs("files", exist_ok=True) # Валидация параметров
os.makedirs("files/photos", exist_ok=True) if not file_id or not message or not message.bot:
os.makedirs("files/videos", exist_ok=True) logger.error("download_file: Неверные параметры - file_id, message или bot отсутствуют")
os.makedirs("files/music", exist_ok=True) return None
os.makedirs("files/voice", exist_ok=True)
os.makedirs("files/video_notes", exist_ok=True) # Определяем папку по типу контента
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 = 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) 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 return file_path
except Exception as e: 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 return None
@@ -195,33 +244,123 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''):
return media_group 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: Args:
sent_message: sent_message объект из Telegram API sent_message: sent_message объект из Telegram API
bot_db: Экземпляр базы данных bot_db: Экземпляр базы данных
main_post_id: ID основного поста медиагруппы (если не указан, используется последний message_id)
Returns: Returns:
None bool: True если весь контент успешно добавлен, False в случае ошибки
""" """
post_id = sent_message[-1].message_id # ID поста (первое сообщение в медиа-группе) start_time = time.time()
for i, message in enumerate(sent_message):
if message.photo: try:
file_id = message.photo[-1].file_id # Валидация параметров
file_path = await download_file(message, file_id=file_id) if not sent_message or not bot_db or not isinstance(sent_message, list):
await bot_db.add_post_content(post_id, message.message_id, file_path, 'photo') logger.error("add_in_db_media_mediagroup: Неверные параметры - sent_message, bot_db или sent_message не является списком")
elif message.video: return False
file_id = message.video.file_id
file_path = await download_file(message, file_id=file_id) if len(sent_message) == 0:
await bot_db.add_post_content(post_id, message.message_id, file_path, 'video') 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: else:
# Если нет фото, видео или аудио, или другой контент, пропускаем сообщение logger.info(f"add_in_db_media_mediagroup: Успешно обработана медиагруппа {post_id} - {processed_count} сообщений, время: {processing_time:.2f}с")
continue
# Записываем метрики
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: Экземпляр базы данных bot_db: Экземпляр базы данных
Returns: Returns:
None bool: True если контент успешно добавлен, False в случае ошибки
""" """
post_id = sent_message.message_id # ID поста (это же сообщение) start_time = time.time()
if sent_message.photo:
file_id = sent_message.photo[-1].file_id try:
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') if not sent_message or not bot_db:
elif sent_message.video: logger.error("add_in_db_media: Неверные параметры - sent_message или bot_db отсутствуют")
file_id = sent_message.video.file_id return False
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') post_id = sent_message.message_id # ID поста (это же сообщение)
elif sent_message.voice: content_type = None
file_id = sent_message.voice.file_id file_id = None
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') # Определяем тип контента и file_id
elif sent_message.audio: if sent_message.photo:
file_id = sent_message.audio.file_id content_type = 'photo'
file_path = await download_file(sent_message, file_id=file_id) file_id = sent_message.photo[-1].file_id
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'audio') elif sent_message.video:
elif sent_message.video_note: content_type = 'video'
file_id = sent_message.video_note.file_id file_id = sent_message.video.file_id
file_path = await download_file(sent_message, file_id=file_id) elif sent_message.voice:
await bot_db.add_post_content(post_id, sent_message.message_id, file_path, 'video_note') 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, 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( sent_message = await message.bot.send_media_group(
chat_id=chat_id, chat_id=chat_id,
media=media_group, 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()) created_at=int(datetime.now().timestamp())
) )
await bot_db.add_post(post) 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 message_id = sent_message[-1].message_id
return message_id return message_id

View File

@@ -52,7 +52,7 @@ constants = {
'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)", 'EMOJI_INFO_MESSAGE': "Любые войсы будут помечены эмоджи. <b>Твой эмоджи - </b>{emoji}Таким эмоджи будут помечены твои сообщения для других Но другие люди не узнают кто за каким эмоджи скрывается:)",
'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help", 'HELP_INFO_MESSAGE': "Так же можешь ознакомиться с инструкцией к боту по команде /help",
'FINAL_MESSAGE': "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤", 'FINAL_MESSAGE': "<b>Ну всё, достаточно инструкций. записывайся! Микрофон твой - </b> 🎤",
'HELP_MESSAGE': "Скорее всего ответы на твои вопросы есть здесь, ознакомься: https://telegra.ph/Instrukciya-k-botu-Golosa-Bijsk-10-11-2\nЕсли это не поможет, пиши в личку: @Kerrad1", 'HELP_MESSAGE': "Когда-нибудь здесь будет инструкция к боту. А пока по вопросам пиши в личку: @Kerrad1 или в Связаться с админами",
'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌", 'VOICE_SAVED_MESSAGE': "Окей, сохранил!👌",
'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗", 'LISTENINGS_CLEARED_MESSAGE': "Прослушивания очищены. Можешь начать слушать заново🤗",
'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится", 'NO_AUDIO_MESSAGE': "Прости, ты прослушал все аудио😔. Возвращайся позже, возможно наша база пополнится",

View File

@@ -52,6 +52,13 @@ class BotMetrics:
registry=self.registry registry=self.registry
) )
# Total users gauge (отдельная метрика)
self.total_users = Gauge(
'total_users',
'Total number of users in database',
registry=self.registry
)
# Database query metrics # Database query metrics
self.db_query_duration_seconds = Histogram( self.db_query_duration_seconds = Histogram(
'db_query_duration_seconds', 'db_query_duration_seconds',
@@ -111,6 +118,46 @@ class BotMetrics:
registry=self.registry 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"): def record_command(self, command_type: str, handler_type: str = "unknown", user_type: str = "unknown", status: str = "success"):
"""Record a bot command execution.""" """Record a bot command execution."""
self.bot_commands_total.labels( self.bot_commands_total.labels(
@@ -140,6 +187,10 @@ class BotMetrics:
"""Set the number of active users for a specific type.""" """Set the number of active users for a specific type."""
self.active_users.labels(user_type=user_type).set(count) 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"): def record_db_query(self, query_type: str, duration: float, table_name: str = "unknown", operation: str = "unknown"):
"""Record database query duration.""" """Record database query duration."""
self.db_query_duration_seconds.labels( self.db_query_duration_seconds.labels(
@@ -168,6 +219,54 @@ class BotMetrics:
status=status status=status
).observe(duration) ).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: def get_metrics(self) -> bytes:
"""Generate metrics in Prometheus format.""" """Generate metrics in Prometheus format."""
return generate_latest(self.registry) return generate_latest(self.registry)

View File

@@ -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 helper_bot.utils.auto_unban_scheduler import get_auto_unban_scheduler
from logs.custom_logger import logger 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(): 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() bdf = get_global_instance()
# Создаем бота для автоматического разбана # Создаем бота для автоматического разбана
@@ -78,6 +111,10 @@ async def main():
# Отменяем задачу бота # Отменяем задачу бота
bot_task.cancel() bot_task.cancel()
# Очищаем PID файл (если PID менеджер доступен)
if pid_manager:
pid_manager.cleanup_pid_file()
# Ждем завершения задачи бота и получаем результат main bot # Ждем завершения задачи бота и получаем результат main bot
try: try:
results = await asyncio.gather(bot_task, return_exceptions=True) results = await asyncio.gather(bot_task, return_exceptions=True)

View File

@@ -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__])

View File

@@ -331,6 +331,20 @@ class TestAudioRepository:
assert result is None 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 @pytest.mark.asyncio
async def test_add_audio_record_logging(self, audio_repository, sample_audio_message): async def test_add_audio_record_logging(self, audio_repository, sample_audio_message):
"""Тест логирования при добавлении аудио записи""" """Тест логирования при добавлении аудио записи"""

View File

@@ -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__])

View File

@@ -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__])

View File

@@ -288,15 +288,20 @@ class TestDownloadFile:
# Мокаем download_file # Мокаем download_file
mock_message.bot.download_file = AsyncMock() mock_message.bot.download_file = AsyncMock()
# Мокаем os.makedirs # Мокаем os.makedirs и другие зависимости
with patch('os.makedirs') as mock_makedirs: with patch('os.makedirs') as mock_makedirs:
with patch('os.path.join', return_value="files/photos/file_123.jpg"): with patch('os.path.join', return_value="files/photos/file_123.jpg"):
result = await download_file(mock_message, "file_id_123") 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" assert result == "files/photos/file_123.jpg"
mock_makedirs.assert_called() mock_makedirs.assert_called()
mock_message.bot.get_file.assert_called_once_with("file_id_123") mock_message.bot.get_file.assert_called_once_with("file_id_123")
mock_message.bot.download_file.assert_called_once() mock_message.bot.download_file.assert_called_once()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_download_file_exception(self): async def test_download_file_exception(self):