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
This commit is contained in:
Fringg
2026-02-24 02:50:18 +03:00
parent 751e312f28
commit 8375d7ecc5
36 changed files with 1906 additions and 636 deletions
+1 -3
View File
@@ -116,10 +116,8 @@ BLACKLIST_UPDATE_INTERVAL_HOURS=24 # Интервал обновле
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
# Обязательная подписка на канал
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
# Channel subscription settings (channels are managed via admin panel)
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
CHANNEL_LINK= # Опционально ссылка на канал
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
+8 -8
View File
@@ -45,6 +45,7 @@ from app.handlers.admin import (
referrals as admin_referrals,
remnawave as admin_remnawave,
reports as admin_reports,
required_channels as admin_required_channels,
rules as admin_rules,
servers as admin_servers,
statistics as admin_statistics,
@@ -58,6 +59,7 @@ from app.handlers.admin import (
users as admin_users,
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
@@ -135,15 +137,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
dp.callback_query.middleware(button_stats_middleware)
logger.info('📊 ButtonStatsMiddleware активирован')
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.middlewares.channel_checker import ChannelCheckerMiddleware
from app.middlewares.channel_checker import ChannelCheckerMiddleware
channel_checker_middleware = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker_middleware)
dp.callback_query.middleware(channel_checker_middleware)
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
else:
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
channel_checker = ChannelCheckerMiddleware()
dp.message.middleware(channel_checker)
dp.callback_query.middleware(channel_checker)
dp.message.middleware(AuthMiddleware())
dp.callback_query.middleware(AuthMiddleware())
dp.pre_checkout_query.middleware(AuthMiddleware())
@@ -194,6 +192,8 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_bulk_ban.register_bulk_ban_handlers(dp)
admin_blacklist.register_blacklist_handlers(dp)
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
+18 -43
View File
@@ -1,9 +1,6 @@
"""FastAPI dependencies for cabinet module."""
import asyncio
import structlog
from aiogram import Bot
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,17 +20,6 @@ logger = structlog.get_logger(__name__)
security = HTTPBearer(auto_error=False)
# Кешированный Bot для проверки подписки на канал
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
async def get_cabinet_db() -> AsyncSession:
"""Get database session for cabinet operations."""
@@ -163,42 +149,31 @@ async def get_current_cabinet_user(
},
)
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
# Пропускаем проверку для email-only юзеров (нет telegram_id)
# Check required channel subscription - Telegram users only
if settings.CHANNEL_IS_REQUIRED_SUB:
# Skip for email-only users (no telegram_id)
if user.telegram_id is not None:
# Проверяем админа по telegram_id ИЛИ email
# Skip admin check
is_admin = settings.is_admin(
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
)
if not is_admin:
try:
bot = _get_channel_check_bot()
chat_member = await asyncio.wait_for(
bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=user.telegram_id),
timeout=10.0,
)
# Не закрываем сессию - бот переиспользуется
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except TimeoutError:
logger.warning('Timeout checking channel subscription for user', telegram_id=user.telegram_id)
# Don't block user if check times out
except Exception as e:
logger.warning(
'Failed to check channel subscription for user', telegram_id=user.telegram_id, error=e
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
is_subscribed = (
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
)
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
# Don't block user if check fails
return user
+2
View File
@@ -7,6 +7,7 @@ from .admin_ban_system import router as admin_ban_system_router
from .admin_broadcasts import router as admin_broadcasts_router
from .admin_button_styles import router as admin_button_styles_router
from .admin_campaigns import router as admin_campaigns_router
from .admin_channels import router as admin_channels_router
from .admin_email_templates import router as admin_email_templates_router
from .admin_partners import router as admin_partners_router
from .admin_payment_methods import router as admin_payment_methods_router
@@ -101,6 +102,7 @@ router.include_router(admin_updates_router)
router.include_router(admin_traffic_router)
router.include_router(admin_pinned_messages_router)
router.include_router(admin_button_styles_router)
router.include_router(admin_channels_router)
# WebSocket route
router.include_router(websocket_router)
+95
View File
@@ -0,0 +1,95 @@
"""Admin API for managing required channels."""
import structlog
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
toggle_channel,
update_channel,
)
from app.database.models import User
from app.services.channel_subscription_service import channel_subscription_service
from ..dependencies import get_cabinet_db, get_current_admin_user
from ..schemas.channel import (
ChannelCreateRequest,
ChannelListResponse,
ChannelResponse,
ChannelUpdateRequest,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix='/admin/channel-subscriptions', tags=['Cabinet Admin Channels'])
@router.get('', response_model=ChannelListResponse)
async def list_channels(
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelListResponse:
channels = await get_all_channels(db)
return ChannelListResponse(
items=[ChannelResponse.model_validate(ch) for ch in channels],
total=len(channels),
)
@router.post('', response_model=ChannelResponse, status_code=201)
async def create_channel(
data: ChannelCreateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
# NOTE: REST API does not resolve @username to numeric channel ID (no Bot instance available).
# The bot FSM handler is the primary creation path and resolves automatically.
# When using REST API, callers should provide numeric channel IDs (e.g. -1001234567890)
# to ensure ChatMemberUpdated event matching works correctly.
ch = await add_channel(db, channel_id=data.channel_id, channel_link=data.channel_link, title=data.title)
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.patch('/{channel_db_id}', response_model=ChannelResponse)
async def update_channel_endpoint(
channel_db_id: int,
data: ChannelUpdateRequest,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
update_data = data.model_dump(exclude_unset=True)
ch = await update_channel(db, channel_db_id, **update_data)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.post('/{channel_db_id}/toggle', response_model=ChannelResponse)
async def toggle_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> ChannelResponse:
ch = await toggle_channel(db, channel_db_id)
if not ch:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
return ChannelResponse.model_validate(ch)
@router.delete('/{channel_db_id}', status_code=204)
async def delete_channel_endpoint(
channel_db_id: int,
db: AsyncSession = Depends(get_cabinet_db),
_admin: User = Depends(get_current_admin_user),
) -> None:
ok = await delete_channel(db, channel_db_id)
if not ok:
raise HTTPException(status_code=404, detail='Channel not found')
await channel_subscription_service.invalidate_channels_cache()
+78
View File
@@ -0,0 +1,78 @@
"""Pydantic v2 schemas for channel subscription management."""
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.database.crud.required_channel import validate_channel_id as _validate_channel_id_format
def _validate_channel_link_value(v: str | None) -> str | None:
"""Shared channel_link validation: t.me URL, @username auto-convert, http->https upgrade."""
if v is None:
return v
v = v.strip()
if v.startswith('http://t.me/'):
v = v.replace('http://', 'https://', 1)
if v.startswith('https://t.me/'):
return v
if v.startswith('@'):
return f'https://t.me/{v[1:]}'
raise ValueError('channel_link must be a t.me URL or @username')
class ChannelResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
channel_id: str
channel_link: str | None
title: str | None
is_active: bool
sort_order: int
class ChannelListResponse(BaseModel):
items: list[ChannelResponse]
total: int
class ChannelCreateRequest(BaseModel):
channel_id: str
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str) -> str:
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelUpdateRequest(BaseModel):
channel_id: str | None = None
channel_link: str | None = None
title: str | None = Field(None, max_length=255)
is_active: bool | None = None
sort_order: int | None = None
@field_validator('channel_id')
@classmethod
def validate_channel_id(cls, v: str | None) -> str | None:
if v is None:
return v
return _validate_channel_id_format(v)
@field_validator('channel_link')
@classmethod
def validate_channel_link(cls, v: str | None) -> str | None:
return _validate_channel_link_value(v)
class ChannelSubscriptionStatus(BaseModel):
channel_id: str
channel_link: str | None
title: str | None
is_subscribed: bool
-2
View File
@@ -66,8 +66,6 @@ class Settings(BaseSettings):
ADMIN_REPORTS_TOPIC_ID: int | None = None
ADMIN_REPORTS_SEND_TIME: str | None = None
CHANNEL_SUB_ID: str | None = None
CHANNEL_LINK: str | None = None
CHANNEL_IS_REQUIRED_SUB: bool = False
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
CHANNEL_REQUIRED_FOR_ALL: bool = False
+225
View File
@@ -0,0 +1,225 @@
import re
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import RequiredChannel, UserChannelSubscription
logger = structlog.get_logger(__name__)
# Explicit allowlist of fields that can be updated via update_channel()
_UPDATABLE_FIELDS = frozenset({'channel_id', 'channel_link', 'title', 'is_active', 'sort_order'})
# Validation patterns for channel_id
_CHANNEL_ID_USERNAME = re.compile(r'^@[a-zA-Z][a-zA-Z0-9_]{3,30}$')
_CHANNEL_ID_NUMERIC = re.compile(r'^-100\d{10,13}$')
def validate_channel_id(channel_id: str) -> str:
"""Validate and normalize channel_id. Raises ValueError on invalid input."""
channel_id = channel_id.strip()
if _CHANNEL_ID_USERNAME.match(channel_id) or _CHANNEL_ID_NUMERIC.match(channel_id):
return channel_id
raise ValueError(
f'Invalid channel_id format: {channel_id!r}. Must be @username (4-31 chars) or numeric ID like -100XXXXXXXXXX'
)
async def resolve_channel_id(bot: Bot, channel_id: str) -> str:
"""Resolve @username to numeric channel ID via Telegram API.
Required because ChatMemberUpdated events use numeric IDs.
If channel_id is already numeric, returns as-is.
"""
if _CHANNEL_ID_NUMERIC.match(channel_id):
return channel_id
try:
chat = await bot.get_chat(channel_id)
resolved = str(chat.id)
logger.info('Resolved channel username to numeric ID', username=channel_id, numeric_id=resolved)
return resolved
except Exception as e:
logger.error('Failed to resolve channel ID', channel_id=channel_id, error=e)
raise ValueError(f'Cannot resolve channel {channel_id}: {e}') from e
# -- RequiredChannel CRUD --------------------------------------------------------
async def get_active_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all active required channels (sorted by sort_order)."""
result = await db.execute(
select(RequiredChannel)
.where(RequiredChannel.is_active.is_(True))
.order_by(RequiredChannel.sort_order, RequiredChannel.id)
)
return list(result.scalars().all())
async def get_all_channels(db: AsyncSession) -> list[RequiredChannel]:
"""Get all required channels (including inactive)."""
result = await db.execute(select(RequiredChannel).order_by(RequiredChannel.sort_order, RequiredChannel.id))
return list(result.scalars().all())
async def get_channel_by_id(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.id == channel_db_id))
return result.scalar_one_or_none()
async def get_channel_by_channel_id(db: AsyncSession, channel_id: str) -> RequiredChannel | None:
result = await db.execute(select(RequiredChannel).where(RequiredChannel.channel_id == channel_id))
return result.scalar_one_or_none()
async def add_channel(
db: AsyncSession,
channel_id: str,
channel_link: str | None = None,
title: str | None = None,
) -> RequiredChannel:
channel_id = validate_channel_id(channel_id)
channel = RequiredChannel(
channel_id=channel_id,
channel_link=channel_link,
title=title,
)
db.add(channel)
await db.commit()
await db.refresh(channel)
return channel
async def update_channel(
db: AsyncSession,
channel_db_id: int,
**kwargs,
) -> RequiredChannel | None:
"""Update channel fields. Only fields in _UPDATABLE_FIELDS are accepted."""
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
for key, value in kwargs.items():
if key not in _UPDATABLE_FIELDS:
logger.warning('Rejected update of non-updatable field', field=key)
continue
if key == 'channel_id' and value is not None:
value = validate_channel_id(value)
setattr(channel, key, value)
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
async def delete_channel(db: AsyncSession, channel_db_id: int) -> bool:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return False
# Also clean up user subscriptions for this channel
await db.execute(delete(UserChannelSubscription).where(UserChannelSubscription.channel_id == channel.channel_id))
await db.delete(channel)
await db.commit()
return True
async def toggle_channel(db: AsyncSession, channel_db_id: int) -> RequiredChannel | None:
channel = await get_channel_by_id(db, channel_db_id)
if not channel:
return None
channel.is_active = not channel.is_active
channel.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(channel)
return channel
# -- UserChannelSubscription CRUD ------------------------------------------------
async def upsert_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
is_member: bool,
) -> None:
"""Upsert user subscription status (PostgreSQL ON CONFLICT)."""
now = datetime.now(UTC) # Single timestamp for both INSERT and UPDATE
stmt = (
pg_insert(UserChannelSubscription)
.values(
telegram_id=telegram_id,
channel_id=channel_id,
is_member=is_member,
checked_at=now,
)
.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': is_member,
'checked_at': now,
},
)
)
await db.execute(stmt)
# NOTE: caller is responsible for commit (allows batching)
async def get_user_channel_subs(
db: AsyncSession,
telegram_id: int,
) -> list[UserChannelSubscription]:
"""Get all channel subscriptions for a user."""
result = await db.execute(select(UserChannelSubscription).where(UserChannelSubscription.telegram_id == telegram_id))
return list(result.scalars().all())
async def get_user_channel_sub(
db: AsyncSession,
telegram_id: int,
channel_id: str,
) -> UserChannelSubscription | None:
result = await db.execute(
select(UserChannelSubscription).where(
UserChannelSubscription.telegram_id == telegram_id,
UserChannelSubscription.channel_id == channel_id,
)
)
return result.scalar_one_or_none()
async def bulk_upsert_user_subs(
db: AsyncSession,
telegram_id: int,
subs: dict[str, bool], # {channel_id: is_member}
) -> None:
"""Batch upsert user subscriptions with single multi-row INSERT."""
if not subs:
return
now = datetime.now(UTC)
values = [
{
'telegram_id': telegram_id,
'channel_id': channel_id,
'is_member': is_member,
'checked_at': now,
}
for channel_id, is_member in subs.items()
]
stmt = pg_insert(UserChannelSubscription).values(values)
stmt = stmt.on_conflict_do_update(
constraint='uq_user_channel_sub',
set_={
'is_member': stmt.excluded.is_member,
'checked_at': stmt.excluded.checked_at,
},
)
await db.execute(stmt)
await db.commit()
+45
View File
@@ -2810,3 +2810,48 @@ class PaymentMethodConfig(Base):
def __repr__(self) -> str:
return f"<PaymentMethodConfig method_id='{self.method_id}' order={self.sort_order} enabled={self.is_enabled}>"
class RequiredChannel(Base):
"""Channels that users must subscribe to in order to use the bot."""
__tablename__ = 'required_channels'
id = Column(Integer, primary_key=True, autoincrement=True)
channel_id = Column(String(100), unique=True, nullable=False) # @username or -100xxx (always string)
channel_link = Column(String(500), nullable=True) # https://t.me/xxx
title = Column(String(255), nullable=True) # Display name
is_active = Column(Boolean, nullable=False, server_default='true')
sort_order = Column(Integer, nullable=False, server_default='0')
created_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
updated_at = Column(AwareDateTime(), nullable=True, onupdate=func.now())
def __repr__(self) -> str:
return f'<RequiredChannel id={self.id} channel_id={self.channel_id!r} active={self.is_active}>'
class UserChannelSubscription(Base):
"""Cache of user subscription status per required channel."""
__tablename__ = 'user_channel_subscriptions'
id = Column(Integer, primary_key=True, autoincrement=True)
telegram_id = Column(BigInteger, nullable=False)
channel_id = Column(String(100), nullable=False) # matches RequiredChannel.channel_id
is_member = Column(Boolean, nullable=False, server_default='false')
checked_at = Column(AwareDateTime(), nullable=False, server_default=func.now())
__table_args__ = (
UniqueConstraint('telegram_id', 'channel_id', name='uq_user_channel_sub'),
# UniqueConstraint creates its own index; only add telegram_id index for
# "get all subs for user" queries
Index('ix_user_channel_sub_telegram_id', 'telegram_id'),
# Standalone channel_id index for delete_channel() bulk DELETE
Index('ix_user_channel_sub_channel_id', 'channel_id'),
)
def __repr__(self) -> str:
return (
f'<UserChannelSubscription telegram_id={self.telegram_id}'
f' channel={self.channel_id!r} member={self.is_member}>'
)
+1
View File
@@ -24,6 +24,7 @@ from . import (
referrals,
remnawave,
reports,
required_channels,
rules,
servers,
statistics,
+4 -3
View File
@@ -318,10 +318,11 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
if key == 'core':
token_ok = bool(getattr(settings, 'BOT_TOKEN', ''))
channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB)
if token_ok and channel_ok:
# Channel subscription channels are now managed via DB (admin panel),
# not a single CHANNEL_LINK setting. Dashboard cannot async-query DB here.
if token_ok:
return '🟢', 'Бот готов к работе'
return '🟡', 'Проверьте токен и обязательную подписку'
return '🟡', 'Проверьте токен бота'
if key == 'subscriptions':
price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS
+8 -21
View File
@@ -137,13 +137,16 @@ def _build_notification_settings_view(language: str):
return summary_text, keyboard
def _build_notification_preview_message(language: str, notification_type: str):
async def _build_notification_preview_message(language: str, notification_type: str):
texts = get_texts(language)
now = datetime.now(UTC)
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.keyboards.inline import get_channel_sub_keyboard
from app.services.channel_subscription_service import channel_subscription_service
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
if notification_type == 'trial_channel_unsubscribed':
@@ -157,25 +160,9 @@ def _build_notification_preview_message(language: str, notification_type: str):
)
check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался')
message = template.format(check_button=check_button)
buttons: list[list[InlineKeyboardButton]] = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=settings.CHANNEL_LINK,
)
]
)
buttons.append(
[
InlineKeyboardButton(
text=check_button,
callback_data='sub_channel_check',
)
]
)
keyboard = InlineKeyboardMarkup(inline_keyboard=buttons)
# Use all required channels for the preview keyboard
required_channels = await channel_subscription_service.get_required_channels()
keyboard = get_channel_sub_keyboard(required_channels, language=language)
elif notification_type == 'expired_1d':
template = texts.get(
'SUBSCRIPTION_EXPIRED_1D',
@@ -307,7 +294,7 @@ def _build_notification_preview_message(language: str, notification_type: str):
async def _send_notification_preview(bot, chat_id: int, language: str, notification_type: str) -> None:
message, keyboard = _build_notification_preview_message(language, notification_type)
message, keyboard = await _build_notification_preview_message(language, notification_type)
await bot.send_message(
chat_id,
message,
+285
View File
@@ -0,0 +1,285 @@
"""Admin handler for managing required channel subscriptions."""
import structlog
from aiogram import Bot, F, Router
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from app.database.crud.required_channel import (
add_channel,
delete_channel,
get_all_channels,
get_channel_by_id,
resolve_channel_id,
toggle_channel,
validate_channel_id,
)
from app.database.database import AsyncSessionLocal
from app.services.channel_subscription_service import channel_subscription_service
from app.utils.decorators import admin_required
logger = structlog.get_logger(__name__)
router = Router(name='admin_required_channels')
class AddChannelStates(StatesGroup):
waiting_channel_id = State()
waiting_channel_link = State()
waiting_channel_title = State()
# -- List channels ----------------------------------------------------------------
def _channels_keyboard(channels: list) -> InlineKeyboardMarkup:
buttons = []
for ch in channels:
status = 'ON' if ch.is_active else 'OFF'
title = ch.title or ch.channel_id
buttons.append(
[
InlineKeyboardButton(
text=f'{status} {title}',
callback_data=f'reqch:view:{ch.id}',
)
]
)
buttons.append([InlineKeyboardButton(text='+ Add channel', callback_data='reqch:add')])
buttons.append([InlineKeyboardButton(text='< Back', callback_data='admin:back')])
return InlineKeyboardMarkup(inline_keyboard=buttons)
def _channel_detail_keyboard(channel_id: int, is_active: bool) -> InlineKeyboardMarkup:
toggle_text = 'Disable' if is_active else 'Enable'
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=toggle_text, callback_data=f'reqch:toggle:{channel_id}')],
[InlineKeyboardButton(text='Delete', callback_data=f'reqch:delete:{channel_id}')],
[InlineKeyboardButton(text='< Back to list', callback_data='reqch:list')],
]
)
@router.callback_query(F.data == 'reqch:list')
@admin_required
async def show_channels_list(callback: CallbackQuery, **kwargs) -> None:
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
if not channels:
text = '<b>Required Channels</b>\n\nNo channels configured. Click "Add" to create one.'
else:
lines = ['<b>Required Channels</b>\n']
for ch in channels:
status = 'ON' if ch.is_active else 'OFF'
title = ch.title or ch.channel_id
lines.append(f'{status} <code>{ch.channel_id}</code> -- {title}')
text = '\n'.join(lines)
await callback.message.edit_text(text, reply_markup=_channels_keyboard(channels))
await callback.answer()
@router.callback_query(F.data.startswith('reqch:view:'))
@admin_required
async def view_channel(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Invalid channel ID', show_alert=True)
return
async with AsyncSessionLocal() as db:
ch = await get_channel_by_id(db, channel_db_id)
if not ch:
await callback.answer('Channel not found', show_alert=True)
return
status = 'Active' if ch.is_active else 'Disabled'
text = (
f'<b>{ch.title or "Untitled"}</b>\n\n'
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
f'<b>Link:</b> {ch.channel_link or "--"}\n'
f'<b>Status:</b> {status}\n'
f'<b>Sort order:</b> {ch.sort_order}'
)
await callback.message.edit_text(text, reply_markup=_channel_detail_keyboard(ch.id, ch.is_active))
await callback.answer()
# -- Toggle / Delete ---------------------------------------------------------------
@router.callback_query(F.data.startswith('reqch:toggle:'))
@admin_required
async def toggle_channel_handler(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Invalid channel ID', show_alert=True)
return
async with AsyncSessionLocal() as db:
ch = await toggle_channel(db, channel_db_id)
if ch:
await channel_subscription_service.invalidate_channels_cache()
status = 'enabled' if ch.is_active else 'disabled'
await callback.answer(f'Channel {status}', show_alert=True)
# Refresh list
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await callback.message.edit_text(
'<b>Required Channels</b>',
reply_markup=_channels_keyboard(channels),
)
@router.callback_query(F.data.startswith('reqch:delete:'))
@admin_required
async def delete_channel_handler(callback: CallbackQuery, **kwargs) -> None:
try:
channel_db_id = int(callback.data.split(':')[2])
except (ValueError, IndexError):
await callback.answer('Invalid channel ID', show_alert=True)
return
async with AsyncSessionLocal() as db:
ok = await delete_channel(db, channel_db_id)
if ok:
await channel_subscription_service.invalidate_channels_cache()
await callback.answer('Channel deleted', show_alert=True)
else:
await callback.answer('Delete failed', show_alert=True)
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await callback.message.edit_text(
'<b>Required Channels</b>',
reply_markup=_channels_keyboard(channels),
)
# -- Add channel flow --------------------------------------------------------------
@router.callback_query(F.data == 'reqch:add')
@admin_required
async def start_add_channel(callback: CallbackQuery, state: FSMContext, **kwargs) -> None:
await state.set_state(AddChannelStates.waiting_channel_id)
await callback.message.edit_text(
'<b>Add Channel</b>\n\nSend channel ID (e.g. <code>@mychannel</code> or <code>-1001234567890</code>):'
)
await callback.answer()
@router.message(AddChannelStates.waiting_channel_id)
@admin_required
async def process_channel_id(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Please send a text message.')
return
channel_id = message.text.strip()
# Validate channel_id format
try:
channel_id = validate_channel_id(channel_id)
except ValueError as e:
await message.answer(f'Invalid format. {e}\n\nTry again:')
return
# Resolve @username to numeric ID (ChatMemberUpdated events use numeric IDs)
original_channel_id = channel_id
bot: Bot = message.bot
try:
channel_id = await resolve_channel_id(bot, channel_id)
except ValueError as e:
await message.answer(f'Cannot verify channel: {e}\n\nMake sure the bot is admin in this channel. Try again:')
return
await state.update_data(channel_id=channel_id, original_channel_id=original_channel_id)
await state.set_state(AddChannelStates.waiting_channel_link)
await message.answer(
f'Channel: <code>{channel_id}</code>\n\n'
'Now send the channel link (e.g. <code>https://t.me/mychannel</code>)\n'
'Or send <code>-</code> to skip:'
)
@router.message(AddChannelStates.waiting_channel_link)
@admin_required
async def process_channel_link(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Please send a text message.')
return
link = message.text.strip()
if link == '-':
link = None
if link is not None:
# Validate and normalize channel link
if not link.startswith(('https://t.me/', 'http://t.me/', '@')):
await message.answer('Link must be a t.me URL or @username. Try again:')
return
if link.startswith('@'):
link = f'https://t.me/{link[1:]}'
if link.startswith('http://'):
link = link.replace('http://', 'https://', 1)
await state.update_data(channel_link=link)
await state.set_state(AddChannelStates.waiting_channel_title)
await message.answer(
'Send display name for the channel (e.g. <code>Project News</code>)\nOr send <code>-</code> to skip:'
)
@router.message(AddChannelStates.waiting_channel_title)
@admin_required
async def process_channel_title(message: Message, state: FSMContext, **kwargs) -> None:
if not message.text:
await message.answer('Please send a text message.')
return
title = message.text.strip()
if title == '-':
title = None
data = await state.get_data()
await state.clear()
# Use original @username as title fallback when channel was resolved to numeric
original_id = data.get('original_channel_id')
if not title and original_id and original_id != data['channel_id']:
title = original_id
async with AsyncSessionLocal() as db:
try:
ch = await add_channel(
db,
channel_id=data['channel_id'],
channel_link=data.get('channel_link'),
title=title,
)
await channel_subscription_service.invalidate_channels_cache()
text = (
'Channel added!\n\n'
f'<b>ID:</b> <code>{ch.channel_id}</code>\n'
f'<b>Link:</b> {ch.channel_link or "--"}\n'
f'<b>Title:</b> {ch.title or "--"}'
)
except Exception as e:
text = 'Error adding channel. Please try again.'
logger.error('Error adding channel', error=e)
async with AsyncSessionLocal() as db:
channels = await get_all_channels(db)
await message.answer(text, reply_markup=_channels_keyboard(channels))
def register_handlers(dp_router: Router) -> None:
dp_router.include_router(router)
+184
View File
@@ -0,0 +1,184 @@
"""ChatMemberUpdated event handler for real-time channel subscription tracking.
KEY COMPONENT for scalability: the bot receives push notifications from Telegram
when users join/leave channels, instead of polling via getChatMember.
Requirement: bot must be admin in each required channel.
IMPORTANT: Events are FILTERED to only process required channels.
Without filtering, the bot would process events from ALL channels it admins.
"""
from datetime import UTC, datetime
import structlog
from aiogram import Bot, Router
from aiogram.filters import IS_MEMBER, IS_NOT_MEMBER, ChatMemberUpdatedFilter
from aiogram.types import ChatMemberUpdated
from app.config import settings
from app.database.crud.subscription import deactivate_subscription, is_active_paid_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.channel_subscription_service import channel_subscription_service
from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
router = Router(name='channel_member')
async def _is_required_channel(channel_id: str) -> bool:
"""Check if the channel_id is one of our required channels."""
required_ids = await channel_subscription_service.get_required_channel_ids()
return channel_id in required_ids
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_NOT_MEMBER >> IS_MEMBER))
async def on_user_joined_channel(event: ChatMemberUpdated, bot: Bot) -> None:
"""User subscribed to a channel -- update cache and reactivate VPN if applicable."""
user = event.new_chat_member.user
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
# FILTER: Only process events for required channels
if not await _is_required_channel(channel_id):
return
await channel_subscription_service.on_user_joined(user.id, channel_id)
# Check if user is now subscribed to ALL required channels
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
is_all_subscribed = await channel_subscription_service.is_user_subscribed_to_all(user.id)
if not is_all_subscribed:
return # Still missing some channels
# Reactivate subscription if it was disabled due to channel unsubscribe
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async with AsyncSessionLocal() as db:
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user or not db_user.subscription:
return
if db_user.status == UserStatus.BLOCKED.value:
return
subscription = db_user.subscription
if subscription.status != SubscriptionStatus.DISABLED.value:
return
# Don't reactivate expired subscriptions
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
return
await reactivate_subscription(db, subscription)
logger.info('Subscription reactivated via channel event', telegram_id=user.id)
# Re-enable in RemnaWave panel
if db_user.remnawave_uuid:
service = SubscriptionService()
try:
await service.enable_remnawave_user(db_user.remnawave_uuid)
except Exception as api_error:
logger.error('Failed to enable RemnaWave user', error=api_error)
# Notify the user
try:
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
notification_text = texts.t(
'SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE',
'Your subscription has been restored! Thank you for subscribing to the channels.',
)
await bot.send_message(user.id, notification_text)
except Exception as notify_error:
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
await db.commit()
except Exception as e:
logger.error('Error reactivating subscription on channel join', error=e)
await db.rollback()
@router.chat_member(ChatMemberUpdatedFilter(member_status_changed=IS_MEMBER >> IS_NOT_MEMBER))
async def on_user_left_channel(event: ChatMemberUpdated, bot: Bot) -> None:
"""User unsubscribed from a channel -- update cache and deactivate VPN if applicable."""
user = event.old_chat_member.user
channel_id = str(event.chat.id) # Normalize int to str (DB stores string)
# FILTER: Only process events for required channels
if not await _is_required_channel(channel_id):
return
await channel_subscription_service.on_user_left(user.id, channel_id)
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
# Skip admins -- never deactivate admin subscriptions
if settings.is_admin(user.id):
return
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
return
async with AsyncSessionLocal() as db:
try:
db_user = await get_user_by_telegram_id(db, user.id)
if not db_user or not db_user.subscription:
return
subscription = db_user.subscription
if subscription.status != SubscriptionStatus.ACTIVE.value:
return
# CHANNEL_REQUIRED_FOR_ALL: deactivate regardless of trial status
# CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: only deactivate trial subscriptions
if settings.CHANNEL_REQUIRED_FOR_ALL:
pass # Deactivate any active subscription
elif not subscription.is_trial:
return # Not a trial -- skip
# Guard against paid subscriptions (user paid money, don't punish)
if is_active_paid_subscription(subscription):
return
await deactivate_subscription(db, subscription)
logger.info('Subscription deactivated via channel event', telegram_id=user.id)
# Disable in RemnaWave panel
if db_user.remnawave_uuid:
service = SubscriptionService()
try:
await service.disable_remnawave_user(db_user.remnawave_uuid)
except Exception as api_error:
logger.error('Failed to disable RemnaWave user', error=api_error)
# Notify the user with channel subscription keyboard
try:
texts = get_texts(db_user.language or DEFAULT_LANGUAGE)
unsub_channels = await channel_subscription_service.get_unsubscribed_channels(user.id)
notification_text = texts.t(
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
'Your subscription has been paused because you left a required channel.',
)
channel_kb = get_channel_sub_keyboard(unsub_channels, language=db_user.language or DEFAULT_LANGUAGE)
await bot.send_message(user.id, notification_text, reply_markup=channel_kb)
except Exception as notify_error:
logger.warning('Failed to send notification', telegram_id=user.id, error=notify_error)
await db.commit()
except Exception as e:
logger.error('Error deactivating subscription on channel leave', error=e)
await db.rollback()
def register_handlers(dp_router: Router) -> None:
"""Register channel member event handlers on the dispatcher/router."""
dp_router.include_router(router)
+11 -9
View File
@@ -2,7 +2,6 @@ from datetime import UTC, datetime
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
@@ -37,6 +36,7 @@ from app.middlewares.channel_checker import (
)
from app.services.admin_notification_service import AdminNotificationService
from app.services.campaign_service import AdvertisingCampaignService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.pinned_message_service import (
deliver_pinned_message_to_user,
@@ -1866,20 +1866,22 @@ async def required_sub_channel_check(
texts = get_texts(language)
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=query.from_user.id)
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = bot
if chat_member.status not in [
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
]:
# Invalidate cache for fresh check (user just clicked "I subscribed")
await channel_subscription_service.invalidate_user_cache(query.from_user.id)
is_subscribed = await channel_subscription_service.is_user_subscribed_to_all(query.from_user.id)
if not is_subscribed:
# НЕ удаляем payload - пользователь может попробовать снова после подписки
logger.info(
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '' сохранён для следующей попытки",
'CHANNEL CHECK: Подписка не подтверждена, payload сохранён для следующей попытки',
pending_start_payload=pending_start_payload,
)
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', '❌ Вы не подписались на канал!'),
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', 'Please subscribe to all required channels first!'),
show_alert=True,
)
+26 -11
View File
@@ -275,22 +275,37 @@ def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeybo
def get_channel_sub_keyboard(
channel_link: str | None,
channels: list[dict] | str | None = None,
language: str = DEFAULT_LANGUAGE,
) -> InlineKeyboardMarkup:
texts = get_texts(language)
"""Subscription keyboard for required channels.
Args:
channels: List of dicts with 'channel_link' and 'title' keys,
OR a string (legacy single channel_link for backwards compat).
language: Locale code for button text.
"""
texts = get_texts(language)
buttons: list[list[InlineKeyboardButton]] = []
if channel_link:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=channel_link,
)
]
)
if isinstance(channels, str):
# Legacy: single channel link string
if channels:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=channels,
)
]
)
elif isinstance(channels, list):
for ch in channels:
link = ch.get('channel_link')
title = ch.get('title')
if link:
label = title or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
buttons.append([InlineKeyboardButton(text=label, url=link)])
buttons.append(
[
+4 -2
View File
@@ -930,9 +930,9 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
"CHANNEL_REQUIRED_TEXT": "🔒 Please join the announcement channel to access the bot, then press the button below.",
"CHANNEL_REQUIRED_TEXT": "Please subscribe to the required channels and then press the button below.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Subscribe",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ You haven't joined the channel!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Please subscribe to all required channels first!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
"CHECK_STATUS_BUTTON": "📊 Check status",
"CHECK_STATUS_NO_CHANGES": "Status has not changed",
@@ -1431,10 +1431,12 @@
"SUBSCRIPTION_NO_SERVERS": "No servers",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📅 Valid until: {end_date}\n⏰ Time left: {time_left}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Balance: {balance}\n📱 Subscription: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Subscription details\n🎭 Type: {subscription_type}\n📈 Traffic: {traffic}\n🌍 Servers: {servers}\n📱 Devices: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Your subscription has been paused because you left a required channel.\n\nSubscribe to all channels to restore VPN access.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with other discounts.",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Your subscription has been restored!\n\nThank you for subscribing to the channels. VPN is active again.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Settings",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Subscription settings</b>\n\n📊 <b>Current parameters:</b>\n🌐 Countries: {countries_count}\n📈 Traffic: {traffic_used} / {traffic_limit}\n📱 Devices: {devices_used} / {devices_limit}\n\nChoose what you want to change:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
+4 -2
View File
@@ -951,9 +951,9 @@
"CHANGE_DEVICES_TITLE": "📱 تغییر تعداد دستگاه",
"CHANGE_TARIFF_BUTTON": "📦 تعرفه",
"CHANNEL_CHECK_BUTTON": "✅ عضو شدم",
"CHANNEL_REQUIRED_TEXT": "🔒 برای استفاده از ربات در کانال خبری عضو شوید، سپس دکمه زیر بزنید.",
"CHANNEL_REQUIRED_TEXT": "لطفاً در کانال‌های اجباری عضو شوید و سپس دکمه زیر را فشار دهید.",
"CHANNEL_SUBSCRIBE_BUTTON": "📢 عضویت در کانال",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ هنوز عضو کانال نشده‌اید!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "لطفاً ابتدا در همه کانال‌های اجباری عضو شوید!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ ممنون از عضویت",
"CHECK_STATUS_BUTTON": "📊 بررسی وضعیت",
"CHECK_STATUS_NO_CHANGES": "وضعیت تغییر نکرده",
@@ -1452,10 +1452,12 @@
"SUBSCRIPTION_NO_SERVERS": "❌ سروری در دسترس نیست",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "📱 <b>اشتراک شما</b>\n\n📊 وضعیت: {status}\n📅 اعتبار: {expiry}\n📈 ترافیک: {traffic}\n📱 دستگاه‌ها: {devices}\n🌍 سرورها: {servers}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 موجودی: {balance}\n📱 اشتراک: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 اطلاعات اشتراک\n🎭 نوع: {subscription_type}\n📈 ترافیک: {traffic}\n🌍 سرورها: {servers}\n📱 دستگاه‌ها: {devices}\n🌍 کشورها: {countries}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "اشتراک شما متوقف شده است زیرا از کانال اجباری خارج شدید.\n\nبرای بازیابی دسترسی VPN در همه کانال‌ها عضو شوید.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ تخفیف اضافی {percent}% فعال شد.\n\nبا سایر تخفیف‌ها جمع می‌شود!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ تخفیف اضافی {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "\n⏳ اعتبار تخفیف: {time_left} <code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 اشتراک خریداری شد!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "اشتراک شما بازیابی شد!\n\nبا تشکر از عضویت در کانال‌ها. VPN دوباره فعال است.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ تنظیمات",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>تنظیمات اشتراک</b>",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ تنظیمات فقط برای اشتراک پولی",
+4 -2
View File
@@ -951,9 +951,9 @@
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
"CHANNEL_REQUIRED_TEXT": "Пожалуйста, подпишитесь на обязательные каналы и нажмите кнопку ниже.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Пожалуйста, подпишитесь на все обязательные каналы!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
"CHECK_STATUS_NO_CHANGES": "Статус не изменился",
@@ -1452,10 +1452,12 @@
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📅 Действует до: {end_date}\n⏰ Осталось: {time_left}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Подписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Информация о подписке\n🎭 Тип: {subscription_type}\n📈 Трафик: {traffic}\n🌍 Серверы: {servers}\n📱 Устройства: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Ваша подписка приостановлена, так как вы отписались от обязательного канала.\n\nПодпишитесь на все каналы для восстановления доступа к VPN.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Ваша подписка восстановлена!\n\nСпасибо за подписку на каналы. VPN снова активен.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Настройки подписки</b>\n\n📊 <b>Текущие параметры:</b>\n🌐 Стран: {countries_count}\n📈 Трафик: {traffic_used} / {traffic_limit}\n📱 Устройства: {devices_used} / {devices_limit}\n\nВыберите что хотите изменить:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
+4 -2
View File
@@ -871,9 +871,9 @@
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n  ✅ Кількість пристроїв збільшено!\n\n  📱 Було: {old_count} → Стало: {new_count}\n  💰 Списано: {amount}\n  ",
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
"CHANNEL_CHECK_BUTTON": "✅ Я підписався",
"CHANNEL_REQUIRED_TEXT": "🔒 Для використання бота підпишіться на канал новин, а потім натисніть кнопку нижче.",
"CHANNEL_REQUIRED_TEXT": "Будь ласка, підпишіться на обов'язкові канали та натисніть кнопку нижче.",
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Підписатися",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Ви не підписалися на канал!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Будь ласка, підпишіться на всі обов'язкові канали!",
"CHANNEL_SUBSCRIBE_THANKS": "✅ Дякуємо за підписку",
"CHECK_STATUS_BUTTON": "📊 Перевірити статус",
"CHECK_STATUS_NO_CHANGES": "Статус не змінився",
@@ -1362,10 +1362,12 @@
"SUBSCRIPTION_NO_SERVERS": "Немає серверів",
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📅 Діє до: {end_date}\n⏰ Залишилося: {time_left}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE": "👤 {full_name}\n💰 Баланс: {balance}\n📱 Підписка: {status_emoji} {status_display}{warning}{tariff_info_block}\n\n📱 Інформація про підписку\n🎭 Тип: {subscription_type}\n📈 Трафік: {traffic}\n🌍 Сервери: {servers}\n📱 Пристрої: {devices_used} / {device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE": "Вашу підписку призупинено, оскільки ви відписались від обов'язкового каналу.\n\nПідпишіться на всі канали для відновлення доступу до VPN.",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED": "🎉 Підписку успішно придбано!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Вашу підписку відновлено!\n\nДякуємо за підписку на канали. VPN знову активний.",
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Налаштування підписки",
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>Налаштування підписки</b>\n\n📊 <b>Поточні параметри:</b>\n🌐 Країн: {countries_count}\n📈 Трафік: {traffic_used} / {traffic_limit}\n📱 Пристрої: {devices_used} / {devices_limit}\n\nОберіть що хочете змінити:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Налаштування доступні лише для платних підписок",
+6 -2
View File
@@ -869,9 +869,9 @@
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n  ✅设备数量已增加!\n\n  📱之前:{old_count}→现在:{new_count}\n  💰已扣除:{amount}\n  ",
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
"CHANNEL_CHECK_BUTTON":"✅我已订阅",
"CHANNEL_REQUIRED_TEXT":"🔒要使用机器人,请订阅新闻频道,然后点击下方按钮。",
"CHANNEL_REQUIRED_TEXT":"请订阅所有必需频道,然后点击下方按钮。",
"CHANNEL_SUBSCRIBE_BUTTON":"🔗订阅",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"❌您没有订阅该频道!",
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"请先订阅所有必需频道!",
"CHANNEL_SUBSCRIBE_THANKS":"✅感谢您的订阅",
"CHECK_STATUS_BUTTON":"📊检查状态",
"CHECK_STATUS_NO_CHANGES":"状态未更改",
@@ -1360,10 +1360,12 @@
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE":"由于您退出了必需频道,您的订阅已暂停。\n\n请订阅所有频道以恢复VPN访问。",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
"SUBSCRIPTION_SETTINGS_OVERVIEW":"⚙️<b>订阅设置</b>\n\n📊<b>当前参数:</b>\n🌐国家:{countries_count}\n📈流量:{traffic_used}/{traffic_limit}\n📱设备:{devices_used}/{devices_limit}\n\n请选择要更改的内容:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
@@ -1693,10 +1695,12 @@
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
"SUBSCRIPTION_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📅有效期至:{end_date}\n⏰剩余时间:{time_left}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DAILY_OVERVIEW_TEMPLATE":"👤{full_name}\n💰余额:{balance}\n📱订阅:{status_emoji}{status_display}{warning}{tariff_info_block}\n\n📱订阅信息\n🎭类型:{subscription_type}\n📈流量:{traffic}\n🌍服务器:{servers}\n📱设备:{devices_used}/{device_limit}",
"SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE":"由于您退出了必需频道,您的订阅已暂停。\n\n请订阅所有频道以恢复VPN访问。",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
"SUBSCRIPTION_SETTINGS_OVERVIEW":"⚙️<b>订阅设置</b>\n\n📊<b>当前参数:</b>\n🌐国家:{countries_count}\n📈流量:{traffic_used}/{traffic_limit}\n📱设备:{devices_used}/{devices_limit}\n\n请选择要更改的内容:",
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
+176 -196
View File
@@ -2,11 +2,9 @@ from collections.abc import Awaitable, Callable
from datetime import UTC, datetime
from typing import Any
import redis.asyncio as aioredis
import structlog
from aiogram import BaseMiddleware, Bot, types
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
@@ -20,69 +18,62 @@ 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__)
# Ключ для хранения pending_start_payload в Redis (резервный механизм)
# Redis key prefix and TTL for pending /start payload backup
REDIS_PAYLOAD_KEY_PREFIX = 'pending_start_payload:'
REDIS_PAYLOAD_TTL = 3600 # 1 час
REDIS_PAYLOAD_TTL = 3600 # 1 hour
async def save_pending_payload_to_redis(telegram_id: int, payload: str) -> bool:
"""Сохраняет pending_start_payload в Redis напрямую (резервный механизм)."""
"""Save pending_start_payload to Redis via the shared cache singleton."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
await redis_client.aclose()
logger.info(
"💾 [Redis fallback] Сохранен payload '' для пользователя", payload=payload, telegram_id=telegram_id
)
return True
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('❌ [Redis fallback] Ошибка сохранения payload для', telegram_id=telegram_id, e=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:
"""Получает pending_start_payload из Redis (резервный механизм)."""
"""Get pending_start_payload from Redis via the shared cache singleton."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
payload = await redis_client.get(key)
await redis_client.aclose()
if payload:
return payload.decode('utf-8') if isinstance(payload, bytes) else payload
return None
return await cache.get(key)
except Exception as e:
logger.debug('❌ [Redis fallback] Ошибка получения payload для', telegram_id=telegram_id, e=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:
"""Удаляет pending_start_payload из Redis."""
"""Delete pending_start_payload from Redis via the shared cache singleton."""
try:
redis_client = aioredis.from_url(settings.REDIS_URL)
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
await redis_client.delete(key)
await redis_client.aclose()
await cache.delete(key)
except Exception:
pass
class ChannelCheckerMiddleware(BaseMiddleware):
"""
Middleware для проверки подписки на канал.
ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос.
"""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):
self.BAD_MEMBER_STATUS = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED, ChatMemberStatus.RESTRICTED)
self.GOOD_MEMBER_STATUS = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
logger.info('🔧 ChannelCheckerMiddleware инициализирован')
logger.info('ChannelCheckerMiddleware initialized (multi-channel mode)')
async def __call__(
self,
@@ -90,6 +81,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
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
@@ -100,7 +96,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
telegram_id = event.callback_query.from_user.id
if telegram_id is None:
logger.debug('❌ telegram_id не найден, пропускаем')
return await handler(event, data)
# Skip channel check for lightweight UI callbacks (close/delete notifications)
@@ -112,110 +107,123 @@ class ChannelCheckerMiddleware(BaseMiddleware):
):
return await handler(event, data)
# Админам разрешаем пропускать проверку подписки
if settings.is_admin(telegram_id):
logger.debug(
'✅ Пользователь является администратором — пропускаем проверку подписки', telegram_id=telegram_id
)
return await handler(event, data)
state: FSMContext = data.get('state')
current_state = None
if state:
current_state = await state.get_state()
is_reg_process = is_registration_process(event, current_state)
if is_reg_process:
logger.debug('✅ Событие разрешено (процесс регистрации), пропускаем проверку')
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
channel_id = settings.CHANNEL_SUB_ID
# Multi-channel check (Redis -> DB -> API)
unsubscribed = await channel_subscription_service.get_unsubscribed_channels(telegram_id)
if not channel_id:
logger.warning('⚠️ CHANNEL_SUB_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)
is_required = settings.CHANNEL_IS_REQUIRED_SUB
# 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)
if not is_required:
logger.debug('⚠️ Обязательная подписка отключена, пропускаем проверку')
return await handler(event, data)
await self._capture_start_payload(state, event, bot)
channel_link = self._normalize_channel_link(settings.CHANNEL_LINK, channel_id)
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)
if not channel_link:
logger.warning('⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта')
# Re-check via API for immediate feedback (invalidate cache first)
await channel_subscription_service.invalidate_user_cache(telegram_id)
try:
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
if member.status in self.GOOD_MEMBER_STATUS:
# Реактивируем подписку если была отключена из-за отписки от канала
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
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)
if member.status in self.BAD_MEMBER_STATUS:
logger.info(
'❌ Пользователь не подписан на канал (статус: )', telegram_id=telegram_id, status=member.status
)
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
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
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, unsubscribed)
if isinstance(event, CallbackQuery) and event.data == 'sub_channel_check':
await event.answer(
'❌ Вы еще не подписались на канал! Подпишитесь и попробуйте снова.', show_alert=True
)
return None
return await self._deny_message(event, bot, channel_link, channel_id)
logger.warning('⚠️ Неожиданный статус пользователя', telegram_id=telegram_id, status=member.status)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramForbiddenError as e:
logger.error('❌ Бот заблокирован в канале', channel_id=channel_id, error=e)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramBadRequest as e:
if 'chat not found' in str(e).lower():
logger.error('❌ Канал не найден', channel_id=channel_id, error=e)
elif 'user not found' in str(e).lower():
logger.error('❌ Пользователь не найден', telegram_id=telegram_id, error=e)
else:
logger.error('❌ Ошибка запроса к каналу', channel_id=channel_id, error=e)
await self._capture_start_payload(state, event, bot)
return await self._deny_message(event, bot, channel_link, channel_id)
except TelegramNetworkError as e:
logger.warning('⚠️ Таймаут при проверке подписки на канал', error=e)
return await handler(event, data)
except Exception as e:
logger.error('❌ Неожиданная ошибка при проверке подписки', error=e)
return await handler(event, data)
# -- _deny_message (multi-channel) -----------------------------------------
@staticmethod
def _normalize_channel_link(channel_link: str | None, channel_id: str | None) -> str | None:
link = (channel_link or '').strip()
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
if link.startswith('@'): # raw username
return f'https://t.me/{link.lstrip("@")}'
language = DEFAULT_LANGUAGE
if user and user.language_code:
language = user.language_code.split('-')[0]
if link and not link.lower().startswith(('http://', 'https://', 'tg://')):
return f'https://{link}'
# 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)
if link:
return link
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(normalized, language=language)
text = texts.t(
'CHANNEL_REQUIRED_TEXT',
'🔒 Для использования бота подпишитесь на новостной канал, '
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
)
if channel_id and str(channel_id).startswith('@'):
return f'https://t.me/{str(channel_id).lstrip("@")}'
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)
return None
# -- _capture_start_payload ------------------------------------------------
async def _capture_start_payload(
self,
@@ -223,6 +231,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
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
@@ -246,23 +259,21 @@ class ChannelCheckerMiddleware(BaseMiddleware):
payload = parts[1]
# Сохраняем в FSM state
# 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(
"💾 Сохранен start payload '' для пользователя (FSM)", payload=payload, telegram_id=telegram_id
)
logger.info('Saved start payload for user (FSM)', payload=payload, telegram_id=telegram_id)
else:
logger.warning('⚠️ _capture_start_payload: state=None для пользователя', telegram_id=telegram_id)
logger.warning('_capture_start_payload: state=None for user', telegram_id=telegram_id)
# Также сохраняем в Redis как резерв (на случай потери FSM state)
# 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:
if bot and message.from_user and state:
await self._try_send_campaign_visit_notification(
bot,
message.from_user,
@@ -280,9 +291,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
try:
state_data = await state.get_data() or {}
except Exception as error:
logger.error(
'❌ Не удалось получить данные состояния для уведомления по кампании', payload=payload, error=error
)
logger.error('Failed to get state data for campaign notification', payload=payload, error=error)
return
if state_data.get('campaign_notification_sent'):
@@ -311,13 +320,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await state.update_data(campaign_notification_sent=True)
await db.commit()
except Exception as error:
logger.error('❌ Ошибка отправки уведомления о переходе по кампании', payload=payload, error=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, channel_link: str | None
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
@@ -341,15 +355,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
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 = 'Триальная' if subscription.is_trial else 'Платная'
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'🚫 подписка пользователя отключена после отписки от канала',
'Subscription deactivated after channel unsubscribe',
sub_type=sub_type,
telegram_id=telegram_id,
)
@@ -360,38 +374,49 @@ class ChannelCheckerMiddleware(BaseMiddleware):
await service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'❌ Не удалось отключить пользователя RemnaWave',
'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(channel_link, language=user.language)
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
@@ -401,43 +426,42 @@ class ChannelCheckerMiddleware(BaseMiddleware):
if not user or not user.subscription:
return
# НЕ реактивируем подписку заблокированным пользователям
# Do NOT reactivate for blocked users
if user.status == UserStatus.BLOCKED.value:
logger.info('🚫 Пропуск реактивации для заблокированного пользователя', telegram_id=telegram_id)
logger.info('Skipping reactivation for blocked user', telegram_id=telegram_id)
return
subscription = user.subscription
# Реактивируем только DISABLED подписки
# 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 = 'Триальная' if subscription.is_trial else 'Платная'
sub_type = 'trial' if subscription.is_trial else 'paid'
logger.info(
'✅ подписка пользователя реактивирована после подписки на канал',
'Subscription reactivated after channel subscribe',
sub_type=sub_type,
telegram_id=telegram_id,
)
# Включаем в RemnaWave
# 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(
'❌ Не удалось включить пользователя RemnaWave',
'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(
@@ -447,65 +471,21 @@ class ChannelCheckerMiddleware(BaseMiddleware):
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('❌ Ошибка реактивации подписки пользователя', telegram_id=telegram_id, db_error=db_error)
logger.error('Error reactivating subscription', telegram_id=telegram_id, db_error=db_error)
await db.rollback()
@staticmethod
async def _deny_message(
event: TelegramObject,
bot: Bot,
channel_link: str | None,
channel_id: str | None,
):
logger.debug('🚫 Отправляем сообщение о необходимости подписки')
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]
texts = get_texts(language)
channel_sub_kb = get_channel_sub_keyboard(channel_link, language=language)
text = texts.t(
'CHANNEL_REQUIRED_TEXT',
'🔒 Для использования бота подпишитесь на новостной канал, чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
)
if not channel_link and channel_id:
channel_hint = None
if str(channel_id).startswith('@'): # username-based channel id
channel_hint = f'@{str(channel_id).lstrip("@")}'
if channel_hint:
text = f'{text}\n\n{channel_hint}'
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():
logger.debug('ℹ️ Сообщение уже содержит текст проверки подписки, пропускаем редактирование')
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=e)
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
@@ -0,0 +1,272 @@
"""Channel subscription verification service.
Architecture for 100k+ users:
1. ChatMemberUpdated events -> update PostgreSQL (source of truth) + Redis in real-time
2. Middleware reads ONLY from Redis/PostgreSQL (never calls Telegram API directly)
3. Background reconciliation (~10 req/sec) corrects drift
"""
import asyncio
from datetime import UTC, datetime
import structlog
from aiogram import Bot
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError, TelegramRetryAfter
from app.database.crud.required_channel import (
get_active_channels,
get_user_channel_subs,
upsert_user_channel_sub,
)
from app.database.database import AsyncSessionLocal
from app.utils.cache import ChannelSubCache
logger = structlog.get_logger(__name__)
# Rate limiting for Telegram API calls
_API_SEMAPHORE = asyncio.Semaphore(20) # max 20 concurrent getChatMember calls
_API_DELAY = 0.05 # 50ms between calls -> ~20/sec safe rate
GOOD_STATUSES = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
# How long a DB record is considered fresh (no API call needed)
DB_FRESHNESS_SECONDS = 1800 # 30 min
class ChannelSubscriptionService:
"""Centralized service for channel subscription verification."""
def __init__(self, bot: Bot | None = None):
self.bot = bot
# -- Public API ---------------------------------------------------------------
async def get_required_channels(self) -> list[dict]:
"""Get the list of active required channels (cached)."""
cached = await ChannelSubCache.get_required_channels()
if cached is not None:
return cached
async with AsyncSessionLocal() as db:
channels = await get_active_channels(db)
result = [
{
'id': ch.id,
'channel_id': ch.channel_id,
'channel_link': ch.channel_link,
'title': ch.title,
'sort_order': ch.sort_order,
}
for ch in channels
]
await ChannelSubCache.set_required_channels(result)
return result
async def get_required_channel_ids(self) -> set[str]:
"""Get the set of active required channel_ids (for event filtering)."""
channels = await self.get_required_channels()
return {ch['channel_id'] for ch in channels}
async def check_user_subscriptions(self, telegram_id: int) -> dict[str, bool]:
"""Check user subscriptions to all required channels.
Returns {channel_id: is_member}.
Does NOT call Telegram API unless cache miss + stale DB.
Uses a SINGLE DB session for all channels (no N+1).
"""
channels = await self.get_required_channels()
return await self._check_user_subscriptions_for_channels(telegram_id, channels)
async def _check_user_subscriptions_for_channels(
self,
telegram_id: int,
channels: list[dict],
) -> dict[str, bool]:
"""Internal: check subscriptions for a given list of channels.
Avoids double-fetching required_channels when called from
get_unsubscribed_channels or get_channels_with_status.
"""
if not channels:
return {}
result: dict[str, bool] = {}
channels_needing_db: list[dict] = []
# Layer 1: Redis cache (single MGET round-trip)
all_channel_ids = [ch['channel_id'] for ch in channels]
cached_statuses = await ChannelSubCache.get_sub_statuses(telegram_id, all_channel_ids)
for ch in channels:
channel_id = ch['channel_id']
cached = cached_statuses.get(channel_id)
if cached is not None:
result[channel_id] = cached
else:
channels_needing_db.append(ch)
# Layer 2: PostgreSQL (single session for all channels)
channels_needing_api: list[dict] = []
if channels_needing_db:
async with AsyncSessionLocal() as db:
subs = await get_user_channel_subs(db, telegram_id)
sub_map = {s.channel_id: s for s in subs}
for ch in channels_needing_db:
channel_id = ch['channel_id']
sub = sub_map.get(channel_id)
if sub and sub.checked_at:
age = (datetime.now(UTC) - sub.checked_at).total_seconds()
if age < DB_FRESHNESS_SECONDS:
result[channel_id] = sub.is_member
await ChannelSubCache.set_sub_status(telegram_id, channel_id, sub.is_member)
continue
channels_needing_api.append(ch)
# Layer 3: Rate-limited API calls for channels without fresh data
if channels_needing_api and self.bot:
async with AsyncSessionLocal() as db:
for ch in channels_needing_api:
is_member = await self._rate_limited_check(telegram_id, ch['channel_id'])
result[ch['channel_id']] = is_member
# Write DB first (source of truth), then cache
await upsert_user_channel_sub(db, telegram_id, ch['channel_id'], is_member)
await ChannelSubCache.set_sub_status(telegram_id, ch['channel_id'], is_member)
await db.commit()
elif channels_needing_api:
# No bot available (e.g., cabinet API context) -- fail-closed
logger.warning(
'No bot instance for API check -- failing closed',
telegram_id=telegram_id,
channels=[ch['channel_id'] for ch in channels_needing_api],
)
for ch in channels_needing_api:
result[ch['channel_id']] = False
return result
async def is_user_subscribed_to_all(self, telegram_id: int) -> bool:
"""Quick check: is user subscribed to ALL required channels?"""
subs = await self.check_user_subscriptions(telegram_id)
if not subs:
return True # No required channels = subscribed
return all(subs.values())
async def get_unsubscribed_channels(self, telegram_id: int) -> list[dict]:
"""Get the list of channels the user is NOT subscribed to."""
channels = await self.get_required_channels()
subs = await self._check_user_subscriptions_for_channels(telegram_id, channels)
unsubscribed = []
for ch in channels:
if not subs.get(ch['channel_id'], False):
unsubscribed.append(ch)
return unsubscribed
async def get_channels_with_status(self, telegram_id: int) -> list[dict]:
"""Get all required channels with per-channel subscription status (for cabinet API)."""
channels = await self.get_required_channels()
subs = await self._check_user_subscriptions_for_channels(telegram_id, channels)
result = []
for ch in channels:
result.append(
{
'channel_id': ch['channel_id'],
'channel_link': ch.get('channel_link'),
'title': ch.get('title'),
'is_subscribed': subs.get(ch['channel_id'], False),
}
)
return result
async def get_first_channel_id(self) -> str | None:
"""Get the first active channel ID (for announcements, contest posts, etc.).
Channel IDs are always stored as strings in the DB.
Telegram API accepts string channel_id in chat_id parameters.
"""
channels = await self.get_required_channels()
if not channels:
return None
return channels[0]['channel_id']
# -- Event handlers (called from ChatMemberUpdated router) --------------------
async def on_user_joined(self, telegram_id: int, channel_id: str) -> None:
"""Called when ChatMemberUpdated fires: user subscribed."""
logger.info('Channel join event', telegram_id=telegram_id, channel_id=channel_id)
# Write DB first (source of truth), then cache
async with AsyncSessionLocal() as db:
await upsert_user_channel_sub(db, telegram_id, channel_id, True)
await db.commit()
await ChannelSubCache.set_sub_status(telegram_id, channel_id, True)
async def on_user_left(self, telegram_id: int, channel_id: str) -> None:
"""Called when ChatMemberUpdated fires: user unsubscribed."""
logger.info('Channel leave event', telegram_id=telegram_id, channel_id=channel_id)
# Write DB first (source of truth), then cache
async with AsyncSessionLocal() as db:
await upsert_user_channel_sub(db, telegram_id, channel_id, False)
await db.commit()
await ChannelSubCache.set_sub_status(telegram_id, channel_id, False)
# -- Channel list management --------------------------------------------------
async def invalidate_channels_cache(self) -> None:
"""Invalidate the channels list cache (call after CRUD)."""
await ChannelSubCache.invalidate_channels()
async def invalidate_user_cache(self, telegram_id: int) -> None:
"""Invalidate all cached subscription statuses for a user."""
channels = await self.get_required_channels()
channel_ids = [ch['channel_id'] for ch in channels]
await ChannelSubCache.invalidate_user_channels(telegram_id, channel_ids)
# -- Rate-limited Telegram API ------------------------------------------------
async def _rate_limited_check(self, telegram_id: int, channel_id: str) -> bool:
"""Check subscription via Telegram API with rate-limiting.
SECURITY: Fail-closed -- any error returns False (not subscribed).
For a VPN access control system, false negatives (temporary denial)
are preferable to false positives (unauthorized access).
"""
async with _API_SEMAPHORE:
try:
member = await self.bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
await asyncio.sleep(_API_DELAY)
return member.status in GOOD_STATUSES
except TelegramRetryAfter as e:
logger.warning('Rate limited by Telegram', retry_after=e.retry_after, channel_id=channel_id)
await asyncio.sleep(e.retry_after)
try:
member = await self.bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
return member.status in GOOD_STATUSES
except Exception:
logger.error('Double failure after rate-limit retry', channel_id=channel_id)
return False # Fail-closed on double failure
except TelegramForbiddenError:
logger.critical(
'Bot removed/blocked from channel -- all checks will fail-closed',
channel_id=channel_id,
)
return False # Fail-closed -- bot cannot verify membership
except TelegramBadRequest as e:
if 'user not found' in str(e).lower():
return False # User never interacted with bot in that context
logger.error('Bad request checking channel', channel_id=channel_id, error=str(e))
return False # Fail-closed
except TelegramNetworkError:
logger.warning('Network error checking channel', channel_id=channel_id)
return False # Fail-closed
except Exception as e:
logger.error('Unexpected error checking channel', channel_id=channel_id, error=str(e))
return False # Fail-closed
# Singleton instance (bot is set at startup)
channel_subscription_service = ChannelSubscriptionService()
+5 -7
View File
@@ -295,13 +295,11 @@ class ContestRotationService:
async def _send_channel_announce(self, text: str) -> None:
if not self.bot:
return
channel_id_raw = settings.CHANNEL_SUB_ID
if not channel_id_raw:
from app.services.channel_subscription_service import channel_subscription_service
channel_id = await channel_subscription_service.get_first_channel_id()
if not channel_id:
return
try:
channel_id = int(channel_id_raw)
except Exception:
channel_id = channel_id_raw
keyboard = InlineKeyboardMarkup(
inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]]
@@ -315,7 +313,7 @@ class ContestRotationService:
reply_markup=keyboard,
)
except Exception as exc:
logger.error('Не удалось отправить анонс в канал', channel_id_raw=channel_id_raw, exc=exc)
logger.error('Не удалось отправить анонс в канал', channel_id=channel_id, exc=exc)
async def _broadcast_to_users(self, text: str) -> None:
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
+211 -169
View File
@@ -4,7 +4,6 @@ from pathlib import Path
from typing import Any
import structlog
from aiogram.enums import ChatMemberStatus
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
from sqlalchemy import and_, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -72,6 +71,9 @@ from app.utils.timezone import format_local_datetime
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
# Размер батча для проверки подписок на каналы (keyset pagination)
_CHANNEL_CHECK_BATCH_SIZE: int = 100
logger = structlog.get_logger(__name__)
@@ -522,214 +524,254 @@ class MonitoringService:
logger.error('Ошибка проверки истекающих тестовых подписок', error=e)
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
from app.database.crud.subscription import is_recently_updated_by_webhook
"""Background reconciliation of channel subscriptions (rate-limited).
Processes subscriptions in batches using keyset pagination to avoid
loading all trial subscriptions into memory at once. Each batch gets
a fresh DB session to avoid holding a connection pool slot for hours.
When CHANNEL_REQUIRED_FOR_ALL is True, checks ALL active subscriptions
(not just trials). Otherwise only checks trial subscriptions.
"""
from app.database.crud.subscription import is_active_paid_subscription, is_recently_updated_by_webhook
if not settings.CHANNEL_IS_REQUIRED_SUB:
return
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
logger.debug('ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется')
return
channel_id = settings.CHANNEL_SUB_ID
if not channel_id:
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
logger.debug('Channel unsubscribe check disabled')
return
if not self.bot:
logger.debug('⚠️ Пропускаем проверку подписки на канал — бот недоступен')
logger.debug('Skipping channel subscription check - bot unavailable')
return
from app.database.crud.required_channel import upsert_user_channel_sub
from app.services.channel_subscription_service import channel_subscription_service
from app.utils.cache import ChannelSubCache
channels = await channel_subscription_service.get_required_channels()
if not channels:
return
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = self.bot
try:
now = datetime.now(UTC)
notifications_allowed = (
NotificationSettingsService.are_notifications_globally_enabled()
and NotificationSettingsService.is_trial_channel_unsubscribed_enabled()
)
result = await db.execute(
select(Subscription)
.join(Subscription.user)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
.where(
and_(
Subscription.is_trial.is_(True),
Subscription.end_date > now,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.DISABLED.value,
]
),
User.status == UserStatus.ACTIVE.value,
)
)
)
subscriptions = result.scalars().all()
if not subscriptions:
return
disabled_count = 0
restored_count = 0
checked_count = 0
last_id = 0
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
# Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting
from sqlalchemy import true as sa_true
try:
member = await self.bot.get_chat_member(channel_id, user.telegram_id)
member_status = member.status
is_member = member_status in (
ChatMemberStatus.MEMBER,
ChatMemberStatus.ADMINISTRATOR,
ChatMemberStatus.CREATOR,
)
except TelegramForbiddenError as error:
logger.error(
'❌ Не удалось проверить подписку пользователя на канал : бот заблокирован',
telegram_id=user.telegram_id,
channel_id=channel_id,
error=error,
)
continue
except TelegramBadRequest as error:
# PARTICIPANT_ID_INVALID - пользователь никогда не был в канале, это нормально
logger.warning(
'⚠️ Ошибка Telegram при проверке подписки пользователя',
telegram_id=user.telegram_id,
error=error,
)
continue
except Exception as error:
logger.error(
'❌ Неожиданная ошибка при проверке подписки пользователя',
telegram_id=user.telegram_id,
error=error,
)
continue
is_trial_filter = (
sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True)
)
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Пропуск деактивации trial подписки : обновлена вебхуком недавно',
subscription_id=subscription.id,
while True:
# Fresh session per batch to avoid long-running connections
async with AsyncSessionLocal() as batch_db:
result = await batch_db.execute(
select(Subscription)
.join(Subscription.user)
.options(
selectinload(Subscription.user),
selectinload(Subscription.tariff),
)
continue
subscription = await deactivate_subscription(db, subscription)
disabled_count += 1
logger.info(
'🚫 Триальная подписка пользователя (ID) отключена из-за отписки от канала',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
.where(
and_(
Subscription.id > last_id,
is_trial_filter,
Subscription.end_date > now,
Subscription.status.in_(
[
SubscriptionStatus.ACTIVE.value,
SubscriptionStatus.DISABLED.value,
]
),
User.status == UserStatus.ACTIVE.value,
)
)
.order_by(Subscription.id)
.limit(_CHANNEL_CHECK_BATCH_SIZE)
)
if user.remnawave_uuid:
try:
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
except Exception as api_error:
logger.error(
'❌ Не удалось отключить пользователя RemnaWave',
remnawave_uuid=user.remnawave_uuid,
api_error=api_error,
subscriptions = result.scalars().all()
if not subscriptions:
break
last_id = subscriptions[-1].id
for subscription in subscriptions:
user = subscription.user
if not user or not user.telegram_id:
continue
# Existing guard: skip if recently updated by webhook
if is_recently_updated_by_webhook(subscription):
logger.debug(
'Skipping subscription: recently updated by webhook',
subscription_id=subscription.id,
)
continue
checked_count += 1
# Rate-limited check for ALL channels
all_subscribed = True
for ch in channels:
is_member = await channel_subscription_service._rate_limited_check(
user.telegram_id, ch['channel_id']
)
# Update DB + cache
await upsert_user_channel_sub(
batch_db, user.telegram_id, ch['channel_id'], is_member
)
await ChannelSubCache.set_sub_status(
user.telegram_id, ch['channel_id'], is_member
)
if notifications_allowed:
if not await notification_sent(
db,
user.id,
subscription.id,
'trial_channel_unsubscribed',
):
sent = await self._send_trial_channel_unsubscribed_notification(user)
if sent:
await record_notification(
db,
if not is_member:
all_subscribed = False
# DEACTIVATE: was active, now not subscribed to all
if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed:
# Guard: always skip paid subscriptions (user paid money)
if is_active_paid_subscription(subscription):
continue
subscription = await deactivate_subscription(batch_db, subscription)
disabled_count += 1
logger.info(
'Subscription deactivated (channel unsubscribe)',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
is_trial=subscription.is_trial,
)
if user.remnawave_uuid:
try:
await self.subscription_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,
)
if notifications_allowed:
if not await notification_sent(
batch_db,
user.id,
subscription.id,
'trial_channel_unsubscribed',
):
sent = await self._send_trial_channel_unsubscribed_notification(user)
if sent:
await record_notification(
batch_db,
user.id,
subscription.id,
'trial_channel_unsubscribed',
)
# REACTIVATE: was disabled, now subscribed to all
elif subscription.status == SubscriptionStatus.DISABLED.value and all_subscribed:
# Guard: traffic limit exhausted
if (
subscription.traffic_limit_gb
and subscription.traffic_used_gb is not None
and subscription.traffic_used_gb >= subscription.traffic_limit_gb
):
logger.debug(
'Skipping reactivation: traffic exhausted',
subscription_id=subscription.id,
traffic_used=subscription.traffic_used_gb,
traffic_limit=subscription.traffic_limit_gb,
)
elif subscription.status == SubscriptionStatus.DISABLED.value and subscription.is_trial and is_member:
# Don't reactivate if traffic limit is exhausted (RemnaWave will just disable again)
if (
subscription.traffic_limit_gb
and subscription.traffic_used_gb is not None
and subscription.traffic_used_gb >= subscription.traffic_limit_gb
):
logger.debug(
'Пропуск реактивации trial подписки: трафик исчерпан',
subscription_id=subscription.id,
traffic_used=subscription.traffic_used_gb,
traffic_limit=subscription.traffic_limit_gb,
)
continue
continue
# Don't reactivate if subscription was disabled by RemnaWave (webhook)
# rather than by monitoring (channel unsubscribe).
# When webhook disables: last_webhook_update_at ≈ updated_at (both set to now())
# When monitoring disables: updated_at is set, last_webhook_update_at stays old
if (
subscription.last_webhook_update_at
and subscription.updated_at
and subscription.last_webhook_update_at >= subscription.updated_at - timedelta(seconds=10)
):
logger.debug(
'Пропуск реактивации trial подписки: отключена RemnaWave панелью',
subscription_id=subscription.id,
last_webhook_at=subscription.last_webhook_update_at,
updated_at=subscription.updated_at,
)
continue
# Guard: disabled by webhook, not by monitoring
if (
subscription.last_webhook_update_at
and subscription.updated_at
and subscription.last_webhook_update_at
>= subscription.updated_at - timedelta(seconds=10)
):
logger.debug(
'Skipping reactivation: disabled by RemnaWave panel',
subscription_id=subscription.id,
last_webhook_at=subscription.last_webhook_update_at,
updated_at=subscription.updated_at,
)
continue
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(subscription)
restored_count += 1
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
restored_count += 1
logger.info(
'✅ Триальная подписка пользователя (ID) восстановлена после повторной подписки на канал',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
)
logger.info(
'Subscription restored (channel resubscribe)',
telegram_id=user.telegram_id,
subscription_id=subscription.id,
is_trial=subscription.is_trial,
)
try:
if user.remnawave_uuid:
await self.subscription_service.update_remnawave_user(db, subscription)
else:
await self.subscription_service.create_remnawave_user(db, subscription)
except Exception as api_error:
logger.error(
'❌ Не удалось обновить RemnaWave пользователя',
telegram_id=user.telegram_id,
api_error=api_error,
)
try:
if user.remnawave_uuid:
await self.subscription_service.update_remnawave_user(
batch_db, subscription
)
else:
await self.subscription_service.create_remnawave_user(
batch_db, subscription
)
except Exception as api_error:
logger.error(
'Failed to update RemnaWave user',
telegram_id=user.telegram_id,
api_error=api_error,
)
await clear_notification_by_type(
db,
subscription.id,
'trial_channel_unsubscribed',
)
await clear_notification_by_type(
batch_db,
subscription.id,
'trial_channel_unsubscribed',
)
# Commit all changes for this batch
await batch_db.commit()
if disabled_count or restored_count:
check_scope = 'all' if settings.CHANNEL_REQUIRED_FOR_ALL else 'trial'
await self._log_monitoring_event(
db,
'trial_channel_subscription_check',
(
f'Проверено {len(subscriptions)} триальных подписок: отключено {disabled_count}, '
f'восстановлено {restored_count}'
f'Checked {checked_count} {check_scope} subscriptions: '
f'disabled {disabled_count}, restored {restored_count}'
),
{
'checked': len(subscriptions),
'checked': checked_count,
'disabled': disabled_count,
'restored': restored_count,
'scope': check_scope,
},
)
except Exception as error:
logger.error('Ошибка проверки подписки на канал для триальных пользователей', error=error)
logger.error('Error checking channel subscriptions', error=error)
async def _check_expired_subscription_followups(self, db: AsyncSession):
if not NotificationSettingsService.are_notifications_globally_enabled():
@@ -1336,16 +1378,16 @@ class MonitoringService:
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from app.services.channel_subscription_service import channel_subscription_service
unsubscribed = await channel_subscription_service.get_unsubscribed_channels(user.telegram_id)
buttons = []
if settings.CHANNEL_LINK:
buttons.append(
[
InlineKeyboardButton(
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
url=settings.CHANNEL_LINK,
)
]
)
for ch in unsubscribed:
link = ch.get('channel_link')
if link:
title = ch.get('title') or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
buttons.append([InlineKeyboardButton(text=f'🔗 {title}', url=link)])
buttons.append(
[
InlineKeyboardButton(
+4 -3
View File
@@ -258,7 +258,7 @@ class YooKassaPaymentMixin:
status=yookassa_response['status'],
confirmation_url=yookassa_response.get('confirmation_url'), # Используем confirmation URL
metadata_json=payment_metadata,
payment_method_type='bank_card',
payment_method_type='sbp',
yookassa_created_at=None,
test_mode=yookassa_response.get('test_mode', False),
)
@@ -897,6 +897,7 @@ class YooKassaPaymentMixin:
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info(
'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю',
@@ -1079,14 +1080,14 @@ class YooKassaPaymentMixin:
'Успешно обработан платеж YooKassa как покупка подписки: пользователь , сумма ₽',
yookassa_payment_id=payment.yookassa_payment_id,
user_id=payment.user_id,
amount_kopeks=payment.amount_kopeks / 100,
amount_rubles=payment.amount_kopeks / 100,
)
else:
logger.info(
'Успешно обработан платеж YooKassa : пользователь пополнил баланс на ₽',
yookassa_payment_id=payment.yookassa_payment_id,
user_id=payment.user_id,
amount_kopeks=payment.amount_kopeks / 100,
amount_rubles=payment.amount_kopeks / 100,
)
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
+6 -9
View File
@@ -323,14 +323,11 @@ class ReferralContestService:
if not self.bot:
return
channel_id_raw = settings.CHANNEL_SUB_ID
if not channel_id_raw:
return
from app.services.channel_subscription_service import channel_subscription_service
try:
channel_id = int(channel_id_raw)
except Exception:
channel_id = channel_id_raw
channel_id = await channel_subscription_service.get_first_channel_id()
if not channel_id:
return
lines = [
f'🏆 {contest.title}',
@@ -358,9 +355,9 @@ class ReferralContestService:
disable_web_page_preview=True,
)
except (TelegramForbiddenError, TelegramNotFound):
logger.info('Не удалось отправить сводку конкурса в канал', channel_id_raw=channel_id_raw)
logger.info('Не удалось отправить сводку конкурса в канал', channel_id=channel_id)
except Exception as exc:
logger.error('Ошибка отправки сводки конкурса в канал', channel_id_raw=channel_id_raw, exc=exc)
logger.error('Ошибка отправки сводки конкурса в канал', channel_id=channel_id, exc=exc)
def _build_participant_message(
self,
-2
View File
@@ -205,8 +205,6 @@ class BotConfigurationService:
'DATABASE_URL': 'DATABASE',
'DATABASE_MODE': 'DATABASE',
'LOCALES_PATH': 'LOCALIZATION',
'CHANNEL_SUB_ID': 'CHANNEL',
'CHANNEL_LINK': 'CHANNEL',
'CHANNEL_IS_REQUIRED_SUB': 'CHANNEL',
'BOT_USERNAME': 'CORE',
'DEFAULT_LANGUAGE': 'LOCALIZATION',
+6 -109
View File
@@ -102,7 +102,7 @@ class YooKassaService:
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'vat_code': int(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
@@ -208,7 +208,7 @@ class YooKassaService:
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'vat_code': int(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
@@ -223,7 +223,7 @@ class YooKassaService:
payment_request = builder.build()
logger.info(
"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: ). Сумма: . Метаданные: . Чек",
'Создание платежа YooKassa СБП с подтверждением redirect (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
idempotence_key=idempotence_key,
amount=amount,
currency=currency,
@@ -237,14 +237,14 @@ class YooKassaService:
)
logger.info(
'Ответ YooKassa Payment.create (СБП, qr): ID=, Status=, Paid',
'Ответ YooKassa Payment.create (СБП, redirect): ID=, Status=, Paid',
response_id=response.id,
status=response.status,
paid=response.paid,
)
# Возвращаем данные платежа с QR-подтверждением
# Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа
# Возвращаем данные платежа с redirect-подтверждением
# YooKassa покажет QR на десктопе или список банков на мобильном
return {
'id': response.id,
'qr_confirmation_data': response.confirmation.confirmation_data
@@ -270,109 +270,6 @@ class YooKassaService:
logger.error('Ошибка создания платежа YooKassa СБП', error=e, exc_info=True)
return None
async def _create_sbp_payment_with_confirmation_type(
self,
amount: float,
currency: str,
description: str,
metadata: dict[str, Any],
customer_contact_for_receipt: dict[str, str],
confirmation_type: str,
) -> dict[str, Any] | None:
"""Создает SBP платеж с указанным типом подтверждения"""
try:
builder = PaymentRequestBuilder()
builder.set_amount({'value': str(round(amount, 2)), 'currency': currency.upper()})
builder.set_capture(True)
if confirmation_type == 'qr':
builder.set_confirmation({'type': 'qr'})
else: # redirect
builder.set_confirmation({'type': 'redirect', 'return_url': self.return_url})
builder.set_description(description)
builder.set_metadata(metadata)
builder.set_payment_method_data({'type': 'sbp'})
receipt_items_list: list[dict[str, Any]] = [
{
'description': description[:128],
'quantity': '1.00',
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
'vat_code': str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
'payment_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
}
]
receipt_data_dict: dict[str, Any] = {'customer': customer_contact_for_receipt, 'items': receipt_items_list}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
'Создание платежа YooKassa СБП с подтверждением (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
confirmation_type=confirmation_type,
idempotence_key=idempotence_key,
amount=amount,
currency=currency,
metadata=metadata,
receipt_data_dict=receipt_data_dict,
)
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key)
)
logger.info(
'Ответ YooKassa Payment.create (СБП, ): ID=, Status=, Paid',
confirmation_type=confirmation_type,
response_id=response.id,
status=response.status,
paid=response.paid,
)
result = {
'id': response.id,
'status': response.status,
'metadata': response.metadata,
'amount_value': float(response.amount.value),
'amount_currency': response.amount.currency,
'idempotence_key_used': idempotence_key,
'paid': response.paid,
'refundable': response.refundable,
'created_at': response.created_at.isoformat()
if hasattr(response.created_at, 'isoformat')
else str(response.created_at),
'description_from_yk': response.description,
'test_mode': response.test if hasattr(response, 'test') else None,
}
# Добавляем данные подтверждения в зависимости от типа
if confirmation_type == 'qr':
if response.confirmation and hasattr(response.confirmation, 'confirmation_data'):
result['confirmation_data'] = response.confirmation.confirmation_data
elif response.confirmation and hasattr(response.confirmation, 'confirmation_url'):
result['confirmation_url'] = response.confirmation.confirmation_url
return result
except Exception as e:
logger.error(
'Ошибка создания платежа YooKassa СБП с подтверждением',
confirmation_type=confirmation_type,
error=e,
exc_info=True,
)
return None
async def get_payment_info(self, payment_id_in_yookassa: str) -> dict[str, Any] | None:
if not self.configured:
logger.error('YooKassa не сконфигурирован. Невозможно получить информацию о платеже.')
+94
View File
@@ -342,3 +342,97 @@ class RateLimitCache:
async def reset_rate_limit(user_id: int, action: str) -> bool:
key = cache_key('rate_limit', user_id, action)
return await cache.delete(key)
class ChannelSubCache:
"""Cache for user channel subscription statuses.
Redis keys:
- channel_sub:{telegram_id}:{channel_id} -> "1" or "0" (TTL 600s)
- required_channels:active -> JSON list of active channels (TTL 60s)
"""
SUB_TTL = 600 # 10 min -- individual user subscription status
CHANNELS_TTL = 60 # 1 min -- list of required channels
@staticmethod
async def get_sub_status(telegram_id: int, channel_id: str) -> bool | None:
"""Get subscription status from cache. None = cache miss."""
key = cache_key('channel_sub', telegram_id, channel_id)
result = await cache.get(key)
if result is None:
return None
return result == 1
@staticmethod
async def get_sub_statuses(telegram_id: int, channel_ids: list[str]) -> dict[str, bool | None]:
"""Batch-fetch subscription statuses via Redis MGET (single round-trip).
Returns {channel_id: True/False/None} where None = cache miss.
Falls back to sequential gets if Redis pipeline is unavailable.
"""
if not channel_ids:
return {}
if not cache._connected or cache.redis_client is None:
return dict.fromkeys(channel_ids, None)
keys = [cache_key('channel_sub', telegram_id, ch_id) for ch_id in channel_ids]
try:
raw_values = await cache.redis_client.mget(keys)
except Exception as e:
logger.warning('Redis MGET failed, falling back to sequential', error=str(e))
result: dict[str, bool | None] = {}
for ch_id in channel_ids:
result[ch_id] = await ChannelSubCache.get_sub_status(telegram_id, ch_id)
return result
statuses: dict[str, bool | None] = {}
for ch_id, raw in zip(channel_ids, raw_values, strict=True):
if raw is None:
statuses[ch_id] = None
else:
try:
parsed = json.loads(raw)
statuses[ch_id] = parsed == 1
except (ValueError, TypeError):
statuses[ch_id] = None
return statuses
@staticmethod
async def set_sub_status(telegram_id: int, channel_id: str, is_member: bool) -> None:
key = cache_key('channel_sub', telegram_id, channel_id)
await cache.set(key, 1 if is_member else 0, expire=ChannelSubCache.SUB_TTL)
@staticmethod
async def invalidate_sub(telegram_id: int, channel_id: str) -> None:
key = cache_key('channel_sub', telegram_id, channel_id)
await cache.delete(key)
@staticmethod
async def invalidate_user_channels(telegram_id: int, channel_ids: list[str]) -> None:
"""Invalidate specific channel keys for a user using single Redis DELETE.
Uses multi-key DELETE (O(K)) instead of delete_pattern() which uses KEYS (O(N)).
At 100k users * 5 channels = 500k keys, KEYS would block Redis for seconds.
"""
if not channel_ids or not cache._connected or not cache.redis_client:
return
keys = [cache_key('channel_sub', telegram_id, ch_id) for ch_id in channel_ids]
try:
await cache.redis_client.delete(*keys)
except Exception as e:
logger.warning('Failed to invalidate user channel cache', telegram_id=telegram_id, error=e)
@staticmethod
async def get_required_channels() -> list[dict] | None:
"""Get the list of required channels from cache."""
return await cache.get('required_channels:active')
@staticmethod
async def set_required_channels(channels: list[dict]) -> None:
await cache.set('required_channels:active', channels, expire=ChannelSubCache.CHANNELS_TTL)
@staticmethod
async def invalidate_channels() -> None:
await cache.delete('required_channels:active')
+14 -30
View File
@@ -205,17 +205,6 @@ router = APIRouter()
promo_code_service = PromoCodeService()
renewal_service = SubscriptionRenewalService()
# Кешированный Bot для проверки подписки на канал (снижает нагрузку)
_channel_check_bot: Bot | None = None
def _get_channel_check_bot() -> Bot:
"""Получить или создать Bot для проверки подписки на канал."""
global _channel_check_bot
if _channel_check_bot is None:
_channel_check_bot = Bot(token=settings.BOT_TOKEN)
return _channel_check_bot
_CRYPTOBOT_MIN_USD = 1.0
_CRYPTOBOT_MAX_USD = 1000.0
@@ -3100,26 +3089,21 @@ async def get_subscription_details(
) from None
# Check required channel subscription
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
try:
bot = _get_channel_check_bot()
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=telegram_id)
# Не закрываем сессию - бот переиспользуется
if settings.CHANNEL_IS_REQUIRED_SUB:
from app.services.channel_subscription_service import channel_subscription_service
if chat_member.status not in ['member', 'administrator', 'creator']:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to our channel to continue',
'channel_link': settings.CHANNEL_LINK,
},
)
except HTTPException:
raise
except Exception as e:
logger.warning('Failed to check channel subscription for user', telegram_id=telegram_id, error=e)
# Don't block user if check fails
channels_with_status = await channel_subscription_service.get_channels_with_status(telegram_id)
is_subscribed = all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
if not is_subscribed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
'code': 'channel_subscription_required',
'message': 'Please subscribe to the required channels to continue',
'channels': channels_with_status,
},
)
user = await get_user_by_telegram_id(db, telegram_id)
purchase_url = (settings.MINIAPP_PURCHASE_URL or '').strip()
+4
View File
@@ -287,6 +287,10 @@ async def main():
daily_subscription_service.set_bot(bot)
telegram_notifier.set_bot(bot)
from app.services.channel_subscription_service import channel_subscription_service
channel_subscription_service.bot = bot
# Initialize email broadcast service
from app.cabinet.services.email_service import email_service
from app.services.broadcast_service import email_broadcast_service
@@ -0,0 +1,61 @@
"""add required_channels and user_channel_subscriptions tables
Revision ID: 0008
Revises: 0007
Create Date: 2026-02-24
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = '0008'
down_revision: Union[str, None] = '0007'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table_name: str) -> bool:
"""Check if table already exists (idempotency guard)."""
conn = op.get_bind()
result = conn.execute(
sa.text(
'SELECT EXISTS (SELECT 1 FROM information_schema.tables '
"WHERE table_schema = 'public' AND table_name = :name)"
),
{'name': table_name},
)
return result.scalar()
def upgrade() -> None:
if not _has_table('required_channels'):
op.create_table(
'required_channels',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('channel_id', sa.String(100), unique=True, nullable=False),
sa.Column('channel_link', sa.String(500), nullable=True),
sa.Column('title', sa.String(255), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('sort_order', sa.Integer(), nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True),
)
if not _has_table('user_channel_subscriptions'):
op.create_table(
'user_channel_subscriptions',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('telegram_id', sa.BigInteger(), nullable=False),
sa.Column('channel_id', sa.String(100), nullable=False),
sa.Column('is_member', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('checked_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint('telegram_id', 'channel_id', name='uq_user_channel_sub'),
sa.Index('ix_user_channel_sub_telegram_id', 'telegram_id'),
)
def downgrade() -> None:
op.drop_table('user_channel_subscriptions')
op.drop_table('required_channels')
@@ -0,0 +1,28 @@
"""add channel_id index for user_channel_subscriptions
Revision ID: 0009
Revises: 0008
Create Date: 2026-02-24
"""
from typing import Sequence, Union
from alembic import op
revision: str = '0009'
down_revision: Union[str, None] = '0008'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
'ix_user_channel_sub_channel_id',
'user_channel_subscriptions',
['channel_id'],
if_not_exists=True,
)
def downgrade() -> None:
op.drop_index('ix_user_channel_sub_channel_id', table_name='user_channel_subscriptions')
+11
View File
@@ -146,11 +146,22 @@ if 'yookassa' not in sys.modules:
payment_builder_module.PaymentRequestBuilder = _FakePaymentRequestBuilder
confirmation_module.ConfirmationType = _FakeConfirmationType
exceptions_module = types.ModuleType('yookassa.domain.exceptions')
not_found_module = types.ModuleType('yookassa.domain.exceptions.not_found_error')
class _FakeNotFoundError(Exception):
pass
not_found_module.NotFoundError = _FakeNotFoundError
exceptions_module.not_found_error = not_found_module
sys.modules['yookassa.domain'] = domain_module
sys.modules['yookassa.domain.request'] = request_module
sys.modules['yookassa.domain.request.payment_request_builder'] = payment_builder_module
sys.modules['yookassa.domain.common'] = common_module
sys.modules['yookassa.domain.common.confirmation_type'] = confirmation_module
sys.modules['yookassa.domain.exceptions'] = exceptions_module
sys.modules['yookassa.domain.exceptions.not_found_error'] = not_found_module
@pytest.fixture
@@ -212,7 +212,7 @@ async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPat
assert result is not None
assert result['confirmation_token'] == 'token123'
assert captured_args['payment_method_type'] == 'bank_card'
assert captured_args['payment_method_type'] == 'sbp'
assert captured_args['metadata_json']['type'] == 'balance_topup_sbp'