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