From 1ab427a7bafa530ea0df6b53f3e8211601b67864 Mon Sep 17 00:00:00 2001 From: Andrey Date: Tue, 2 Sep 2025 22:20:34 +0300 Subject: [PATCH] Enhance admin handlers with improved logging and error handling - Added detailed logging for user ban processing in `process_ban_target` and `process_ban_reason` functions, including user data and error messages. - Improved error handling for user input validation and database interactions. - Updated `return_to_admin_menu` function to log user return actions. - Enhanced media group handling in `PostPublishService` with better error logging and author ID retrieval. - Added new button options in voice handlers and updated keyboard layouts for improved user interaction. - Refactored album middleware to better handle media group messages and added documentation for clarity. --- .gitignore | 1 + helper_bot/handlers/admin/admin_handlers.py | 31 +++++ helper_bot/handlers/admin/utils.py | 4 + helper_bot/handlers/callback/services.py | 122 ++++++++++++++--- helper_bot/handlers/private/services.py | 29 +++- helper_bot/handlers/voice/constants.py | 5 +- helper_bot/handlers/voice/services.py | 21 ++- helper_bot/handlers/voice/voice_handler.py | 38 +++++- helper_bot/keyboards/keyboards.py | 18 ++- helper_bot/middlewares/album_middleware.py | 81 ++++++----- helper_bot/utils/helper_func.py | 80 ++++++----- tests/README_post_repository_tests.md | 142 -------------------- 12 files changed, 340 insertions(+), 232 deletions(-) delete mode 100644 tests/README_post_repository_tests.md diff --git a/.gitignore b/.gitignore index 4997c47..690367e 100644 --- a/.gitignore +++ b/.gitignore @@ -92,3 +92,4 @@ venv.bak/ # Other files voice_users/ +files/ \ No newline at end of file diff --git a/helper_bot/handlers/admin/admin_handlers.py b/helper_bot/handlers/admin/admin_handlers.py index 143a74d..ce71e57 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -145,32 +145,42 @@ async def process_ban_target( bot_db: MagicData("bot_db") ): """Обработка введенного username/ID для блокировки""" + logger.info(f"process_ban_target: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}") + try: user_data = await state.get_data() ban_type = user_data.get('ban_type') admin_service = AdminService(bot_db) + logger.info(f"process_ban_target: ban_type={ban_type}, user_data={user_data}") # Определяем пользователя if ban_type == "username": + logger.info(f"process_ban_target: Поиск пользователя по username: {message.text}") user = await admin_service.get_user_by_username(message.text) if not user: + logger.warning(f"process_ban_target: Пользователь с username '{message.text}' не найден") await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.") await return_to_admin_menu(message, state) return else: # ban_type == "id" try: + logger.info(f"process_ban_target: Валидация и поиск пользователя по ID: {message.text}") user_id = await admin_service.validate_user_input(message.text) user = await admin_service.get_user_by_id(user_id) if not user: + logger.warning(f"process_ban_target: Пользователь с ID {user_id} не найден в базе данных") await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") await return_to_admin_menu(message, state) return except InvalidInputError as e: + logger.error(f"process_ban_target: Ошибка валидации ID: {e}") await message.answer(str(e)) await return_to_admin_menu(message, state) return + logger.info(f"process_ban_target: Найден пользователь: {user.user_id}, {user.username}, {user.full_name}") + # Сохраняем данные пользователя await state.update_data( target_user_id=user.user_id, @@ -181,13 +191,17 @@ async def process_ban_target( # Показываем информацию о пользователе и запрашиваем причину user_info = format_user_info(user.user_id, user.username, user.full_name) markup = create_keyboard_for_ban_reason() + logger.info(f"process_ban_target: Отправка сообщения с причиной бана, user_info: {user_info}") + await message.answer( text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат", reply_markup=markup ) await state.set_state('AWAIT_BAN_DETAILS') + logger.info("process_ban_target: Состояние изменено на AWAIT_BAN_DETAILS") except Exception as e: + logger.error(f"process_ban_target: Неожиданная ошибка: {e}", exc_info=True) await handle_admin_error(message, e, state, "process_ban_target") @@ -201,16 +215,33 @@ async def process_ban_reason( **kwargs ): """Обработка причины блокировки""" + logger.info(f"process_ban_reason: === НАЧАЛО ОБРАБОТКИ === Получено сообщение от {message.from_user.id}: {message.text}") + try: + # Проверяем текущее состояние + current_state = await state.get_state() + logger.info(f"process_ban_reason: Текущее состояние: {current_state}") + + # Проверяем данные состояния + state_data = await state.get_data() + logger.info(f"process_ban_reason: Данные состояния: {state_data}") + + logger.info(f"process_ban_reason: Обновление данных состояния с причиной: {message.text}") await state.update_data(ban_reason=message.text) + markup = create_keyboard_for_ban_days() safe_reason = escape_html(message.text) + logger.info(f"process_ban_reason: Отправка сообщения с выбором срока бана, причина: {safe_reason}") + await message.answer( f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат", reply_markup=markup ) await state.set_state('AWAIT_BAN_DURATION') + logger.info("process_ban_reason: Состояние изменено на AWAIT_BAN_DURATION") + except Exception as e: + logger.error(f"process_ban_reason: Неожиданная ошибка: {e}", exc_info=True) await handle_admin_error(message, e, state, "process_ban_reason") diff --git a/helper_bot/handlers/admin/utils.py b/helper_bot/handlers/admin/utils.py index 2e52a18..1738e29 100644 --- a/helper_bot/handlers/admin/utils.py +++ b/helper_bot/handlers/admin/utils.py @@ -16,14 +16,18 @@ def escape_html(text: str) -> str: async def return_to_admin_menu(message: types.Message, state: FSMContext, additional_message: Optional[str] = None) -> None: """Универсальная функция для возврата в админ-меню""" + logger.info(f"return_to_admin_menu: Возврат в админ-меню для пользователя {message.from_user.id}") + await state.set_data({}) await state.set_state("ADMIN") markup = get_reply_keyboard_admin() if additional_message: + logger.info(f"return_to_admin_menu: Отправка дополнительного сообщения: {additional_message}") await message.answer(additional_message) await message.answer('Вернулись в меню', reply_markup=markup) + logger.info(f"return_to_admin_menu: Пользователь {message.from_user.id} успешно возвращен в админ-меню") async def handle_admin_error(message: types.Message, error: Exception, diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py index e5b3225..3606710 100644 --- a/helper_bot/handlers/callback/services.py +++ b/helper_bot/handlers/callback/services.py @@ -1,5 +1,5 @@ -import html from datetime import datetime, timedelta +import html from typing import Dict, Any from aiogram import Bot @@ -42,9 +42,14 @@ class PostPublishService: async def publish_post(self, call: CallbackQuery) -> None: """Основной метод публикации поста""" + # Проверяем, является ли сообщение частью медиагруппы + if call.message.media_group_id: + await self._publish_media_group(call) + return + content_type = call.message.content_type - if content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP: + if content_type == CONTENT_TYPE_TEXT: await self._publish_text_post(call) elif content_type == CONTENT_TYPE_PHOTO: await self._publish_photo_post(call) @@ -56,8 +61,6 @@ class PostPublishService: await self._publish_audio_post(call) elif content_type == CONTENT_TYPE_VOICE: await self._publish_voice_post(call) - elif call.message.text == CONTENT_TYPE_MEDIA_GROUP: - await self._publish_media_group(call) else: raise PublishError(f"Неподдерживаемый тип контента: {content_type}") @@ -115,51 +118,111 @@ class PostPublishService: async def _publish_media_group(self, call: CallbackQuery) -> None: """Публикация медиагруппы""" - post_content = await self.db.get_post_content_from_telegram_by_last_id(call.message.message_id) - pre_text = await self.db.get_post_text_from_telegram_by_last_id(call.message.message_id) - post_text = html.escape(str(pre_text)) - author_id = await self._get_author_id_for_media_group(call.message.message_id) - - await send_media_group_to_channel(bot=self._get_bot(call.message), chat_id=self.main_public, post_content=post_content, post_text=post_text) - await self._delete_media_group_and_notify_author(call, author_id) + logger.info(f"Начинаю публикацию медиагруппы. Helper message ID: {call.message.message_id}") + try: + # call.message.message_id - это ID helper сообщения + helper_message_id = call.message.message_id + + # Получаем контент медиагруппы по helper_message_id + logger.debug(f"Получаю контент медиагруппы для helper_message_id: {helper_message_id}") + post_content = await self.db.get_post_content_by_helper_id(helper_message_id) + if not post_content: + logger.error(f"Контент медиагруппы не найден в базе данных для helper_message_id: {helper_message_id}") + raise PublishError("Контент медиагруппы не найден в базе данных") + + # Получаем текст поста по helper_message_id + logger.debug(f"Получаю текст поста для helper_message_id: {helper_message_id}") + pre_text = await self.db.get_post_text_by_helper_id(helper_message_id) + post_text = html.escape(str(pre_text)) if pre_text else "" + logger.debug(f"Текст поста получен: {'пустой' if not post_text else f'длина: {len(post_text)} символов'}") + + # Получаем ID автора по helper_message_id + logger.debug(f"Получаю ID автора для helper_message_id: {helper_message_id}") + author_id = await self.db.get_author_id_by_helper_message_id(helper_message_id) + if not author_id: + logger.error(f"Автор не найден для медиагруппы {helper_message_id}") + raise PostNotFoundError(f"Автор не найден для медиагруппы {helper_message_id}") + logger.debug(f"ID автора получен: {author_id}") + + # Отправляем медиагруппу в канал + logger.info(f"Отправляю медиагруппу в канал {self.main_public}") + await send_media_group_to_channel( + bot=self._get_bot(call.message), + chat_id=self.main_public, + post_content=post_content, + post_text=post_text + ) + + logger.debug(f"Удаляю медиагруппу и уведомляю автора {author_id}") + await self._delete_media_group_and_notify_author(call, author_id) + logger.info(f'Медиагруппа опубликована в канале {self.main_public}.') + + except Exception as e: + logger.error(f"Ошибка при публикации медиагруппы: {e}") + raise PublishError(f"Не удалось опубликовать медиагруппу: {str(e)}") async def decline_post(self, call: CallbackQuery) -> None: """Отклонение поста""" + logger.info(f"Начинаю отклонение поста. Message ID: {call.message.message_id}, Content type: {call.message.content_type}") + # Проверяем, является ли сообщение частью медиагруппы (осознанный костыль, т.к. сообщение к которому прикреплен коллбек без медиагруппы) + if call.message.text == CONTENT_TYPE_MEDIA_GROUP: + logger.debug("Сообщение является частью медиагруппы, вызываю _decline_media_group") + await self._decline_media_group(call) + return + content_type = call.message.content_type - if (content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP) or \ - content_type in [CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]: + if content_type in [CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_AUDIO, + CONTENT_TYPE_VOICE, CONTENT_TYPE_VIDEO, CONTENT_TYPE_VIDEO_NOTE]: + logger.debug(f"Отклоняю одиночный пост типа: {content_type}") await self._decline_single_post(call) - elif call.message.text == CONTENT_TYPE_MEDIA_GROUP: - await self._decline_media_group(call) else: + logger.error(f"Неподдерживаемый тип контента для отклонения: {content_type}") raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}") async def _decline_single_post(self, call: CallbackQuery) -> None: """Отклонение одиночного поста""" + logger.debug(f"Отклоняю одиночный пост. Message ID: {call.message.message_id}") author_id = await self._get_author_id(call.message.message_id) + logger.debug(f"ID автора получен: {author_id}") + + logger.debug(f"Удаляю сообщение из группы {self.group_for_posts}") await self._get_bot(call.message).delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + try: + logger.debug(f"Отправляю уведомление об отклонении автору {author_id}") await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) except Exception as e: if str(e) == ERROR_BOT_BLOCKED: + logger.warning(f"Пользователь {author_id} заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота") + logger.error(f"Ошибка при отправке уведомления автору {author_id}: {e}") raise logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).') async def _decline_media_group(self, call: CallbackQuery) -> None: """Отклонение медиагруппы""" + logger.debug(f"Отклоняю медиагруппу. Helper message ID: {call.message.message_id}") + post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) - message_ids = [row[0] for row in post_ids] + message_ids = post_ids.copy() message_ids.append(call.message.message_id) + logger.debug(f"Получены ID сообщений для удаления: {message_ids}") author_id = await self._get_author_id_for_media_group(call.message.message_id) + logger.debug(f"ID автора медиагруппы получен: {author_id}") + + logger.debug(f"Удаляю {len(message_ids)} сообщений из группы {self.group_for_posts}") await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + try: + logger.debug(f"Отправляю уведомление об отклонении автору медиагруппы {author_id}") await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) except Exception as e: if str(e) == ERROR_BOT_BLOCKED: + logger.warning(f"Пользователь {author_id} заблокировал бота") raise UserBlockedBotError("Пользователь заблокировал бота") + logger.error(f"Ошибка при отправке уведомления автору медиагруппы {author_id}: {e}") raise async def _get_author_id(self, message_id: int) -> int: @@ -171,7 +234,27 @@ class PostPublishService: async def _get_author_id_for_media_group(self, message_id: int) -> int: """Получение ID автора для медиагруппы""" + # Сначала пытаемся найти автора по helper_message_id author_id = await self.db.get_author_id_by_helper_message_id(message_id) + if author_id: + return author_id + + # Если не найден, ищем по основному message_id медиагруппы + # Для этого нужно найти связанные сообщения медиагруппы + try: + # Получаем все ID сообщений медиагруппы + post_ids = await self.db.get_post_ids_from_telegram_by_last_id(message_id) + if post_ids: + # Берем первый ID (основное сообщение медиагруппы) + main_message_id = post_ids[0] + author_id = await self.db.get_author_id_by_message_id(main_message_id) + if author_id: + return author_id + except Exception as e: + logger.warning(f"Не удалось найти автора через связанные сообщения: {e}") + + # Если все способы не сработали, ищем напрямую + author_id = await self.db.get_author_id_by_message_id(message_id) if not author_id: raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}") return author_id @@ -190,9 +273,10 @@ class PostPublishService: async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: """Удаление медиагруппы и уведомление автора""" post_ids = await self.db.get_post_ids_from_telegram_by_last_id(call.message.message_id) - message_ids = [row[0] for row in post_ids] - message_ids.append(call.message.message_id) - await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + + #message_ids = post_ids.copy() + post_ids.append(call.message.message_id) + await self._get_bot(call.message).delete_messages(chat_id=self.group_for_posts, message_ids=post_ids) try: await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) except Exception as e: diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index 60ad1eb..c82ba6f 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -272,6 +272,16 @@ class PostService: if album and album[0].caption: post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username) + # Создаем основной пост для медиагруппы + main_post = TelegramPost( + message_id=message.message_id, # ID основного сообщения медиагруппы + text=post_caption, + author_id=message.from_user.id, + created_at=int(datetime.now().timestamp()) + ) + await self.db.add_post(main_post) + + # Отправляем медиагруппу в группу для модерации 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 @@ -279,11 +289,24 @@ class PostService: await asyncio.sleep(0.2) + # Создаем helper сообщение с кнопками markup = get_reply_keyboard_for_post() - help_message_id = await send_text_message(self.settings.group_for_posts, message, "^", markup) + help_message_id = await send_text_message(self.settings.group_for_posts, message, "ВРУЧНУЮ ВЫКЛАДЫВАТЬ, ПОСЛЕ ВЫКЛАДКИ УДАЛИТЬ ОБА ПОСТА") + # Создаем helper пост и связываем его с основным + helper_post = TelegramPost( + message_id=help_message_id, # ID helper сообщения + text="^", # Специальный маркер для медиагруппы + author_id=message.from_user.id, + helper_text_message_id=main_post.message_id, # Ссылка на основной пост + created_at=int(datetime.now().timestamp()) + ) + await self.db.add_post(helper_post) + + # Обновляем основной пост, чтобы он ссылался на helper await self.db.update_helper_message( - message_id=media_group_message_id, helper_message_id=help_message_id + message_id=main_post.message_id, + helper_message_id=help_message_id ) @track_time("process_post", "post_service") @@ -291,7 +314,7 @@ class PostService: async def process_post(self, message: types.Message, album: Union[list, None] = None) -> None: """Process post based on content type""" first_name = get_first_name(message) - + # TODO: Бесит меня этот функционал if message.media_group_id is not None: safe_username = html.escape(message.from_user.username) if message.from_user.username else "Без никнейма" await send_text_message( diff --git a/helper_bot/handlers/voice/constants.py b/helper_bot/handlers/voice/constants.py index 07c1f9f..aca69e8 100644 --- a/helper_bot/handlers/voice/constants.py +++ b/helper_bot/handlers/voice/constants.py @@ -30,7 +30,10 @@ BTN_LISTEN = "🎧Послушать" # Button to command mapping for metrics BUTTON_COMMAND_MAPPING: Final[Dict[str, str]] = { "🎤Высказаться": "voice_speak", - "🎧Послушать": "voice_listen" + "🎧Послушать": "voice_listen", + "Отменить": "voice_cancel", + "🔄Сбросить прослушивания": "voice_refresh_listen", + "😊Узнать эмодзи": "voice_emoji" } # Callback data diff --git a/helper_bot/handlers/voice/services.py b/helper_bot/handlers/voice/services.py index 8dfc2f0..44c6578 100644 --- a/helper_bot/handlers/voice/services.py +++ b/helper_bot/handlers/voice/services.py @@ -1,5 +1,6 @@ import random import asyncio +import traceback from datetime import datetime from pathlib import Path from typing import List, Optional, Tuple @@ -255,22 +256,40 @@ class AudioFileService: async def download_and_save_audio(self, bot, message, file_name: str) -> None: """Скачать и сохранить аудио файл""" try: + logger.info(f"Начинаем скачивание и сохранение аудио: {file_name}") + # Проверяем наличие голосового сообщения if not message or not message.voice: + logger.error("Сообщение или голосовое сообщение не найдено") raise FileOperationError("Сообщение или голосовое сообщение не найдено") file_id = message.voice.file_id + logger.info(f"Получен file_id: {file_id}") + file_info = await bot.get_file(file_id=file_id) + 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) # Создаем директорию если она не существует import os os.makedirs(VOICE_USERS_DIR, exist_ok=True) + logger.info(f"Директория {VOICE_USERS_DIR} создана/проверена") + + file_path = f'{VOICE_USERS_DIR}/{file_name}.ogg' + logger.info(f"Сохраняем файл по пути: {file_path}") # Сохраняем файл - with open(f'{VOICE_USERS_DIR}/{file_name}.ogg', 'wb') as new_file: + with open(file_path, 'wb') as new_file: new_file.write(downloaded_file.read()) + logger.info(f"Файл успешно сохранен: {file_path}") + except Exception as e: logger.error(f"Ошибка при скачивании и сохранении аудио: {e}") + logger.error(f"Traceback: {traceback.format_exc()}") raise FileOperationError(f"Не удалось скачать и сохранить аудио: {e}") diff --git a/helper_bot/handlers/voice/voice_handler.py b/helper_bot/handlers/voice/voice_handler.py index b7a04cb..d3edbf1 100644 --- a/helper_bot/handlers/voice/voice_handler.py +++ b/helper_bot/handlers/voice/voice_handler.py @@ -18,12 +18,14 @@ from helper_bot.handlers.voice.constants import * from helper_bot.handlers.voice.services import VoiceBotService from helper_bot.handlers.voice.utils import get_last_message_text, validate_voice_message, get_user_emoji_safe from helper_bot.keyboards.keyboards import get_main_keyboard, get_reply_keyboard_for_voice +from helper_bot.keyboards import get_reply_keyboard +from helper_bot.handlers.private.constants import FSM_STATES from helper_bot.handlers.private.constants import BUTTON_TEXTS class VoiceHandlers: def __init__(self, db, settings): - self.db = db + self.db = db.get_db() if hasattr(db, 'get_db') else db self.settings = settings self.router = Router() self._setup_handlers() @@ -93,6 +95,25 @@ class VoiceHandlers: ChatTypeFilter(chat_type=["private"]), F.text == BTN_LISTEN ) + + # Новые обработчики кнопок + self.router.message.register( + self.cancel_handler, + ChatTypeFilter(chat_type=["private"]), + F.text == "Отменить" + ) + + self.router.message.register( + self.refresh_listen_function, + ChatTypeFilter(chat_type=["private"]), + F.text == "🔄Сбросить прослушивания" + ) + + self.router.message.register( + self.handle_emoji_message, + ChatTypeFilter(chat_type=["private"]), + F.text == "😊Узнать эмодзи" + ) async def voice_bot_button_handler(self, message: types.Message, state: FSMContext, bot_db: MagicData("bot_db"), settings: MagicData("settings")): """Обработчик кнопки 'Голосовой бот' из основной клавиатуры""" @@ -180,6 +201,19 @@ class VoiceHandlers: except Exception as e: logger.error(f"Ошибка при отметке получения приветствия: {e}") + async def cancel_handler( + self, + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db"), + settings: MagicData("settings") + ): + """Обработчик кнопки 'Отменить' - возвращает в начальное состояние""" + await message.forward(chat_id=settings['Telegram']['group_for_logs']) + await update_user_info(VOICE_BOT_NAME, message) + markup = await get_reply_keyboard(self.db, message.from_user.id) + await message.answer(text='Добро пожаловать в меню!', reply_markup=markup, parse_mode='HTML') + await state.set_state(FSM_STATES["START"]) async def refresh_listen_function( self, @@ -334,7 +368,7 @@ class VoiceHandlers: await voice_service.mark_audio_as_listened(audio_for_user, message.from_user.id) # Получаем количество оставшихся аудио только после успешной отправки - remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id) - 1 + remaining_count = await voice_service.get_remaining_audio_count(message.from_user.id) await message.answer( text=f'Осталось непрослушанных: {remaining_count}', reply_markup=markup diff --git a/helper_bot/keyboards/keyboards.py b/helper_bot/keyboards/keyboards.py index ffb1205..0f640ce 100644 --- a/helper_bot/keyboards/keyboards.py +++ b/helper_bot/keyboards/keyboards.py @@ -25,13 +25,13 @@ def get_reply_keyboard_for_post(): @track_time("get_reply_keyboard", "keyboard_service") @track_errors("keyboard_service", "get_reply_keyboard") -async def get_reply_keyboard(BotDB, user_id): +async def get_reply_keyboard(db, user_id): builder = ReplyKeyboardBuilder() builder.row(types.KeyboardButton(text="📢Предложить свой пост")) builder.row(types.KeyboardButton(text="📩Связаться с админами")) builder.row(types.KeyboardButton(text=" 🎤Голосовой бот")) builder.row(types.KeyboardButton(text="👋🏼Сказать пока!")) - if not await BotDB.get_stickers_info(user_id): + if not await db.get_stickers_info(user_id): builder.row(types.KeyboardButton(text="🤪Хочу стикеры")) markup = builder.as_markup(resize_keyboard=True, one_time_keyboard=True) return markup @@ -175,8 +175,18 @@ def create_keyboard_for_approve_ban(): def get_main_keyboard(): builder = ReplyKeyboardBuilder() - builder.add(types.KeyboardButton(text="🎤Высказаться")) - builder.add(types.KeyboardButton(text="🎧Послушать")) + # Первая строка: Высказаться и послушать + builder.row( + types.KeyboardButton(text="🎤Высказаться"), + types.KeyboardButton(text="🎧Послушать") + ) + # Вторая строка: сбросить прослушивания и узнать эмодзи + builder.row( + types.KeyboardButton(text="🔄Сбросить прослушивания"), + types.KeyboardButton(text="😊Узнать эмодзи") + ) + # Третья строка: Вернуться в меню + builder.row(types.KeyboardButton(text="Отменить")) markup = builder.as_markup(resize_keyboard=True) return markup diff --git a/helper_bot/middlewares/album_middleware.py b/helper_bot/middlewares/album_middleware.py index 9110267..65f1743 100644 --- a/helper_bot/middlewares/album_middleware.py +++ b/helper_bot/middlewares/album_middleware.py @@ -1,61 +1,82 @@ import asyncio -from typing import Any, Dict, Union +from typing import Any, Dict, Union, List from aiogram import BaseMiddleware from aiogram.types import Message class AlbumMiddleware(BaseMiddleware): - def __init__(self, latency: Union[int, float] = 0.01): # Уменьшено с 0.1 до 0.01 - # Initialize latency and album_data dictionary + """ + Middleware для обработки медиа групп в Telegram. + Собирает все сообщения одной медиа группы и передает их как album в data. + """ + + def __init__(self, latency: Union[int, float] = 0.01): + """ + Инициализация middleware. + + Args: + latency: Задержка в секундах для сбора всех сообщений медиа группы + """ + super().__init__() self.latency = latency - self.album_data = {} + self.album_data: Dict[str, Dict[str, List[Message]]] = {} - # - def collect_album_messages(self, event: Message): + def collect_album_messages(self, event: Message) -> int: """ - Collect messages of the same media group. + Собирает сообщения одной медиа группы. + + Args: + event: Сообщение для обработки + + Returns: + Количество сообщений в текущей медиа группе """ - # # Check if media_group_id exists in album_data + if not event.media_group_id: + return 0 + if event.media_group_id not in self.album_data: - # # Create a new entry for the media group self.album_data[event.media_group_id] = {"messages": []} - # - # # Append the new message to the media group + self.album_data[event.media_group_id]["messages"].append(event) - # - # # Return the total number of messages in the current media group return len(self.album_data[event.media_group_id]["messages"]) - # async def __call__(self, handler, event: Message, data: Dict[str, Any]) -> Any: """ - Main middleware logic. + Основная логика middleware. + + Args: + handler: Обработчик события + event: Событие (сообщение) + data: Данные для передачи в обработчик + + Returns: + Результат выполнения обработчика """ - # # If the event has no media_group_id, pass it to the handler immediately + # Если у события нет media_group_id, передаем его обработчику сразу if not event.media_group_id: return await handler(event, data) - # - # # Collect messages of the same media group + + # Собираем сообщения одной медиа группы total_before = self.collect_album_messages(event) - # - # # Wait for a specified latency period + + # Ждем указанный период для сбора всех сообщений await asyncio.sleep(self.latency) - # - # # Check the total number of messages after the latency + + # Проверяем количество сообщений после задержки total_after = len(self.album_data[event.media_group_id]["messages"]) - # - # # If new messages were added during the latency, exit + + # Если за время задержки добавились новые сообщения, выходим if total_before != total_after: return - # - # # Sort the album messages by message_id and add to data + + # Сортируем сообщения по message_id и добавляем в data album_messages = self.album_data[event.media_group_id]["messages"] album_messages.sort(key=lambda x: x.message_id) data["album"] = album_messages - # - # # Remove the media group from tracking to free up memory + + # Удаляем медиа группу из отслеживания для освобождения памяти del self.album_data[event.media_group_id] - # # Call the original event handler + + # Вызываем оригинальный обработчик события return await handler(event, data) -# diff --git a/helper_bot/utils/helper_func.py b/helper_bot/utils/helper_func.py index 44a9756..a12a5ff 100644 --- a/helper_bot/utils/helper_func.py +++ b/helper_bot/utils/helper_func.py @@ -13,7 +13,7 @@ except ImportError: _emoji_lib_available = False from aiogram import types -from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio +from aiogram.types import InputMediaPhoto, FSInputFile, InputMediaVideo, InputMediaAudio, InputMediaDocument from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance from logs.custom_logger import logger @@ -145,14 +145,14 @@ async def download_file(message: types.Message, file_id: str): async def prepare_media_group_from_middlewares(album, post_caption: str = ''): """ - Создает MediaGroup. + Создает MediaGroup согласно best practices aiogram 3.x. Args: - album: Album объект из Telegram API. - post_caption: Текст подписи к первому фото. + album: Album объект из Telegram API (список сообщений). + post_caption: Текст подписи к первому медиа файлу. Returns: - Список InputMediaPhoto (MediaGroup). + Список InputMedia объектов для MediaGroup. """ # Экранируем post_caption для безопасного использования в HTML safe_post_caption = html.escape(str(post_caption)) if post_caption else "" @@ -162,34 +162,37 @@ async def prepare_media_group_from_middlewares(album, post_caption: str = ''): for i, message in enumerate(album): if message.photo: file_id = message.photo[-1].file_id - media_type = 'photo' + # Для фото используем InputMediaPhoto + if i == 0: # Первое фото получает подпись + media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption)) + else: + media_group.append(InputMediaPhoto(media=file_id)) elif message.video: file_id = message.video.file_id - media_type = 'video' + # Для видео используем InputMediaVideo + if i == 0: # Первое видео получает подпись + media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption)) + else: + media_group.append(InputMediaVideo(media=file_id)) elif message.audio: file_id = message.audio.file_id - media_type = 'audio' + # Для аудио используем InputMediaAudio + if i == 0: # Первое аудио получает подпись + media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption)) + else: + media_group.append(InputMediaAudio(media=file_id)) + elif message.document: + file_id = message.document.file_id + # Для документов используем InputMediaDocument (если поддерживается) + if i == 0: # Первый документ получает подпись + media_group.append(InputMediaDocument(media=file_id, caption=safe_post_caption)) + else: + media_group.append(InputMediaDocument(media=file_id)) else: - # Если нет фото, видео или аудио, пропускаем сообщение + # Если нет поддерживаемого медиа, пропускаем сообщение continue - # Формируем объект MediaGroup с учетом типа медиа - if i == len(album) - 1: - if media_type == 'photo': - media_group.append(InputMediaPhoto(media=file_id, caption=safe_post_caption)) - elif media_type == 'video': - media_group.append(InputMediaVideo(media=file_id, caption=safe_post_caption)) - elif media_type == 'audio': - media_group.append(InputMediaAudio(media=file_id, caption=safe_post_caption)) - else: - if media_type == 'photo': - media_group.append(InputMediaPhoto(media=file_id)) - elif media_type == 'video': - media_group.append(InputMediaVideo(media=file_id)) - elif media_type == 'audio': - media_group.append(InputMediaAudio(media=file_id)) - - return media_group # Возвращаем MediaGroup + return media_group async def add_in_db_media_mediagroup(sent_message, bot_db): @@ -280,26 +283,43 @@ async def send_media_group_to_channel(bot, chat_id: int, post_content: List, pos post_content: Список кортежей с путями к файлам. post_text: Текст подписи. """ + logger.info(f"Начинаю отправку медиа-группы в чат {chat_id}, количество файлов: {len(post_content)}") + media = [] - for file_path in post_content: + for i, file_path in enumerate(post_content): try: file = FSInputFile(path=file_path[0]) type = file_path[1] + logger.debug(f"Обрабатываю файл {i+1}/{len(post_content)}: {file_path[0]} (тип: {type})") + if type == 'video': media.append(types.InputMediaVideo(media=file)) - if type == 'photo': + elif type == 'photo': media.append(types.InputMediaPhoto(media=file)) + else: + logger.warning(f"Неизвестный тип файла: {type} для {file_path[0]}") except FileNotFoundError: logger.error(f"Файл не найден: {file_path[0]}") return + except Exception as e: + logger.error(f"Ошибка при обработке файла {file_path[0]}: {e}") + return + + logger.info(f"Подготовлено {len(media)} медиа-файлов для отправки") # Добавляем подпись к последнему файлу if media: # Экранируем post_text для безопасного использования в HTML safe_post_text = html.escape(str(post_text)) if post_text else "" media[-1].caption = safe_post_text + logger.debug(f"Добавлена подпись к последнему файлу: {safe_post_text[:50]}{'...' if len(safe_post_text) > 50 else ''}") - await bot.send_media_group(chat_id=chat_id, media=media) + try: + await bot.send_media_group(chat_id=chat_id, media=media) + logger.info(f"Медиа-группа успешно отправлена в чат {chat_id}") + except Exception as e: + logger.error(f"Ошибка при отправке медиа-группы в чат {chat_id}: {e}") + raise async def send_text_message(chat_id, message: types.Message, post_text: str, markup: types.ReplyKeyboardMarkup = None): @@ -599,7 +619,7 @@ async def update_user_info(source: str, message: types.Message): @db_query_time("check_emoji_for_user", "users", "select") async def check_user_emoji(message: types.Message): user_id = message.from_user.id - user_emoji = await BotDB.get_stickers_info(user_id=user_id) + user_emoji = await BotDB.get_user_emoji(user_id=user_id) if user_emoji is None or user_emoji in ("Смайл еще не определен", "Эмоджи не определен", ""): user_emoji = await get_random_emoji() await BotDB.update_user_emoji(user_id=user_id, emoji=user_emoji) diff --git a/tests/README_post_repository_tests.md b/tests/README_post_repository_tests.md deleted file mode 100644 index 080e1fb..0000000 --- a/tests/README_post_repository_tests.md +++ /dev/null @@ -1,142 +0,0 @@ -# Тесты для PostRepository - -Этот документ описывает тесты для `PostRepository` - репозитория для работы с постами из Telegram. - -## Структура тестов - -### 1. `test_post_repository.py` - Unit тесты -Содержит модульные тесты с моками для всех методов `PostRepository`: - -- **`test_create_tables`** - тест создания таблиц БД -- **`test_add_post_with_date`** - тест добавления поста с датой -- **`test_add_post_without_date`** - тест добавления поста без даты (автогенерация) -- **`test_add_post_logs_correctly`** - тест логирования при добавлении поста -- **`test_update_helper_message`** - тест обновления helper сообщения -- **`test_add_post_content_success`** - тест успешного добавления контента -- **`test_add_post_content_exception`** - тест обработки исключений -- **`test_get_post_content_by_helper_id`** - тест получения контента по helper ID -- **`test_get_post_text_by_helper_id_found`** - тест получения текста поста (найден) -- **`test_get_post_text_by_helper_id_not_found`** - тест получения текста поста (не найден) -- **`test_get_post_ids_by_helper_id`** - тест получения ID сообщений -- **`test_get_author_id_by_message_id_found`** - тест получения ID автора по message ID (найден) -- **`test_get_author_id_by_message_id_not_found`** - тест получения ID автора по message ID (не найден) -- **`test_get_author_id_by_helper_message_id_found`** - тест получения ID автора по helper message ID (найден) -- **`test_get_author_id_by_helper_message_id_not_found`** - тест получения ID автора по helper message ID (не найден) -- **`test_create_tables_logs_success`** - тест логирования успешного создания таблиц - -### 2. `test_post_repository_integration.py` - Интеграционные тесты -Содержит тесты с реальной базой данных SQLite: - -- **`test_create_tables_integration`** - интеграционный тест создания таблиц -- **`test_add_post_integration`** - интеграционный тест добавления поста -- **`test_add_post_without_date_integration`** - интеграционный тест добавления поста без даты -- **`test_update_helper_message_integration`** - интеграционный тест обновления helper сообщения -- **`test_add_post_content_integration`** - интеграционный тест добавления контента поста -- **`test_add_post_content_with_helper_message_integration`** - интеграционный тест добавления контента с helper сообщением -- **`test_get_post_text_by_helper_id_integration`** - интеграционный тест получения текста поста -- **`test_get_post_text_by_helper_id_not_found_integration`** - интеграционный тест получения текста несуществующего поста -- **`test_get_post_ids_by_helper_id_integration`** - интеграционный тест получения ID сообщений -- **`test_get_author_id_by_message_id_integration`** - интеграционный тест получения ID автора по message ID -- **`test_get_author_id_by_message_id_not_found_integration`** - интеграционный тест получения ID автора несуществующего поста -- **`test_get_author_id_by_helper_message_id_integration`** - интеграционный тест получения ID автора по helper message ID -- **`test_get_author_id_by_helper_message_id_not_found_integration`** - интеграционный тест получения ID автора несуществующего helper сообщения -- **`test_multiple_posts_integration`** - интеграционный тест работы с несколькими постами -- **`test_post_content_relationships_integration`** - интеграционный тест связей между постами и контентом - -### 3. `conftest_post_repository.py` - Общие фикстуры -Содержит фикстуры для всех тестов: - -- **`mock_post_repository`** - мок PostRepository для unit тестов -- **`sample_telegram_post`** - тестовый объект TelegramPost -- **`sample_telegram_post_with_helper`** - тестовый объект TelegramPost с helper сообщением -- **`sample_telegram_post_no_date`** - тестовый объект TelegramPost без даты -- **`sample_post_content`** - тестовый объект PostContent -- **`sample_message_content_link`** - тестовый объект MessageContentLink -- **`mock_db_execute_query`** - мок для _execute_query -- **`mock_db_execute_query_with_result`** - мок для _execute_query_with_result -- **`mock_logger`** - мок для logger -- **`temp_db_file`** - временный файл БД для интеграционных тестов -- **`real_post_repository`** - реальный PostRepository с временной БД -- **`sample_posts_batch`** - набор тестовых постов для batch тестов -- **`sample_content_batch`** - набор тестового контента для batch тестов -- **`mock_database_connection`** - мок для DatabaseConnection -- **`sample_helper_message_ids`** - набор тестовых helper message ID -- **`sample_message_ids`** - набор тестовых message ID -- **`sample_author_ids`** - набор тестовых author ID -- **`mock_sql_queries`** - мок для SQL запросов - -## Запуск тестов - -### Запуск всех тестов для PostRepository: -```bash -pytest tests/test_post_repository.py -v -pytest tests/test_post_repository_integration.py -v -``` - -### Запуск с покрытием: -```bash -pytest tests/test_post_repository.py --cov=database.repositories.post_repository --cov-report=html -pytest tests/test_post_repository_integration.py --cov=database.repositories.post_repository --cov-report=html -``` - -### Запуск конкретного теста: -```bash -pytest tests/test_post_repository.py::TestPostRepository::test_add_post_with_date -v -``` - -## Требования - -- `pytest` - фреймворк для тестирования -- `pytest-asyncio` - поддержка асинхронных тестов -- `pytest-cov` - для измерения покрытия кода (опционально) - -## Особенности тестирования - -### Unit тесты -- Используют моки для изоляции тестируемого кода -- Проверяют логику методов без зависимости от БД -- Быстрые и надежные - -### Интеграционные тесты -- Используют реальную SQLite БД в памяти -- Проверяют взаимодействие с БД -- Создают временные файлы БД для каждого теста -- Автоматически очищают ресурсы после тестов - -### Фикстуры -- Переиспользуемые объекты для тестов -- Автоматическая очистка ресурсов -- Разделение на unit и integration фикстуры - -## Покрытие тестами - -Тесты покрывают все публичные методы `PostRepository`: - -- ✅ `create_tables()` - создание таблиц БД -- ✅ `add_post()` - добавление поста -- ✅ `update_helper_message()` - обновление helper сообщения -- ✅ `add_post_content()` - добавление контента поста -- ✅ `get_post_content_by_helper_id()` - получение контента по helper ID -- ✅ `get_post_text_by_helper_id()` - получение текста поста по helper ID -- ✅ `get_post_ids_by_helper_id()` - получение ID сообщений по helper ID -- ✅ `get_author_id_by_message_id()` - получение ID автора по message ID -- ✅ `get_author_id_by_helper_message_id()` - получение ID автора по helper message ID - -## Добавление новых тестов - -При добавлении новых методов в `PostRepository`: - -1. Добавьте unit тест в `test_post_repository.py` -2. Добавьте интеграционный тест в `test_post_repository_integration.py` -3. Добавьте необходимые фикстуры в `conftest_post_repository.py` -4. Обновите этот README файл - -## Отладка тестов - -Для отладки тестов используйте: - -```bash -pytest tests/test_post_repository.py -v -s --tb=long -``` - -Флаг `-s` позволяет видеть print statements, `--tb=long` показывает полный traceback ошибок.