Files
remnawave-bedolaga-telegram…/app/middlewares/channel_checker.py
T
Fringg 8375d7ecc5 feat: add multi-channel mandatory subscription system
- Multi-channel subscription enforcement via middleware, events, and cabinet API
- 3-layer cache architecture: Redis -> PostgreSQL -> rate-limited Telegram API
- ChatMemberUpdated event-driven tracking with automatic VPN access control
- Admin management via bot FSM handler and REST API with full CRUD
- Channel ID normalization: @username resolved to numeric ID at creation time
- Fail-closed error handling: API errors deny access (security-first)
- Background reconciliation with keyset pagination (100 per batch)
- Per-user rate limiting on subscription check button (5s cooldown)
- Redis connection pooling via cache singleton (no per-request connections)
- Database: channel_id index, multi-row upsert optimization
- Localization: en, ru, zh, fa, ua translations for all new strings
- Frontend blocking UI with channel list and subscription status
- Admin channel management page with toggle, delete, and create
2026-02-24 02:50:31 +03:00

492 lines
20 KiB
Python

from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
import structlog
from aiogram import BaseMiddleware, Bot, types
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
from app.config import settings
from app.database.crud.campaign import get_campaign_by_start_parameter
from app.database.crud.subscription import deactivate_subscription, reactivate_subscription
from app.database.crud.user import get_user_by_telegram_id
from app.database.database import AsyncSessionLocal
from app.database.models import SubscriptionStatus, UserStatus
from app.keyboards.inline import get_channel_sub_keyboard
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_texts
from app.services.admin_notification_service import AdminNotificationService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.subscription_service import SubscriptionService
from app.utils.cache import cache
from app.utils.check_reg_process import is_registration_process
logger = structlog.get_logger(__name__)
# Redis key prefix and TTL for pending /start payload backup
REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:'
REDIS_PAYLOAD_TTL = 3600 # 1 hour
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
"""Save pending_start_payload to Redis via the shared cache singleton."""
try:
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
result = await cache.set(key, payload, expire=REDIS_PAYLOAD_TTL)
if result:
logger.info('Saved pending payload to Redis', payload=payload, telegram_id=telegram_id)
return result
except Exception as e:
logger.error('Failed to save payload to Redis', telegram_id=telegram_id, error=e)
return False
async def get_pending_payload_from_redis(telegram_id: int) -> str | None:
"""Get pending_start_payload from Redis via the shared cache singleton."""
try:
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
return await cache.get(key)
except Exception as e:
logger.debug('Failed to get payload from Redis', telegram_id=telegram_id, error=e)
return None
async def delete_pending_payload_from_redis(telegram_id: int) -> None:
"""Delete pending_start_payload from Redis via the shared cache singleton."""
try:
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
await cache.delete(key)
except Exception:
pass
class ChannelCheckerMiddleware(BaseMiddleware):
"""Middleware for checking required channel subscriptions.
OPTIMIZED FOR 100k+ USERS:
- Does NOT call Telegram API directly in the hot path
- Reads from Redis cache (TTL 600s) -> PostgreSQL -> rate-limited API fallback
- Updated in real-time via ChatMemberUpdated events
"""
def __init__(self):
logger.info('ChannelCheckerMiddleware initialized (multi-channel mode)')
async def __call__(
self,
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[Any]],
event: TelegramObject,
data: dict[str, Any],
) -> Any:
# Runtime check (supports toggling without restart)
if not settings.CHANNEL_IS_REQUIRED_SUB:
return await handler(event, data)
# Fast-path bypasses
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id
elif isinstance(event, Update):
if event.message:
telegram_id = event.message.from_user.id
elif event.callback_query:
telegram_id = event.callback_query.from_user.id
if telegram_id is None:
return await handler(event, data)
# Skip channel check for lightweight UI callbacks (close/delete notifications)
if isinstance(event, CallbackQuery) and event.data in (
'webhook:close',
'ban_notify:delete',
'noop',
'current_page',
):
return await handler(event, data)
if settings.is_admin(telegram_id):
return await handler(event, data)
state: FSMContext = data.get('state')
current_state = await state.get_state() if state else None
if is_registration_process(event, current_state):
return await handler(event, data)
# Ensure service has bot reference for API fallback
bot: Bot = data['bot']
if not channel_subscription_service.bot:
channel_subscription_service.bot = bot
# Multi-channel check (Redis -> DB -> API)
unsubscribed = await channel_subscription_service.get_unsubscribed_channels(telegram_id)
if not unsubscribed:
# All subscribed -- reactivate if needed
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
return await handler(event, data)
# User is NOT subscribed to all channels
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, unsubscribed)
await self._capture_start_payload(state, event, bot)
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
# Rate limit: max 1 check per 5 seconds per user
rate_key = f'sub_check_rate:{telegram_id}'
if await cache.exists(rate_key):
await event.answer()
return None
await cache.set(rate_key, 1, expire=5)
# Re-check via API for immediate feedback (invalidate cache first)
await channel_subscription_service.invalidate_user_cache(telegram_id)
unsubscribed_fresh = await channel_subscription_service.get_unsubscribed_channels(telegram_id)
if not unsubscribed_fresh:
# Now subscribed to all channels
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
return await handler(event, data)
user_lang = (
event.from_user.language_code.split('-')[0]
if event.from_user and event.from_user.language_code
else DEFAULT_LANGUAGE
)
texts = get_texts(user_lang)
await event.answer(
texts.t(
'CHANNEL_CHECK_NOT_SUBSCRIBED',
'You are not subscribed to all required channels. Please subscribe and try again.',
),
show_alert=True,
)
return None
return await self._deny_message(event, bot, unsubscribed)
# -- _deny_message (multi-channel) -----------------------------------------
@staticmethod
async def _deny_message(
event: TelegramObject,
bot: Bot,
unsubscribed_channels: list[dict],
):
user = None
if isinstance(event, (Message, CallbackQuery)):
user = getattr(event, 'from_user', None)
elif isinstance(event, Update):
if event.message and event.message.from_user:
user = event.message.from_user
elif event.callback_query and event.callback_query.from_user:
user = event.callback_query.from_user
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
# Normalize channel links (convert @username -> https://t.me/username)
normalized = []
for ch in unsubscribed_channels:
ch_copy = dict(ch)
link = ch_copy.get('channel_link')
if link:
ch_copy['channel_link'] = _normalize_channel_link(link)
normalized.append(ch_copy)
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(normalized, language=language)
text = texts.t(
'CHANNEL_REQUIRED_TEXT',
'🔒 Для использования бота подпишитесь на новостной канал, '
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
)
try:
if isinstance(event, Message):
return await event.answer(text, reply_markup=channel_sub_kb)
if isinstance(event, CallbackQuery):
try:
return await event.message.edit_text(text, reply_markup=channel_sub_kb)
except TelegramBadRequest as e:
if 'message is not modified' in str(e).lower():
return await event.answer(text, show_alert=True)
raise
elif isinstance(event, Update) and event.message:
return await bot.send_message(event.message.chat.id, text, reply_markup=channel_sub_kb)
except Exception as e:
logger.error('Error sending subscription prompt', error=e)
# -- _capture_start_payload ------------------------------------------------
async def _capture_start_payload(
self,
state: FSMContext | None,
event: TelegramObject,
bot: Bot | None = None,
) -> None:
"""Save /start payload to FSM + Redis so it can be restored after subscription.
This preserves referral codes, deep links, and other start parameters
when a user is blocked by the channel subscription requirement.
"""
telegram_id = None
if isinstance(event, (Message, CallbackQuery)):
telegram_id = event.from_user.id if event.from_user else None
message: Message | None = None
if isinstance(event, Message):
message = event
elif isinstance(event, (CallbackQuery, Update)):
message = event.message
if not message or not message.text:
return
text = message.text.strip()
if not text.startswith('/start'):
return
parts = text.split(maxsplit=1)
if len(parts) < 2 or not parts[1]:
return
payload = parts[1]
# Save to FSM state
if state:
state_data = await state.get_data() or {}
if state_data.get('pending_start_payload') != payload:
state_data['pending_start_payload'] = payload
await state.set_data(state_data)
logger.info('Saved start payload for user (FSM)', payload=payload, telegram_id=telegram_id)
else:
logger.warning('_capture_start_payload: state=None for user', telegram_id=telegram_id)
# Also save to Redis as backup (in case FSM state is lost)
if telegram_id:
await save_pending_payload_to_redis(telegram_id, payload)
if bot and message.from_user and state:
await self._try_send_campaign_visit_notification(
bot,
message.from_user,
state,
payload,
)
async def _try_send_campaign_visit_notification(
self,
bot: Bot,
telegram_user: types.User,
state: FSMContext,
payload: str,
) -> None:
try:
state_data = await state.get_data() or {}
except Exception as error:
logger.error('Failed to get state data for campaign notification', payload=payload, error=error)
return
if state_data.get('campaign_notification_sent'):
return
async with AsyncSessionLocal() as db:
try:
campaign = await get_campaign_by_start_parameter(
db,
payload,
only_active=True,
)
if not campaign:
return
user = await get_user_by_telegram_id(db, telegram_user.id)
notification_service = AdminNotificationService(bot)
sent = await notification_service.send_campaign_link_visit_notification(
db,
telegram_user,
campaign,
user,
)
if sent:
await state.update_data(campaign_notification_sent=True)
await db.commit()
except Exception as error:
logger.error('Error sending campaign visit notification', payload=payload, error=error)
await db.rollback()
# -- _deactivate (multi-channel) -------------------------------------------
async def _deactivate_subscription_on_unsubscribe(
self,
telegram_id: int,
bot: Bot,
unsubscribed_channels: list[dict],
) -> None:
"""Deactivate subscription when user unsubscribes from required channels."""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async with AsyncSessionLocal() as db:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user or not user.subscription:
return
subscription = user.subscription
if subscription.status != SubscriptionStatus.ACTIVE.value:
return
from app.database.crud.subscription import is_active_paid_subscription
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass
elif not subscription.is_trial:
return
if is_active_paid_subscription(subscription):
logger.info(
'Skipping deactivation: user has active paid subscription',
telegram_id=telegram_id,
)
return
await deactivate_subscription(db, subscription)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription deactivated after channel unsubscribe',
sub_type=sub_type,
telegram_id=telegram_id,
)
if user.remnawave_uuid:
service = SubscriptionService()
try:
await service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'Failed to disable RemnaWave user',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
)
# Notify user about deactivation
try:
# Normalize links for keyboard
normalized = []
for ch in unsubscribed_channels:
ch_copy = dict(ch)
link = ch_copy.get('channel_link')
if link:
ch_copy['channel_link'] = _normalize_channel_link(link)
normalized.append(ch_copy)
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
)
channel_kb = get_channel_sub_keyboard(normalized, language=user.language)
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.error(
'Failed to send deactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error(
'Error deactivating subscription after channel unsubscribe',
telegram_id=telegram_id,
db_error=db_error,
)
await db.rollback()
# -- _reactivate -----------------------------------------------------------
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
"""Reactivate subscription after user subscribes to all required channels."""
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async with AsyncSessionLocal() as db:
try:
user = await get_user_by_telegram_id(db, telegram_id)
if not user or not user.subscription:
return
# Do NOT reactivate for blocked users
if user.status == UserStatus.BLOCKED.value:
logger.info('Skipping reactivation for blocked user', telegram_id=telegram_id)
return
subscription = user.subscription
# Only reactivate DISABLED subscriptions
if subscription.status != SubscriptionStatus.DISABLED.value:
return
# Check subscription has not expired
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
return
await reactivate_subscription(db, subscription)
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'Subscription reactivated after channel subscribe',
sub_type=sub_type,
telegram_id=telegram_id,
)
# Enable in RemnaWave
if user.remnawave_uuid:
service = SubscriptionService()
try:
await service.enable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'Failed to enable RemnaWave user',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
)
# Notify user about reactivation
try:
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
'✅ Ваша подписка восстановлена!\n\nСпасибо, что подписались на канал. VPN снова работает.',
)
await bot.send_message(telegram_id, notification_text)
except Exception as notify_error:
logger.warning(
'Failed to send reactivation notification to user',
telegram_id=telegram_id,
notify_error=notify_error,
)
await db.commit()
except Exception as db_error:
logger.error('Error reactivating subscription', telegram_id=telegram_id, db_error=db_error)
await db.rollback()
def _normalize_channel_link(link: str) -> str:
"""Normalize channel link: convert @username to https://t.me/username."""
if not link:
return link
link = link.strip()
if link.startswith('@'):
return f'https://t.me/{link[1:]}'
return link