""" Middleware для проверки подписки пользователей на каналы """ from time import time from typing import Callable, Awaitable, Any, Dict, Optional, Union from dataclasses import dataclass from aiogram import BaseMiddleware, Bot from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError from aiogram.types import TelegramObject, Message, CallbackQuery, InlineKeyboardButton, Chat from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.enums import ChatMemberStatus from middleware.loggers import logger from configs import settings __all__ = ('SubscriptionMiddleware', 'ChannelConfig') @dataclass class ChannelConfig: """ Конфигурация канала для проверки подписки. Attributes: id: ID или username канала name: Название канала (для отображения) invite_link: Пригласительная ссылка required: Обязательная ли подписка """ id: Union[str, int] name: Optional[str] = None invite_link: Optional[str] = None required: bool = True class SubscriptionCache: """ Кэш для проверок подписки. Уменьшает количество запросов к Telegram API. """ def __init__(self, ttl: float = 300.0): """ Args: ttl: Время жизни кэша в секундах (по умолчанию 5 минут) """ self.ttl = ttl # Структура: {(user_id, channel_id): (is_subscribed, timestamp)} self._cache: Dict[tuple[int, Union[str, int]], tuple[bool, float]] = {} def get(self, user_id: int, channel_id: Union[str, int]) -> Optional[bool]: """ Получает значение из кэша. Args: user_id: ID пользователя channel_id: ID канала Returns: bool или None: True/False если в кэше и актуально, иначе None """ key = (user_id, channel_id) if key in self._cache: is_subscribed, timestamp = self._cache[key] # Проверяем актуальность if time() - timestamp < self.ttl: return is_subscribed else: # Удаляем устаревшую запись del self._cache[key] return None def set(self, user_id: int, channel_id: Union[str, int], is_subscribed: bool) -> None: """ Сохраняет значение в кэш. Args: user_id: ID пользователя channel_id: ID канала is_subscribed: Статус подписки """ key = (user_id, channel_id) self._cache[key] = (is_subscribed, time()) def invalidate(self, user_id: Optional[int] = None, channel_id: Optional[Union[str, int]] = None) -> None: """ Инвалидирует кэш. Args: user_id: ID пользователя (если None, инвалидирует все) channel_id: ID канала (если None, инвалидирует все для пользователя) """ if user_id is None and channel_id is None: # Полная очистка self._cache.clear() elif user_id is not None and channel_id is None: # Удаляем все записи пользователя keys_to_delete = [key for key in self._cache if key[0] == user_id] for key in keys_to_delete: del self._cache[key] elif user_id is not None and channel_id is not None: # Удаляем конкретную запись key = (user_id, channel_id) if key in self._cache: del self._cache[key] def cleanup(self) -> int: """ Удаляет устаревшие записи. Returns: int: Количество удаленных записей """ current_time = time() keys_to_delete = [ key for key, (_, timestamp) in self._cache.items() if current_time - timestamp >= self.ttl ] for key in keys_to_delete: del self._cache[key] return len(keys_to_delete) class SubscriptionMiddleware(BaseMiddleware): """ Middleware для проверки подписки пользователя на каналы. Возможности: - Проверка подписки на один или несколько каналов - Кэширование результатов проверки - Whitelist для администраторов - Автоматическое получение ссылок на каналы - Гибкая настройка обязательных/необязательных каналов - Красивое сообщение с кнопками подписки Attributes: bot: Экземпляр бота channels: Список конфигураций каналов cache_ttl: Время жизни кэша в секундах whitelist_admins: Пропускать ли администраторов бота show_buttons: Показывать ли кнопки для подписки Example: ```python from middleware.subscription import SubscriptionMiddleware, ChannelConfig channels = [ ChannelConfig( id="@my_channel", name="Основной канал", invite_link="https://t.me/my_channel" ), ChannelConfig( id=-1001234567890, name="Закрытый канал", required=True ) ] dp.message.middleware(SubscriptionMiddleware(bot, channels)) dp.callback_query.middleware(SubscriptionMiddleware(bot, channels)) ``` """ def __init__( self, bot: Bot, channels: list[Union[ChannelConfig, str, int]], cache_ttl: float = 300.0, whitelist_admins: bool = True, show_buttons: bool = True, auto_fetch_links: bool = True ): """ Инициализация middleware. Args: bot: Экземпляр бота channels: Список каналов (ChannelConfig, ID или username) cache_ttl: Время жизни кэша в секундах whitelist_admins: Пропускать администраторов бота show_buttons: Показывать кнопки подписки auto_fetch_links: Автоматически получать ссылки на каналы """ super().__init__() self.bot = bot self.cache = SubscriptionCache(ttl=cache_ttl) self.whitelist_admins = whitelist_admins self.show_buttons = show_buttons self.auto_fetch_links = auto_fetch_links # Преобразуем channels в ChannelConfig self.channels: list[ChannelConfig] = [] for channel in channels: if isinstance(channel, ChannelConfig): self.channels.append(channel) else: # Простой ID/username -> ChannelConfig self.channels.append(ChannelConfig(id=channel)) # Кэш информации о каналах self._channel_info_cache: Dict[Union[str, int], Optional[Chat]] = {} async def _get_channel_info(self, channel_id: Union[str, int]) -> Optional[Chat]: """ Получает информацию о канале. Args: channel_id: ID или username канала Returns: Chat или None: Информация о канале """ if channel_id in self._channel_info_cache: return self._channel_info_cache[channel_id] try: chat = await self.bot.get_chat(channel_id) self._channel_info_cache[channel_id] = chat return chat except (TelegramBadRequest, TelegramForbiddenError) as e: logger.error( f"Не удалось получить информацию о канале {channel_id}: {e}", log_type='SUBSCRIPTION' ) self._channel_info_cache[channel_id] = None return None async def _check_subscription( self, user_id: int, channel_config: ChannelConfig ) -> bool: """ Проверяет подписку пользователя на канал. Args: user_id: ID пользователя channel_config: Конфигурация канала Returns: bool: True если подписан """ channel_id = channel_config.id # Проверяем кэш cached = self.cache.get(user_id, channel_id) if cached is not None: logger.debug( f"Использован кэш для проверки подписки на {channel_id}: {cached}", log_type='SUBSCRIPTION' ) return cached # Выполняем проверку try: member = await self.bot.get_chat_member( chat_id=channel_id, user_id=user_id ) is_subscribed = member.status in ( ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR ) # Сохраняем в кэш self.cache.set(user_id, channel_id, is_subscribed) logger.debug( f"Проверка подписки user={user_id} на канал={channel_id}: " f"{member.status.value} ({'✅' if is_subscribed else '❌'})", log_type='SUBSCRIPTION' ) return is_subscribed except TelegramBadRequest as e: logger.warning( f"Канал {channel_id} недоступен или неверный: {e}", log_type='SUBSCRIPTION' ) # В случае ошибки считаем что не подписан self.cache.set(user_id, channel_id, False) return False except TelegramForbiddenError as e: logger.error( f"Бот не имеет доступа к каналу {channel_id}: {e}", log_type='SUBSCRIPTION' ) self.cache.set(user_id, channel_id, False) return False async def _build_subscription_message( self, not_subscribed: list[ChannelConfig] ) -> tuple[str, InlineKeyboardBuilder]: """ Создает сообщение и клавиатуру для подписки. Args: not_subscribed: Список каналов без подписки Returns: tuple: (текст_сообщения, клавиатура) """ # Текст сообщения text = "📢 Для использования бота необходимо подписаться на каналы:\n\n" # Клавиатура keyboard = InlineKeyboardBuilder() for i, channel_config in enumerate(not_subscribed, 1): # Получаем информацию о канале channel_info = await self._get_channel_info(channel_config.id) # Определяем название канала if channel_config.name: channel_name = channel_config.name elif channel_info: channel_name = channel_info.title else: channel_name = f"Канал {i}" # Добавляем в текст text += f"{i}. {channel_name}\n" # Определяем ссылку invite_link = channel_config.invite_link if not invite_link and self.auto_fetch_links and channel_info: # Пытаемся получить ссылку if channel_info.username: invite_link = f"https://t.me/{channel_info.username}" elif channel_info.invite_link: invite_link = channel_info.invite_link # Добавляем кнопку если есть ссылка if invite_link and self.show_buttons: keyboard.row( InlineKeyboardButton( text=f"📌 {channel_name}", url=invite_link ) ) text += "\n✅ После подписки нажмите кнопку ниже для проверки." # Кнопка проверки подписки keyboard.row( InlineKeyboardButton( text="✅ Я подписался", callback_data="check_subscription" ) ) return text, keyboard async def __call__( self, handler: Callable[[TelegramObject, Dict[str, Any]], Awaitable[Any]], event: TelegramObject, data: Dict[str, Any] ) -> Optional[Any]: """ Проверяет подписку перед выполнением хендлера. Args: handler: Функция хендлера event: Объект события data: Дополнительные данные Returns: Результат хендлера или None если не подписан """ # Пропускаем не-сообщения и не-callback if not isinstance(event, (Message, CallbackQuery)): return await handler(event, data) # Извлекаем user_id user_id = event.from_user.id if event.from_user else None if user_id is None: return await handler(event, data) user_str = f"@{event.from_user.username}" if event.from_user.username else f"id{user_id}" # Whitelist для администраторов if self.whitelist_admins and user_id in settings.super_admin_ids: logger.debug( f"Администратор {user_str} пропущен без проверки подписки", log_type='SUBSCRIPTION' ) return await handler(event, data) # Проверяем подписку на все каналы not_subscribed: list[ChannelConfig] = [] for channel_config in self.channels: # Пропускаем необязательные каналы if not channel_config.required: continue is_subscribed = await self._check_subscription(user_id, channel_config) if not is_subscribed: not_subscribed.append(channel_config) # Если есть каналы без подписки if not_subscribed: logger.info( f"Пользователь не подписан на {len(not_subscribed)} каналов", log_type='SUBSCRIPTION', user=user_str ) # Создаем сообщение text, keyboard = await self._build_subscription_message(not_subscribed) # Отправляем сообщение if isinstance(event, Message): await event.answer( text, reply_markup=keyboard.as_markup(), parse_mode="HTML" ) elif isinstance(event, CallbackQuery): # Для callback отправляем в чат или редактируем if event.message: try: await event.message.edit_text( text, reply_markup=keyboard.as_markup(), parse_mode="HTML" ) except: await event.message.answer( text, reply_markup=keyboard.as_markup(), parse_mode="HTML" ) await event.answer( "⚠️ Требуется подписка на каналы", show_alert=True ) return None # Все подписки в порядке logger.debug( f"Проверка подписки пройдена", log_type='SUBSCRIPTION', user=user_str ) return await handler(event, data) def invalidate_cache( self, user_id: Optional[int] = None, channel_id: Optional[Union[str, int]] = None ) -> None: """ Публичный метод для инвалидации кэша. Используется при обработке callback "check_subscription". Args: user_id: ID пользователя channel_id: ID канала """ self.cache.invalidate(user_id, channel_id) # ================= HANDLER ДЛЯ ПРОВЕРКИ ПОДПИСКИ ================= async def handle_check_subscription( callback: CallbackQuery, subscription_middleware: SubscriptionMiddleware ): """ Обработчик callback для повторной проверки подписки. Example: ```python from filters.callback import CallbackStartsWith from middleware.subscription import handle_check_subscription, subscription_middleware @router.callback_query(CallbackStartsWith("check_subscription")) async def check_sub(callback: CallbackQuery): await handle_check_subscription(callback, subscription_middleware) ``` """ user_id = callback.from_user.id # Инвалидируем кэш для пользователя subscription_middleware.invalidate_cache(user_id=user_id) await callback.answer("🔄 Проверяю подписку...", show_alert=False) # Перепроверяем подписку not_subscribed = [] for channel_config in subscription_middleware.channels: if not channel_config.required: continue is_subscribed = await subscription_middleware._check_subscription( user_id, channel_config ) if not is_subscribed: not_subscribed.append(channel_config) if not_subscribed: # Все еще не подписан text, keyboard = await subscription_middleware._build_subscription_message(not_subscribed) await callback.message.edit_text( text, reply_markup=keyboard.as_markup(), parse_mode="HTML" ) await callback.answer( f"❌ Вы еще не подписаны на {len(not_subscribed)} каналов", show_alert=True ) else: # Подписка подтверждена await callback.message.delete() await callback.message.answer( "✅ Подписка подтверждена!\n\n" "Теперь вы можете пользоваться ботом. Используйте /start", parse_mode="HTML" ) logger.info( f"Подписка успешно подтверждена", log_type='SUBSCRIPTION', user=f"@{callback.from_user.username}" if callback.from_user.username else f"id{user_id}" )