Add middleware and refactor admin handlers for improved functionality

- Introduced `DependenciesMiddleware` and `BlacklistMiddleware` for enhanced request handling across all routers.
- Refactored admin handlers to utilize new middleware, improving access control and error handling.
- Updated the `admin_router` to include middleware for access checks and streamlined the process of banning users.
- Enhanced the structure of admin handler imports for better organization and maintainability.
- Improved error handling in various admin functions to ensure robust user interactions.
This commit is contained in:
2025-08-28 23:54:17 +03:00
parent f75e7f82c9
commit 8cee629e28
32 changed files with 1922 additions and 1574 deletions

View File

@@ -1 +1,37 @@
from .admin_handlers import admin_router 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'
]

View File

@@ -1,52 +1,55 @@
import traceback
import html
from aiogram import Router, types, F 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 aiogram.fsm.context import FSMContext
from helper_bot.filters.main import ChatTypeFilter from helper_bot.filters.main import ChatTypeFilter
from helper_bot.keyboards.keyboards import get_reply_keyboard_admin, create_keyboard_with_pagination, \ from helper_bot.keyboards.keyboards import (
create_keyboard_for_ban_days, create_keyboard_for_approve_ban, create_keyboard_for_ban_reason get_reply_keyboard_admin,
from helper_bot.utils.base_dependency_factory import get_global_instance create_keyboard_with_pagination,
from helper_bot.utils.helper_func import check_access, add_days_to_date, get_banned_users_buttons, get_banned_users_list 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 from logs.custom_logger import logger
# Создаем роутер с middleware для проверки доступа
admin_router = Router() 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( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
Command('admin') Command('admin')
) )
async def admin_panel(message: types.Message, state: FSMContext): async def admin_panel(
message: types.Message,
state: FSMContext
):
"""Главное меню администратора"""
try: try:
if check_access(message.from_user.id, BotDB): await state.set_state("ADMIN")
await state.set_state("ADMIN") logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}")
logger.info(f"Запуск админ панели для пользователя: {message.from_user.id}") markup = get_reply_keyboard_admin()
markup = get_reply_keyboard_admin() await message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
await message.answer("Добро пожаловать в админку. Выбери что хочешь:",
reply_markup=markup)
else:
await message.answer('Доступ запрещен, досвидания!')
await state.set_state("START")
except Exception as e: except Exception as e:
logger.error(f"Ошибка при запуске админ панели: {e}") await handle_admin_error(message, e, state, "admin_panel")
await message.bot.send_message(IMPORTANT_LOGS,
f'Ошибка в функции admin_panel {e}. Traceback: {traceback.format_exc()}')
await state.set_state("START")
@admin_router.message( @admin_router.message(
@@ -54,150 +57,30 @@ async def admin_panel(message: types.Message, state: FSMContext):
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Бан (Список)' F.text == 'Бан (Список)'
) )
async def get_last_users(message: types.Message, state: FSMContext): async def get_last_users(
# Дополнительная проверка на админские права message: types.Message,
if not check_access(message.from_user.id, BotDB): state: FSMContext,
await message.answer('Доступ запрещен!') bot_db: MagicData("bot_db")
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"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {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
try: try:
user_id = int(message.text) logger.info(f"Получение списка последних пользователей. Пользователь: {message.from_user.full_name}")
logger.info(f"Функция ban_by_id_step_2. Получен ID пользователя: {user_id}") admin_service = AdminService(bot_db)
users = admin_service.get_last_users()
# Проверяем, существует ли пользователь в базе # Преобразуем в формат для клавиатуры (кортежи как ожидает create_keyboard_with_pagination)
user_info = BotDB.get_user_info_by_id(user_id) users_data = [
if not user_info: (user.full_name, user.username) # (full_name, username) - формат кортежей
await message.answer(f"Пользователь с ID {user_id} не найден в базе данных.") for user in users
await state.set_state('ADMIN') ]
markup = get_reply_keyboard_admin()
await message.answer('Вернулись в меню', reply_markup=markup)
return
user_name = user_info.get('username', 'Неизвестно') keyboard = create_keyboard_with_pagination(1, len(users_data), users_data, 'ban')
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))
await message.answer( await message.answer(
text=f"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\n" text="Список пользователей которые последними обращались к боту",
f"Имя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", reply_markup=keyboard
reply_markup=markup) )
await state.set_state('BAN_2') except Exception as e:
await handle_admin_error(message, e, state, "get_last_users")
except ValueError:
await message.answer("Пожалуйста, введите корректный числовой ID пользователя.")
await state.set_state('ADMIN')
markup = get_reply_keyboard_admin()
await message.answer('Вернулись в меню', reply_markup=markup)
@admin_router.message( @admin_router.message(
@@ -205,80 +88,222 @@ async def ban_by_id_step_2(message: types.Message, state: FSMContext):
StateFilter("ADMIN"), StateFilter("ADMIN"),
F.text == 'Разбан (список)' F.text == 'Разбан (список)'
) )
async def get_banned_users(message): async def get_banned_users(
logger.info( message: types.Message,
f"Попытка получения списка заблокированных пользователей. Текст сообщения: {message.text} Имя автора сообщения: {message.from_user.full_name})") state: FSMContext,
message_text = get_banned_users_list(0, BotDB) bot_db: MagicData("bot_db")
buttons_list = get_banned_users_buttons(BotDB) ):
if buttons_list: """Получение списка заблокированных пользователей"""
k = create_keyboard_with_pagination(1, len(buttons_list), buttons_list, 'unlock') try:
await message.answer(text=message_text, reply_markup=k) logger.info(f"Получение списка заблокированных пользователей. Пользователь: {message.from_user.full_name}")
else: admin_service = AdminService(bot_db)
await message.answer(text="В списке забанненых пользователей никого нет") 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( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_2") StateFilter("ADMIN"),
F.text.in_(['Бан по нику', 'Бан по ID'])
) )
async def ban_user_step_2(message: types.Message, state: FSMContext): async def start_ban_process(
user_data = await state.get_data() message: types.Message,
logger.info(f"Переход на шаг 2 бана пользователя. Словарь с данными для бана: {user_data})") state: FSMContext,
await state.update_data(message_for_user=message.text) ):
markup = create_keyboard_for_ban_days() """Начало процесса блокировки пользователя"""
# Экранируем message.text для безопасного использования try:
safe_message_text = html.escape(str(message.text)) if message.text else "" ban_type = "username" if message.text == 'Бан по нику' else "id"
await message.answer(f"Выбрана причина: {safe_message_text}. Выбери срок бана в днях или напиши " await state.update_data(ban_type=ban_type)
f"его в чат", reply_markup=markup)
await state.set_state("BAN_3") 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( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), ChatTypeFilter(chat_type=["private"]),
StateFilter("BAN_3") StateFilter("AWAIT_BAN_TARGET")
) )
async def ban_user_step_3(message: types.Message, state: FSMContext): async def process_ban_target(
logger.info(f"ban_user_step_3. Расчет даты разбана. Входные данные {message.text}") message: types.Message,
if message.text != 'Навсегда': state: FSMContext,
count_days = int(message.text) bot_db: MagicData("bot_db")
date_to_unban = add_days_to_date(count_days) ):
else: """Обработка введенного username/ID для блокировки"""
date_to_unban = None try:
logger.info(f"ban_user_step_3. Расчет даты разбана. date_to_unban: {date_to_unban}") user_data = await state.get_data()
await state.update_data(date_to_unban=date_to_unban) ban_type = user_data.get('ban_type')
user_data = await state.get_data() admin_service = AdminService(bot_db)
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 "" if ban_type == "username":
await message.answer( user = admin_service.get_user_by_username(message.text)
f"Необходимо подтверждение:\nПользователь:{user_data['user_id']}\nПричина бана:{safe_message_for_user}\nСрок бана:{safe_date_to_unban}", if not user:
reply_markup=markup) await message.answer(f"Пользователь с username '{escape_html(message.text)}' не найден.")
await state.set_state("BAN_FINAL") 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( @admin_router.message(
ChatTypeFilter(chat_type=["private"]), 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 == 'Подтвердить' F.text == 'Подтвердить'
) )
async def approve_ban(message: types.Message, state: FSMContext): async def confirm_ban(
user_data = await state.get_data() message: types.Message,
logger.info(f"Переход на финальный шаг бана пользователя. Словарь с данными для бана: {user_data})") state: FSMContext,
exists = BotDB.check_user_in_blacklist(user_data['user_id']) bot_db: MagicData("bot_db")
if exists: ):
await message.reply(f"Пользователь уже был заблокирован ранее.") """Подтверждение блокировки пользователя"""
logger.info(f"Пользователь: {user_data['user_id']} был заблокирован ранее)") try:
await state.set_state('ADMIN') user_data = await state.get_data()
else: admin_service = AdminService(bot_db)
BotDB.set_user_blacklist(user_data['user_id'],
user_data['user_name'],
user_data['message_for_user'], # Выполняем блокировку
user_data['date_to_unban']) admin_service.ban_user(
# Экранируем user_name для безопасного использования user_id=user_data['target_user_id'],
safe_user_name = html.escape(str(user_data['user_name'])) if user_data.get('user_name') else "Неизвестный пользователь" username=user_data['target_username'],
await message.reply(f"Пользователь {safe_user_name} успешно заблокирован.") reason=user_data['ban_reason'],
logger.info(f"Пользователь: {user_data['user_id']} успешно заблокирован)") ban_days=user_data['ban_days']
await state.set_state('ADMIN') )
markup = get_reply_keyboard_admin()
await message.answer('Вернулись в меню', reply_markup=markup) 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")

