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:
+1
-3
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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}>'
|
||||
)
|
||||
|
||||
@@ -24,6 +24,7 @@ from . import (
|
||||
referrals,
|
||||
remnawave,
|
||||
reports,
|
||||
required_channels,
|
||||
rules,
|
||||
servers,
|
||||
statistics,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
[
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "⚠️ تنظیمات فقط برای اشتراک پولی",
|
||||
|
||||
@@ -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": "⚠️ Настройки доступны только для платных подписок",
|
||||
|
||||
@@ -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": "⚠️ Налаштування доступні лише для платних підписок",
|
||||
|
||||
@@ -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
@@ -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()
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 не сконфигурирован. Невозможно получить информацию о платеже.')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
@@ -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'
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user