Мидлвеер на проверку подписки на каналы
This commit is contained in:
553
bot/middlewares/sub_mdw.py
Normal file
553
bot/middlewares/sub_mdw.py
Normal file
@@ -0,0 +1,553 @@
|
|||||||
|
"""
|
||||||
|
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 = "📢 <b>Для использования бота необходимо подписаться на каналы:</b>\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(
|
||||||
|
"✅ <b>Подписка подтверждена!</b>\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}"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user