View File

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

View File

@@ -0,0 +1,23 @@
class AdminError(Exception):
"""Базовое исключение для административных операций"""
pass
class AdminAccessDeniedError(AdminError):
"""Исключение при отказе в административном доступе"""
pass
class UserNotFoundError(AdminError):
"""Исключение при отсутствии пользователя"""
pass
class InvalidInputError(AdminError):
"""Исключение при некорректном вводе данных"""
pass
class UserAlreadyBannedError(AdminError):
"""Исключение при попытке забанить уже заблокированного пользователя"""
pass

View File

@@ -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

View File

@@ -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"<b>Выбран пользователь:</b>\n"
f"<b>ID:</b> {user_id}\n"
f"<b>Username:</b> {safe_username}\n"
f"<b>Имя:</b> {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"<b>Необходимо подтверждение:</b>\n"
f"<b>Пользователь:</b> {user_id}\n"
f"<b>Причина бана:</b> {safe_reason}\n"
f"<b>Срок бана:</b> {ban_text}")

View File

@@ -1 +1,24 @@
from .callback_handlers import callback_router 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'
]

View File

@@ -1,322 +1,188 @@
import html import html
from tkinter import S
import traceback import traceback
from datetime import datetime, timedelta
from aiogram import Router, F from aiogram import Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery 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, \ from helper_bot.keyboards.keyboards import create_keyboard_with_pagination, get_reply_keyboard_admin, \
create_keyboard_for_ban_reason 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.base_dependency_factory import get_global_instance
from helper_bot.utils.helper_func import send_text_message, send_photo_message, get_banned_users_list, \ from .dependency_factory import get_post_publish_service, get_ban_service
get_banned_users_buttons, delete_user_blacklist, send_media_group_to_channel, \ from .exceptions import UserBlockedBotError, PostNotFoundError, UserNotFoundError, PublishError, BanError
send_video_message, send_video_note_message, send_audio_message, send_voice_message 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 from logs.custom_logger import logger
callback_router = Router() 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 == CALLBACK_PUBLISH)
async def post_for_group(
call: CallbackQuery,
@callback_router.callback_query( settings: MagicData("settings")
F.data == "publish" ):
) publish_service = get_post_publish_service()
async def post_for_group(call: CallbackQuery, state: FSMContext): # TODO: переделать на MagicData
logger.info( logger.info(
f'Получен callback-запрос с действием: {call.data} от пользователя {call.from_user.full_name} (ID сообщения: {call.message.message_id})') 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)
# Получаем из базы автора try:
author_id = BotDB.get_author_id_by_message_id(call.message.message_id) await publish_service.publish_post(call)
await call.answer(text=MESSAGE_PUBLISHED, cache_time=3)
# Очищаем предложку и удаляем оттуда пост except UserBlockedBotError:
await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
logger.info(f'Текст сообщения опубликован в канале {MAIN_PUBLIC}.') except (PostNotFoundError, PublishError) as e:
await call.answer(text='Выложено!', cache_time=3) logger.error(f'Ошибка при публикации поста: {str(e)}')
await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
# Отвечаем пользователю except Exception as e:
await send_text_message(author_id, call.message, 'Твой пост был выложен🥰') if str(e) == ERROR_BOT_BLOCKED:
except Exception as e: await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
if e.message != 'Forbidden: bot was blocked by the user': else:
await call.bot.send_message(chat_id=IMPORTANT_LOGS, important_logs = settings['Telegram']['important_logs']
text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") await call.bot.send_message(
logger.error(f'Ошибка при публикации текста в канал {MAIN_PUBLIC}: {str(e)}') chat_id=important_logs,
await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) text=f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
elif call.message.content_type == 'photo': )
try: logger.error(f'Неожиданная ошибка при публикации поста: {str(e)}')
await send_photo_message(MAIN_PUBLIC, call.message, call.message.photo[-1].file_id, text_post_with_photo) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
# Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки
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, 'Твой пост был выложен🥰')
@callback_router.callback_query( @callback_router.callback_query(F.data == CALLBACK_DECLINE)
F.data == "decline" async def decline_post_for_group(
) call: CallbackQuery,
async def decline_post_for_group(call: CallbackQuery, state: FSMContext): settings: MagicData("settings")
):
publish_service = get_post_publish_service()
# TODO: переделать на MagicData
logger.info( logger.info(
f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})') f'Получен callback-запрос с данными: {call.data} от пользователя {call.from_user.full_name} (ID: {call.from_user.id})')
try: try:
if call.message.content_type == 'text' and call.message.text != "^" or call.message.content_type == 'photo' \ await publish_service.decline_post(call)
or call.message.content_type == 'audio' or call.message.content_type == 'voice' \ await call.answer(text=MESSAGE_DECLINED, cache_time=3)
or call.message.content_type == 'video' or call.message.content_type == 'video_note': except UserBlockedBotError:
await call.bot.delete_message(chat_id=GROUP_FOR_POST, message_id=call.message.message_id) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (PostNotFoundError, PublishError) as e:
# Получаем из базы автора + отправляем сообщение + удаляем сообщение из предложки logger.error(f'Ошибка при отклонении поста: {str(e)}')
author_id = BotDB.get_author_id_by_message_id(call.message.message_id) await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
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, 'Твой пост был отклонен😔')
except Exception as e: except Exception as e:
if e.message != 'Forbidden: bot was blocked by the user': if str(e) == ERROR_BOT_BLOCKED:
await call.bot.send_message(IMPORTANT_LOGS, await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
f"Произошла ошибка: {str(e)}\n\nTraceback:\n{traceback.format_exc()}") else:
logger.error(f'Ошибка при удалении сообщения в группе {GROUP_FOR_POST}: {str(e)}') important_logs = settings['Telegram']['important_logs']
await call.answer(text='Что-то пошло не так!', show_alert=True, cache_time=3) 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( @callback_router.callback_query(F.data == CALLBACK_BAN)
F.data == "ban"
)
async def ban_user_from_post(call: CallbackQuery): async def ban_user_from_post(call: CallbackQuery):
ban_service = get_ban_service()
# TODO: переделать на MagicData
try: try:
# Получаем информацию о пользователе из сообщения await ban_service.ban_user_from_post(call)
author_id = BotDB.get_author_id_by_message_id(call.message.message_id) await call.answer(text=MESSAGE_USER_BANNED, cache_time=3)
user_name = BotDB.get_username(user_id=author_id) except UserBlockedBotError:
full_name = call.message.from_user.full_name if call.message.from_user else "Неизвестно" await call.answer(text=MESSAGE_ERROR, show_alert=True, cache_time=3)
except (UserNotFoundError, BanError) as e:
# Устанавливаем причину бана и дату разблокировки (+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:
logger.error(f'Ошибка при блокировке пользователя: {str(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( @callback_router.callback_query(F.data.contains(CALLBACK_BAN))
F.data.contains('ban')
)
async def process_ban_user(call: CallbackQuery, state: FSMContext): async def process_ban_user(call: CallbackQuery, state: FSMContext):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[4:] user_id = call.data[4:]
logger.info( logger.info(f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
f"Вызов функции process_ban_user. Данные callback: {call.data} пользователь: {user_id}")
user_name = BotDB.get_username(user_id=user_id) try:
if user_name: 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, await state.update_data(user_id=user_id, user_name=user_name, message_for_user=None, date_to_unban=None)
date_to_unban=None)
markup = create_keyboard_for_ban_reason() markup = create_keyboard_for_ban_reason()
# Экранируем потенциально проблемные символы
user_name_escaped = html.escape(str(user_name)) user_name_escaped = html.escape(str(user_name))
full_name_escaped = html.escape(str(call.message.from_user.full_name)) full_name_escaped = html.escape(str(call.message.from_user.full_name))
await call.message.answer( await call.message.answer(
text=f"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат", text=f"<b>Выбран пользователь:\nid:</b> {user_id}\n<b>username:</b> {user_name_escaped}\nИмя:{full_name_escaped}\nВыбери причину бана из списка или напиши ее в чат",
reply_markup=markup) reply_markup=markup
)
await state.set_state('BAN_2') await state.set_state('BAN_2')
else: except UserNotFoundError:
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup) await call.message.answer(text='Пользователь с таким ID не найден в базе', reply_markup=markup)
await state.set_state('ADMIN') await state.set_state('ADMIN')
@callback_router.callback_query( @callback_router.callback_query(F.data.contains(CALLBACK_UNLOCK))
F.data.contains('unlock')
)
async def process_unlock_user(call: CallbackQuery): async def process_unlock_user(call: CallbackQuery):
ban_service = get_ban_service()
# TODO: переделать на MagicData
user_id = call.data[7:] user_id = call.data[7:]
user_name = BotDB.get_username(user_id=user_id)
delete_user_blacklist(user_id, BotDB) try:
logger.info(f"Разблокирован пользователь с ID: {user_id} username:{user_name}") username = await ban_service.unlock_user(user_id)
username = BotDB.get_username(user_id) await call.answer(f'{MESSAGE_USER_UNLOCKED} {username}', show_alert=True)
await call.answer(f'Пользователь разблокирован {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( @callback_router.callback_query(F.data == CALLBACK_RETURN)
F.data == 'return'
)
async def return_to_main_menu(call: CallbackQuery): async def return_to_main_menu(call: CallbackQuery):
await call.message.delete() await call.message.delete()
logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}") logger.info(f"Запуск админ панели для пользователя: {call.message.from_user.id}")
markup = get_reply_keyboard_admin() markup = get_reply_keyboard_admin()
await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", await call.message.answer("Добро пожаловать в админку. Выбери что хочешь:", reply_markup=markup)
reply_markup=markup)
@callback_router.callback_query( @callback_router.callback_query(F.data.contains(CALLBACK_PAGE))
F.data.contains('page') async def change_page(
) call: CallbackQuery,
async def change_page(call: CallbackQuery): bot_db: MagicData("bot_db")
):
page_number = int(call.data[5:]) page_number = int(call.data[5:])
logger.info(f"Переход на страницу {page_number}") logger.info(f"Переход на страницу {page_number}")
if call.message.text == 'Список пользователей которые последними обращались к боту': if call.message.text == 'Список пользователей которые последними обращались к боту':
list_users = BotDB.get_last_users_from_db() list_users = bot_db.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')
keyboard = create_keyboard_with_pagination(int(page_number), len(list_users), list_users, await call.bot.edit_message_reply_markup(
'ban') chat_id=call.message.chat.id,
message_id=call.message.message_id,
await call.bot.edit_message_reply_markup(chat_id=call.message.chat.id, message_id=call.message.message_id, reply_markup=keyboard
reply_markup=keyboard) )
else: else:
# Готовим сообщения message_user = get_banned_users_list(int(page_number) * 7 - 7, bot_db)
message_user = get_banned_users_list(int(page_number) * 7 - 7, BotDB) await call.bot.edit_message_text(
await call.bot.edit_message_text(chat_id=call.message.chat.id, message_id=call.message.message_id, chat_id=call.message.chat.id,
text=message_user) message_id=call.message.message_id,
text=message_user
)
# Готовим клавиатуру buttons = get_banned_users_buttons(bot_db)
buttons = get_banned_users_buttons(BotDB)
keyboard = create_keyboard_with_pagination(int(call.data[5:]), len(buttons), buttons, 'unlock') 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, await call.bot.edit_message_reply_markup(
reply_markup=keyboard) chat_id=call.message.chat.id,
message_id=call.message.message_id,
reply_markup=keyboard
)

