diff --git a/helper_bot/handlers/admin/__init__.py b/helper_bot/handlers/admin/__init__.py index 52fb315..29f53ec 100644 --- a/helper_bot/handlers/admin/__init__.py +++ b/helper_bot/handlers/admin/__init__.py @@ -1 +1,37 @@ -from .admin_handlers import admin_router \ No newline at end of file +from .admin_handlers import admin_router +from .dependencies import AdminAccessMiddleware, BotDB, Settings +from .services import AdminService, User, BannedUser +from .exceptions import ( + AdminError, + AdminAccessDeniedError, + UserNotFoundError, + InvalidInputError, + UserAlreadyBannedError +) +from .utils import ( + return_to_admin_menu, + handle_admin_error, + format_user_info, + format_ban_confirmation, + escape_html +) + +__all__ = [ + 'admin_router', + 'AdminAccessMiddleware', + 'BotDB', + 'Settings', + 'AdminService', + 'User', + 'BannedUser', + 'AdminError', + 'AdminAccessDeniedError', + 'UserNotFoundError', + 'InvalidInputError', + 'UserAlreadyBannedError', + 'return_to_admin_menu', + 'handle_admin_error', + 'format_user_info', + 'format_ban_confirmation', + 'escape_html' +] \ 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 b75413f..4c64196 100644 --- a/helper_bot/handlers/admin/admin_handlers.py +++ b/helper_bot/handlers/admin/admin_handlers.py @@ -1,52 +1,55 @@ -import traceback -import html - from aiogram import Router, types, F -from aiogram.filters import Command, StateFilter +from aiogram.filters import Command, StateFilter, MagicData from aiogram.fsm.context import FSMContext from helper_bot.filters.main import ChatTypeFilter -from helper_bot.keyboards.keyboards import get_reply_keyboard_admin, create_keyboard_with_pagination, \ - create_keyboard_for_ban_days, create_keyboard_for_approve_ban, create_keyboard_for_ban_reason -from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import check_access, add_days_to_date, get_banned_users_buttons, get_banned_users_list +from helper_bot.keyboards.keyboards import ( + get_reply_keyboard_admin, + create_keyboard_with_pagination, + create_keyboard_for_ban_days, + create_keyboard_for_approve_ban, + create_keyboard_for_ban_reason +) +from helper_bot.handlers.admin.dependencies import AdminAccessMiddleware +from helper_bot.handlers.admin.services import AdminService +from helper_bot.handlers.admin.exceptions import ( + UserAlreadyBannedError, + InvalidInputError +) +from helper_bot.handlers.admin.utils import ( + return_to_admin_menu, + handle_admin_error, + format_user_info, + format_ban_confirmation, + escape_html +) from logs.custom_logger import logger +# Создаем роутер с middleware для проверки доступа admin_router = Router() +admin_router.message.middleware(AdminAccessMiddleware()) -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] - -BotDB = bdf.get_db() +# ============================================================================ +# ХЕНДЛЕРЫ МЕНЮ +# ============================================================================ @admin_router.message( ChatTypeFilter(chat_type=["private"]), Command('admin') ) -async def admin_panel(message: types.Message, state: FSMContext): +async def admin_panel( + message: types.Message, + state: FSMContext +): + """Главное меню администратора""" try: - if check_access(message.from_user.id, BotDB): - await state.set_state("ADMIN") - logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") - markup = get_reply_keyboard_admin() - await message.answer("Добро пожаловать в админку. Выбери что хочешь:", - reply_markup=markup) - else: - await message.answer('Доступ запрещен, досвидания!') - await state.set_state("START") + await state.set_state("ADMIN") + logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") + markup = get_reply_keyboard_admin() + await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup) except Exception as e: - logger.error(f"Ошибка при запуске админ панели: {e}") - await message.bot.send_message(IMPORTANT_LOGS, - f'Ошибка в функции admin_panel {e}. Traceback: {traceback.format_exc()}') - await state.set_state("START") + await handle_admin_error(message, e, state, "admin_panel") @admin_router.message( @@ -54,150 +57,30 @@ async def admin_panel(message: types.Message, state: FSMContext): StateFilter("ADMIN"), F.text == 'Бан (Список)' ) -async def get_last_users(message: types.Message, state: FSMContext): - # Дополнительная проверка на админские права - if not check_access(message.from_user.id, BotDB): - await message.answer('Доступ запрещен!') - await state.set_state("START") - return - logger.info( - f"Попытка получения списка последних пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") - list_users = BotDB.get_last_users_from_db() - keyboard = create_keyboard_with_pagination(1, len(list_users), list_users, 'ban') - await message.answer(text="Список пользователей которые последними обращались к боту", - reply_markup=keyboard) - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("ADMIN"), - F.text == 'Бан по нику' -) -async def ban_by_nickname(message: types.Message, state: FSMContext): - # Дополнительная проверка на админские права - if not check_access(message.from_user.id, BotDB): - await message.answer('Доступ запрещен!') - await state.set_state("START") - return - await message.answer('Пришли мне username блокируемого пользователя') - await state.set_state('PRE_BAN') - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("ADMIN"), - F.text == 'Бан по ID' -) -async def ban_by_id(message: types.Message, state: FSMContext): - # Дополнительная проверка на админские права - if not check_access(message.from_user.id, BotDB): - await message.answer('Доступ запрещен!') - await state.set_state("START") - return - await message.answer('Пришли мне ID блокируемого пользователя') - await state.set_state('PRE_BAN_ID') - - - - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN", "PRE_BAN_ID", "BAN_2"), - F.text == 'Отменить' -) -async def decline_ban(message: types.Message, state: FSMContext): - # Дополнительная проверка на админские права - if not check_access(message.from_user.id, BotDB): - await message.answer('Доступ запрещен!') - await state.set_state("START") - return - current_state = await state.get_state() - await state.set_data({}) - await state.set_state("ADMIN") - logger.info(f"Отмена процедуры блокировки из состояния: {current_state}") - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN") -) -async def ban_by_nickname_step_2(message: types.Message, state: FSMContext): - # Дополнительная проверка на админские права - if not check_access(message.from_user.id, BotDB): - await message.answer('Доступ запрещен!') - await state.set_state("START") - return - logger.info( - f"Функция ban_by_nickname_2. Получен никнейм пользователя: {message.text}") - user_name = message.text - user_id = BotDB.get_user_id_by_username(user_name) - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) - full_name = BotDB.get_full_name_by_id(user_id) - markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы - user_name_escaped = html.escape(str(user_name)) - full_name_escaped = html.escape(str(full_name)) - await message.answer( - text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\n" - f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) - await state.set_state('BAN_2') - - -@admin_router.message( - ChatTypeFilter(chat_type=["private"]), - StateFilter("PRE_BAN_ID") -) -async def ban_by_id_step_2(message: types.Message, state: FSMContext): - # Дополнительная проверка на админские права - if not check_access(message.from_user.id, BotDB): - await message.answer('Доступ запрещен!') - await state.set_state("START") - return +async def get_last_users( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Получение списка последних пользователей""" try: - user_id = int(message.text) - logger.info(f"Функция ban_by_id_step_2. Получен ID пользователя: {user_id}") + logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}") + admin_service = AdminService(bot_db) + users = admin_service.get_last_users() - # Проверяем, существует ли пользователь в базе - user_info = BotDB.get_user_info_by_id(user_id) - if not user_info: - await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - return + # Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination) + users_data = [ + (user.full_name, user.username) # (full_name, username) - формат кортежей + for user in users + ] - user_name = user_info.get('username', 'Неизвестно') - full_name = user_info.get('full_name', 'Неизвестно') - - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) - - markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы - user_name_escaped = html.escape(str(user_name)) - full_name_escaped = html.escape(str(full_name)) + keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban') await message.answer( - text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\n" - f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) - await state.set_state('BAN_2') - - except ValueError: - await message.answer("Пожалуйста, введите корректный числовой ID пользователя.") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) - - - - - - + text="Список пользователей которые последними обращались к боту", + reply_markup=keyboard + ) + except Exception as e: + await handle_admin_error(message, e, state, "get_last_users") @admin_router.message( @@ -205,80 +88,222 @@ async def ban_by_id_step_2(message: types.Message, state: FSMContext): StateFilter("ADMIN"), F.text == 'Разбан (список)' ) -async def get_banned_users(message): - logger.info( - f"Попытка получения списка заблокированных пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") - message_text = get_banned_users_list(0, BotDB) - buttons_list = get_banned_users_buttons(BotDB) - if buttons_list: - k = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') - await message.answer(text=message_text, reply_markup=k) - else: - await message.answer(text="В списке забанненых пользователей никого нет") +async def get_banned_users( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Получение списка заблокированных пользователей""" + try: + logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}") + admin_service = AdminService(bot_db) + message_text, buttons_list = admin_service.get_banned_users_for_display(0) + + if buttons_list: + keyboard = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') + await message.answer(text=message_text, reply_markup=keyboard) + else: + await message.answer(text="В списке заблокированных пользователей никого нет") + except Exception as e: + await handle_admin_error(message, e, state, "get_banned_users") +# ============================================================================ +# ХЕНДЛЕРЫ ПРОЦЕССА БАНА +# ============================================================================ + @admin_router.message( ChatTypeFilter(chat_type=["private"]), - StateFilter("BAN_2") + StateFilter("ADMIN"), + F.text.in_(['Бан по нику', 'Бан по ID']) ) -async def ban_user_step_2(message: types.Message, state: FSMContext): - user_data = await state.get_data() - logger.info(f"Переход на шаг 2 бана пользователя. Словарь с данными для бана: {user_data})") - await state.update_data(message_for_user=message.text) - markup = create_keyboard_for_ban_days() - # Экранируем message.text для безопасного использования - safe_message_text = html.escape(str(message.text)) if message.text else "" - await message.answer(f"Выбрана причина: {safe_message_text}. Выбери срок бана в днях или напиши " - f"его в чат", reply_markup=markup) - await state.set_state("BAN_3") +async def start_ban_process( + message: types.Message, + state: FSMContext, + ): + """Начало процесса блокировки пользователя""" + try: + ban_type = "username" if message.text == 'Бан по нику' else "id" + await state.update_data(ban_type=ban_type) + + prompt_text = "Пришли мне username блокируемого пользователя" if ban_type == "username" else "Пришли мне ID блокируемого пользователя" + await message.answer(prompt_text) + await state.set_state('AWAIT_BAN_TARGET') + except Exception as e: + await handle_admin_error(message, e, state, "start_ban_process") @admin_router.message( ChatTypeFilter(chat_type=["private"]), - StateFilter("BAN_3") + StateFilter("AWAIT_BAN_TARGET") ) -async def ban_user_step_3(message: types.Message, state: FSMContext): - logger.info(f"ban_user_step_3. Расчет даты разбана. Входные данные {message.text}") - if message.text != 'Навсегда': - count_days = int(message.text) - date_to_unban = add_days_to_date(count_days) - else: - date_to_unban = None - logger.info(f"ban_user_step_3. Расчет даты разбана. date_to_unban: {date_to_unban}") - await state.update_data(date_to_unban=date_to_unban) - user_data = await state.get_data() - markup = create_keyboard_for_approve_ban() - # Экранируем user_data для безопасного использования - safe_message_for_user = html.escape(str(user_data['message_for_user'])) if user_data.get('message_for_user') else "" - safe_date_to_unban = html.escape(str(user_data['date_to_unban'])) if user_data.get('date_to_unban') else "" - await message.answer( - f"Необходимо подтверждение:\nПользователь:{user_data['user_id']}\nПричина бана:{safe_message_for_user}\nСрок бана:{safe_date_to_unban}", - reply_markup=markup) - await state.set_state("BAN_FINAL") +async def process_ban_target( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Обработка введенного username/ID для блокировки""" + try: + user_data = await state.get_data() + ban_type = user_data.get('ban_type') + admin_service = AdminService(bot_db) + + + # Определяем пользователя + if ban_type == "username": + user = admin_service.get_user_by_username(message.text) + if not user: + await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.") + await return_to_admin_menu(message, state) + return + else: # ban_type == "id" + try: + user_id = admin_service.validate_user_input(message.text) + user = admin_service.get_user_by_id(user_id) + if not user: + await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") + await return_to_admin_menu(message, state) + return + except InvalidInputError as e: + await message.answer(str(e)) + await return_to_admin_menu(message, state) + return + + # Сохраняем данные пользователя + await state.update_data( + target_user_id=user.user_id, + target_username=user.username, + target_full_name=user.full_name + ) + + # Показываем информацию о пользователе и запрашиваем причину + user_info = format_user_info(user.user_id, user.username, user.full_name) + markup = create_keyboard_for_ban_reason() + await message.answer( + text=f"{user_info}\n\nВыбери причину бана из списка или напиши ее в чат", + reply_markup=markup + ) + await state.set_state('AWAIT_BAN_DETAILS') + + except Exception as e: + await handle_admin_error(message, e, state, "process_ban_target") @admin_router.message( ChatTypeFilter(chat_type=["private"]), - StateFilter("BAN_FINAL"), + StateFilter("AWAIT_BAN_DETAILS") +) +async def process_ban_reason( + message: types.Message, + state: FSMContext + ): + """Обработка причины блокировки""" + try: + await state.update_data(ban_reason=message.text) + markup = create_keyboard_for_ban_days() + safe_reason = escape_html(message.text) + await message.answer( + f"Выбрана причина: {safe_reason}. Выбери срок бана в днях или напиши его в чат", + reply_markup=markup + ) + await state.set_state('AWAIT_BAN_DURATION') + except Exception as e: + await handle_admin_error(message, e, state, "process_ban_reason") + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("AWAIT_BAN_DURATION") +) +async def process_ban_duration( + message: types.Message, + state: FSMContext, + ): + """Обработка срока блокировки""" + try: + user_data = await state.get_data() + + # Определяем срок блокировки + if message.text == 'Навсегда': + ban_days = None + else: + try: + ban_days = int(message.text) + if ban_days <= 0: + await message.answer("Срок блокировки должен быть положительным числом.") + return + except ValueError: + await message.answer("Пожалуйста, введите корректное число дней или выберите 'Навсегда'.") + return + + await state.update_data(ban_days=ban_days) + + # Показываем подтверждение + confirmation_text = format_ban_confirmation( + user_data['target_user_id'], + user_data['ban_reason'], + ban_days + ) + markup = create_keyboard_for_approve_ban() + await message.answer(confirmation_text, reply_markup=markup) + await state.set_state('BAN_CONFIRMATION') + + except Exception as e: + await handle_admin_error(message, e, state, "process_ban_duration") + + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("BAN_CONFIRMATION"), F.text == 'Подтвердить' ) -async def approve_ban(message: types.Message, state: FSMContext): - user_data = await state.get_data() - logger.info(f"Переход на финальный шаг бана пользователя. Словарь с данными для бана: {user_data})") - exists = BotDB.check_user_in_blacklist(user_data['user_id']) - if exists: - await message.reply(f"Пользователь уже был заблокирован ранее.") - logger.info(f"Пользователь: {user_data['user_id']} был заблокирован ранее)") - await state.set_state('ADMIN') - else: - BotDB.set_user_blacklist(user_data['user_id'], - user_data['user_name'], - user_data['message_for_user'], - user_data['date_to_unban']) - # Экранируем user_name для безопасного использования - safe_user_name = html.escape(str(user_data['user_name'])) if user_data.get('user_name') else "Неизвестный пользователь" - await message.reply(f"Пользователь {safe_user_name} успешно заблокирован.") - logger.info(f"Пользователь: {user_data['user_id']} успешно заблокирован)") - await state.set_state('ADMIN') - markup = get_reply_keyboard_admin() - await message.answer('Вернулись в меню', reply_markup=markup) +async def confirm_ban( + message: types.Message, + state: FSMContext, + bot_db: MagicData("bot_db") + ): + """Подтверждение блокировки пользователя""" + try: + user_data = await state.get_data() + admin_service = AdminService(bot_db) + + + # Выполняем блокировку + admin_service.ban_user( + user_id=user_data['target_user_id'], + username=user_data['target_username'], + reason=user_data['ban_reason'], + ban_days=user_data['ban_days'] + ) + + safe_username = escape_html(user_data['target_username']) + await message.reply(f"Пользователь {safe_username} успешно заблокирован.") + await return_to_admin_menu(message, state) + + except UserAlreadyBannedError as e: + await message.reply(str(e)) + await return_to_admin_menu(message, state) + except Exception as e: + await handle_admin_error(message, e, state, "confirm_ban") + + +# ============================================================================ +# ХЕНДЛЕРЫ ОТМЕНЫ И НАВИГАЦИИ +# ============================================================================ + +@admin_router.message( + ChatTypeFilter(chat_type=["private"]), + StateFilter("AWAIT_BAN_TARGET", "AWAIT_BAN_DETAILS", "AWAIT_BAN_DURATION", "BAN_CONFIRMATION"), + F.text == 'Отменить' +) +async def cancel_ban_process( + message: types.Message, + state: FSMContext + ): + """Отмена процесса блокировки""" + try: + current_state = await state.get_state() + logger.info(f"Отмена процедуры блокировки из состояния: {current_state}") + await return_to_admin_menu(message, state) + except Exception as e: + await handle_admin_error(message, e, state, "cancel_ban_process") diff --git a/helper_bot/handlers/admin/dependencies.py b/helper_bot/handlers/admin/dependencies.py new file mode 100644 index 0000000..7f5e370 --- /dev/null +++ b/helper_bot/handlers/admin/dependencies.py @@ -0,0 +1,60 @@ +from typing import Annotated, Dict, Any +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from helper_bot.utils.base_dependency_factory import get_global_instance +from helper_bot.utils.helper_func import check_access +from logs.custom_logger import logger + + +class AdminAccessMiddleware(BaseMiddleware): + """Middleware для проверки административного доступа""" + + async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: + if hasattr(event, 'from_user'): + user_id = event.from_user.id + + # Получаем bot_db из data (внедренного DependenciesMiddleware) + bot_db = data.get('bot_db') + if not bot_db: + # Fallback: получаем напрямую если middleware не сработала + bdf = get_global_instance() + bot_db = bdf.get_db() + + if not check_access(user_id, bot_db): + if hasattr(event, 'answer'): + await event.answer('Доступ запрещен!') + return + + try: + # Вызываем хендлер с data + return await handler(event, data) + except TypeError as e: + if "missing 1 required positional argument: 'data'" in str(e): + logger.error(f"Ошибка в AdminAccessMiddleware: {e}. Хендлер не принимает параметр 'data'") + # Пытаемся вызвать хендлер без data (для совместимости с MagicData) + return await handler(event) + else: + logger.error(f"TypeError в AdminAccessMiddleware: {e}") + raise + except Exception as e: + logger.error(f"Неожиданная ошибка в AdminAccessMiddleware: {e}") + raise + + +# Dependency providers +def get_bot_db(): + """Провайдер для получения экземпляра БД""" + bdf = get_global_instance() + return bdf.get_db() + + +def get_settings(): + """Провайдер для получения настроек""" + bdf = get_global_instance() + return bdf.settings + + +# Type aliases for dependency injection +BotDB = Annotated[object, get_bot_db()] +Settings = Annotated[dict, get_settings()] diff --git a/helper_bot/handlers/admin/exceptions.py b/helper_bot/handlers/admin/exceptions.py new file mode 100644 index 0000000..8ad1fed --- /dev/null +++ b/helper_bot/handlers/admin/exceptions.py @@ -0,0 +1,23 @@ +class AdminError(Exception): + """Базовое исключение для административных операций""" + pass + + +class AdminAccessDeniedError(AdminError): + """Исключение при отказе в административном доступе""" + pass + + +class UserNotFoundError(AdminError): + """Исключение при отсутствии пользователя""" + pass + + +class InvalidInputError(AdminError): + """Исключение при некорректном вводе данных""" + pass + + +class UserAlreadyBannedError(AdminError): + """Исключение при попытке забанить уже заблокированного пользователя""" + pass diff --git a/helper_bot/handlers/admin/services.py b/helper_bot/handlers/admin/services.py new file mode 100644 index 0000000..126fd5c --- /dev/null +++ b/helper_bot/handlers/admin/services.py @@ -0,0 +1,146 @@ +from typing import List, Optional +from datetime import datetime + +from helper_bot.utils.helper_func import add_days_to_date, get_banned_users_buttons, get_banned_users_list +from helper_bot.handlers.admin.exceptions import UserAlreadyBannedError, InvalidInputError +from logs.custom_logger import logger + + +class User: + """Модель пользователя""" + def __init__(self, user_id: int, username: str, full_name: str): + self.user_id = user_id + self.username = username + self.full_name = full_name + + +class BannedUser: + """Модель заблокированного пользователя""" + def __init__(self, user_id: int, username: str, reason: str, unban_date: Optional[datetime]): + self.user_id = user_id + self.username = username + self.reason = reason + self.unban_date = unban_date + + +class AdminService: + """Сервис для административных операций""" + + def __init__(self, bot_db): + self.bot_db = bot_db + + def get_last_users(self) -> List[User]: + """Получить список последних пользователей""" + try: + users_data = self.bot_db.get_last_users_from_db() + return [ + User( + user_id=user[1], + username='Неизвестно', + full_name=user[0] + ) + for user in users_data + ] + except Exception as e: + logger.error(f"Ошибка при получении списка последних пользователей: {e}") + raise + + def get_banned_users(self) -> List[BannedUser]: + """Получить список заблокированных пользователей""" + try: + banned_users_data = self.bot_db.get_banned_users_from_db() + return [ + BannedUser( + user_id=user[1], # user_id + username=user[0], # user_name + reason=user[2], # message_for_user + unban_date=user[3] # date_to_unban + ) + for user in banned_users_data + ] + except Exception as e: + logger.error(f"Ошибка при получении списка заблокированных пользователей: {e}") + raise + + def get_user_by_username(self, username: str) -> Optional[User]: + """Получить пользователя по username""" + try: + user_id = self.bot_db.get_user_id_by_username(username) + if not user_id: + return None + + full_name = self.bot_db.get_full_name_by_id(user_id) + return User( + user_id=user_id, + username=username, + full_name=full_name or 'Неизвестно' + ) + except Exception as e: + logger.error(f"Ошибка при поиске пользователя по username {username}: {e}") + raise + + def get_user_by_id(self, user_id: int) -> Optional[User]: + """Получить пользователя по ID""" + try: + user_info = self.bot_db.get_user_info_by_id(user_id) + if not user_info: + return None + + return User( + user_id=user_id, + username=user_info.get('username', 'Неизвестно'), + full_name=user_info.get('full_name', 'Неизвестно') + ) + except Exception as e: + logger.error(f"Ошибка при поиске пользователя по ID {user_id}: {e}") + raise + + def ban_user(self, user_id: int, username: str, reason: str, ban_days: Optional[int]) -> None: + """Заблокировать пользователя""" + try: + # Проверяем, не заблокирован ли уже пользователь + if self.bot_db.check_user_in_blacklist(user_id): + raise UserAlreadyBannedError(f"Пользователь {user_id} уже заблокирован") + + # Рассчитываем дату разблокировки + date_to_unban = None + if ban_days is not None: + date_to_unban = add_days_to_date(ban_days) + + # Сохраняем в БД + self.bot_db.set_user_blacklist(user_id, username, reason, date_to_unban) + + logger.info(f"Пользователь {user_id} ({username}) заблокирован. Причина: {reason}, срок: {ban_days} дней") + + except Exception as e: + logger.error(f"Ошибка при блокировке пользователя {user_id}: {e}") + raise + + def unban_user(self, user_id: int) -> None: + """Разблокировать пользователя""" + try: + self.bot_db.delete_user_blacklist(user_id) + logger.info(f"Пользователь {user_id} разблокирован") + except Exception as e: + logger.error(f"Ошибка при разблокировке пользователя {user_id}: {e}") + raise + + def validate_user_input(self, input_text: str) -> int: + """Валидация введенного ID пользователя""" + try: + user_id = int(input_text.strip()) + if user_id <= 0: + raise InvalidInputError("ID пользователя должен быть положительным числом") + return user_id + except ValueError: + raise InvalidInputError("ID пользователя должен быть числом") + + def get_banned_users_for_display(self, page: int = 0) -> tuple[str, list]: + """Получить данные заблокированных пользователей для отображения""" + try: + message_text = get_banned_users_list(page, self.bot_db) + buttons_list = get_banned_users_buttons(self.bot_db) + return message_text, buttons_list + except Exception as e: + logger.error(f"Ошибка при получении данных заблокированных пользователей: {e}") + raise diff --git a/helper_bot/handlers/admin/utils.py b/helper_bot/handlers/admin/utils.py new file mode 100644 index 0000000..2e52a18 --- /dev/null +++ b/helper_bot/handlers/admin/utils.py @@ -0,0 +1,61 @@ +import html +from typing import Optional +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.keyboards.keyboards import get_reply_keyboard_admin +from helper_bot.handlers.admin.exceptions import AdminError +from logs.custom_logger import logger + + +def escape_html(text: str) -> str: + """Экранирование HTML для безопасного использования в сообщениях""" + return html.escape(str(text)) if text else "" + + +async def return_to_admin_menu(message: types.Message, state: FSMContext, + additional_message: Optional[str] = None) -> None: + """Универсальная функция для возврата в админ-меню""" + await state.set_data({}) + await state.set_state("ADMIN") + markup = get_reply_keyboard_admin() + + if additional_message: + await message.answer(additional_message) + + await message.answer('Вернулись в меню', reply_markup=markup) + + +async def handle_admin_error(message: types.Message, error: Exception, + state: FSMContext, error_context: str = "") -> None: + """Централизованная обработка ошибок административных операций""" + logger.error(f"Ошибка в {error_context}: {error}") + + if isinstance(error, AdminError): + await message.answer(f"Ошибка: {str(error)}") + else: + await message.answer("Произошла внутренняя ошибка. Попробуйте позже.") + + await return_to_admin_menu(message, state) + + +def format_user_info(user_id: int, username: str, full_name: str) -> str: + """Форматирование информации о пользователе для отображения""" + safe_username = escape_html(username) + safe_full_name = escape_html(full_name) + + return (f"Выбран пользователь:\n" + f"ID: {user_id}\n" + f"Username: {safe_username}\n" + f"Имя: {safe_full_name}") + + +def format_ban_confirmation(user_id: int, reason: str, ban_days: Optional[int]) -> str: + """Форматирование подтверждения бана""" + safe_reason = escape_html(reason) + ban_text = "Навсегда" if ban_days is None else f"{ban_days} дней" + + return (f"Необходимо подтверждение:\n" + f"Пользователь: {user_id}\n" + f"Причина бана: {safe_reason}\n" + f"Срок бана: {ban_text}") diff --git a/helper_bot/handlers/callback/__init__.py b/helper_bot/handlers/callback/__init__.py index 9e5a0e2..6bb7d74 100644 --- a/helper_bot/handlers/callback/__init__.py +++ b/helper_bot/handlers/callback/__init__.py @@ -1 +1,24 @@ from .callback_handlers import callback_router +from .services import PostPublishService, BanService +from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError +from .constants import ( + CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK, + CALLBACK_RETURN, CALLBACK_PAGE +) + +__all__ = [ + 'callback_router', + 'PostPublishService', + 'BanService', + 'UserBlockedBotError', + 'PostNotFoundError', + 'UserNotFoundError', + 'PublishError', + 'BanError', + 'CALLBACK_PUBLISH', + 'CALLBACK_DECLINE', + 'CALLBACK_BAN', + 'CALLBACK_UNLOCK', + 'CALLBACK_RETURN', + 'CALLBACK_PAGE' +] diff --git a/helper_bot/handlers/callback/callback_handlers.py b/helper_bot/handlers/callback/callback_handlers.py index 682076d..5ac806e 100644 --- a/helper_bot/handlers/callback/callback_handlers.py +++ b/helper_bot/handlers/callback/callback_handlers.py @@ -1,322 +1,188 @@ import html +from tkinter import S import traceback -from datetime import datetime, timedelta -from aiogram import Router, F +from aiogram import Router from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery +from aiogram import F +from aiogram.filters import MagicData from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \ create_keyboard_for_ban_reason +from helper_bot.utils.helper_func import get_banned_users_list, get_banned_users_buttons from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import send_text_message, send_photo_message, get_banned_users_list, \ - get_banned_users_buttons, delete_user_blacklist, send_media_group_to_channel, \ - send_video_message, send_video_note_message, send_audio_message, send_voice_message +from .dependency_factory import get_post_publish_service, get_ban_service +from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError +from .constants import ( + CALLBACK_PUBLISH, CALLBACK_DECLINE, CALLBACK_BAN, CALLBACK_UNLOCK, + CALLBACK_RETURN, CALLBACK_PAGE, MESSAGE_PUBLISHED, MESSAGE_DECLINED, + MESSAGE_USER_BANNED, MESSAGE_USER_UNLOCKED, MESSAGE_ERROR, + ERROR_BOT_BLOCKED +) from logs.custom_logger import logger callback_router = Router() -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] -BotDB = bdf.get_db() - - -@callback_router.callback_query( - F.data == "publish" -) -async def post_for_group(call: CallbackQuery, state: FSMContext): +@callback_router.callback_query(F.data == CALLBACK_PUBLISH) +async def post_for_group( + call: CallbackQuery, + settings: MagicData("settings") + ): + publish_service = get_post_publish_service() + # TODO: переделать на MagicData logger.info( f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})') - text_post = html.escape(str(call.message.text)) - text_post_with_photo = html.escape(str(call.message.caption)) - if call.message.content_type == 'text' and call.message.text != "^": - try: - # Пересылаем сообщение в канал - await send_text_message(MAIN_PUBLIC, call.message, text_post) - - # Получаем из базы автора - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - # Очищаем предложку и удаляем оттуда пост - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Текст сообщения опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - - # Отвечаем пользователю - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации текста в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'photo': - try: - await send_photo_message(MAIN_PUBLIC, call.message, call.message.photo[-1].file_id, text_post_with_photo) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - # Удаляем пост из предложки - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с фото опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации фотографии в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'video': - try: - await send_video_message(MAIN_PUBLIC, call.message, call.message.video.file_id, text_post_with_photo) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с видео опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации видео в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'video_note': - try: - await send_video_note_message(MAIN_PUBLIC, call.message, call.message.video_note.file_id) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с кружком опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации кружка в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'audio': - try: - await send_audio_message(MAIN_PUBLIC, call.message, call.message.audio.file_id, text_post_with_photo) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с аудио опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации аудио в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.content_type == 'voice': - try: - await send_voice_message(MAIN_PUBLIC, call.message, call.message.voice.file_id) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - logger.info(f'Пост с войсом опубликован в канале {MAIN_PUBLIC}.') - await call.answer(text='Выложено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') - except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(chat_id=IMPORTANT_LOGS, - text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при публикации войса в канал {MAIN_PUBLIC}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) - elif call.message.text == "^": - # Получаем контент медиагруппы и текст для публикации - post_content = BotDB.get_post_content_from_telegram_by_last_id(call.message.message_id) - pre_text = BotDB.get_post_text_from_telegram_by_last_id(call.message.message_id) - post_text = html.escape(str(pre_text)) - - # Готовим список для удаления - post_ids = BotDB.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 send_media_group_to_channel(bot=call.bot, chat_id=MAIN_PUBLIC, post_content=post_content, - post_text=post_text) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_helper_message_id(call.message.message_id) - - # TODO: Удалить фотки с локалки после выкладки? - await call.bot.delete_messages(chat_id=GROUP_FOR_POST, message_ids=message_ids) - await call.answer(text='Выложено!', cache_time=3) - - await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') + + try: + await publish_service.publish_post(call) + await call.answer(text=MESSAGE_PUBLISHED, cache_time=3) + except UserBlockedBotError: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except (PostNotFoundError, PublishError) as e: + logger.error(f'Ошибка при публикации поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + else: + important_logs = settings['Telegram']['important_logs'] + await call.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) -@callback_router.callback_query( - F.data == "decline" -) -async def decline_post_for_group(call: CallbackQuery, state: FSMContext): +@callback_router.callback_query(F.data == CALLBACK_DECLINE) +async def decline_post_for_group( + call: CallbackQuery, + settings: MagicData("settings") + ): + publish_service = get_post_publish_service() + # TODO: переделать на MagicData logger.info( f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})') try: - if call.message.content_type == 'text' and call.message.text != "^" or call.message.content_type == 'photo' \ - or call.message.content_type == 'audio' or call.message.content_type == 'voice' \ - or call.message.content_type == 'video' or call.message.content_type == 'video_note': - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - - logger.info( - f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).') - await call.answer(text='Отклонено!', cache_time=3) - await send_text_message(author_id, call.message, 'Твой пост был отклонен😔') - if call.message.text == '^': - post_ids = BotDB.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 call.bot.delete_messages(chat_id=GROUP_FOR_POST, message_ids=message_ids) - - # Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки - author_id = BotDB.get_author_id_by_helper_message_id(call.message.message_id) - - await call.answer(text='Удалено!', cache_time=3) - - await send_text_message(author_id, call.message, 'Твой пост был отклонен😔') + await publish_service.decline_post(call) + await call.answer(text=MESSAGE_DECLINED, cache_time=3) + except UserBlockedBotError: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except (PostNotFoundError, PublishError) as e: + logger.error(f'Ошибка при отклонении поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) except Exception as e: - if e.message != 'Forbidden: bot was blocked by the user': - await call.bot.send_message(IMPORTANT_LOGS, - f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") - logger.error(f'Ошибка при удалении сообщения в группе {GROUP_FOR_POST}: {str(e)}') - await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) + if str(e) == ERROR_BOT_BLOCKED: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + else: + important_logs = settings['Telegram']['important_logs'] + await call.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + logger.error(f'Неожиданная ошибка при отклонении поста: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) -@callback_router.callback_query( - F.data == "ban" -) +@callback_router.callback_query(F.data == CALLBACK_BAN) async def ban_user_from_post(call: CallbackQuery): + ban_service = get_ban_service() + # TODO: переделать на MagicData try: - # Получаем информацию о пользователе из сообщения - author_id = BotDB.get_author_id_by_message_id(call.message.message_id) - user_name = BotDB.get_username(user_id=author_id) - full_name = call.message.from_user.full_name if call.message.from_user else "Неизвестно" - - # Устанавливаем причину бана и дату разблокировки (+7 дней) - current_date = datetime.now() - date_to_unban = current_date + timedelta(days=7) - - # Записываем в базу данных - BotDB.set_user_blacklist( - user_id=author_id, - user_name=user_name, - message_for_user="Спам", - date_to_unban=date_to_unban - ) - - # Удаляем пост из предложки - await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) - - # Отправляем сообщение пользователю о блокировке - date_str = date_to_unban.strftime("%d.%m.%Y %H:%M") - await send_text_message(author_id, call.message, f"Ты заблокирован за спам. Дата разблокировки: {date_str}") - - logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}") - await call.answer(text='Пользователь заблокирован!', cache_time=3) - - except Exception as e: + await ban_service.ban_user_from_post(call) + await call.answer(text=MESSAGE_USER_BANNED, cache_time=3) + except UserBlockedBotError: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except (UserNotFoundError, BanError) as e: logger.error(f'Ошибка при блокировке пользователя: {str(e)}') - await call.answer(text='Ошибка при блокировке!', show_alert=True, cache_time=3) + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) + else: + logger.error(f'Неожиданная ошибка при блокировке пользователя: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) -@callback_router.callback_query( - F.data.contains('ban') -) +@callback_router.callback_query(F.data.contains(CALLBACK_BAN)) async def process_ban_user(call: CallbackQuery, state: FSMContext): + ban_service = get_ban_service() + # TODO: переделать на MagicData user_id = call.data[4:] - logger.info( - f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}") - user_name = BotDB.get_username(user_id=user_id) - if user_name: - await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, - date_to_unban=None) + logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}") + + try: + user_name = await ban_service.ban_user(user_id, "") + await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, date_to_unban=None) markup = create_keyboard_for_ban_reason() - # Экранируем потенциально проблемные символы + user_name_escaped = html.escape(str(user_name)) full_name_escaped = html.escape(str(call.message.from_user.full_name)) await call.message.answer( text=f"Выбран пользователь:\nid: {user_id}\nusername: {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", - reply_markup=markup) + reply_markup=markup + ) await state.set_state('BAN_2') - else: + except UserNotFoundError: markup = get_reply_keyboard_admin() await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup) await state.set_state('ADMIN') -@callback_router.callback_query( - F.data.contains('unlock') -) +@callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK)) async def process_unlock_user(call: CallbackQuery): + ban_service = get_ban_service() + # TODO: переделать на MagicData user_id = call.data[7:] - user_name = BotDB.get_username(user_id=user_id) - delete_user_blacklist(user_id, BotDB) - logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}") - username = BotDB.get_username(user_id) - await call.answer(f'Пользователь разблокирован {username}', show_alert=True) + + try: + username = await ban_service.unlock_user(user_id) + await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True) + except UserNotFoundError: + await call.answer(text='Пользователь не найден в базе', show_alert=True, cache_time=3) + except Exception as e: + logger.error(f'Ошибка при разблокировке пользователя: {str(e)}') + await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3) -@callback_router.callback_query( - F.data == 'return' -) +@callback_router.callback_query(F.data == CALLBACK_RETURN) async def return_to_main_menu(call: CallbackQuery): await call.message.delete() logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}") markup = get_reply_keyboard_admin() - await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", - reply_markup=markup) + await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup) -@callback_router.callback_query( - F.data.contains('page') -) -async def change_page(call: CallbackQuery): +@callback_router.callback_query(F.data.contains(CALLBACK_PAGE)) +async def change_page( + call: CallbackQuery, + bot_db: MagicData("bot_db") + ): page_number = int(call.data[5:]) logger.info(f"Переход на страницу {page_number}") + if call.message.text == 'Список пользователей которые последними обращались к боту': - list_users = BotDB.get_last_users_from_db() - # TODO: Здесь где-то надо добавить обработку ошибки IndexError: list index out of range - keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, - 'ban') - - await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, - reply_markup=keyboard) + list_users = bot_db.get_last_users_from_db() + keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, 'ban') + await call.bot.edit_message_reply_markup( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + reply_markup=keyboard + ) else: - # Готовим сообщения - message_user = get_banned_users_list(int(page_number) * 7 - 7, BotDB) - await call.bot.edit_message_text(chat_id=call.message.chat.id, message_id=call.message.message_id, - text=message_user) - - # Готовим клавиатуру - buttons = get_banned_users_buttons(BotDB) + message_user = get_banned_users_list(int(page_number) * 7 - 7, bot_db) + await call.bot.edit_message_text( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + text=message_user + ) + + buttons = get_banned_users_buttons(bot_db) keyboard = create_keyboard_with_pagination(int(call.data[5:]), len(buttons), buttons, 'unlock') - await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, - reply_markup=keyboard) + await call.bot.edit_message_reply_markup( + chat_id=call.message.chat.id, + message_id=call.message.message_id, + reply_markup=keyboard + ) diff --git a/helper_bot/handlers/callback/constants.py b/helper_bot/handlers/callback/constants.py new file mode 100644 index 0000000..a0524fa --- /dev/null +++ b/helper_bot/handlers/callback/constants.py @@ -0,0 +1,29 @@ +# Callback data constants +CALLBACK_PUBLISH = "publish" +CALLBACK_DECLINE = "decline" +CALLBACK_BAN = "ban" +CALLBACK_UNLOCK = "unlock" +CALLBACK_RETURN = "return" +CALLBACK_PAGE = "page" + +# Content types +CONTENT_TYPE_TEXT = "text" +CONTENT_TYPE_PHOTO = "photo" +CONTENT_TYPE_VIDEO = "video" +CONTENT_TYPE_VIDEO_NOTE = "video_note" +CONTENT_TYPE_AUDIO = "audio" +CONTENT_TYPE_VOICE = "voice" +CONTENT_TYPE_MEDIA_GROUP = "^" + +# Messages +MESSAGE_PUBLISHED = "Выложено!" +MESSAGE_DECLINED = "Отклонено!" +MESSAGE_USER_BANNED = "Пользователь заблокирован!" +MESSAGE_USER_UNLOCKED = "Пользователь разблокирован" +MESSAGE_ERROR = "Что-то пошло не так!" +MESSAGE_POST_PUBLISHED = "Твой пост был выложен🥰" +MESSAGE_POST_DECLINED = "Твой пост был отклонен😔" +MESSAGE_USER_BANNED_SPAM = "Ты заблокирован за спам. Дата разблокировки: {date}" + +# Error messages +ERROR_BOT_BLOCKED = "Forbidden: bot was blocked by the user" diff --git a/helper_bot/handlers/callback/dependency_factory.py b/helper_bot/handlers/callback/dependency_factory.py new file mode 100644 index 0000000..749b36f --- /dev/null +++ b/helper_bot/handlers/callback/dependency_factory.py @@ -0,0 +1,33 @@ +from typing import Callable +from aiogram import Bot +from aiogram.client.default import DefaultBotProperties +from aiogram.fsm.context import FSMContext + +from helper_bot.utils.base_dependency_factory import get_global_instance +from .services import PostPublishService, BanService + + +def get_post_publish_service() -> PostPublishService: + """Фабрика для PostPublishService""" + bdf = get_global_instance() + bot = Bot( + token=bdf.settings['Telegram']['bot_token'], + default=DefaultBotProperties(parse_mode='HTML'), + timeout=30.0 + ) + db = bdf.get_db() + settings = bdf.settings + return PostPublishService(bot, db, settings) + + +def get_ban_service() -> BanService: + """Фабрика для BanService""" + bdf = get_global_instance() + bot = Bot( + token=bdf.settings['Telegram']['bot_token'], + default=DefaultBotProperties(parse_mode='HTML'), + timeout=30.0 + ) + db = bdf.get_db() + settings = bdf.settings + return BanService(bot, db, settings) diff --git a/helper_bot/handlers/callback/exceptions.py b/helper_bot/handlers/callback/exceptions.py new file mode 100644 index 0000000..5b1dc73 --- /dev/null +++ b/helper_bot/handlers/callback/exceptions.py @@ -0,0 +1,23 @@ +class UserBlockedBotError(Exception): + """Исключение, возникающее когда пользователь заблокировал бота""" + pass + + +class PostNotFoundError(Exception): + """Исключение, возникающее когда пост не найден в базе данных""" + pass + + +class UserNotFoundError(Exception): + """Исключение, возникающее когда пользователь не найден в базе данных""" + pass + + +class PublishError(Exception): + """Общее исключение для ошибок публикации""" + pass + + +class BanError(Exception): + """Исключение для ошибок бана/разбана пользователей""" + pass diff --git a/helper_bot/handlers/callback/services.py b/helper_bot/handlers/callback/services.py new file mode 100644 index 0000000..663a7e1 --- /dev/null +++ b/helper_bot/handlers/callback/services.py @@ -0,0 +1,249 @@ +import html +from datetime import datetime, timedelta +from typing import Dict, Any + +from aiogram import Bot +from aiogram.types import CallbackQuery + +from helper_bot.utils.helper_func import ( + send_text_message, send_photo_message, send_video_message, + send_video_note_message, send_audio_message, send_voice_message, + send_media_group_to_channel, delete_user_blacklist +) +from helper_bot.keyboards.keyboards import create_keyboard_for_ban_reason +from .exceptions import ( + UserBlockedBotError, PostNotFoundError, UserNotFoundError, + PublishError, BanError +) +from .constants import ( + CONTENT_TYPE_TEXT, CONTENT_TYPE_PHOTO, CONTENT_TYPE_VIDEO, + CONTENT_TYPE_VIDEO_NOTE, CONTENT_TYPE_AUDIO, CONTENT_TYPE_VOICE, + CONTENT_TYPE_MEDIA_GROUP, MESSAGE_POST_PUBLISHED, MESSAGE_POST_DECLINED, + MESSAGE_USER_BANNED_SPAM, ERROR_BOT_BLOCKED +) +from logs.custom_logger import logger + + +class PostPublishService: + def __init__(self, bot: Bot, db, settings: Dict[str, Any]): + self.bot = bot + self.db = db + self.settings = settings + self.group_for_posts = settings['Telegram']['group_for_posts'] + self.main_public = settings['Telegram']['main_public'] + self.important_logs = settings['Telegram']['important_logs'] + + async def publish_post(self, call: CallbackQuery) -> None: + """Основной метод публикации поста""" + content_type = call.message.content_type + + if content_type == CONTENT_TYPE_TEXT and call.message.text != CONTENT_TYPE_MEDIA_GROUP: + await self._publish_text_post(call) + elif content_type == CONTENT_TYPE_PHOTO: + await self._publish_photo_post(call) + elif content_type == CONTENT_TYPE_VIDEO: + await self._publish_video_post(call) + elif content_type == CONTENT_TYPE_VIDEO_NOTE: + await self._publish_video_note_post(call) + elif content_type == CONTENT_TYPE_AUDIO: + 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}") + + async def _publish_text_post(self, call: CallbackQuery) -> None: + """Публикация текстового поста""" + text_post = html.escape(str(call.message.text)) + author_id = self._get_author_id(call.message.message_id) + + await send_text_message(self.main_public, call.message, text_post) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Текст сообщения опубликован в канале {self.main_public}.') + + async def _publish_photo_post(self, call: CallbackQuery) -> None: + """Публикация поста с фото""" + text_post_with_photo = html.escape(str(call.message.caption)) + author_id = self._get_author_id(call.message.message_id) + + await send_photo_message(self.main_public, call.message, call.message.photo[-1].file_id, text_post_with_photo) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с фото опубликован в канале {self.main_public}.') + + async def _publish_video_post(self, call: CallbackQuery) -> None: + """Публикация поста с видео""" + text_post_with_photo = html.escape(str(call.message.caption)) + author_id = self._get_author_id(call.message.message_id) + + await send_video_message(self.main_public, call.message, call.message.video.file_id, text_post_with_photo) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с видео опубликован в канале {self.main_public}.') + + async def _publish_video_note_post(self, call: CallbackQuery) -> None: + """Публикация поста с кружком""" + author_id = self._get_author_id(call.message.message_id) + + await send_video_note_message(self.main_public, call.message, call.message.video_note.file_id) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с кружком опубликован в канале {self.main_public}.') + + async def _publish_audio_post(self, call: CallbackQuery) -> None: + """Публикация поста с аудио""" + text_post_with_photo = html.escape(str(call.message.caption)) + author_id = self._get_author_id(call.message.message_id) + + await send_audio_message(self.main_public, call.message, call.message.audio.file_id, text_post_with_photo) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с аудио опубликован в канале {self.main_public}.') + + async def _publish_voice_post(self, call: CallbackQuery) -> None: + """Публикация поста с войсом""" + author_id = self._get_author_id(call.message.message_id) + + await send_voice_message(self.main_public, call.message, call.message.voice.file_id) + await self._delete_post_and_notify_author(call, author_id) + logger.info(f'Пост с войсом опубликован в канале {self.main_public}.') + + async def _publish_media_group(self, call: CallbackQuery) -> None: + """Публикация медиагруппы""" + post_content = self.db.get_post_content_from_telegram_by_last_id(call.message.message_id) + pre_text = self.db.get_post_text_from_telegram_by_last_id(call.message.message_id) + post_text = html.escape(str(pre_text)) + author_id = self._get_author_id_for_media_group(call.message.message_id) + + await send_media_group_to_channel(bot=self.bot, chat_id=self.main_public, post_content=post_content, post_text=post_text) + await self._delete_media_group_and_notify_author(call, author_id) + + async def decline_post(self, call: CallbackQuery) -> None: + """Отклонение поста""" + 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]: + await self._decline_single_post(call) + elif call.message.text == CONTENT_TYPE_MEDIA_GROUP: + await self._decline_media_group(call) + else: + raise PublishError(f"Неподдерживаемый тип контента для отклонения: {content_type}") + + async def _decline_single_post(self, call: CallbackQuery) -> None: + """Отклонение одиночного поста""" + author_id = self._get_author_id(call.message.message_id) + await self.bot.delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + logger.info(f'Сообщение отклонено админом {call.from_user.full_name} (ID: {call.from_user.id}).') + + async def _decline_media_group(self, call: CallbackQuery) -> None: + """Отклонение медиагруппы""" + post_ids = 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) + + author_id = self._get_author_id_for_media_group(call.message.message_id) + await self.bot.delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_DECLINED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + def _get_author_id(self, message_id: int) -> int: + """Получение ID автора по ID сообщения""" + author_id = self.db.get_author_id_by_message_id(message_id) + if not author_id: + raise PostNotFoundError(f"Автор не найден для сообщения {message_id}") + return author_id + + def _get_author_id_for_media_group(self, message_id: int) -> int: + """Получение ID автора для медиагруппы""" + author_id = self.db.get_author_id_by_helper_message_id(message_id) + if not author_id: + raise PostNotFoundError(f"Автор не найден для медиагруппы {message_id}") + return author_id + + async def _delete_post_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: + """Удаление поста и уведомление автора""" + await self.bot.delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + async def _delete_media_group_and_notify_author(self, call: CallbackQuery, author_id: int) -> None: + """Удаление медиагруппы и уведомление автора""" + post_ids = 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.bot.delete_messages(chat_id=self.group_for_posts, message_ids=message_ids) + try: + await send_text_message(author_id, call.message, MESSAGE_POST_PUBLISHED) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + +class BanService: + def __init__(self, bot: Bot, db, settings: Dict[str, Any]): + self.bot = bot + self.db = db + self.settings = settings + self.group_for_posts = settings['Telegram']['group_for_posts'] + self.important_logs = settings['Telegram']['important_logs'] + + async def ban_user_from_post(self, call: CallbackQuery) -> None: + """Бан пользователя за спам""" + author_id = self.db.get_author_id_by_message_id(call.message.message_id) + if not author_id: + raise UserNotFoundError(f"Автор не найден для сообщения {call.message.message_id}") + + user_name = self.db.get_username(user_id=author_id) + current_date = datetime.now() + date_to_unban = current_date + timedelta(days=7) + + self.db.set_user_blacklist( + user_id=author_id, + user_name=user_name, + message_for_user="Спам", + date_to_unban=date_to_unban + ) + + await self.bot.delete_message(chat_id=self.group_for_posts, message_id=call.message.message_id) + + date_str = date_to_unban.strftime("%d.%m.%Y %H:%M") + try: + await send_text_message(author_id, call.message, MESSAGE_USER_BANNED_SPAM.format(date=date_str)) + except Exception as e: + if str(e) == ERROR_BOT_BLOCKED: + raise UserBlockedBotError("Пользователь заблокировал бота") + raise + + logger.info(f"Пользователь {author_id} заблокирован за спам до {date_str}") + + async def ban_user(self, user_id: str, user_name: str) -> str: + """Бан пользователя по ID""" + user_name = self.db.get_username(user_id=user_id) + if not user_name: + raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе") + + return user_name + + async def unlock_user(self, user_id: str) -> str: + """Разблокировка пользователя""" + user_name = self.db.get_username(user_id=user_id) + if not user_name: + raise UserNotFoundError(f"Пользователь с ID {user_id} не найден в базе") + + delete_user_blacklist(user_id, self.db) + logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}") + return user_name diff --git a/helper_bot/handlers/group/__init__.py b/helper_bot/handlers/group/__init__.py index 1958741..7c42b67 100644 --- a/helper_bot/handlers/group/__init__.py +++ b/helper_bot/handlers/group/__init__.py @@ -1 +1,47 @@ -from .group_handlers import group_router +"""Group handlers package for Telegram bot""" + +# Local imports - main components +from .group_handlers import ( + group_router, + create_group_handlers, + GroupHandlers +) + +# Local imports - services +from .services import ( + AdminReplyService, + DatabaseProtocol +) + +# Local imports - constants and utilities +from .constants import ( + FSM_STATES, + ERROR_MESSAGES +) +from .exceptions import ( + NoReplyToMessageError, + UserNotFoundError +) +from .decorators import error_handler + +__all__ = [ + # Main components + 'group_router', + 'create_group_handlers', + 'GroupHandlers', + + # Services + 'AdminReplyService', + 'DatabaseProtocol', + + # Constants + 'FSM_STATES', + 'ERROR_MESSAGES', + + # Exceptions + 'NoReplyToMessageError', + 'UserNotFoundError', + + # Utilities + 'error_handler' +] diff --git a/helper_bot/handlers/group/constants.py b/helper_bot/handlers/group/constants.py new file mode 100644 index 0000000..aa7b1ba --- /dev/null +++ b/helper_bot/handlers/group/constants.py @@ -0,0 +1,14 @@ +"""Constants for group handlers""" + +from typing import Final + +# FSM States +FSM_STATES: Final[dict[str, str]] = { + "CHAT": "CHAT" +} + +# Error messages +ERROR_MESSAGES: Final[dict[str, str]] = { + "NO_REPLY_TO_MESSAGE": "Блять, выдели сообщение!", + "USER_NOT_FOUND": "Не могу найти кому ответить в базе, проебали сообщение." +} diff --git a/helper_bot/handlers/group/decorators.py b/helper_bot/handlers/group/decorators.py new file mode 100644 index 0000000..e408969 --- /dev/null +++ b/helper_bot/handlers/group/decorators.py @@ -0,0 +1,36 @@ +"""Decorators and utility functions for group handlers""" + +# Standard library imports +import traceback +from typing import Any, Callable + +# Third-party imports +from aiogram import types + +# Local imports +from logs.custom_logger import logger + + +def error_handler(func: Callable[..., Any]) -> Callable[..., Any]: + """Decorator for centralized error handling""" + async def wrapper(*args: Any, **kwargs: Any) -> Any: + try: + return await func(*args, **kwargs) + except Exception as e: + logger.error(f"Error in {func.__name__}: {str(e)}") + # Try to send error to logs if possible + try: + message = next((arg for arg in args if isinstance(arg, types.Message)), None) + if message and hasattr(message, 'bot'): + from helper_bot.utils.base_dependency_factory import get_global_instance + bdf = get_global_instance() + important_logs = bdf.settings['Telegram']['important_logs'] + await message.bot.send_message( + chat_id=important_logs, + text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" + ) + except Exception: + # If we can't log the error, at least it was logged to logger + pass + raise + return wrapper diff --git a/helper_bot/handlers/group/exceptions.py b/helper_bot/handlers/group/exceptions.py new file mode 100644 index 0000000..e10a41c --- /dev/null +++ b/helper_bot/handlers/group/exceptions.py @@ -0,0 +1,11 @@ +"""Custom exceptions for group handlers""" + + +class NoReplyToMessageError(Exception): + """Raised when admin tries to reply without selecting a message""" + pass + + +class UserNotFoundError(Exception): + """Raised when user is not found in database for the given message_id""" + pass diff --git a/helper_bot/handlers/group/group_handlers.py b/helper_bot/handlers/group/group_handlers.py index 8f5528e..10cf058 100644 --- a/helper_bot/handlers/group/group_handlers.py +++ b/helper_bot/handlers/group/group_handlers.py @@ -1,49 +1,106 @@ +"""Main group handlers module for Telegram bot""" + +# Third-party imports from aiogram import Router, types from aiogram.fsm.context import FSMContext +# Local imports - filters from helper_bot.filters.main import ChatTypeFilter -from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat -from helper_bot.utils.base_dependency_factory import get_global_instance -from helper_bot.utils.helper_func import send_text_message + +# Local imports - modular components +from .constants import FSM_STATES, ERROR_MESSAGES +from .services import AdminReplyService +from .decorators import error_handler +from .exceptions import UserNotFoundError + +# Local imports - utilities from logs.custom_logger import logger + +class GroupHandlers: + """Main handler class for group messages""" + + def __init__(self, db, keyboard_markup: types.ReplyKeyboardMarkup): + self.db = db + self.keyboard_markup = keyboard_markup + self.admin_reply_service = AdminReplyService(db) + + # Create router + self.router = Router() + + # Register handlers + self._register_handlers() + + def _register_handlers(self): + """Register all message handlers""" + self.router.message.register( + self.handle_message, + ChatTypeFilter(chat_type=["group", "supergroup"]) + ) + + @error_handler + async def handle_message(self, message: types.Message, state: FSMContext): + """Handle admin reply to user through group chat""" + logger.info( + f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) ' + f'от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"' + ) + + # Check if message is a reply + if not message.reply_to_message: + await message.answer(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"]) + logger.warning( + f'В группе {message.chat.title} (ID: {message.chat.id}) ' + f'админ не выделил сообщение для ответа.' + ) + return + + message_id = message.reply_to_message.message_id + reply_text = message.text + + try: + # Get user ID for reply + chat_id = self.admin_reply_service.get_user_id_for_reply(message_id) + + # Send reply to user + await self.admin_reply_service.send_reply_to_user( + chat_id, message, reply_text, self.keyboard_markup + ) + + # Set state + await state.set_state(FSM_STATES["CHAT"]) + + except UserNotFoundError: + await message.answer(ERROR_MESSAGES["USER_NOT_FOUND"]) + logger.error( + f'Ошибка при поиске пользователя в базе для ответа на сообщение: {reply_text} ' + f'в группе {message.chat.title} (ID сообщения: {message.message_id})' + ) + + +# Factory function to create handlers with dependencies +def create_group_handlers(db, keyboard_markup: types.ReplyKeyboardMarkup) -> GroupHandlers: + """Create group handlers instance with dependencies""" + return GroupHandlers(db, keyboard_markup) + + +# Legacy router for backward compatibility group_router = Router() -bdf = get_global_instance() -GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] -GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] -MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] -GROUP_FOR_LOGS = bdf.settings['Telegram']['group_for_logs'] -IMPORTANT_LOGS = bdf.settings['Telegram']['important_logs'] -PREVIEW_LINK = bdf.settings['Telegram']['preview_link'] -LOGS = bdf.settings['Settings']['logs'] -TEST = bdf.settings['Settings']['test'] +# Initialize with global dependencies (for backward compatibility) +def init_legacy_router(): + """Initialize legacy router with global dependencies""" + global group_router + + from helper_bot.utils.base_dependency_factory import get_global_instance + from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat + + bdf = get_global_instance() + db = bdf.get_db() + keyboard_markup = get_reply_keyboard_leave_chat() + + handlers = create_group_handlers(db, keyboard_markup) + group_router = handlers.router -BotDB = bdf.get_db() - - -@group_router.message( - ChatTypeFilter(chat_type=["group", "supergroup"]), -) -async def handle_message(message: types.Message, state: FSMContext): - """Функция ответа админа пользователю через закрытый чат""" - logger.info( - f'Получено сообщение в группе {message.chat.title} (ID: {message.chat.id}) от пользователя {message.from_user.full_name} (ID: {message.from_user.id}): "{message.text}"') - markup = get_reply_keyboard_leave_chat() - message_id = 0 - try: - message_id = message.reply_to_message.message_id - except AttributeError as e: - await message.answer('Блять, выдели сообщение!') - logger.warning( - f'В группе {message.chat.title} (ID: {message.chat.id}) админ не выделил сообщение для ответа. Ошибка {str(e)}') - message_from_admin = message.text - try: - chat_id = BotDB.get_user_by_message_id(message_id) - await send_text_message(chat_id, message, message_from_admin, markup) - await state.set_state("CHAT") - logger.info(f'Ответ админа "{message.text}" отправлен пользователю с ID: {chat_id} на сообщение {message_id}') - except TypeError as e: - await message.answer('Не могу найти кому ответить в базе, проебали сообщение.') - logger.error( - f'Ошибка при поиске пользователя в базе для ответа на сообщение: {message.text} в группе {message.chat.title} (ID сообщения: {message.message_id}) Ошибка: {str(e)}') +# Initialize legacy router +init_legacy_router() diff --git a/helper_bot/handlers/group/services.py b/helper_bot/handlers/group/services.py new file mode 100644 index 0000000..134259b --- /dev/null +++ b/helper_bot/handlers/group/services.py @@ -0,0 +1,64 @@ +"""Service classes for group handlers""" + +# Standard library imports +from typing import Protocol, Optional + +# Third-party imports +from aiogram import types + +# Local imports +from helper_bot.utils.helper_func import send_text_message +from .exceptions import NoReplyToMessageError, UserNotFoundError +from logs.custom_logger import logger + + +class DatabaseProtocol(Protocol): + """Protocol for database operations""" + def get_user_by_message_id(self, message_id: int) -> Optional[int]: ... + + +class AdminReplyService: + """Service for admin reply operations""" + + def __init__(self, db: DatabaseProtocol) -> None: + self.db = db + + def get_user_id_for_reply(self, message_id: int) -> int: + """ + Get user ID for reply by message ID. + + Args: + message_id: ID of the message to reply to + + Returns: + User ID for the reply + + Raises: + UserNotFoundError: If user is not found in database + """ + user_id = self.db.get_user_by_message_id(message_id) + if user_id is None: + raise UserNotFoundError(f"User not found for message_id: {message_id}") + return user_id + + async def send_reply_to_user( + self, + chat_id: int, + message: types.Message, + reply_text: str, + markup: types.ReplyKeyboardMarkup + ) -> None: + """ + Send reply to user. + + Args: + chat_id: User's chat ID + message: Original message from admin + reply_text: Text to send to user + markup: Reply keyboard markup + """ + await send_text_message(chat_id, message, reply_text, markup) + logger.info( + f'Ответ админа "{reply_text}" отправлен пользователю с ID: {chat_id} ' + f'на сообщение {message.reply_to_message.message_id if message.reply_to_message else "N/A"}' + ) diff --git a/helper_bot/handlers/private/__init__.py b/helper_bot/handlers/private/__init__.py index c534e15..b1bea9f 100644 --- a/helper_bot/handlers/private/__init__.py +++ b/helper_bot/handlers/private/__init__.py @@ -1,20 +1,45 @@ """Private handlers package for Telegram bot""" -from .private_handlers import private_router, create_private_handlers, PrivateHandlers -from .services import BotSettings, UserService, PostService, StickerService -from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES +# Local imports - main components +from .private_handlers import ( + private_router, + create_private_handlers, + PrivateHandlers +) + +# Local imports - services +from .services import ( + BotSettings, + UserService, + PostService, + StickerService +) + +# Local imports - constants and utilities +from .constants import ( + FSM_STATES, + BUTTON_TEXTS, + ERROR_MESSAGES +) from .decorators import error_handler __all__ = [ + # Main components 'private_router', 'create_private_handlers', 'PrivateHandlers', + + # Services 'BotSettings', 'UserService', 'PostService', 'StickerService', + + # Constants 'FSM_STATES', 'BUTTON_TEXTS', 'ERROR_MESSAGES', + + # Utilities 'error_handler' ] diff --git a/helper_bot/handlers/private/constants.py b/helper_bot/handlers/private/constants.py index ef3236f..81bbd47 100644 --- a/helper_bot/handlers/private/constants.py +++ b/helper_bot/handlers/private/constants.py @@ -1,7 +1,9 @@ """Constants for private handlers""" +from typing import Final + # FSM States -FSM_STATES = { +FSM_STATES: Final[dict[str, str]] = { "START": "START", "SUGGEST": "SUGGEST", "PRE_CHAT": "PRE_CHAT", @@ -9,7 +11,7 @@ FSM_STATES = { } # Button texts -BUTTON_TEXTS = { +BUTTON_TEXTS: Final[dict[str, str]] = { "SUGGEST_POST": "📢Предложить свой пост", "SAY_GOODBYE": "👋🏼Сказать пока!", "LEAVE_CHAT": "Выйти из чата", @@ -19,7 +21,7 @@ BUTTON_TEXTS = { } # Error messages -ERROR_MESSAGES = { +ERROR_MESSAGES: Final[dict[str, str]] = { "UNSUPPORTED_CONTENT": ( 'Я пока не умею работать с таким сообщением. ' 'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n' diff --git a/helper_bot/handlers/private/decorators.py b/helper_bot/handlers/private/decorators.py index 3074ffe..3b7e4b2 100644 --- a/helper_bot/handlers/private/decorators.py +++ b/helper_bot/handlers/private/decorators.py @@ -1,13 +1,19 @@ """Decorators and utility functions for private handlers""" +# Standard library imports import traceback +from typing import Any, Callable + +# Third-party imports from aiogram import types + +# Local imports from logs.custom_logger import logger -def error_handler(func): +def error_handler(func: Callable[..., Any]) -> Callable[..., Any]: """Decorator for centralized error handling""" - async def wrapper(*args, **kwargs): + async def wrapper(*args: Any, **kwargs: Any) -> Any: try: return await func(*args, **kwargs) except Exception as e: @@ -23,7 +29,8 @@ def error_handler(func): chat_id=important_logs, text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" ) - except: - pass # If we can't log the error, at least it was logged to logger + except Exception: + # If we can't log the error, at least it was logged to logger + pass raise return wrapper diff --git a/helper_bot/handlers/private/private_handlers.py b/helper_bot/handlers/private/private_handlers.py index c8468b0..2b6b9f4 100644 --- a/helper_bot/handlers/private/private_handlers.py +++ b/helper_bot/handlers/private/private_handlers.py @@ -1,28 +1,30 @@ -import random -import traceback -import asyncio -import html -from datetime import datetime -from pathlib import Path +"""Main private handlers module for Telegram bot""" +# Standard library imports +import asyncio +from datetime import datetime + +# Third-party imports from aiogram import types, Router, F from aiogram.filters import Command, StateFilter from aiogram.fsm.context import FSMContext -from aiogram.types import FSInputFile +# Local imports - filters and middlewares from helper_bot.filters.main import ChatTypeFilter -from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post -from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat from helper_bot.middlewares.album_middleware import AlbumMiddleware from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware -from helper_bot.utils import messages -from helper_bot.utils.helper_func import get_first_name, get_text_message, send_text_message, send_photo_message, \ - send_media_group_message_to_private_chat, prepare_media_group_from_middlewares, send_video_message, \ - send_video_note_message, send_audio_message, send_voice_message, add_in_db_media, \ - check_user_emoji, check_username_and_full_name, update_user_info -from logs.custom_logger import logger -# Import new modular components +# Local imports - utilities +from helper_bot.keyboards import get_reply_keyboard, get_reply_keyboard_for_post +from helper_bot.keyboards.keyboards import get_reply_keyboard_leave_chat +from helper_bot.utils import messages +from helper_bot.utils.helper_func import ( + get_first_name, + update_user_info, + check_user_emoji +) + +# Local imports - modular components from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES from .services import BotSettings, UserService, PostService, StickerService from .decorators import error_handler @@ -112,10 +114,7 @@ class PrivateHandlers: markup = types.ReplyKeyboardRemove() suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS') - await message.answer(suggest_news) - await asyncio.sleep(0.3) - suggest_news_2 = messages.get_message(get_first_name(message), 'SUGGEST_NEWS_2') - await message.answer(suggest_news_2, reply_markup=markup) + await message.answer(suggest_news, reply_markup=markup) @error_handler async def end_message(self, message: types.Message, state: FSMContext, **kwargs): diff --git a/helper_bot/handlers/private/services.py b/helper_bot/handlers/private/services.py index f46eb98..622c7b0 100644 --- a/helper_bot/handlers/private/services.py +++ b/helper_bot/handlers/private/services.py @@ -1,25 +1,50 @@ """Service classes for private handlers""" +# Standard library imports import random import asyncio import html from datetime import datetime from pathlib import Path -from typing import Dict, Callable +from typing import Dict, Callable, Any, Protocol, Union from dataclasses import dataclass +# Third-party imports from aiogram import types from aiogram.types import FSInputFile +# Local imports - utilities from helper_bot.utils.helper_func import ( - get_first_name, get_text_message, send_text_message, send_photo_message, - send_media_group_message_to_private_chat, prepare_media_group_from_middlewares, - send_video_message, send_video_note_message, send_audio_message, send_voice_message, - add_in_db_media, check_username_and_full_name + get_first_name, + get_text_message, + send_text_message, + send_photo_message, + send_media_group_message_to_private_chat, + prepare_media_group_from_middlewares, + send_video_message, + send_video_note_message, + send_audio_message, + send_voice_message, + add_in_db_media, + check_username_and_full_name ) from helper_bot.keyboards import get_reply_keyboard_for_post +class DatabaseProtocol(Protocol): + """Protocol for database operations""" + def user_exists(self, user_id: int) -> bool: ... + def add_new_user_in_db(self, user_id: int, first_name: str, full_name: str, + username: str, is_bot: bool, language_code: str, + emoji: str, created_date: str, updated_date: str) -> None: ... + def update_username_and_full_name(self, user_id: int, username: str, full_name: str) -> None: ... + def update_date_for_user(self, date: str, user_id: int) -> None: ... + def add_post_in_db(self, message_id: int, text: str, user_id: int) -> None: ... + def update_info_about_stickers(self, user_id: int) -> None: ... + def add_new_message_in_db(self, text: str, user_id: int, message_id: int, date: str) -> None: ... + def update_helper_message_in_db(self, message_id: int, helper_message_id: int) -> None: ... + + @dataclass class BotSettings: """Bot configuration settings""" @@ -36,7 +61,7 @@ class BotSettings: class UserService: """Service for user-related operations""" - def __init__(self, db, settings: BotSettings): + def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None: self.db = db self.settings = settings @@ -90,7 +115,7 @@ class UserService: class PostService: """Service for post-related operations""" - def __init__(self, db, settings: BotSettings): + def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None: self.db = db self.settings = settings @@ -168,7 +193,7 @@ class PostService: """Handle media group post submission""" post_caption = " " - if album[0].caption: + if album and album[0].caption: post_caption = get_text_message(album[0].caption.lower(), first_name, message.from_user.username) media_group = await prepare_media_group_from_middlewares(album, post_caption) @@ -185,7 +210,7 @@ class PostService: message_id=media_group_message_id, helper_message_id=help_message_id ) - async def process_post(self, message: types.Message, album: list = None) -> None: + async def process_post(self, message: types.Message, album: Union[list[types.Message], None] = None) -> None: """Process post based on content type""" first_name = get_first_name(message) @@ -220,12 +245,14 @@ class PostService: class StickerService: """Service for sticker-related operations""" - def __init__(self, settings: BotSettings): + def __init__(self, settings: BotSettings) -> None: self.settings = settings async def send_random_hello_sticker(self, message: types.Message) -> None: """Send random hello sticker""" name_stick_hello = list(Path('Stick').rglob('Hello_*')) + if not name_stick_hello: + return random_stick_hello = random.choice(name_stick_hello) random_stick_hello = FSInputFile(path=random_stick_hello) await message.answer_sticker(random_stick_hello) @@ -234,6 +261,8 @@ class StickerService: async def send_random_goodbye_sticker(self, message: types.Message) -> None: """Send random goodbye sticker""" name_stick_bye = list(Path('Stick').rglob('Universal_*')) + if not name_stick_bye: + return random_stick_bye = random.choice(name_stick_bye) random_stick_bye = FSInputFile(path=random_stick_bye) await message.answer_sticker(random_stick_bye) diff --git a/helper_bot/main.py b/helper_bot/main.py index 8e9cdfb..a9e4106 100644 --- a/helper_bot/main.py +++ b/helper_bot/main.py @@ -7,6 +7,8 @@ from helper_bot.handlers.admin import admin_router from helper_bot.handlers.callback import callback_router from helper_bot.handlers.group import group_router from helper_bot.handlers.private import private_router +from helper_bot.middlewares.dependencies_middleware import DependenciesMiddleware +from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware async def start_bot(bdf): @@ -16,6 +18,10 @@ async def start_bot(bdf): link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] ), timeout=30.0) # Добавляем таймаут для предотвращения зависаний dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) + + # ✅ Глобальная middleware для всех роутеров + dp.update.outer_middleware(DependenciesMiddleware()) + dp.include_routers(admin_router, private_router, callback_router, group_router) await bot.delete_webhook(drop_pending_updates=True) await dp.start_polling(bot, skip_updates=True) diff --git a/helper_bot/middlewares/dependencies_middleware.py b/helper_bot/middlewares/dependencies_middleware.py new file mode 100644 index 0000000..3a9f3de --- /dev/null +++ b/helper_bot/middlewares/dependencies_middleware.py @@ -0,0 +1,31 @@ +from typing import Any, Dict +from aiogram import BaseMiddleware +from aiogram.types import TelegramObject + +from helper_bot.utils.base_dependency_factory import get_global_instance +from logs.custom_logger import logger + + +class DependenciesMiddleware(BaseMiddleware): + """Универсальная middleware для внедрения зависимостей во все хендлеры""" + + async def __call__(self, handler, event: TelegramObject, data: Dict[str, Any]) -> Any: + try: + # Получаем глобальные зависимости + bdf = get_global_instance() + + # Внедряем зависимости в data для MagicData + if 'bot_db' not in data: + data['bot_db'] = bdf.get_db() + if 'settings' not in data: + data['settings'] = bdf.settings + data['bot'] = data.get('bot') + data['dp'] = data.get('dp') + + logger.debug(f"DependenciesMiddleware: внедрены зависимости для {type(event).__name__}") + + except Exception as e: + logger.error(f"Ошибка в DependenciesMiddleware: {e}") + # Не прерываем выполнение, продолжаем без зависимостей + + return await handler(event, data) diff --git a/helper_bot/utils/messages.py b/helper_bot/utils/messages.py index 5e8d4b5..eeb17c3 100644 --- a/helper_bot/utils/messages.py +++ b/helper_bot/utils/messages.py @@ -4,24 +4,22 @@ import html def get_message(username: str, type_message: str): constants = { 'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" - "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" - "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" - "&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧" - "&Предлагай свой пост мне и я обязательно его опубликую😉" - "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" - "&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала." - "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" - "&&Основная группа в ВК: https://vk.com/love_bsk" - "&Основной канал в ТГ: https://t.me/love_bsk", + "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" + "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" + "&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧" + "&Предлагай свой пост мне и я обязательно его опубликую😉" + "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" + "&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала." + "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" + "&&Основная группа в ВК: https://vk.com/love_bsk" + "&Основной канал в ТГ: https://t.me/love_bsk", 'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼" - "&В данный момент я работаю в тестовом режиме, поэтому к посту можно прикрепить не более одного фото и никаких аудио или видео👻" - "&&Обещаю, я научусь их обрабатывать, но позже🤝🤖", - 'SUGGEST_NEWS_2': "Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉" - "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." - "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." - "&&❗️❗️❗️Я обучен только на команды, указанные мной выше❗️❗️❗️👆" - "&‼Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно" - "&Пост будет опубликован только в группе ТГ📩", + "&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉" + "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." + "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." + "&&❗️❗️Я обучен только на команды, указанные мной выше👆" + "&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно" + "&Пост будет опубликован только в группе ТГ📩", "CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️" "&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️", "DEL_MESSAGE": "username, напиши свое обращение или предложение✍" diff --git a/tests/test_bot.py b/tests/test_bot.py deleted file mode 100644 index a08942f..0000000 --- a/tests/test_bot.py +++ /dev/null @@ -1,339 +0,0 @@ -# Импортируем моки в самом начале -import tests.mocks - -import pytest -import asyncio -from unittest.mock import Mock, AsyncMock, patch, MagicMock -from aiogram import Bot, Dispatcher -from aiogram.types import Message, User, Chat, MessageEntity -from aiogram.fsm.context import FSMContext -from aiogram.fsm.storage.memory import MemoryStorage - -from helper_bot.main import start_bot -from helper_bot.handlers.private.private_handlers import ( - handle_start_message, - restart_function, - suggest_post, - end_message, - suggest_router, - stickers, - connect_with_admin, - resend_message_in_group_for_message -) -from helper_bot.utils.base_dependency_factory import BaseDependencyFactory, get_global_instance -from database.db import BotDB - - -class TestBotStartup: - """Тесты для проверки запуска бота""" - - @pytest.mark.asyncio - async def test_bot_initialization(self): - """Тест инициализации бота""" - with patch('helper_bot.main.Bot') as mock_bot_class: - with patch('helper_bot.main.Dispatcher') as mock_dp_class: - with patch('helper_bot.main.MemoryStorage') as mock_storage: - # Мокаем зависимости - mock_bot = AsyncMock(spec=Bot) - mock_dp = AsyncMock(spec=Dispatcher) - mock_bot_class.return_value = mock_bot - mock_dp_class.return_value = mock_dp - - # Мокаем factory - mock_factory = Mock(spec=BaseDependencyFactory) - mock_factory.settings = { - 'Telegram': { - 'bot_token': 'test_token', - 'preview_link': False - } - } - - # Запускаем бота - await start_bot(mock_factory) - - # Проверяем, что бот был создан с правильными параметрами - mock_bot_class.assert_called_once() - call_args = mock_bot_class.call_args - assert call_args[1]['token'] == 'test_token' - assert call_args[1]['default'].parse_mode == 'HTML' - assert call_args[1]['default'].link_preview_is_disabled is False - - # Проверяем, что диспетчер был настроен - mock_dp.include_routers.assert_called_once() - mock_bot.delete_webhook.assert_called_once_with(drop_pending_updates=True) - mock_dp.start_polling.assert_called_once_with(mock_bot, skip_updates=True) - - -class TestPrivateHandlers: - """Тесты для приватных хэндлеров""" - - @pytest.fixture - def mock_message(self): - """Создает мок сообщения""" - message = Mock(spec=Message) - message.from_user = Mock(spec=User) - message.from_user.id = 123456 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.first_name = "Test" - message.from_user.is_bot = False - message.from_user.language_code = "ru" - message.chat = Mock(spec=Chat) - message.chat.id = 123456 - message.chat.type = "private" - message.text = "/start" - message.message_id = 1 - message.forward = AsyncMock() - message.answer = AsyncMock() - message.answer_sticker = AsyncMock() - message.bot.send_message = AsyncMock() - return message - - @pytest.fixture - def mock_state(self): - """Создает мок состояния""" - state = Mock(spec=FSMContext) - state.set_state = AsyncMock() - state.get_state = AsyncMock(return_value="START") - return state - - @pytest.fixture - def mock_db(self): - """Создает мок базы данных""" - db = Mock(spec=BotDB) - db.user_exists = Mock(return_value=False) - db.add_new_user_in_db = Mock() - db.update_date_for_user = Mock() - db.update_username_and_full_name = Mock() - db.add_post_in_db = Mock() - db.update_info_about_stickers = Mock() - db.add_new_message_in_db = Mock() - return db - - @pytest.mark.asyncio - async def test_handle_start_message_new_user(self, mock_message, mock_state, mock_db): - """Тест обработки команды /start для нового пользователя""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_db.user_exists.assert_called_once_with(123456) - mock_db.add_new_user_in_db.assert_called_once() - mock_state.set_state.assert_called_with("START") - mock_message.answer_sticker.assert_called_once() - mock_message.answer.assert_called_once() - - @pytest.mark.asyncio - async def test_handle_start_message_existing_user(self, mock_message, mock_state, mock_db): - """Тест обработки команды /start для существующего пользователя""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - with patch('helper_bot.handlers.private.private_handlers.check_username_and_full_name') as mock_check: - # Настройка моков - mock_db.user_exists.return_value = True - mock_check.return_value = False - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_db.user_exists.assert_called_once_with(123456) - mock_db.add_new_user_in_db.assert_not_called() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_restart_function(self, mock_message, mock_state): - """Тест функции перезапуска""" - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - mock_keyboard.return_value = Mock() - - await restart_function(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_message.answer.assert_called_once_with( - text='Я перезапущен!', - reply_markup=mock_keyboard.return_value - ) - mock_state.set_state.assert_called_with('START') - - @pytest.mark.asyncio - async def test_suggest_post(self, mock_message, mock_state, mock_db): - """Тест функции предложения поста""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - mock_message.text = '📢Предложить свой пост' - mock_messages.side_effect = ["Введите текст поста", "Дополнительная информация"] - - await suggest_post(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_state.set_state.assert_called_with("SUGGEST") - assert mock_message.answer.call_count == 2 - - @pytest.mark.asyncio - async def test_end_message(self, mock_message, mock_state): - """Тест функции прощания""" - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - mock_message.text = '👋🏼Сказать пока!' - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - mock_messages.return_value = "До свидания!" - - await end_message(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_message.answer_sticker.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_text(self, mock_message, mock_state, mock_db): - """Тест обработки текстового поста""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.content_type = 'text' - mock_message.text = 'Тестовый пост' - mock_message.media_group_id = None - mock_get_text.return_value = 'Обработанный текст' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send.return_value = 123 - mock_messages.return_value = "Пост отправлен!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send.assert_called() - mock_db.add_post_in_db.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_stickers(self, mock_message, mock_state, mock_db): - """Тест функции стикеров""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - mock_message.text = '🤪Хочу стикеры' - mock_keyboard.return_value = Mock() - - await stickers(mock_message, mock_state) - - mock_message.forward.assert_called_once() - mock_db.update_info_about_stickers.assert_called_once_with(user_id=123456) - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_connect_with_admin(self, mock_message, mock_state, mock_db): - """Тест функции связи с админами""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - mock_message.text = '📩Связаться с админами' - mock_messages.return_value = "Свяжитесь с админами" - - await connect_with_admin(mock_message, mock_state) - - mock_db.update_date_for_user.assert_called_once() - mock_message.answer.assert_called_once() - mock_message.forward.assert_called_once() - mock_state.set_state.assert_called_with("PRE_CHAT") - - @pytest.mark.asyncio - async def test_resend_message_in_group_pre_chat(self, mock_message, mock_state, mock_db): - """Тест пересылки сообщения в группу (PRE_CHAT состояние)""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - mock_message.text = 'Тестовое сообщение' - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Вопрос" - mock_state.get_state.return_value = "PRE_CHAT" - - await resend_message_in_group_for_message(mock_message, mock_state) - - mock_db.update_date_for_user.assert_called_once() - mock_message.forward.assert_called_once() - mock_db.add_new_message_in_db.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - -class TestDependencyFactory: - """Тесты для фабрики зависимостей""" - - def test_get_global_instance_singleton(self): - """Тест что get_global_instance возвращает синглтон""" - instance1 = get_global_instance() - instance2 = get_global_instance() - assert instance1 is instance2 - - def test_base_dependency_factory_initialization(self): - """Тест инициализации BaseDependencyFactory""" - # Этот тест пропускаем из-за сложности мокирования configparser в уже загруженном модуле - pass - - -class TestBotIntegration: - """Интеграционные тесты бота""" - - @pytest.mark.asyncio - async def test_bot_router_registration(self): - """Тест регистрации роутеров в диспетчере""" - with patch('helper_bot.main.Bot') as mock_bot_class: - with patch('helper_bot.main.Dispatcher') as mock_dp_class: - mock_bot = AsyncMock(spec=Bot) - mock_dp = AsyncMock(spec=Dispatcher) - mock_bot_class.return_value = mock_bot - mock_dp_class.return_value = mock_dp - - mock_factory = Mock(spec=BaseDependencyFactory) - mock_factory.settings = { - 'Telegram': { - 'bot_token': 'test_token', - 'preview_link': False - } - } - - await start_bot(mock_factory) - - # Проверяем, что все роутеры были зарегистрированы - mock_dp.include_routers.assert_called_once() - call_args = mock_dp.include_routers.call_args[0] - assert len(call_args) == 4 # private, callback, group, admin routers - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_error_handling.py b/tests/test_error_handling.py deleted file mode 100644 index b934f45..0000000 --- a/tests/test_error_handling.py +++ /dev/null @@ -1,339 +0,0 @@ -# Импортируем моки в самом начале -import tests.mocks - -import pytest -from unittest.mock import Mock, AsyncMock, patch -from aiogram.types import Message, User, Chat - -from helper_bot.handlers.private.private_handlers import ( - handle_start_message, - suggest_router, - end_message, - stickers -) -from database.db import BotDB - - -class TestErrorHandling: - """Тесты для обработки ошибок и граничных случаев""" - - @pytest.fixture - def mock_message(self): - """Создает базовый мок сообщения""" - message = Mock(spec=Message) - message.from_user = Mock(spec=User) - message.from_user.id = 123456 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.first_name = "Test" - message.from_user.is_bot = False - message.from_user.language_code = "ru" - message.chat = Mock(spec=Chat) - message.chat.id = 123456 - message.chat.type = "private" - message.message_id = 1 - message.forward = AsyncMock() - message.answer = AsyncMock() - message.answer_sticker = AsyncMock() - message.bot.send_message = AsyncMock() - return message - - @pytest.fixture - def mock_state(self): - """Создает мок состояния""" - state = Mock() - state.set_state = AsyncMock() - state.get_state = AsyncMock(return_value="START") - return state - - @pytest.fixture - def mock_db(self): - """Создает мок базы данных""" - db = Mock(spec=BotDB) - db.user_exists = Mock(return_value=False) - db.add_new_user_in_db = Mock() - db.update_date_for_user = Mock() - db.update_username_and_full_name = Mock() - db.add_post_in_db = Mock() - db.update_info_about_stickers = Mock() - return db - - @pytest.mark.asyncio - async def test_handle_start_message_user_without_username(self, mock_message, mock_state, mock_db): - """Тест обработки пользователя без username""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.from_user.username = None - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - # Проверяем, что отправлено сообщение о пользователе без username - call_args = mock_message.bot.send_message.call_args_list - username_log_call = next( - (call for call in call_args if 'без username' in call[1]['text']), - None - ) - assert username_log_call is not None - - @pytest.mark.asyncio - async def test_handle_start_message_sticker_error(self, mock_message, mock_state, mock_db): - """Тест обработки ошибки при получении стикера""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков с ошибкой - mock_path.return_value.rglob.side_effect = Exception("Sticker error") - mock_keyboard.return_value = Mock() - mock_messages.return_value = "Привет!" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - # Проверяем, что отправлено сообщение об ошибке - call_args = mock_message.bot.send_message.call_args_list - error_call = next( - (call for call in call_args if 'ошибка при получении стикеров' in call[1]['text']), - None - ) - assert error_call is not None - - @pytest.mark.asyncio - async def test_handle_start_message_message_error(self, mock_message, mock_state, mock_db): - """Тест обработки ошибки при отправке приветственного сообщения""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_keyboard.return_value = Mock() - mock_messages.side_effect = Exception("Message error") - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - - # Выполнение теста - await handle_start_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - # Проверяем, что отправлено сообщение об ошибке - call_args = mock_message.bot.send_message.call_args_list - # Проверяем, что было отправлено хотя бы одно сообщение - assert len(call_args) > 0 - # Проверяем, что в одном из сообщений есть текст об ошибке - error_found = False - for call in call_args: - text = call.kwargs.get('text', '') or (call[0][1] if len(call[0]) > 1 else '') - if 'Произошла ошибка' in text: - error_found = True - break - assert error_found - - @pytest.mark.asyncio - async def test_suggest_router_exception_handling(self, mock_message, mock_state): - """Тест обработки исключений в suggest_router""" - with patch('helper_bot.handlers.private.private_handlers.BotDB') as mock_db: - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - # Настройка моков с ошибкой - mock_message.content_type = 'text' - mock_message.text = 'Тестовый пост' - mock_message.media_group_id = None - mock_get_text.side_effect = Exception("Processing error") - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called_once() - call_args = mock_message.bot.send_message.call_args - assert 'Произошла ошибка' in call_args[1]['text'] - - @pytest.mark.asyncio - async def test_end_message_sticker_error(self, mock_message, mock_state): - """Тест обработки ошибки при получении стикера в end_message""" - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков с ошибкой - mock_message.text = '👋🏼Сказать пока!' - mock_path.return_value.rglob.side_effect = Exception("Sticker error") - mock_messages.return_value = "До свидания!" - - # Выполнение теста - await end_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - call_args = mock_message.bot.send_message.call_args_list - # Проверяем, что в одном из сообщений есть текст об ошибке - error_found = False - for call in call_args: - text = call.kwargs.get('text', '') or (call[0][1] if len(call[0]) > 1 else '') - if 'Произошла ошибка' in text: - error_found = True - break - assert error_found - - @pytest.mark.asyncio - async def test_end_message_message_error(self, mock_message, mock_state): - """Тест обработки ошибки при отправке сообщения в end_message""" - with patch('helper_bot.handlers.private.private_handlers.Path') as mock_path: - with patch('helper_bot.handlers.private.private_handlers.FSInputFile') as mock_fs: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.text = '👋🏼Сказать пока!' - mock_path.return_value.rglob.return_value = ["sticker1.tgs"] - mock_fs.return_value = "sticker_file" - mock_messages.side_effect = Exception("Message error") - - # Выполнение теста - await end_message(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called() - call_args = mock_message.bot.send_message.call_args_list - # Проверяем, что в одном из сообщений есть текст об ошибке - error_found = False - for call in call_args: - text = call.kwargs.get('text', '') or (call[0][1] if len(call[0]) > 1 else '') - if 'Произошла ошибка' in text: - error_found = True - break - assert error_found - - @pytest.mark.asyncio - async def test_stickers_exception_handling(self, mock_message, mock_state, mock_db): - """Тест обработки исключений в stickers""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - # Настройка моков с ошибкой - mock_message.text = '🤪Хочу стикеры' - mock_db.update_info_about_stickers.side_effect = Exception("Database error") - mock_keyboard.return_value = Mock() - - # Выполнение теста - await stickers(mock_message, mock_state) - - # Проверки - mock_message.bot.send_message.assert_called_once() - call_args = mock_message.bot.send_message.call_args - assert 'Произошла ошибка' in call_args[1]['text'] - - @pytest.mark.asyncio - async def test_suggest_router_empty_text(self, mock_message, mock_state, mock_db): - """Тест обработки пустого текста""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков - mock_message.content_type = 'text' - mock_message.text = '' - mock_message.media_group_id = None - mock_get_text.return_value = '' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send.return_value = 123 - mock_messages.return_value = "Пост отправлен!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - даже пустой текст должен обрабатываться - mock_message.forward.assert_called_once() - mock_send.assert_called() - mock_db.add_post_in_db.assert_called_once() - - @pytest.mark.asyncio - async def test_suggest_router_photo_without_caption(self, mock_message, mock_state, mock_db): - """Тест обработки фото без подписи""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_photo_message') as mock_send_photo: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для фото без подписи - mock_message.content_type = 'photo' - mock_message.caption = None - mock_message.media_group_id = None - mock_message.photo = [Mock()] - mock_message.photo[-1].file_id = 'photo_file_id' - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_photo.return_value = Mock() - mock_send_photo.return_value.message_id = 123 - mock_send_photo.return_value.caption = '' - mock_messages.return_value = "Фото отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_photo.assert_called_once() - # Проверяем, что send_photo_message вызван с пустой подписью - call_args = mock_send_photo.call_args - assert call_args.kwargs.get('caption', '') == '' - - @pytest.mark.asyncio - async def test_suggest_router_media_group_without_caption(self, mock_message, mock_state, mock_db): - """Тест обработки медиагруппы без подписи""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.prepare_media_group_from_middlewares') as mock_prepare: - with patch('helper_bot.handlers.private.private_handlers.send_media_group_message_to_private_chat') as mock_send_group: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send_text: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для медиагруппы без подписи - mock_message.media_group_id = 'group_123' - mock_message.content_type = 'photo' - - # Создаем мок альбома без подписи - album = [mock_message] - album[0].caption = None - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_prepare.return_value = ['media1', 'media2'] - mock_send_group.return_value = 123 - mock_send_text.return_value = 456 - mock_messages.return_value = "Медиагруппа отправлена!" - - # Выполнение теста - await suggest_router(mock_message, mock_state, album) - - # Проверки - mock_prepare.assert_called_once() - # Проверяем, что prepare_media_group_from_middlewares вызван с пустой подписью - call_args = mock_prepare.call_args - assert call_args.kwargs.get('post_caption', '') == '' - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_media_handlers.py b/tests/test_media_handlers.py deleted file mode 100644 index fff30d6..0000000 --- a/tests/test_media_handlers.py +++ /dev/null @@ -1,292 +0,0 @@ -# Импортируем моки в самом начале -import tests.mocks - -import pytest -from unittest.mock import Mock, AsyncMock, patch -from aiogram.types import Message, User, Chat, PhotoSize, Video, Audio, Voice, VideoNote - -from helper_bot.handlers.private.private_handlers import suggest_router -from database.db import BotDB - - -class TestMediaHandlers: - """Тесты для обработки медиа-контента""" - - @pytest.fixture - def mock_message(self): - """Создает базовый мок сообщения""" - message = Mock(spec=Message) - message.from_user = Mock(spec=User) - message.from_user.id = 123456 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.first_name = "Test" - message.chat = Mock(spec=Chat) - message.chat.id = 123456 - message.chat.type = "private" - message.message_id = 1 - message.forward = AsyncMock() - message.answer = AsyncMock() - message.bot.send_message = AsyncMock() - return message - - @pytest.fixture - def mock_state(self): - """Создает мок состояния""" - state = Mock() - state.set_state = AsyncMock() - return state - - @pytest.fixture - def mock_db(self): - """Создает мок базы данных""" - db = Mock(spec=BotDB) - db.add_post_in_db = Mock() - return db - - @pytest.mark.asyncio - async def test_suggest_router_photo(self, mock_message, mock_state, mock_db): - """Тест обработки фото""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_photo_message') as mock_send_photo: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для фото - mock_message.content_type = 'photo' - mock_message.caption = 'Тестовое фото' - mock_message.media_group_id = None - mock_message.photo = [Mock(spec=PhotoSize)] - mock_message.photo[-1].file_id = 'photo_file_id' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_photo.return_value = Mock() - mock_send_photo.return_value.message_id = 123 - mock_send_photo.return_value.caption = 'Обработанная подпись' - mock_messages.return_value = "Фото отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_photo.assert_called_once() - mock_db.add_post_in_db.assert_called_once() - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_video(self, mock_message, mock_state, mock_db): - """Тест обработки видео""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_video_message') as mock_send_video: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для видео - mock_message.content_type = 'video' - mock_message.caption = 'Тестовое видео' - mock_message.media_group_id = None - mock_message.video = Mock(spec=Video) - mock_message.video.file_id = 'video_file_id' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_video.return_value = Mock() - mock_send_video.return_value.message_id = 123 - mock_send_video.return_value.caption = 'Обработанная подпись' - mock_messages.return_value = "Видео отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_video.assert_called_once() - # Проверяем, что add_post_in_db был вызван (может быть вызван несколько раз) - assert mock_db.add_post_in_db.call_count >= 1 - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_video_note(self, mock_message, mock_state, mock_db): - """Тест обработки видеокружка""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_video_note_message') as mock_send_video_note: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для видеокружка - mock_message.content_type = 'video_note' - mock_message.media_group_id = None - mock_message.video_note = Mock(spec=VideoNote) - mock_message.video_note.file_id = 'video_note_file_id' - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_video_note.return_value = Mock() - mock_send_video_note.return_value.message_id = 123 - mock_messages.return_value = "Видеокружок отправлен!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_video_note.assert_called_once() - # Проверяем, что add_post_in_db был вызван (может быть вызван несколько раз) - assert mock_db.add_post_in_db.call_count >= 1 - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_audio(self, mock_message, mock_state, mock_db): - """Тест обработки аудио""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_audio_message') as mock_send_audio: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для аудио - mock_message.content_type = 'audio' - mock_message.caption = 'Тестовое аудио' - mock_message.media_group_id = None - mock_message.audio = Mock(spec=Audio) - mock_message.audio.file_id = 'audio_file_id' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_audio.return_value = Mock() - mock_send_audio.return_value.message_id = 123 - mock_send_audio.return_value.caption = 'Обработанная подпись' - mock_messages.return_value = "Аудио отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_audio.assert_called_once() - # Проверяем, что add_post_in_db был вызван (может быть вызван несколько раз) - assert mock_db.add_post_in_db.call_count >= 1 - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_voice(self, mock_message, mock_state, mock_db): - """Тест обработки голосового сообщения""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.send_voice_message') as mock_send_voice: - with patch('helper_bot.handlers.private.private_handlers.add_in_db_media') as mock_add_media: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для голосового сообщения - mock_message.content_type = 'voice' - mock_message.media_group_id = None - mock_message.voice = Mock(spec=Voice) - mock_message.voice.file_id = 'voice_file_id' - - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_send_voice.return_value = Mock() - mock_send_voice.return_value.message_id = 123 - mock_messages.return_value = "Голосовое сообщение отправлено!" - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверки - mock_message.forward.assert_called_once() - mock_send_voice.assert_called_once() - mock_db.add_post_in_db.assert_called_once() - # Проверяем, что add_in_db_media был вызван (может быть вызван несколько раз) - assert mock_add_media.call_count >= 1 - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_media_group(self, mock_message, mock_state, mock_db): - """Тест обработки медиагруппы""" - with patch('helper_bot.handlers.private.private_handlers.BotDB', mock_db): - with patch('helper_bot.handlers.private.private_handlers.get_text_message') as mock_get_text: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard_for_post') as mock_keyboard_post: - with patch('helper_bot.handlers.private.private_handlers.get_reply_keyboard') as mock_keyboard: - with patch('helper_bot.handlers.private.private_handlers.prepare_media_group_from_middlewares') as mock_prepare: - with patch('helper_bot.handlers.private.private_handlers.send_media_group_message_to_private_chat') as mock_send_group: - with patch('helper_bot.handlers.private.private_handlers.send_text_message') as mock_send_text: - with patch('helper_bot.handlers.private.private_handlers.messages.get_message') as mock_messages: - with patch('helper_bot.handlers.private.private_handlers.sleep'): - # Настройка моков для медиагруппы - mock_message.media_group_id = 'group_123' - mock_message.content_type = 'photo' - - # Создаем мок альбома - album = [mock_message] - album[0].caption = 'Подпись к медиагруппе' - - mock_get_text.return_value = 'Обработанная подпись' - mock_keyboard_post.return_value = Mock() - mock_keyboard.return_value = Mock() - mock_prepare.return_value = ['media1', 'media2'] - mock_send_group.return_value = 123 - mock_send_text.return_value = 456 - mock_messages.return_value = "Медиагруппа отправлена!" - - # Выполнение теста - await suggest_router(mock_message, mock_state, album) - - # Проверки - mock_get_text.assert_called_once() - mock_prepare.assert_called_once() - mock_send_group.assert_called_once() - # Проверяем, что send_text_message был вызван (может быть вызван несколько раз) - assert mock_send_text.call_count >= 1 - mock_db.update_helper_message_in_db.assert_called_once() - mock_message.answer.assert_called_once() - mock_state.set_state.assert_called_with("START") - - @pytest.mark.asyncio - async def test_suggest_router_unsupported_content(self, mock_message, mock_state): - """Тест обработки неподдерживаемого типа контента""" - # Настройка моков для неподдерживаемого контента - mock_message.content_type = 'document' - mock_message.media_group_id = None - - # Выполнение теста - await suggest_router(mock_message, mock_state) - - # Проверяем, что отправлено сообщение о неподдерживаемом типе - mock_message.bot.send_message.assert_called_once() - call_args = mock_message.bot.send_message.call_args - # Проверяем текст сообщения (может быть в позиционных или именованных аргументах) - text = call_args.kwargs.get('text', '') or (call_args[0][1] if len(call_args[0]) > 1 else '') - assert 'не умею работать с таким сообщением' in text - - -if __name__ == '__main__': - pytest.main([__file__, '-v']) diff --git a/tests/test_refactored_admin_handlers.py b/tests/test_refactored_admin_handlers.py new file mode 100644 index 0000000..a9172ce --- /dev/null +++ b/tests/test_refactored_admin_handlers.py @@ -0,0 +1,221 @@ +import pytest +from unittest.mock import Mock, AsyncMock, patch +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.handlers.admin.services import AdminService, User, BannedUser +from helper_bot.handlers.admin.exceptions import ( + UserNotFoundError, + UserAlreadyBannedError, + InvalidInputError +) + + +class TestAdminService: + """Тесты для AdminService""" + + def setup_method(self): + """Настройка перед каждым тестом""" + self.mock_db = Mock() + self.admin_service = AdminService(self.mock_db) + + def test_get_last_users_success(self): + """Тест успешного получения списка последних пользователей""" + # Arrange + # Формат данных: кортежи (full_name, user_id) как возвращает БД + mock_users_data = [ + ('User One', 1), # (full_name, user_id) + ('User Two', 2) # (full_name, user_id) + ] + self.mock_db.get_last_users_from_db.return_value = mock_users_data + + # Act + result = self.admin_service.get_last_users() + + # Assert + assert len(result) == 2 + assert result[0].user_id == 1 + assert result[0].username == 'Неизвестно' # username не возвращается из БД + assert result[0].full_name == 'User One' + assert result[1].user_id == 2 + assert result[1].username == 'Неизвестно' # username не возвращается из БД + assert result[1].full_name == 'User Two' + + def test_get_user_by_username_success(self): + """Тест успешного получения пользователя по username""" + # Arrange + user_id = 123 + username = "test_user" + full_name = "Test User" + self.mock_db.get_user_id_by_username.return_value = user_id + self.mock_db.get_full_name_by_id.return_value = full_name + + # Act + result = self.admin_service.get_user_by_username(username) + + # Assert + assert result is not None + assert result.user_id == user_id + assert result.username == username + assert result.full_name == full_name + + def test_get_user_by_username_not_found(self): + """Тест получения пользователя по несуществующему username""" + # Arrange + username = "nonexistent_user" + self.mock_db.get_user_id_by_username.return_value = None + + # Act + result = self.admin_service.get_user_by_username(username) + + # Assert + assert result is None + + def test_get_user_by_id_success(self): + """Тест успешного получения пользователя по ID""" + # Arrange + user_id = 123 + user_info = {'username': 'test_user', 'full_name': 'Test User'} + self.mock_db.get_user_info_by_id.return_value = user_info + + # Act + result = self.admin_service.get_user_by_id(user_id) + + # Assert + assert result is not None + assert result.user_id == user_id + assert result.username == 'test_user' + assert result.full_name == 'Test User' + + def test_get_user_by_id_not_found(self): + """Тест получения пользователя по несуществующему ID""" + # Arrange + user_id = 999 + self.mock_db.get_user_info_by_id.return_value = None + + # Act + result = self.admin_service.get_user_by_id(user_id) + + # Assert + assert result is None + + def test_validate_user_input_success(self): + """Тест успешной валидации ID пользователя""" + # Act + result = self.admin_service.validate_user_input("123") + + # Assert + assert result == 123 + + def test_validate_user_input_invalid_number(self): + """Тест валидации некорректного ID""" + # Act & Assert + with pytest.raises(InvalidInputError, match="ID пользователя должен быть числом"): + self.admin_service.validate_user_input("abc") + + def test_validate_user_input_negative_number(self): + """Тест валидации отрицательного ID""" + # Act & Assert + with pytest.raises(InvalidInputError, match="ID пользователя должен быть положительным числом"): + self.admin_service.validate_user_input("-1") + + def test_validate_user_input_zero(self): + """Тест валидации нулевого ID""" + # Act & Assert + with pytest.raises(InvalidInputError, match="ID пользователя должен быть положительным числом"): + self.admin_service.validate_user_input("0") + + def test_ban_user_success(self): + """Тест успешной блокировки пользователя""" + # Arrange + user_id = 123 + username = "test_user" + reason = "Test ban" + ban_days = 7 + + self.mock_db.check_user_in_blacklist.return_value = False + self.mock_db.set_user_blacklist.return_value = None + + # Act + self.admin_service.ban_user(user_id, username, reason, ban_days) + + # Assert + self.mock_db.check_user_in_blacklist.assert_called_once_with(user_id) + self.mock_db.set_user_blacklist.assert_called_once() + + def test_ban_user_already_banned(self): + """Тест попытки заблокировать уже заблокированного пользователя""" + # Arrange + user_id = 123 + username = "test_user" + reason = "Test ban" + ban_days = 7 + + self.mock_db.check_user_in_blacklist.return_value = True + + # Act & Assert + with pytest.raises(UserAlreadyBannedError, match=f"Пользователь {user_id} уже заблокирован"): + self.admin_service.ban_user(user_id, username, reason, ban_days) + + def test_ban_user_permanent(self): + """Тест постоянной блокировки пользователя""" + # Arrange + user_id = 123 + username = "test_user" + reason = "Permanent ban" + ban_days = None + + self.mock_db.check_user_in_blacklist.return_value = False + self.mock_db.set_user_blacklist.return_value = None + + # Act + self.admin_service.ban_user(user_id, username, reason, ban_days) + + # Assert + self.mock_db.set_user_blacklist.assert_called_once_with(user_id, username, reason, None) + + def test_unban_user_success(self): + """Тест успешной разблокировки пользователя""" + # Arrange + user_id = 123 + self.mock_db.delete_user_blacklist.return_value = None + + # Act + self.admin_service.unban_user(user_id) + + # Assert + self.mock_db.delete_user_blacklist.assert_called_once_with(user_id) + + +class TestUser: + """Тесты для модели User""" + + def test_user_creation(self): + """Тест создания объекта User""" + # Act + user = User(user_id=123, username="test_user", full_name="Test User") + + # Assert + assert user.user_id == 123 + assert user.username == "test_user" + assert user.full_name == "Test User" + + +class TestBannedUser: + """Тесты для модели BannedUser""" + + def test_banned_user_creation(self): + """Тест создания объекта BannedUser""" + # Act + banned_user = BannedUser( + user_id=123, + username="test_user", + reason="Test ban", + unban_date="2025-01-01" + ) + + # Assert + assert banned_user.user_id == 123 + assert banned_user.username == "test_user" + assert banned_user.reason == "Test ban" + assert banned_user.unban_date == "2025-01-01" diff --git a/tests/test_refactored_group_handlers.py b/tests/test_refactored_group_handlers.py new file mode 100644 index 0000000..f9cf84d --- /dev/null +++ b/tests/test_refactored_group_handlers.py @@ -0,0 +1,189 @@ +"""Tests for refactored group handlers""" + +import pytest +from unittest.mock import Mock, AsyncMock, MagicMock +from aiogram import types +from aiogram.fsm.context import FSMContext + +from helper_bot.handlers.group.group_handlers import ( + create_group_handlers, GroupHandlers +) +from helper_bot.handlers.group.services import AdminReplyService +from helper_bot.handlers.group.exceptions import NoReplyToMessageError, UserNotFoundError +from helper_bot.handlers.group.constants import FSM_STATES, ERROR_MESSAGES + + +class TestGroupHandlers: + """Test class for GroupHandlers""" + + @pytest.fixture + def mock_db(self): + """Mock database""" + db = Mock() + db.get_user_by_message_id = Mock() + return db + + @pytest.fixture + def mock_keyboard_markup(self): + """Mock keyboard markup""" + return Mock() + + @pytest.fixture + def mock_message(self): + """Mock Telegram message""" + message = Mock() + message.from_user = Mock() + message.from_user.id = 12345 + message.from_user.full_name = "Test Admin" + message.text = "test reply message" + message.chat = Mock() + message.chat.title = "Test Group" + message.chat.id = 67890 + message.message_id = 111 + message.answer = AsyncMock() + message.bot = Mock() + message.bot.send_message = AsyncMock() + return message + + @pytest.fixture + def mock_reply_message(self, mock_message): + """Mock reply message""" + reply_message = Mock() + reply_message.message_id = 222 + mock_message.reply_to_message = reply_message + return mock_message + + @pytest.fixture + def mock_state(self): + """Mock FSM state""" + state = Mock(spec=FSMContext) + state.set_state = AsyncMock() + return state + + def test_create_group_handlers(self, mock_db, mock_keyboard_markup): + """Test creating group handlers instance""" + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + assert isinstance(handlers, GroupHandlers) + assert handlers.db == mock_db + assert handlers.keyboard_markup == mock_keyboard_markup + + def test_group_handlers_initialization(self, mock_db, mock_keyboard_markup): + """Test GroupHandlers initialization""" + handlers = GroupHandlers(mock_db, mock_keyboard_markup) + assert handlers.db == mock_db + assert handlers.keyboard_markup == mock_keyboard_markup + assert handlers.admin_reply_service is not None + assert handlers.router is not None + + async def test_handle_message_success(self, mock_db, mock_keyboard_markup, mock_reply_message, mock_state): + """Test successful message handling""" + mock_db.get_user_by_message_id.return_value = 99999 + + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + + # Mock the send_reply_to_user method + handlers.admin_reply_service.send_reply_to_user = AsyncMock() + + await handlers.handle_message(mock_reply_message, mock_state) + + # Verify database call + mock_db.get_user_by_message_id.assert_called_once_with(222) + + # Verify service call + handlers.admin_reply_service.send_reply_to_user.assert_called_once_with( + 99999, mock_reply_message, "test reply message", mock_keyboard_markup + ) + + # Verify state was set + mock_state.set_state.assert_called_once_with(FSM_STATES["CHAT"]) + + async def test_handle_message_no_reply(self, mock_db, mock_keyboard_markup, mock_message, mock_state): + """Test message handling without reply""" + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + + # Mock the send_reply_to_user method to prevent it from being called + handlers.admin_reply_service.send_reply_to_user = AsyncMock() + + # Ensure reply_to_message is None + mock_message.reply_to_message = None + + await handlers.handle_message(mock_message, mock_state) + + # Verify error message was sent + mock_message.answer.assert_called_once_with(ERROR_MESSAGES["NO_REPLY_TO_MESSAGE"]) + + # Verify no database calls + mock_db.get_user_by_message_id.assert_not_called() + + # Verify send_reply_to_user was not called + handlers.admin_reply_service.send_reply_to_user.assert_not_called() + + # Verify state was not set + mock_state.set_state.assert_not_called() + + async def test_handle_message_user_not_found(self, mock_db, mock_keyboard_markup, mock_reply_message, mock_state): + """Test message handling when user is not found""" + mock_db.get_user_by_message_id.return_value = None + + handlers = create_group_handlers(mock_db, mock_keyboard_markup) + + await handlers.handle_message(mock_reply_message, mock_state) + + # Verify error message was sent + mock_reply_message.answer.assert_called_once_with(ERROR_MESSAGES["USER_NOT_FOUND"]) + + # Verify database call + mock_db.get_user_by_message_id.assert_called_once_with(222) + + # Verify state was not set + mock_state.set_state.assert_not_called() + + +class TestAdminReplyService: + """Test class for AdminReplyService""" + + @pytest.fixture + def mock_db(self): + """Mock database""" + db = Mock() + db.get_user_by_message_id = Mock() + return db + + @pytest.fixture + def service(self, mock_db): + """Create service instance""" + return AdminReplyService(mock_db) + + def test_get_user_id_for_reply_success(self, service, mock_db): + """Test successful user ID retrieval""" + mock_db.get_user_by_message_id.return_value = 12345 + + result = service.get_user_id_for_reply(111) + + assert result == 12345 + mock_db.get_user_by_message_id.assert_called_once_with(111) + + def test_get_user_id_for_reply_not_found(self, service, mock_db): + """Test user ID retrieval when user not found""" + mock_db.get_user_by_message_id.return_value = None + + with pytest.raises(UserNotFoundError, match="User not found for message_id: 111"): + service.get_user_id_for_reply(111) + + mock_db.get_user_by_message_id.assert_called_once_with(111) + + async def test_send_reply_to_user(self, service, mock_db): + """Test sending reply to user""" + message = Mock() + message.reply_to_message = Mock() + message.reply_to_message.message_id = 222 + markup = Mock() + + # Mock the send_text_message function + with pytest.MonkeyPatch().context() as m: + mock_send_text = AsyncMock() + m.setattr('helper_bot.handlers.group.services.send_text_message', mock_send_text) + + await service.send_reply_to_user(12345, message, "test reply", markup) + + mock_send_text.assert_called_once_with(12345, message, "test reply", markup) diff --git a/tests/test_refactored_private_handlers.py b/tests/test_refactored_private_handlers.py index c3390a5..c9ce139 100644 --- a/tests/test_refactored_private_handlers.py +++ b/tests/test_refactored_private_handlers.py @@ -46,13 +46,22 @@ class TestPrivateHandlers: def mock_message(self): """Mock Telegram message""" message = Mock(spec=types.Message) - message.from_user.id = 12345 - message.from_user.full_name = "Test User" - message.from_user.username = "testuser" - message.from_user.is_bot = False - message.from_user.language_code = "ru" + # Создаем мок для from_user + from_user = Mock() + from_user.id = 12345 + from_user.full_name = "Test User" + from_user.username = "testuser" + from_user.is_bot = False + from_user.language_code = "ru" + message.from_user = from_user + message.text = "test message" - message.chat.id = 12345 + + # Создаем мок для chat + chat = Mock() + chat.id = 12345 + message.chat = chat + message.bot = Mock() message.bot.send_message = AsyncMock() message.forward = AsyncMock()