Фильтр на проверку подписки на канал от пользователя

This commit is contained in:
2026-02-23 14:24:11 +07:00
parent fe1bcb3509
commit 49e9c56bd1

246
bot/filters/subscription.py Normal file
View File

@@ -0,0 +1,246 @@
"""
Фильтр проверки подписки пользователя на каналы/группы
"""
from typing import Union, Optional
from dataclasses import dataclass
from aiogram import Bot
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import BaseFilter
from aiogram.types import Message, CallbackQuery
from middleware.loggers import logger
__all__ = ('IsSubscribed', 'SubscriptionChecker')
@dataclass
class ChannelInfo:
"""Информация о канале для проверки подписки"""
id: Union[str, int]
name: Optional[str] = None
invite_link: Optional[str] = None
class SubscriptionChecker:
"""
Вспомогательный класс для проверки подписок.
Может использоваться отдельно от фильтра.
"""
# Статусы, считающиеся подпиской
SUBSCRIBED_STATUSES: set[str] = {
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR
}
# Статусы, означающие отсутствие подписки
NOT_SUBSCRIBED_STATUSES: set[str] = {
ChatMemberStatus.LEFT,
ChatMemberStatus.KICKED,
ChatMemberStatus.RESTRICTED # Опционально
}
@classmethod
async def is_subscribed(
cls,
bot: Bot,
user_id: int,
channel_id: Union[str, int]
) -> bool:
"""
Проверяет подписку одного пользователя на один канал.
Args:
bot: Экземпляр бота
user_id: ID пользователя
channel_id: ID или username канала
Returns:
bool: True если подписан
"""
try:
member = await bot.get_chat_member(
chat_id=channel_id,
user_id=user_id
)
is_sub = member.status in cls.SUBSCRIBED_STATUSES
logger.debug(
f"Проверка подписки user={user_id} на канал={channel_id}: {member.status} ({'' if is_sub else ''})",
log_type='SUBSCRIPTION'
)
return is_sub
except TelegramBadRequest as e:
logger.warning(
f"Канал {channel_id} недоступен или неверный ID: {e}",
log_type='SUBSCRIPTION'
)
return False
except TelegramForbiddenError as e:
logger.error(
f"Бот не имеет доступа к каналу {channel_id}: {e}",
log_type='SUBSCRIPTION'
)
return False
except Exception as e:
logger.error(
f"Непредвиденная ошибка проверки подписки на {channel_id}: {e}",
log_type='SUBSCRIPTION'
)
return False
@classmethod
async def check_all_channels(
cls,
bot: Bot,
user_id: int,
channels: list[Union[str, int]]
) -> dict[Union[str, int], bool]:
"""
Проверяет подписку на несколько каналов одновременно.
Args:
bot: Экземпляр бота
user_id: ID пользователя
channels: Список ID/username каналов
Returns:
dict: Словарь {channel_id: is_subscribed}
"""
results = {}
for channel in channels:
results[channel] = await cls.is_subscribed(bot, user_id, channel)
return results
@classmethod
async def get_not_subscribed_channels(
cls,
bot: Bot,
user_id: int,
channels: list[Union[str, int]]
) -> list[Union[str, int]]:
"""
Возвращает список каналов, на которые пользователь НЕ подписан.
Args:
bot: Экземпляр бота
user_id: ID пользователя
channels: Список ID/username каналов
Returns:
list: Список каналов без подписки
"""
not_subscribed = []
for channel in channels:
if not await cls.is_subscribed(bot, user_id, channel):
not_subscribed.append(channel)
return not_subscribed
class IsSubscribed(BaseFilter):
"""
Фильтр для проверки подписки пользователя на каналы/группы.
Поддерживает:
- Публичные каналы (username: "@channel_name")
- Приватные каналы/группы (ID: -1001234567890)
- Проверку всех или хотя бы одного канала
- Работу с Message и CallbackQuery
Attributes:
channels: Список ID или username каналов для проверки
require_all: Требовать подписку на все каналы (True) или хотя бы один (False)
Examples:
>> # Проверка подписки на один канал
>> @router.message(IsSubscribed(["@my_channel"]))
>> async def handler(message: Message):
... await message.answer("Ты подписан!")
>> # Проверка на несколько каналов (все обязательны)
>> @router.message(IsSubscribed(["@channel1", -1001234567890], require_all=True))
>> async def handler(message: Message):
... await message.answer("Ты подписан на все каналы!")
>> # Проверка на несколько каналов (хотя бы один)
>> @router.message(IsSubscribed(["@channel1", "@channel2"], require_all=False))
>> async def handler(message: Message):
... await message.answer("Ты подписан хотя бы на один канал!")
"""
def __init__(
self,
channels: list[Union[str, int]],
require_all: bool = True
) -> None:
"""
Инициализация фильтра.
Args:
channels: Список ID или username каналов
require_all: True = все каналы, False = хотя бы один
"""
if not channels:
raise ValueError("Список каналов не может быть пустым")
self.channels = channels
self.require_all = require_all
async def __call__(
self,
event: Union[Message, CallbackQuery],
bot: Bot
) -> Union[bool, dict]:
"""
Проверка подписки.
Args:
event: Message или CallbackQuery
bot: Экземпляр бота
Returns:
bool или dict: True/False для простой проверки,
dict с деталями для сложной логики
"""
user_id = event.from_user.id
# Проверка всех каналов
results = await SubscriptionChecker.check_all_channels(
bot, user_id, self.channels
)
# Логика проверки
if self.require_all:
# Все каналы обязательны
is_passed = all(results.values())
else:
# Хотя бы один канал
is_passed = any(results.values())
# Логирование
if not is_passed:
not_subscribed = [ch for ch, sub in results.items() if not sub]
logger.info(
f"Пользователь {user_id} не подписан на: {not_subscribed}",
log_type='SUBSCRIPTION',
message=event if isinstance(event, Message) else None
)
# Возвращаем результат + детали для handler
return {
'is_subscribed': is_passed,
'subscription_results': results,
'not_subscribed_channels': [ch for ch, sub in results.items() if not sub]
} if not is_passed else is_passed