View File

@@ -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"

View File

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

View File

@@ -0,0 +1,23 @@
class UserBlockedBotError(Exception):
"""Исключение, возникающее когда пользователь заблокировал бота"""
pass
class PostNotFoundError(Exception):
"""Исключение, возникающее когда пост не найден в базе данных"""
pass
class UserNotFoundError(Exception):
"""Исключение, возникающее когда пользователь не найден в базе данных"""
pass
class PublishError(Exception):
"""Общее исключение для ошибок публикации"""
pass
class BanError(Exception):
"""Исключение для ошибок бана/разбана пользователей"""
pass

View File

@@ -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

View File

@@ -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'
]

View File

@@ -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": "Не могу найти кому ответить в базе, проебали сообщение."
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,49 +1,106 @@
"""Main group handlers module for Telegram bot"""
# Third-party imports
from aiogram import Router, types from aiogram import Router, types
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
# Local imports - filters
from helper_bot.filters.main import ChatTypeFilter 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 # Local imports - modular components
from helper_bot.utils.helper_func import send_text_message 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 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() group_router = Router()
bdf = get_global_instance() # Initialize with global dependencies (for backward compatibility)
GROUP_FOR_POST = bdf.settings['Telegram']['group_for_posts'] def init_legacy_router():
GROUP_FOR_MESSAGE = bdf.settings['Telegram']['group_for_message'] """Initialize legacy router with global dependencies"""
MAIN_PUBLIC = bdf.settings['Telegram']['main_public'] global group_router
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() 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()
@group_router.message( handlers = create_group_handlers(db, keyboard_markup)
ChatTypeFilter(chat_type=["group", "supergroup"]), group_router = handlers.router
)
async def handle_message(message: types.Message, state: FSMContext): # Initialize legacy router
"""Функция ответа админа пользователю через закрытый чат""" init_legacy_router()
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)}')

View File

@@ -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"}'
)

View File

@@ -1,20 +1,45 @@
"""Private handlers package for Telegram bot""" """Private handlers package for Telegram bot"""
from .private_handlers import private_router, create_private_handlers, PrivateHandlers # Local imports - main components
from .services import BotSettings, UserService, PostService, StickerService from .private_handlers import (
from .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES 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 from .decorators import error_handler
__all__ = [ __all__ = [
# Main components
'private_router', 'private_router',
'create_private_handlers', 'create_private_handlers',
'PrivateHandlers', 'PrivateHandlers',
# Services
'BotSettings', 'BotSettings',
'UserService', 'UserService',
'PostService', 'PostService',
'StickerService', 'StickerService',
# Constants
'FSM_STATES', 'FSM_STATES',
'BUTTON_TEXTS', 'BUTTON_TEXTS',
'ERROR_MESSAGES', 'ERROR_MESSAGES',
# Utilities
'error_handler' 'error_handler'
] ]

View File

@@ -1,7 +1,9 @@
"""Constants for private handlers""" """Constants for private handlers"""
from typing import Final
# FSM States # FSM States
FSM_STATES = { FSM_STATES: Final[dict[str, str]] = {
"START": "START", "START": "START",
"SUGGEST": "SUGGEST", "SUGGEST": "SUGGEST",
"PRE_CHAT": "PRE_CHAT", "PRE_CHAT": "PRE_CHAT",
@@ -9,7 +11,7 @@ FSM_STATES = {
} }
# Button texts # Button texts
BUTTON_TEXTS = { BUTTON_TEXTS: Final[dict[str, str]] = {
"SUGGEST_POST": "📢Предложить свой пост", "SUGGEST_POST": "📢Предложить свой пост",
"SAY_GOODBYE": "👋🏼Сказать пока!", "SAY_GOODBYE": "👋🏼Сказать пока!",
"LEAVE_CHAT": "Выйти из чата", "LEAVE_CHAT": "Выйти из чата",
@@ -19,7 +21,7 @@ BUTTON_TEXTS = {
} }
# Error messages # Error messages
ERROR_MESSAGES = { ERROR_MESSAGES: Final[dict[str, str]] = {
"UNSUPPORTED_CONTENT": ( "UNSUPPORTED_CONTENT": (
'Я пока не умею работать с таким сообщением. ' 'Я пока не умею работать с таким сообщением. '
'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n' 'Пришли текст и фото/фоты(ы). А лучше перешли это сообщение админу @kerrad1\n'

View File

@@ -1,13 +1,19 @@
"""Decorators and utility functions for private handlers""" """Decorators and utility functions for private handlers"""
# Standard library imports
import traceback import traceback
from typing import Any, Callable
# Third-party imports
from aiogram import types from aiogram import types
# Local imports
from logs.custom_logger import logger from logs.custom_logger import logger
def error_handler(func): def error_handler(func: Callable[..., Any]) -> Callable[..., Any]:
"""Decorator for centralized error handling""" """Decorator for centralized error handling"""
async def wrapper(*args, **kwargs): async def wrapper(*args: Any, **kwargs: Any) -> Any:
try: try:
return await func(*args, **kwargs) return await func(*args, **kwargs)
except Exception as e: except Exception as e:
@@ -23,7 +29,8 @@ def error_handler(func):
chat_id=important_logs, chat_id=important_logs,
text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}" text=f"Произошла ошибка в {func.__name__}: {str(e)}\n\nTraceback:\n{traceback.format_exc()}"
) )
except: except Exception:
pass # If we can't log the error, at least it was logged to logger # If we can't log the error, at least it was logged to logger
pass
raise raise
return wrapper return wrapper

View File

@@ -1,28 +1,30 @@
import random """Main private handlers module for Telegram bot"""
import traceback
import asyncio
import html
from datetime import datetime
from pathlib import Path
# Standard library imports
import asyncio
from datetime import datetime
# Third-party imports
from aiogram import types, Router, F from aiogram import types, Router, F
from aiogram.filters import Command, StateFilter from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext 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.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.album_middleware import AlbumMiddleware
from helper_bot.middlewares.blacklist_middleware import BlacklistMiddleware 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 .constants import FSM_STATES, BUTTON_TEXTS, ERROR_MESSAGES
from .services import BotSettings, UserService, PostService, StickerService from .services import BotSettings, UserService, PostService, StickerService
from .decorators import error_handler from .decorators import error_handler
@@ -112,10 +114,7 @@ class PrivateHandlers:
markup = types.ReplyKeyboardRemove() markup = types.ReplyKeyboardRemove()
suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS') suggest_news = messages.get_message(get_first_name(message), 'SUGGEST_NEWS')
await message.answer(suggest_news) await message.answer(suggest_news, reply_markup=markup)
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)
@error_handler @error_handler
async def end_message(self, message: types.Message, state: FSMContext, **kwargs): async def end_message(self, message: types.Message, state: FSMContext, **kwargs):

View File

@@ -1,25 +1,50 @@
"""Service classes for private handlers""" """Service classes for private handlers"""
# Standard library imports
import random import random
import asyncio import asyncio
import html import html
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Dict, Callable from typing import Dict, Callable, Any, Protocol, Union
from dataclasses import dataclass from dataclasses import dataclass
# Third-party imports
from aiogram import types from aiogram import types
from aiogram.types import FSInputFile from aiogram.types import FSInputFile
# Local imports - utilities
from helper_bot.utils.helper_func import ( from helper_bot.utils.helper_func import (
get_first_name, get_text_message, send_text_message, send_photo_message, get_first_name,
send_media_group_message_to_private_chat, prepare_media_group_from_middlewares, get_text_message,
send_video_message, send_video_note_message, send_audio_message, send_voice_message, send_text_message,
add_in_db_media, check_username_and_full_name 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 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 @dataclass
class BotSettings: class BotSettings:
"""Bot configuration settings""" """Bot configuration settings"""
@@ -36,7 +61,7 @@ class BotSettings:
class UserService: class UserService:
"""Service for user-related operations""" """Service for user-related operations"""
def __init__(self, db, settings: BotSettings): def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None:
self.db = db self.db = db
self.settings = settings self.settings = settings
@@ -90,7 +115,7 @@ class UserService:
class PostService: class PostService:
"""Service for post-related operations""" """Service for post-related operations"""
def __init__(self, db, settings: BotSettings): def __init__(self, db: DatabaseProtocol, settings: BotSettings) -> None:
self.db = db self.db = db
self.settings = settings self.settings = settings
@@ -168,7 +193,7 @@ class PostService:
"""Handle media group post submission""" """Handle media group post submission"""
post_caption = " " 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) 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) 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 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""" """Process post based on content type"""
first_name = get_first_name(message) first_name = get_first_name(message)
@@ -220,12 +245,14 @@ class PostService:
class StickerService: class StickerService:
"""Service for sticker-related operations""" """Service for sticker-related operations"""
def __init__(self, settings: BotSettings): def __init__(self, settings: BotSettings) -> None:
self.settings = settings self.settings = settings
async def send_random_hello_sticker(self, message: types.Message) -> None: async def send_random_hello_sticker(self, message: types.Message) -> None:
"""Send random hello sticker""" """Send random hello sticker"""
name_stick_hello = list(Path('Stick').rglob('Hello_*')) 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 = random.choice(name_stick_hello)
random_stick_hello = FSInputFile(path=random_stick_hello) random_stick_hello = FSInputFile(path=random_stick_hello)
await message.answer_sticker(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: async def send_random_goodbye_sticker(self, message: types.Message) -> None:
"""Send random goodbye sticker""" """Send random goodbye sticker"""
name_stick_bye = list(Path('Stick').rglob('Universal_*')) 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 = random.choice(name_stick_bye)
random_stick_bye = FSInputFile(path=random_stick_bye) random_stick_bye = FSInputFile(path=random_stick_bye)
await message.answer_sticker(random_stick_bye) await message.answer_sticker(random_stick_bye)

View File

@@ -7,6 +7,8 @@ from helper_bot.handlers.admin import admin_router
from helper_bot.handlers.callback import callback_router from helper_bot.handlers.callback import callback_router
from helper_bot.handlers.group import group_router from helper_bot.handlers.group import group_router
from helper_bot.handlers.private import private_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): async def start_bot(bdf):
@@ -16,6 +18,10 @@ async def start_bot(bdf):
link_preview_is_disabled=bdf.settings['Telegram']['preview_link'] link_preview_is_disabled=bdf.settings['Telegram']['preview_link']
), timeout=30.0) # Добавляем таймаут для предотвращения зависаний ), timeout=30.0) # Добавляем таймаут для предотвращения зависаний
dp = Dispatcher(storage=MemoryStorage(), fsm_strategy=FSMStrategy.GLOBAL_USER) 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) dp.include_routers(admin_router, private_router, callback_router, group_router)
await bot.delete_webhook(drop_pending_updates=True) await bot.delete_webhook(drop_pending_updates=True)
await dp.start_polling(bot, skip_updates=True) await dp.start_polling(bot, skip_updates=True)

View File

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

View File

@@ -4,24 +4,22 @@ import html
def get_message(username: str, type_message: str): def get_message(username: str, type_message: str):
constants = { constants = {
'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖" 'HELLO_MESSAGE': "Привет, username!👋🏼&Меня зовут Виби, я бот канала 'Влюбленный Бийск'❤🤖"
"&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉" "&Я был создан для того, чтобы помочь тебе выложить пост в наш канал и если это необходимо, связаться с админами ✍✉"
"&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂" "&Так же я могу выдать тебе набор стикеров, где я буду главным героем🦸‍♂"
"&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧" "&Наш бот голосового общения переехал сюда: https://t.me/podslushano_biysk_bot 🎤&Там можно послушать о чем говорит наш город🎧"
"&Предлагай свой пост мне и я обязательно его опубликую😉" "&Предлагай свой пост мне и я обязательно его опубликую😉"
"&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇" "&Для продолжения взаимодействия воспользуйся меню внизу твоего дисплея⬇"
"&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала." "&&Если что-то пошло не так: введи в чат команду /start, это перезапустит сценарий сначала."
"&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже" "&Не жми кнопку несколько раз если я не ответил с первого раза. Возможно ведутся тех.работы и я отвечу позже"
"&&Основная группа в ВК: https://vk.com/love_bsk" "&&Основная группа в ВК: https://vk.com/love_bsk"
"&Основной канал в ТГ: https://t.me/love_bsk", "&Основной канал в ТГ: https://t.me/love_bsk",
'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼" 'SUGGEST_NEWS': "username, окей, жду от тебя текст поста🙌🏼"
"&В данный момент я работаю в тестовом режиме, поэтому к посту можно прикрепить не более одного фото и никаких аудио или видео👻" "&Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉"
"&&Обещаю, я научусь их обрабатывать, но позже🤝🤖", "&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'."
'SUGGEST_NEWS_2': "Обрати внимание, что я умный и смогу из твоего текста понять команды указанные ниже😉" "&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего."
"&Если хочешь чтобы пост был опубликован анонимно, напиши в любом месте своего поста слово 'анон'." "&&❗️❗️Я обучен только на команды, указанные мной выше👆"
"&Если хочешь опубликовать пост не анонимно, то напиши 'не анон', 'неанон' или не пиши ничего." "&❗️❗️Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
"&&❗️❗️❗️Я обучен только на команды, указанные мной выше❗️❗️❗️👆" "&Пост будет опубликован только в группе ТГ📩",
"&‼Проверь, чтобы указание авторства было выполнено так как я попросил, иначе пост будет выложен не корректно"
"&Пост будет опубликован только в группе ТГ📩",
"CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️" "CONNECT_WITH_ADMIN": "username, напиши свое обращение или предложение✍️"
"&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️", "&Мы рассмотрим и ответим тебе в ближайшее время☺️❤️",
"DEL_MESSAGE": "username, напиши свое обращение или предложение✍" "DEL_MESSAGE": "username, напиши свое обращение или предложение✍"

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

@@ -46,13 +46,22 @@ class TestPrivateHandlers:
def mock_message(self): def mock_message(self):
"""Mock Telegram message""" """Mock Telegram message"""
message = Mock(spec=types.Message) message = Mock(spec=types.Message)
message.from_user.id = 12345 # Создаем мок для from_user
message.from_user.full_name = "Test User" from_user = Mock()
message.from_user.username = "testuser" from_user.id = 12345
message.from_user.is_bot = False from_user.full_name = "Test User"
message.from_user.language_code = "ru" from_user.username = "testuser"
from_user.is_bot = False
from_user.language_code = "ru"
message.from_user = from_user
message.text = "test message" message.text = "test message"
message.chat.id = 12345
# Создаем мок для chat
chat = Mock()
chat.id = 12345
message.chat = chat
message.bot = Mock() message.bot = Mock()
message.bot.send_message = AsyncMock() message.bot.send_message = AsyncMock()
message.forward = AsyncMock() message.forward = AsyncMock()