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) при проверке черного списка
|
BLACKLIST_IGNORE_ADMINS=true # Игнорировать администраторов (из ADMIN_IDS) при проверке черного списка
|
||||||
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
|
SUBSCRIPTION_RENEWAL_BALANCE_THRESHOLD_KOPEKS=20000 # Порог баланса (в копейках) для фильтра «готовы к продлению»
|
||||||
|
|
||||||
# Обязательная подписка на канал
|
# Channel subscription settings (channels are managed via admin panel)
|
||||||
CHANNEL_SUB_ID= # Опционально ID твоего канала (-100)
|
|
||||||
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
CHANNEL_IS_REQUIRED_SUB=false # Обязательна ли подписка на канал
|
||||||
CHANNEL_LINK= # Опционально ссылка на канал
|
|
||||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE=true # Отключать триальные подписки при отписке от канала
|
||||||
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
CHANNEL_REQUIRED_FOR_ALL=false # Требовать подписку на канал для ВСЕХ пользователей (платных и триальных)
|
||||||
|
|
||||||
|
|||||||
+8
-8
@@ -45,6 +45,7 @@ from app.handlers.admin import (
|
|||||||
referrals as admin_referrals,
|
referrals as admin_referrals,
|
||||||
remnawave as admin_remnawave,
|
remnawave as admin_remnawave,
|
||||||
reports as admin_reports,
|
reports as admin_reports,
|
||||||
|
required_channels as admin_required_channels,
|
||||||
rules as admin_rules,
|
rules as admin_rules,
|
||||||
servers as admin_servers,
|
servers as admin_servers,
|
||||||
statistics as admin_statistics,
|
statistics as admin_statistics,
|
||||||
@@ -58,6 +59,7 @@ from app.handlers.admin import (
|
|||||||
users as admin_users,
|
users as admin_users,
|
||||||
welcome_text as admin_welcome_text,
|
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.handlers.stars_payments import register_stars_handlers
|
||||||
from app.middlewares.auth import AuthMiddleware
|
from app.middlewares.auth import AuthMiddleware
|
||||||
from app.middlewares.blacklist import BlacklistMiddleware
|
from app.middlewares.blacklist import BlacklistMiddleware
|
||||||
@@ -135,15 +137,11 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
|
|||||||
dp.callback_query.middleware(button_stats_middleware)
|
dp.callback_query.middleware(button_stats_middleware)
|
||||||
logger.info('📊 ButtonStatsMiddleware активирован')
|
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()
|
channel_checker = ChannelCheckerMiddleware()
|
||||||
dp.message.middleware(channel_checker_middleware)
|
dp.message.middleware(channel_checker)
|
||||||
dp.callback_query.middleware(channel_checker_middleware)
|
dp.callback_query.middleware(channel_checker)
|
||||||
logger.info('🔒 Обязательная подписка включена - ChannelCheckerMiddleware активирован')
|
|
||||||
else:
|
|
||||||
logger.info('🔓 Обязательная подписка отключена - ChannelCheckerMiddleware не зарегистрирован')
|
|
||||||
dp.message.middleware(AuthMiddleware())
|
dp.message.middleware(AuthMiddleware())
|
||||||
dp.callback_query.middleware(AuthMiddleware())
|
dp.callback_query.middleware(AuthMiddleware())
|
||||||
dp.pre_checkout_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_bulk_ban.register_bulk_ban_handlers(dp)
|
||||||
admin_blacklist.register_blacklist_handlers(dp)
|
admin_blacklist.register_blacklist_handlers(dp)
|
||||||
admin_blocked_users.register_handlers(dp)
|
admin_blocked_users.register_handlers(dp)
|
||||||
|
admin_required_channels.register_handlers(dp)
|
||||||
|
register_channel_member_handlers(dp)
|
||||||
common.register_handlers(dp)
|
common.register_handlers(dp)
|
||||||
register_stars_handlers(dp)
|
register_stars_handlers(dp)
|
||||||
user_contests.register_handlers(dp)
|
user_contests.register_handlers(dp)
|
||||||
|
|||||||
+18
-43
@@ -1,9 +1,6 @@
|
|||||||
"""FastAPI dependencies for cabinet module."""
|
"""FastAPI dependencies for cabinet module."""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from aiogram import Bot
|
|
||||||
from fastapi import Depends, HTTPException, Request, status
|
from fastapi import Depends, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -23,17 +20,6 @@ logger = structlog.get_logger(__name__)
|
|||||||
|
|
||||||
security = HTTPBearer(auto_error=False)
|
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:
|
async def get_cabinet_db() -> AsyncSession:
|
||||||
"""Get database session for cabinet operations."""
|
"""Get database session for cabinet operations."""
|
||||||
@@ -163,42 +149,31 @@ async def get_current_cabinet_user(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check required channel subscription - ТОЛЬКО для Telegram юзеров
|
# Check required channel subscription - Telegram users only
|
||||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||||
# Пропускаем проверку для email-only юзеров (нет telegram_id)
|
# Skip for email-only users (no telegram_id)
|
||||||
if user.telegram_id is not None:
|
if user.telegram_id is not None:
|
||||||
# Проверяем админа по telegram_id ИЛИ email
|
# Skip admin check
|
||||||
is_admin = settings.is_admin(
|
is_admin = settings.is_admin(
|
||||||
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
telegram_id=user.telegram_id, email=user.email if user.email_verified else None
|
||||||
)
|
)
|
||||||
if not is_admin:
|
if not is_admin:
|
||||||
try:
|
from app.services.channel_subscription_service import channel_subscription_service
|
||||||
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,
|
|
||||||
)
|
|
||||||
# Не закрываем сессию - бот переиспользуется
|
|
||||||
|
|
||||||
if chat_member.status not in ['member', 'administrator', 'creator']:
|
channels_with_status = await channel_subscription_service.get_channels_with_status(user.telegram_id)
|
||||||
raise HTTPException(
|
is_subscribed = (
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
|
||||||
detail={
|
)
|
||||||
'code': 'channel_subscription_required',
|
|
||||||
'message': 'Please subscribe to our channel to continue',
|
if not is_subscribed:
|
||||||
'channel_link': settings.CHANNEL_LINK,
|
raise HTTPException(
|
||||||
},
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
)
|
detail={
|
||||||
except HTTPException:
|
'code': 'channel_subscription_required',
|
||||||
raise
|
'message': 'Please subscribe to the required channels to continue',
|
||||||
except TimeoutError:
|
'channels': channels_with_status,
|
||||||
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
|
|
||||||
)
|
)
|
||||||
# Don't block user if check fails
|
|
||||||
|
|
||||||
return user
|
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_broadcasts import router as admin_broadcasts_router
|
||||||
from .admin_button_styles import router as admin_button_styles_router
|
from .admin_button_styles import router as admin_button_styles_router
|
||||||
from .admin_campaigns import router as admin_campaigns_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_email_templates import router as admin_email_templates_router
|
||||||
from .admin_partners import router as admin_partners_router
|
from .admin_partners import router as admin_partners_router
|
||||||
from .admin_payment_methods import router as admin_payment_methods_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_traffic_router)
|
||||||
router.include_router(admin_pinned_messages_router)
|
router.include_router(admin_pinned_messages_router)
|
||||||
router.include_router(admin_button_styles_router)
|
router.include_router(admin_button_styles_router)
|
||||||
|
router.include_router(admin_channels_router)
|
||||||
|
|
||||||
# WebSocket route
|
# WebSocket route
|
||||||
router.include_router(websocket_router)
|
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_TOPIC_ID: int | None = None
|
||||||
ADMIN_REPORTS_SEND_TIME: str | 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_IS_REQUIRED_SUB: bool = False
|
||||||
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE: bool = True
|
||||||
CHANNEL_REQUIRED_FOR_ALL: bool = False
|
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:
|
def __repr__(self) -> str:
|
||||||
return f"<PaymentMethodConfig method_id='{self.method_id}' order={self.sort_order} enabled={self.is_enabled}>"
|
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,
|
referrals,
|
||||||
remnawave,
|
remnawave,
|
||||||
reports,
|
reports,
|
||||||
|
required_channels,
|
||||||
rules,
|
rules,
|
||||||
servers,
|
servers,
|
||||||
statistics,
|
statistics,
|
||||||
|
|||||||
@@ -318,10 +318,11 @@ def _get_group_status(group_key: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
if key == 'core':
|
if key == 'core':
|
||||||
token_ok = bool(getattr(settings, 'BOT_TOKEN', ''))
|
token_ok = bool(getattr(settings, 'BOT_TOKEN', ''))
|
||||||
channel_ok = bool(settings.CHANNEL_LINK or not settings.CHANNEL_IS_REQUIRED_SUB)
|
# Channel subscription channels are now managed via DB (admin panel),
|
||||||
if token_ok and channel_ok:
|
# not a single CHANNEL_LINK setting. Dashboard cannot async-query DB here.
|
||||||
|
if token_ok:
|
||||||
return '🟢', 'Бот готов к работе'
|
return '🟢', 'Бот готов к работе'
|
||||||
return '🟡', 'Проверьте токен и обязательную подписку'
|
return '🟡', 'Проверьте токен бота'
|
||||||
|
|
||||||
if key == 'subscriptions':
|
if key == 'subscriptions':
|
||||||
price_ready = settings.PRICE_30_DAYS > 0 and settings.AVAILABLE_SUBSCRIPTION_PERIODS
|
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
|
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)
|
texts = get_texts(language)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
price_30_days = settings.format_price(settings.PRICE_30_DAYS)
|
||||||
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
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'
|
header = '🧪 <b>Тестовое уведомление мониторинга</b>\n\n'
|
||||||
|
|
||||||
if notification_type == 'trial_channel_unsubscribed':
|
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', '✅ Я подписался')
|
check_button = texts.t('CHANNEL_CHECK_BUTTON', '✅ Я подписался')
|
||||||
message = template.format(check_button=check_button)
|
message = template.format(check_button=check_button)
|
||||||
buttons: list[list[InlineKeyboardButton]] = []
|
# Use all required channels for the preview keyboard
|
||||||
if settings.CHANNEL_LINK:
|
required_channels = await channel_subscription_service.get_required_channels()
|
||||||
buttons.append(
|
keyboard = get_channel_sub_keyboard(required_channels, language=language)
|
||||||
[
|
|
||||||
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)
|
|
||||||
elif notification_type == 'expired_1d':
|
elif notification_type == 'expired_1d':
|
||||||
template = texts.get(
|
template = texts.get(
|
||||||
'SUBSCRIPTION_EXPIRED_1D',
|
'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:
|
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(
|
await bot.send_message(
|
||||||
chat_id,
|
chat_id,
|
||||||
message,
|
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
|
import structlog
|
||||||
from aiogram import Bot, Dispatcher, F, types
|
from aiogram import Bot, Dispatcher, F, types
|
||||||
from aiogram.enums import ChatMemberStatus
|
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
|
||||||
from aiogram.filters import Command, StateFilter
|
from aiogram.filters import Command, StateFilter
|
||||||
from aiogram.fsm.context import FSMContext
|
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.admin_notification_service import AdminNotificationService
|
||||||
from app.services.campaign_service import AdvertisingCampaignService
|
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.main_menu_button_service import MainMenuButtonService
|
||||||
from app.services.pinned_message_service import (
|
from app.services.pinned_message_service import (
|
||||||
deliver_pinned_message_to_user,
|
deliver_pinned_message_to_user,
|
||||||
@@ -1866,20 +1866,22 @@ async def required_sub_channel_check(
|
|||||||
|
|
||||||
texts = get_texts(language)
|
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 [
|
# Invalidate cache for fresh check (user just clicked "I subscribed")
|
||||||
ChatMemberStatus.MEMBER,
|
await channel_subscription_service.invalidate_user_cache(query.from_user.id)
|
||||||
ChatMemberStatus.ADMINISTRATOR,
|
|
||||||
ChatMemberStatus.CREATOR,
|
is_subscribed = await channel_subscription_service.is_user_subscribed_to_all(query.from_user.id)
|
||||||
]:
|
if not is_subscribed:
|
||||||
# НЕ удаляем payload - пользователь может попробовать снова после подписки
|
# НЕ удаляем payload - пользователь может попробовать снова после подписки
|
||||||
logger.info(
|
logger.info(
|
||||||
"📦 CHANNEL CHECK: Подписка не подтверждена, payload '' сохранён для следующей попытки",
|
'CHANNEL CHECK: Подписка не подтверждена, payload сохранён для следующей попытки',
|
||||||
pending_start_payload=pending_start_payload,
|
pending_start_payload=pending_start_payload,
|
||||||
)
|
)
|
||||||
return await query.answer(
|
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,
|
show_alert=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+26
-11
@@ -275,22 +275,37 @@ def get_privacy_policy_keyboard(language: str = DEFAULT_LANGUAGE) -> InlineKeybo
|
|||||||
|
|
||||||
|
|
||||||
def get_channel_sub_keyboard(
|
def get_channel_sub_keyboard(
|
||||||
channel_link: str | None,
|
channels: list[dict] | str | None = None,
|
||||||
language: str = DEFAULT_LANGUAGE,
|
language: str = DEFAULT_LANGUAGE,
|
||||||
) -> InlineKeyboardMarkup:
|
) -> 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]] = []
|
buttons: list[list[InlineKeyboardButton]] = []
|
||||||
|
|
||||||
if channel_link:
|
if isinstance(channels, str):
|
||||||
buttons.append(
|
# Legacy: single channel link string
|
||||||
[
|
if channels:
|
||||||
InlineKeyboardButton(
|
buttons.append(
|
||||||
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
|
[
|
||||||
url=channel_link,
|
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(
|
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_SUCCESS_INCREASE": "\n✅ Device limit increased!\n\n📱 Was: {old_count} → Now: {new_count}\n💰 Charged: {amount}\n",
|
||||||
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
"CHANGE_DEVICES_TITLE": "📱 Change device limit",
|
||||||
"CHANNEL_CHECK_BUTTON": "✅ I have joined",
|
"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_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",
|
"CHANNEL_SUBSCRIBE_THANKS": "✅ Thanks for subscribing",
|
||||||
"CHECK_STATUS_BUTTON": "📊 Check status",
|
"CHECK_STATUS_BUTTON": "📊 Check status",
|
||||||
"CHECK_STATUS_NO_CHANGES": "Status has not changed",
|
"CHECK_STATUS_NO_CHANGES": "Status has not changed",
|
||||||
@@ -1431,10 +1431,12 @@
|
|||||||
"SUBSCRIPTION_NO_SERVERS": "No servers",
|
"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_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_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_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_NOTE": "⚡ Extra discount {percent}%: -{amount}",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Discount active for {time_left}\n<code>{bar}</code>",
|
||||||
"SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!",
|
"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_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_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",
|
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Settings are available only for paid subscriptions",
|
||||||
|
|||||||
@@ -951,9 +951,9 @@
|
|||||||
"CHANGE_DEVICES_TITLE": "📱 تغییر تعداد دستگاه",
|
"CHANGE_DEVICES_TITLE": "📱 تغییر تعداد دستگاه",
|
||||||
"CHANGE_TARIFF_BUTTON": "📦 تعرفه",
|
"CHANGE_TARIFF_BUTTON": "📦 تعرفه",
|
||||||
"CHANNEL_CHECK_BUTTON": "✅ عضو شدم",
|
"CHANNEL_CHECK_BUTTON": "✅ عضو شدم",
|
||||||
"CHANNEL_REQUIRED_TEXT": "🔒 برای استفاده از ربات در کانال خبری عضو شوید، سپس دکمه زیر بزنید.",
|
"CHANNEL_REQUIRED_TEXT": "لطفاً در کانالهای اجباری عضو شوید و سپس دکمه زیر را فشار دهید.",
|
||||||
"CHANNEL_SUBSCRIBE_BUTTON": "📢 عضویت در کانال",
|
"CHANNEL_SUBSCRIBE_BUTTON": "📢 عضویت در کانال",
|
||||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ هنوز عضو کانال نشدهاید!",
|
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "لطفاً ابتدا در همه کانالهای اجباری عضو شوید!",
|
||||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ ممنون از عضویت",
|
"CHANNEL_SUBSCRIBE_THANKS": "✅ ممنون از عضویت",
|
||||||
"CHECK_STATUS_BUTTON": "📊 بررسی وضعیت",
|
"CHECK_STATUS_BUTTON": "📊 بررسی وضعیت",
|
||||||
"CHECK_STATUS_NO_CHANGES": "وضعیت تغییر نکرده",
|
"CHECK_STATUS_NO_CHANGES": "وضعیت تغییر نکرده",
|
||||||
@@ -1452,10 +1452,12 @@
|
|||||||
"SUBSCRIPTION_NO_SERVERS": "❌ سروری در دسترس نیست",
|
"SUBSCRIPTION_NO_SERVERS": "❌ سروری در دسترس نیست",
|
||||||
"SUBSCRIPTION_OVERVIEW_TEMPLATE": "📱 <b>اشتراک شما</b>\n\n📊 وضعیت: {status}\n📅 اعتبار: {expiry}\n📈 ترافیک: {traffic}\n📱 دستگاهها: {devices}\n🌍 سرورها: {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_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_HINT": "⚡ تخفیف اضافی {percent}% فعال شد.\n\nبا سایر تخفیفها جمع میشود!",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ تخفیف اضافی {percent}%: -{amount}",
|
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ تخفیف اضافی {percent}%: -{amount}",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "\n⏳ اعتبار تخفیف: {time_left} <code>{bar}</code>",
|
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "\n⏳ اعتبار تخفیف: {time_left} <code>{bar}</code>",
|
||||||
"SUBSCRIPTION_PURCHASED": "🎉 اشتراک خریداری شد!",
|
"SUBSCRIPTION_PURCHASED": "🎉 اشتراک خریداری شد!",
|
||||||
|
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "اشتراک شما بازیابی شد!\n\nبا تشکر از عضویت در کانالها. VPN دوباره فعال است.",
|
||||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ تنظیمات",
|
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ تنظیمات",
|
||||||
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>تنظیمات اشتراک</b>",
|
"SUBSCRIPTION_SETTINGS_OVERVIEW": "⚙️ <b>تنظیمات اشتراک</b>",
|
||||||
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ تنظیمات فقط برای اشتراک پولی",
|
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ تنظیمات فقط برای اشتراک پولی",
|
||||||
|
|||||||
@@ -951,9 +951,9 @@
|
|||||||
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
"CHANGE_DEVICES_TITLE": "📱 Изменение количества устройств",
|
||||||
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
|
"CHANGE_TARIFF_BUTTON": "📦 Тариф",
|
||||||
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
"CHANNEL_CHECK_BUTTON": "✅ Я подписался",
|
||||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для использования бота подпишитесь на новостной канал, а затем нажмите кнопку ниже.",
|
"CHANNEL_REQUIRED_TEXT": "Пожалуйста, подпишитесь на обязательные каналы и нажмите кнопку ниже.",
|
||||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Подписаться",
|
||||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Вы не подписались на канал!",
|
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Пожалуйста, подпишитесь на все обязательные каналы!",
|
||||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
"CHANNEL_SUBSCRIBE_THANKS": "✅ Спасибо за подписку",
|
||||||
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
"CHECK_STATUS_BUTTON": "📊 Проверить статус",
|
||||||
"CHECK_STATUS_NO_CHANGES": "Статус не изменился",
|
"CHECK_STATUS_NO_CHANGES": "Статус не изменился",
|
||||||
@@ -1452,10 +1452,12 @@
|
|||||||
"SUBSCRIPTION_NO_SERVERS": "Нет серверов",
|
"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_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_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_HINT": "⚡ Активирована доп. скидка {percent}%. \n\nСуммируется с другими скидками!",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
|
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
|
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Скидка действует ещё: {time_left}\n<code>{bar}</code>",
|
||||||
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
"SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!",
|
||||||
|
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Ваша подписка восстановлена!\n\nСпасибо за подписку на каналы. VPN снова активен.",
|
||||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки",
|
"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_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": "⚠️ Настройки доступны только для платных подписок",
|
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Настройки доступны только для платных подписок",
|
||||||
|
|||||||
@@ -871,9 +871,9 @@
|
|||||||
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Кількість пристроїв збільшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
"CHANGE_DEVICES_SUCCESS_INCREASE": "\n ✅ Кількість пристроїв збільшено!\n\n 📱 Було: {old_count} → Стало: {new_count}\n 💰 Списано: {amount}\n ",
|
||||||
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
|
"CHANGE_DEVICES_TITLE": "📱 Зміна кількості пристроїв",
|
||||||
"CHANNEL_CHECK_BUTTON": "✅ Я підписався",
|
"CHANNEL_CHECK_BUTTON": "✅ Я підписався",
|
||||||
"CHANNEL_REQUIRED_TEXT": "🔒 Для використання бота підпишіться на канал новин, а потім натисніть кнопку нижче.",
|
"CHANNEL_REQUIRED_TEXT": "Будь ласка, підпишіться на обов'язкові канали та натисніть кнопку нижче.",
|
||||||
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Підписатися",
|
"CHANNEL_SUBSCRIBE_BUTTON": "🔗 Підписатися",
|
||||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "❌ Ви не підписалися на канал!",
|
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT": "Будь ласка, підпишіться на всі обов'язкові канали!",
|
||||||
"CHANNEL_SUBSCRIBE_THANKS": "✅ Дякуємо за підписку",
|
"CHANNEL_SUBSCRIBE_THANKS": "✅ Дякуємо за підписку",
|
||||||
"CHECK_STATUS_BUTTON": "📊 Перевірити статус",
|
"CHECK_STATUS_BUTTON": "📊 Перевірити статус",
|
||||||
"CHECK_STATUS_NO_CHANGES": "Статус не змінився",
|
"CHECK_STATUS_NO_CHANGES": "Статус не змінився",
|
||||||
@@ -1362,10 +1362,12 @@
|
|||||||
"SUBSCRIPTION_NO_SERVERS": "Немає серверів",
|
"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_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_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_HINT": "⚡ Активовано дод. знижку {percent}%. \n\nСумується з іншими знижками!",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
|
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Дод. знижка {percent}%: -{amount}",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
|
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER": "⏳ Знижка діє ще: {time_left}\n<code>{bar}</code>",
|
||||||
"SUBSCRIPTION_PURCHASED": "🎉 Підписку успішно придбано!",
|
"SUBSCRIPTION_PURCHASED": "🎉 Підписку успішно придбано!",
|
||||||
|
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE": "Вашу підписку відновлено!\n\nДякуємо за підписку на канали. VPN знову активний.",
|
||||||
"SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Налаштування підписки",
|
"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_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": "⚠️ Налаштування доступні лише для платних підписок",
|
"SUBSCRIPTION_SETTINGS_PAID_ONLY": "⚠️ Налаштування доступні лише для платних підписок",
|
||||||
|
|||||||
@@ -869,9 +869,9 @@
|
|||||||
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n ✅设备数量已增加!\n\n 📱之前:{old_count}→现在:{new_count}\n 💰已扣除:{amount}\n ",
|
"CHANGE_DEVICES_SUCCESS_INCREASE":"\n ✅设备数量已增加!\n\n 📱之前:{old_count}→现在:{new_count}\n 💰已扣除:{amount}\n ",
|
||||||
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
|
"CHANGE_DEVICES_TITLE":"📱更改设备数量",
|
||||||
"CHANNEL_CHECK_BUTTON":"✅我已订阅",
|
"CHANNEL_CHECK_BUTTON":"✅我已订阅",
|
||||||
"CHANNEL_REQUIRED_TEXT":"🔒要使用机器人,请订阅新闻频道,然后点击下方按钮。",
|
"CHANNEL_REQUIRED_TEXT":"请订阅所有必需频道,然后点击下方按钮。",
|
||||||
"CHANNEL_SUBSCRIBE_BUTTON":"🔗订阅",
|
"CHANNEL_SUBSCRIBE_BUTTON":"🔗订阅",
|
||||||
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"❌您没有订阅该频道!",
|
"CHANNEL_SUBSCRIBE_REQUIRED_ALERT":"请先订阅所有必需频道!",
|
||||||
"CHANNEL_SUBSCRIBE_THANKS":"✅感谢您的订阅",
|
"CHANNEL_SUBSCRIBE_THANKS":"✅感谢您的订阅",
|
||||||
"CHECK_STATUS_BUTTON":"📊检查状态",
|
"CHECK_STATUS_BUTTON":"📊检查状态",
|
||||||
"CHECK_STATUS_NO_CHANGES":"状态未更改",
|
"CHECK_STATUS_NO_CHANGES":"状态未更改",
|
||||||
@@ -1360,10 +1360,12 @@
|
|||||||
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
|
"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_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_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_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
|
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
|
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
|
||||||
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
|
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
|
||||||
|
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
|
||||||
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
|
"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_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":"⚠️设置仅适用于付费订阅",
|
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
|
||||||
@@ -1693,10 +1695,12 @@
|
|||||||
"SUBSCRIPTION_NO_SERVERS":"没有服务器",
|
"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_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_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_HINT":"⚡已激活额外{percent}%折扣。\n\n可与其他折扣叠加!",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
|
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE":"⚡额外{percent}%折扣:-{amount}",
|
||||||
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
|
"SUBSCRIPTION_PROMO_DISCOUNT_TIMER":"⏳折扣剩余时间:{time_left}\n<code>{bar}</code>",
|
||||||
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
|
"SUBSCRIPTION_PURCHASED":"🎉订阅已成功购买!",
|
||||||
|
"SUBSCRIPTION_REACTIVATED_CHANNEL_SUBSCRIBE":"您的订阅已恢复!\n\n感谢您订阅频道。VPN已重新激活。",
|
||||||
"SUBSCRIPTION_SETTINGS_BUTTON":"⚙️订阅设置",
|
"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_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":"⚠️设置仅适用于付费订阅",
|
"SUBSCRIPTION_SETTINGS_PAID_ONLY":"⚠️设置仅适用于付费订阅",
|
||||||
|
|||||||
+176
-196
@@ -2,11 +2,9 @@ from collections.abc import Awaitable, Callable
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
|
||||||
import structlog
|
import structlog
|
||||||
from aiogram import BaseMiddleware, Bot, types
|
from aiogram import BaseMiddleware, Bot, types
|
||||||
from aiogram.enums import ChatMemberStatus
|
from aiogram.exceptions import TelegramBadRequest
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
|
||||||
from aiogram.fsm.context import FSMContext
|
from aiogram.fsm.context import FSMContext
|
||||||
from aiogram.types import CallbackQuery, Message, TelegramObject, Update
|
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.loader import DEFAULT_LANGUAGE
|
||||||
from app.localization.texts import get_texts
|
from app.localization.texts import get_texts
|
||||||
from app.services.admin_notification_service import AdminNotificationService
|
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.services.subscription_service import SubscriptionService
|
||||||
|
from app.utils.cache import cache
|
||||||
from app.utils.check_reg_process import is_registration_process
|
from app.utils.check_reg_process import is_registration_process
|
||||||
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
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_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:
|
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:
|
try:
|
||||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
|
||||||
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
||||||
await redis_client.set(key, payload, ex=REDIS_PAYLOAD_TTL)
|
result = await cache.set(key, payload, expire=REDIS_PAYLOAD_TTL)
|
||||||
await redis_client.aclose()
|
if result:
|
||||||
logger.info(
|
logger.info('Saved pending payload to Redis', payload=payload, telegram_id=telegram_id)
|
||||||
"💾 [Redis fallback] Сохранен payload '' для пользователя", payload=payload, telegram_id=telegram_id
|
return result
|
||||||
)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
async def get_pending_payload_from_redis(telegram_id: int) -> str | None:
|
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:
|
try:
|
||||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
|
||||||
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
||||||
payload = await redis_client.get(key)
|
return await cache.get(key)
|
||||||
await redis_client.aclose()
|
|
||||||
if payload:
|
|
||||||
return payload.decode('utf-8') if isinstance(payload, bytes) else payload
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def delete_pending_payload_from_redis(telegram_id: int) -> 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:
|
try:
|
||||||
redis_client = aioredis.from_url(settings.REDIS_URL)
|
|
||||||
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
key = f'{REDIS_PAYLOAD_KEY_PREFIX}{telegram_id}'
|
||||||
await redis_client.delete(key)
|
await cache.delete(key)
|
||||||
await redis_client.aclose()
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ChannelCheckerMiddleware(BaseMiddleware):
|
class ChannelCheckerMiddleware(BaseMiddleware):
|
||||||
"""
|
"""Middleware for checking required channel subscriptions.
|
||||||
Middleware для проверки подписки на канал.
|
|
||||||
ОПТИМИЗИРОВАНО: создаёт максимум одну сессию БД на запрос.
|
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):
|
def __init__(self):
|
||||||
self.BAD_MEMBER_STATUS = (ChatMemberStatus.LEFT, ChatMemberStatus.KICKED, ChatMemberStatus.RESTRICTED)
|
logger.info('ChannelCheckerMiddleware initialized (multi-channel mode)')
|
||||||
self.GOOD_MEMBER_STATUS = (ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.CREATOR)
|
|
||||||
logger.info('🔧 ChannelCheckerMiddleware инициализирован')
|
|
||||||
|
|
||||||
async def __call__(
|
async def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -90,6 +81,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
data: dict[str, Any],
|
data: dict[str, Any],
|
||||||
) -> 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
|
telegram_id = None
|
||||||
if isinstance(event, (Message, CallbackQuery)):
|
if isinstance(event, (Message, CallbackQuery)):
|
||||||
telegram_id = event.from_user.id
|
telegram_id = event.from_user.id
|
||||||
@@ -100,7 +96,6 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
telegram_id = event.callback_query.from_user.id
|
telegram_id = event.callback_query.from_user.id
|
||||||
|
|
||||||
if telegram_id is None:
|
if telegram_id is None:
|
||||||
logger.debug('❌ telegram_id не найден, пропускаем')
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
# Skip channel check for lightweight UI callbacks (close/delete notifications)
|
# Skip channel check for lightweight UI callbacks (close/delete notifications)
|
||||||
@@ -112,110 +107,123 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
):
|
):
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
# Админам разрешаем пропускать проверку подписки
|
|
||||||
if settings.is_admin(telegram_id):
|
if settings.is_admin(telegram_id):
|
||||||
logger.debug(
|
|
||||||
'✅ Пользователь является администратором — пропускаем проверку подписки', telegram_id=telegram_id
|
|
||||||
)
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
state: FSMContext = data.get('state')
|
state: FSMContext = data.get('state')
|
||||||
current_state = None
|
current_state = await state.get_state() if state else None
|
||||||
|
if is_registration_process(event, current_state):
|
||||||
if state:
|
|
||||||
current_state = await state.get_state()
|
|
||||||
|
|
||||||
is_reg_process = is_registration_process(event, current_state)
|
|
||||||
|
|
||||||
if is_reg_process:
|
|
||||||
logger.debug('✅ Событие разрешено (процесс регистрации), пропускаем проверку')
|
|
||||||
return await handler(event, data)
|
return await handler(event, data)
|
||||||
|
|
||||||
|
# Ensure service has bot reference for API fallback
|
||||||
bot: Bot = data['bot']
|
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:
|
if not unsubscribed:
|
||||||
logger.warning('⚠️ CHANNEL_SUB_ID не установлен, пропускаем проверку')
|
# 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)
|
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:
|
await self._capture_start_payload(state, event, bot)
|
||||||
logger.debug('⚠️ Обязательная подписка отключена, пропускаем проверку')
|
|
||||||
return await handler(event, data)
|
|
||||||
|
|
||||||
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:
|
# Re-check via API for immediate feedback (invalidate cache first)
|
||||||
logger.warning('⚠️ CHANNEL_LINK не задан или невалиден, кнопка подписки будет скрыта')
|
await channel_subscription_service.invalidate_user_cache(telegram_id)
|
||||||
|
|
||||||
try:
|
unsubscribed_fresh = await channel_subscription_service.get_unsubscribed_channels(telegram_id)
|
||||||
member = await bot.get_chat_member(chat_id=channel_id, user_id=telegram_id)
|
if not unsubscribed_fresh:
|
||||||
|
# Now subscribed to all channels
|
||||||
if member.status in self.GOOD_MEMBER_STATUS:
|
if settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||||
# Реактивируем подписку если была отключена из-за отписки от канала
|
|
||||||
if telegram_id and (settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE or settings.CHANNEL_REQUIRED_FOR_ALL):
|
|
||||||
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
await self._reactivate_subscription_on_subscribe(telegram_id, bot)
|
||||||
return await handler(event, data)
|
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):
|
user_lang = (
|
||||||
await self._deactivate_subscription_on_unsubscribe(telegram_id, bot, channel_link)
|
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':
|
# -- _deny_message (multi-channel) -----------------------------------------
|
||||||
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)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_channel_link(channel_link: str | None, channel_id: str | None) -> str | None:
|
async def _deny_message(
|
||||||
link = (channel_link or '').strip()
|
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
|
language = DEFAULT_LANGUAGE
|
||||||
return f'https://t.me/{link.lstrip("@")}'
|
if user and user.language_code:
|
||||||
|
language = user.language_code.split('-')[0]
|
||||||
|
|
||||||
if link and not link.lower().startswith(('http://', 'https://', 'tg://')):
|
# Normalize channel links (convert @username -> https://t.me/username)
|
||||||
return f'https://{link}'
|
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:
|
texts = get_texts(language)
|
||||||
return link
|
channel_sub_kb = get_channel_sub_keyboard(normalized, language=language)
|
||||||
|
text = texts.t(
|
||||||
|
'CHANNEL_REQUIRED_TEXT',
|
||||||
|
'🔒 Для использования бота подпишитесь на новостной канал, '
|
||||||
|
'чтобы получать уведомления о новых возможностях и обновлениях бота. Спасибо!',
|
||||||
|
)
|
||||||
|
|
||||||
if channel_id and str(channel_id).startswith('@'):
|
try:
|
||||||
return f'https://t.me/{str(channel_id).lstrip("@")}'
|
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(
|
async def _capture_start_payload(
|
||||||
self,
|
self,
|
||||||
@@ -223,6 +231,11 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
event: TelegramObject,
|
event: TelegramObject,
|
||||||
bot: Bot | None = None,
|
bot: Bot | None = 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
|
telegram_id = None
|
||||||
if isinstance(event, (Message, CallbackQuery)):
|
if isinstance(event, (Message, CallbackQuery)):
|
||||||
telegram_id = event.from_user.id if event.from_user else None
|
telegram_id = event.from_user.id if event.from_user else None
|
||||||
@@ -246,23 +259,21 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
payload = parts[1]
|
payload = parts[1]
|
||||||
|
|
||||||
# Сохраняем в FSM state
|
# Save to FSM state
|
||||||
if state:
|
if state:
|
||||||
state_data = await state.get_data() or {}
|
state_data = await state.get_data() or {}
|
||||||
if state_data.get('pending_start_payload') != payload:
|
if state_data.get('pending_start_payload') != payload:
|
||||||
state_data['pending_start_payload'] = payload
|
state_data['pending_start_payload'] = payload
|
||||||
await state.set_data(state_data)
|
await state.set_data(state_data)
|
||||||
logger.info(
|
logger.info('Saved start payload for user (FSM)', payload=payload, telegram_id=telegram_id)
|
||||||
"💾 Сохранен start payload '' для пользователя (FSM)", payload=payload, telegram_id=telegram_id
|
|
||||||
)
|
|
||||||
else:
|
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:
|
if telegram_id:
|
||||||
await save_pending_payload_to_redis(telegram_id, payload)
|
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(
|
await self._try_send_campaign_visit_notification(
|
||||||
bot,
|
bot,
|
||||||
message.from_user,
|
message.from_user,
|
||||||
@@ -280,9 +291,7 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
try:
|
try:
|
||||||
state_data = await state.get_data() or {}
|
state_data = await state.get_data() or {}
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error(
|
logger.error('Failed to get state data for campaign notification', payload=payload, error=error)
|
||||||
'❌ Не удалось получить данные состояния для уведомления по кампании', payload=payload, error=error
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if state_data.get('campaign_notification_sent'):
|
if state_data.get('campaign_notification_sent'):
|
||||||
@@ -311,13 +320,18 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
await state.update_data(campaign_notification_sent=True)
|
await state.update_data(campaign_notification_sent=True)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
logger.error('❌ Ошибка отправки уведомления о переходе по кампании', payload=payload, error=error)
|
logger.error('Error sending campaign visit notification', payload=payload, error=error)
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|
||||||
|
# -- _deactivate (multi-channel) -------------------------------------------
|
||||||
|
|
||||||
async def _deactivate_subscription_on_unsubscribe(
|
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:
|
) -> None:
|
||||||
"""Деактивация подписки при отписке от канала."""
|
"""Deactivate subscription when user unsubscribes from required channels."""
|
||||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -341,15 +355,15 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
|
|
||||||
if is_active_paid_subscription(subscription):
|
if is_active_paid_subscription(subscription):
|
||||||
logger.info(
|
logger.info(
|
||||||
'⏭️ Пропуск отключения: у пользователя активная оплаченная подписка',
|
'Skipping deactivation: user has active paid subscription',
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
await deactivate_subscription(db, subscription)
|
await deactivate_subscription(db, subscription)
|
||||||
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
|
sub_type = 'trial' if subscription.is_trial else 'paid'
|
||||||
logger.info(
|
logger.info(
|
||||||
'🚫 подписка пользователя отключена после отписки от канала',
|
'Subscription deactivated after channel unsubscribe',
|
||||||
sub_type=sub_type,
|
sub_type=sub_type,
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
)
|
)
|
||||||
@@ -360,38 +374,49 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
await service.disable_remnawave_user(user.remnawave_uuid)
|
await service.disable_remnawave_user(user.remnawave_uuid)
|
||||||
except Exception as api_error:
|
except Exception as api_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
'❌ Не удалось отключить пользователя RemnaWave',
|
'Failed to disable RemnaWave user',
|
||||||
remnawave_uuid=user.remnawave_uuid,
|
remnawave_uuid=user.remnawave_uuid,
|
||||||
api_error=api_error,
|
api_error=api_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Уведомляем пользователя о деактивации
|
# Notify user about deactivation
|
||||||
try:
|
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)
|
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||||
notification_text = texts.t(
|
notification_text = texts.t(
|
||||||
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
|
'SUBSCRIPTION_DEACTIVATED_CHANNEL_UNSUBSCRIBE',
|
||||||
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
|
'🚫 Ваша подписка приостановлена, так как вы отписались от канала.\n\n'
|
||||||
'Подпишитесь на канал снова, чтобы восстановить доступ к VPN.',
|
'Подпишитесь на канал снова, чтобы восстановить доступ к 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)
|
await bot.send_message(telegram_id, notification_text, reply_markup=channel_kb)
|
||||||
except Exception as notify_error:
|
except Exception as notify_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
'❌ Не удалось отправить уведомление о деактивации пользователю',
|
'Failed to send deactivation notification to user',
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
notify_error=notify_error,
|
notify_error=notify_error,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as db_error:
|
except Exception as db_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
'❌ Ошибка деактивации подписки пользователя после отписки',
|
'Error deactivating subscription after channel unsubscribe',
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
db_error=db_error,
|
db_error=db_error,
|
||||||
)
|
)
|
||||||
await db.rollback()
|
await db.rollback()
|
||||||
|
|
||||||
|
# -- _reactivate -----------------------------------------------------------
|
||||||
|
|
||||||
async def _reactivate_subscription_on_subscribe(self, telegram_id: int, bot: Bot) -> None:
|
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:
|
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -401,43 +426,42 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
if not user or not user.subscription:
|
if not user or not user.subscription:
|
||||||
return
|
return
|
||||||
|
|
||||||
# НЕ реактивируем подписку заблокированным пользователям
|
# Do NOT reactivate for blocked users
|
||||||
if user.status == UserStatus.BLOCKED.value:
|
if user.status == UserStatus.BLOCKED.value:
|
||||||
logger.info('🚫 Пропуск реактивации для заблокированного пользователя', telegram_id=telegram_id)
|
logger.info('Skipping reactivation for blocked user', telegram_id=telegram_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
subscription = user.subscription
|
subscription = user.subscription
|
||||||
|
|
||||||
# Реактивируем только DISABLED подписки
|
# Only reactivate DISABLED subscriptions
|
||||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Проверяем что подписка ещё не истекла
|
# Check subscription has not expired
|
||||||
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
if subscription.end_date and subscription.end_date <= datetime.now(UTC):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Реактивируем в БД
|
|
||||||
await reactivate_subscription(db, subscription)
|
await reactivate_subscription(db, subscription)
|
||||||
sub_type = 'Триальная' if subscription.is_trial else 'Платная'
|
sub_type = 'trial' if subscription.is_trial else 'paid'
|
||||||
logger.info(
|
logger.info(
|
||||||
'✅ подписка пользователя реактивирована после подписки на канал',
|
'Subscription reactivated after channel subscribe',
|
||||||
sub_type=sub_type,
|
sub_type=sub_type,
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Включаем в RemnaWave
|
# Enable in RemnaWave
|
||||||
if user.remnawave_uuid:
|
if user.remnawave_uuid:
|
||||||
service = SubscriptionService()
|
service = SubscriptionService()
|
||||||
try:
|
try:
|
||||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||||
except Exception as api_error:
|
except Exception as api_error:
|
||||||
logger.error(
|
logger.error(
|
||||||
'❌ Не удалось включить пользователя RemnaWave',
|
'Failed to enable RemnaWave user',
|
||||||
remnawave_uuid=user.remnawave_uuid,
|
remnawave_uuid=user.remnawave_uuid,
|
||||||
api_error=api_error,
|
api_error=api_error,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Уведомляем пользователя о реактивации
|
# Notify user about reactivation
|
||||||
try:
|
try:
|
||||||
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
texts = get_texts(user.language if user.language else DEFAULT_LANGUAGE)
|
||||||
notification_text = texts.t(
|
notification_text = texts.t(
|
||||||
@@ -447,65 +471,21 @@ class ChannelCheckerMiddleware(BaseMiddleware):
|
|||||||
await bot.send_message(telegram_id, notification_text)
|
await bot.send_message(telegram_id, notification_text)
|
||||||
except Exception as notify_error:
|
except Exception as notify_error:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'Не удалось отправить уведомление о реактивации пользователю',
|
'Failed to send reactivation notification to user',
|
||||||
telegram_id=telegram_id,
|
telegram_id=telegram_id,
|
||||||
notify_error=notify_error,
|
notify_error=notify_error,
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception as db_error:
|
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()
|
await db.rollback()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _deny_message(
|
|
||||||
event: TelegramObject,
|
|
||||||
bot: Bot,
|
|
||||||
channel_link: str | None,
|
|
||||||
channel_id: str | None,
|
|
||||||
):
|
|
||||||
logger.debug('🚫 Отправляем сообщение о необходимости подписки')
|
|
||||||
|
|
||||||
user = None
|
def _normalize_channel_link(link: str) -> str:
|
||||||
if isinstance(event, (Message, CallbackQuery)):
|
"""Normalize channel link: convert @username to https://t.me/username."""
|
||||||
user = getattr(event, 'from_user', None)
|
if not link:
|
||||||
elif isinstance(event, Update):
|
return link
|
||||||
if event.message and event.message.from_user:
|
link = link.strip()
|
||||||
user = event.message.from_user
|
if link.startswith('@'):
|
||||||
elif event.callback_query and event.callback_query.from_user:
|
return f'https://t.me/{link[1:]}'
|
||||||
user = event.callback_query.from_user
|
return link
|
||||||
|
|
||||||
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)
|
|
||||||
|
|||||||
@@ -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:
|
async def _send_channel_announce(self, text: str) -> None:
|
||||||
if not self.bot:
|
if not self.bot:
|
||||||
return
|
return
|
||||||
channel_id_raw = settings.CHANNEL_SUB_ID
|
from app.services.channel_subscription_service import channel_subscription_service
|
||||||
if not channel_id_raw:
|
|
||||||
|
channel_id = await channel_subscription_service.get_first_channel_id()
|
||||||
|
if not channel_id:
|
||||||
return
|
return
|
||||||
try:
|
|
||||||
channel_id = int(channel_id_raw)
|
|
||||||
except Exception:
|
|
||||||
channel_id = channel_id_raw
|
|
||||||
|
|
||||||
keyboard = InlineKeyboardMarkup(
|
keyboard = InlineKeyboardMarkup(
|
||||||
inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]]
|
inline_keyboard=[[InlineKeyboardButton(text='🎲 Играть', callback_data='contests_menu')]]
|
||||||
@@ -315,7 +313,7 @@ class ContestRotationService:
|
|||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
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:
|
async def _broadcast_to_users(self, text: str) -> None:
|
||||||
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
|
"""Отправляет анонс всем пользователям с активной/триальной подпиской."""
|
||||||
|
|||||||
+211
-169
@@ -4,7 +4,6 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
from aiogram.enums import ChatMemberStatus
|
|
||||||
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError, TelegramNetworkError
|
||||||
from sqlalchemy import and_, or_, select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -72,6 +71,9 @@ from app.utils.timezone import format_local_datetime
|
|||||||
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
|
# Кулдаун между повторными уведомлениями об автоплатеже с недостаточным балансом (6 часов)
|
||||||
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
|
AUTOPAY_INSUFFICIENT_BALANCE_COOLDOWN_SECONDS: int = 21600
|
||||||
|
|
||||||
|
# Размер батча для проверки подписок на каналы (keyset pagination)
|
||||||
|
_CHANNEL_CHECK_BATCH_SIZE: int = 100
|
||||||
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
@@ -522,214 +524,254 @@ class MonitoringService:
|
|||||||
logger.error('Ошибка проверки истекающих тестовых подписок', error=e)
|
logger.error('Ошибка проверки истекающих тестовых подписок', error=e)
|
||||||
|
|
||||||
async def _check_trial_channel_subscriptions(self, db: AsyncSession):
|
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:
|
if not settings.CHANNEL_IS_REQUIRED_SUB:
|
||||||
return
|
return
|
||||||
|
|
||||||
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE:
|
if not settings.CHANNEL_DISABLE_TRIAL_ON_UNSUBSCRIBE and not settings.CHANNEL_REQUIRED_FOR_ALL:
|
||||||
logger.debug('ℹ️ Проверка отписок от канала отключена — деактивация триальных подписок не требуется')
|
logger.debug('Channel unsubscribe check disabled')
|
||||||
return
|
|
||||||
|
|
||||||
channel_id = settings.CHANNEL_SUB_ID
|
|
||||||
if not channel_id:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.bot:
|
if not self.bot:
|
||||||
logger.debug('⚠️ Пропускаем проверку подписки на канал — бот недоступен')
|
logger.debug('Skipping channel subscription check - bot unavailable')
|
||||||
return
|
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:
|
try:
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
notifications_allowed = (
|
notifications_allowed = (
|
||||||
NotificationSettingsService.are_notifications_globally_enabled()
|
NotificationSettingsService.are_notifications_globally_enabled()
|
||||||
and NotificationSettingsService.is_trial_channel_unsubscribed_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
|
disabled_count = 0
|
||||||
restored_count = 0
|
restored_count = 0
|
||||||
|
checked_count = 0
|
||||||
|
last_id = 0
|
||||||
|
|
||||||
for subscription in subscriptions:
|
# Build the trial/all filter based on CHANNEL_REQUIRED_FOR_ALL setting
|
||||||
user = subscription.user
|
from sqlalchemy import true as sa_true
|
||||||
if not user or not user.telegram_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
is_trial_filter = (
|
||||||
member = await self.bot.get_chat_member(channel_id, user.telegram_id)
|
sa_true() if settings.CHANNEL_REQUIRED_FOR_ALL else Subscription.is_trial.is_(True)
|
||||||
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
|
|
||||||
|
|
||||||
if subscription.status == SubscriptionStatus.ACTIVE.value and subscription.is_trial and not is_member:
|
while True:
|
||||||
if is_recently_updated_by_webhook(subscription):
|
# Fresh session per batch to avoid long-running connections
|
||||||
logger.debug(
|
async with AsyncSessionLocal() as batch_db:
|
||||||
'Пропуск деактивации trial подписки : обновлена вебхуком недавно',
|
result = await batch_db.execute(
|
||||||
subscription_id=subscription.id,
|
select(Subscription)
|
||||||
|
.join(Subscription.user)
|
||||||
|
.options(
|
||||||
|
selectinload(Subscription.user),
|
||||||
|
selectinload(Subscription.tariff),
|
||||||
)
|
)
|
||||||
continue
|
.where(
|
||||||
subscription = await deactivate_subscription(db, subscription)
|
and_(
|
||||||
disabled_count += 1
|
Subscription.id > last_id,
|
||||||
logger.info(
|
is_trial_filter,
|
||||||
'🚫 Триальная подписка пользователя (ID) отключена из-за отписки от канала',
|
Subscription.end_date > now,
|
||||||
telegram_id=user.telegram_id,
|
Subscription.status.in_(
|
||||||
subscription_id=subscription.id,
|
[
|
||||||
|
SubscriptionStatus.ACTIVE.value,
|
||||||
|
SubscriptionStatus.DISABLED.value,
|
||||||
|
]
|
||||||
|
),
|
||||||
|
User.status == UserStatus.ACTIVE.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.order_by(Subscription.id)
|
||||||
|
.limit(_CHANNEL_CHECK_BATCH_SIZE)
|
||||||
)
|
)
|
||||||
|
|
||||||
if user.remnawave_uuid:
|
subscriptions = result.scalars().all()
|
||||||
try:
|
if not subscriptions:
|
||||||
await self.subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
break
|
||||||
except Exception as api_error:
|
|
||||||
logger.error(
|
last_id = subscriptions[-1].id
|
||||||
'❌ Не удалось отключить пользователя RemnaWave',
|
|
||||||
remnawave_uuid=user.remnawave_uuid,
|
for subscription in subscriptions:
|
||||||
api_error=api_error,
|
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 is_member:
|
||||||
if not await notification_sent(
|
all_subscribed = False
|
||||||
db,
|
|
||||||
user.id,
|
# DEACTIVATE: was active, now not subscribed to all
|
||||||
subscription.id,
|
if subscription.status == SubscriptionStatus.ACTIVE.value and not all_subscribed:
|
||||||
'trial_channel_unsubscribed',
|
# Guard: always skip paid subscriptions (user paid money)
|
||||||
):
|
if is_active_paid_subscription(subscription):
|
||||||
sent = await self._send_trial_channel_unsubscribed_notification(user)
|
continue
|
||||||
if sent:
|
|
||||||
await record_notification(
|
subscription = await deactivate_subscription(batch_db, subscription)
|
||||||
db,
|
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,
|
user.id,
|
||||||
subscription.id,
|
subscription.id,
|
||||||
'trial_channel_unsubscribed',
|
'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:
|
continue
|
||||||
# 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
|
|
||||||
|
|
||||||
# Don't reactivate if subscription was disabled by RemnaWave (webhook)
|
# Guard: disabled by webhook, not by monitoring
|
||||||
# rather than by monitoring (channel unsubscribe).
|
if (
|
||||||
# When webhook disables: last_webhook_update_at ≈ updated_at (both set to now())
|
subscription.last_webhook_update_at
|
||||||
# When monitoring disables: updated_at is set, last_webhook_update_at stays old
|
and subscription.updated_at
|
||||||
if (
|
and subscription.last_webhook_update_at
|
||||||
subscription.last_webhook_update_at
|
>= subscription.updated_at - timedelta(seconds=10)
|
||||||
and subscription.updated_at
|
):
|
||||||
and subscription.last_webhook_update_at >= subscription.updated_at - timedelta(seconds=10)
|
logger.debug(
|
||||||
):
|
'Skipping reactivation: disabled by RemnaWave panel',
|
||||||
logger.debug(
|
subscription_id=subscription.id,
|
||||||
'Пропуск реактивации trial подписки: отключена RemnaWave панелью',
|
last_webhook_at=subscription.last_webhook_update_at,
|
||||||
subscription_id=subscription.id,
|
updated_at=subscription.updated_at,
|
||||||
last_webhook_at=subscription.last_webhook_update_at,
|
)
|
||||||
updated_at=subscription.updated_at,
|
continue
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||||
subscription.updated_at = datetime.now(UTC)
|
subscription.updated_at = datetime.now(UTC)
|
||||||
await db.commit()
|
restored_count += 1
|
||||||
await db.refresh(subscription)
|
|
||||||
restored_count += 1
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'✅ Триальная подписка пользователя (ID) восстановлена после повторной подписки на канал',
|
'Subscription restored (channel resubscribe)',
|
||||||
telegram_id=user.telegram_id,
|
telegram_id=user.telegram_id,
|
||||||
subscription_id=subscription.id,
|
subscription_id=subscription.id,
|
||||||
)
|
is_trial=subscription.is_trial,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if user.remnawave_uuid:
|
if user.remnawave_uuid:
|
||||||
await self.subscription_service.update_remnawave_user(db, subscription)
|
await self.subscription_service.update_remnawave_user(
|
||||||
else:
|
batch_db, subscription
|
||||||
await self.subscription_service.create_remnawave_user(db, subscription)
|
)
|
||||||
except Exception as api_error:
|
else:
|
||||||
logger.error(
|
await self.subscription_service.create_remnawave_user(
|
||||||
'❌ Не удалось обновить RemnaWave пользователя',
|
batch_db, subscription
|
||||||
telegram_id=user.telegram_id,
|
)
|
||||||
api_error=api_error,
|
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(
|
await clear_notification_by_type(
|
||||||
db,
|
batch_db,
|
||||||
subscription.id,
|
subscription.id,
|
||||||
'trial_channel_unsubscribed',
|
'trial_channel_unsubscribed',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Commit all changes for this batch
|
||||||
|
await batch_db.commit()
|
||||||
|
|
||||||
if disabled_count or restored_count:
|
if disabled_count or restored_count:
|
||||||
|
check_scope = 'all' if settings.CHANNEL_REQUIRED_FOR_ALL else 'trial'
|
||||||
await self._log_monitoring_event(
|
await self._log_monitoring_event(
|
||||||
db,
|
db,
|
||||||
'trial_channel_subscription_check',
|
'trial_channel_subscription_check',
|
||||||
(
|
(
|
||||||
f'Проверено {len(subscriptions)} триальных подписок: отключено {disabled_count}, '
|
f'Checked {checked_count} {check_scope} subscriptions: '
|
||||||
f'восстановлено {restored_count}'
|
f'disabled {disabled_count}, restored {restored_count}'
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
'checked': len(subscriptions),
|
'checked': checked_count,
|
||||||
'disabled': disabled_count,
|
'disabled': disabled_count,
|
||||||
'restored': restored_count,
|
'restored': restored_count,
|
||||||
|
'scope': check_scope,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as error:
|
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):
|
async def _check_expired_subscription_followups(self, db: AsyncSession):
|
||||||
if not NotificationSettingsService.are_notifications_globally_enabled():
|
if not NotificationSettingsService.are_notifications_globally_enabled():
|
||||||
@@ -1336,16 +1378,16 @@ class MonitoringService:
|
|||||||
|
|
||||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
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 = []
|
buttons = []
|
||||||
if settings.CHANNEL_LINK:
|
for ch in unsubscribed:
|
||||||
buttons.append(
|
link = ch.get('channel_link')
|
||||||
[
|
if link:
|
||||||
InlineKeyboardButton(
|
title = ch.get('title') or texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться')
|
||||||
text=texts.t('CHANNEL_SUBSCRIBE_BUTTON', '🔗 Подписаться'),
|
buttons.append([InlineKeyboardButton(text=f'🔗 {title}', url=link)])
|
||||||
url=settings.CHANNEL_LINK,
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
buttons.append(
|
buttons.append(
|
||||||
[
|
[
|
||||||
InlineKeyboardButton(
|
InlineKeyboardButton(
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ class YooKassaPaymentMixin:
|
|||||||
status=yookassa_response['status'],
|
status=yookassa_response['status'],
|
||||||
confirmation_url=yookassa_response.get('confirmation_url'), # Используем confirmation URL
|
confirmation_url=yookassa_response.get('confirmation_url'), # Используем confirmation URL
|
||||||
metadata_json=payment_metadata,
|
metadata_json=payment_metadata,
|
||||||
payment_method_type='bank_card',
|
payment_method_type='sbp',
|
||||||
yookassa_created_at=None,
|
yookassa_created_at=None,
|
||||||
test_mode=yookassa_response.get('test_mode', False),
|
test_mode=yookassa_response.get('test_mode', False),
|
||||||
)
|
)
|
||||||
@@ -897,6 +897,7 @@ class YooKassaPaymentMixin:
|
|||||||
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
f'🔄 При наличии сохранённой корзины подписки и включенной автопокупке, '
|
||||||
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
f'подписка будет приобретена автоматически после пополнения баланса.\n\n{cart_message}',
|
||||||
reply_markup=keyboard,
|
reply_markup=keyboard,
|
||||||
|
parse_mode='HTML',
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю',
|
'Отправлено уведомление с кнопкой возврата к оформлению подписки пользователю',
|
||||||
@@ -1079,14 +1080,14 @@ class YooKassaPaymentMixin:
|
|||||||
'Успешно обработан платеж YooKassa как покупка подписки: пользователь , сумма ₽',
|
'Успешно обработан платеж YooKassa как покупка подписки: пользователь , сумма ₽',
|
||||||
yookassa_payment_id=payment.yookassa_payment_id,
|
yookassa_payment_id=payment.yookassa_payment_id,
|
||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
amount_kopeks=payment.amount_kopeks / 100,
|
amount_rubles=payment.amount_kopeks / 100,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(
|
logger.info(
|
||||||
'Успешно обработан платеж YooKassa : пользователь пополнил баланс на ₽',
|
'Успешно обработан платеж YooKassa : пользователь пополнил баланс на ₽',
|
||||||
yookassa_payment_id=payment.yookassa_payment_id,
|
yookassa_payment_id=payment.yookassa_payment_id,
|
||||||
user_id=payment.user_id,
|
user_id=payment.user_id,
|
||||||
amount_kopeks=payment.amount_kopeks / 100,
|
amount_rubles=payment.amount_kopeks / 100,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
|
# Создаем чек через NaloGO (если NALOGO_ENABLED=true)
|
||||||
|
|||||||
@@ -323,14 +323,11 @@ class ReferralContestService:
|
|||||||
if not self.bot:
|
if not self.bot:
|
||||||
return
|
return
|
||||||
|
|
||||||
channel_id_raw = settings.CHANNEL_SUB_ID
|
from app.services.channel_subscription_service import channel_subscription_service
|
||||||
if not channel_id_raw:
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
channel_id = await channel_subscription_service.get_first_channel_id()
|
||||||
channel_id = int(channel_id_raw)
|
if not channel_id:
|
||||||
except Exception:
|
return
|
||||||
channel_id = channel_id_raw
|
|
||||||
|
|
||||||
lines = [
|
lines = [
|
||||||
f'🏆 {contest.title}',
|
f'🏆 {contest.title}',
|
||||||
@@ -358,9 +355,9 @@ class ReferralContestService:
|
|||||||
disable_web_page_preview=True,
|
disable_web_page_preview=True,
|
||||||
)
|
)
|
||||||
except (TelegramForbiddenError, TelegramNotFound):
|
except (TelegramForbiddenError, TelegramNotFound):
|
||||||
logger.info('Не удалось отправить сводку конкурса в канал', channel_id_raw=channel_id_raw)
|
logger.info('Не удалось отправить сводку конкурса в канал', channel_id=channel_id)
|
||||||
except Exception as exc:
|
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(
|
def _build_participant_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -205,8 +205,6 @@ class BotConfigurationService:
|
|||||||
'DATABASE_URL': 'DATABASE',
|
'DATABASE_URL': 'DATABASE',
|
||||||
'DATABASE_MODE': 'DATABASE',
|
'DATABASE_MODE': 'DATABASE',
|
||||||
'LOCALES_PATH': 'LOCALIZATION',
|
'LOCALES_PATH': 'LOCALIZATION',
|
||||||
'CHANNEL_SUB_ID': 'CHANNEL',
|
|
||||||
'CHANNEL_LINK': 'CHANNEL',
|
|
||||||
'CHANNEL_IS_REQUIRED_SUB': 'CHANNEL',
|
'CHANNEL_IS_REQUIRED_SUB': 'CHANNEL',
|
||||||
'BOT_USERNAME': 'CORE',
|
'BOT_USERNAME': 'CORE',
|
||||||
'DEFAULT_LANGUAGE': 'LOCALIZATION',
|
'DEFAULT_LANGUAGE': 'LOCALIZATION',
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class YooKassaService:
|
|||||||
'description': description[:128],
|
'description': description[:128],
|
||||||
'quantity': '1.00',
|
'quantity': '1.00',
|
||||||
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
|
'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_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||||
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
|
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@ class YooKassaService:
|
|||||||
'description': description[:128],
|
'description': description[:128],
|
||||||
'quantity': '1.00',
|
'quantity': '1.00',
|
||||||
'amount': {'value': str(round(amount, 2)), 'currency': currency.upper()},
|
'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_mode': getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
|
||||||
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
|
'payment_subject': getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service'),
|
||||||
}
|
}
|
||||||
@@ -223,7 +223,7 @@ class YooKassaService:
|
|||||||
payment_request = builder.build()
|
payment_request = builder.build()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Создание платежа YooKassa СБП с подтверждением 'qr' (Idempotence-Key: ). Сумма: . Метаданные: . Чек",
|
'Создание платежа YooKassa СБП с подтверждением redirect (Idempotence-Key: ). Сумма: . Метаданные: . Чек',
|
||||||
idempotence_key=idempotence_key,
|
idempotence_key=idempotence_key,
|
||||||
amount=amount,
|
amount=amount,
|
||||||
currency=currency,
|
currency=currency,
|
||||||
@@ -237,14 +237,14 @@ class YooKassaService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
'Ответ YooKassa Payment.create (СБП, qr): ID=, Status=, Paid',
|
'Ответ YooKassa Payment.create (СБП, redirect): ID=, Status=, Paid',
|
||||||
response_id=response.id,
|
response_id=response.id,
|
||||||
status=response.status,
|
status=response.status,
|
||||||
paid=response.paid,
|
paid=response.paid,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Возвращаем данные платежа с QR-подтверждением
|
# Возвращаем данные платежа с redirect-подтверждением
|
||||||
# Пользователь может использовать QR-код или оплатить через приложение банка по ID платежа
|
# YooKassa покажет QR на десктопе или список банков на мобильном
|
||||||
return {
|
return {
|
||||||
'id': response.id,
|
'id': response.id,
|
||||||
'qr_confirmation_data': response.confirmation.confirmation_data
|
'qr_confirmation_data': response.confirmation.confirmation_data
|
||||||
@@ -270,109 +270,6 @@ class YooKassaService:
|
|||||||
logger.error('Ошибка создания платежа YooKassa СБП', error=e, exc_info=True)
|
logger.error('Ошибка создания платежа YooKassa СБП', error=e, exc_info=True)
|
||||||
return None
|
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:
|
async def get_payment_info(self, payment_id_in_yookassa: str) -> dict[str, Any] | None:
|
||||||
if not self.configured:
|
if not self.configured:
|
||||||
logger.error('YooKassa не сконфигурирован. Невозможно получить информацию о платеже.')
|
logger.error('YooKassa не сконфигурирован. Невозможно получить информацию о платеже.')
|
||||||
|
|||||||
@@ -342,3 +342,97 @@ class RateLimitCache:
|
|||||||
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
async def reset_rate_limit(user_id: int, action: str) -> bool:
|
||||||
key = cache_key('rate_limit', user_id, action)
|
key = cache_key('rate_limit', user_id, action)
|
||||||
return await cache.delete(key)
|
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()
|
promo_code_service = PromoCodeService()
|
||||||
renewal_service = SubscriptionRenewalService()
|
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_MIN_USD = 1.0
|
||||||
_CRYPTOBOT_MAX_USD = 1000.0
|
_CRYPTOBOT_MAX_USD = 1000.0
|
||||||
@@ -3100,26 +3089,21 @@ async def get_subscription_details(
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
# Check required channel subscription
|
# Check required channel subscription
|
||||||
if settings.CHANNEL_IS_REQUIRED_SUB and settings.CHANNEL_SUB_ID:
|
if settings.CHANNEL_IS_REQUIRED_SUB:
|
||||||
try:
|
from app.services.channel_subscription_service import channel_subscription_service
|
||||||
bot = _get_channel_check_bot()
|
|
||||||
chat_member = await bot.get_chat_member(chat_id=settings.CHANNEL_SUB_ID, user_id=telegram_id)
|
|
||||||
# Не закрываем сессию - бот переиспользуется
|
|
||||||
|
|
||||||
if chat_member.status not in ['member', 'administrator', 'creator']:
|
channels_with_status = await channel_subscription_service.get_channels_with_status(telegram_id)
|
||||||
raise HTTPException(
|
is_subscribed = all(ch['is_subscribed'] for ch in channels_with_status) if channels_with_status else True
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail={
|
if not is_subscribed:
|
||||||
'code': 'channel_subscription_required',
|
raise HTTPException(
|
||||||
'message': 'Please subscribe to our channel to continue',
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
'channel_link': settings.CHANNEL_LINK,
|
detail={
|
||||||
},
|
'code': 'channel_subscription_required',
|
||||||
)
|
'message': 'Please subscribe to the required channels to continue',
|
||||||
except HTTPException:
|
'channels': channels_with_status,
|
||||||
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
|
|
||||||
|
|
||||||
user = await get_user_by_telegram_id(db, telegram_id)
|
user = await get_user_by_telegram_id(db, telegram_id)
|
||||||
purchase_url = (settings.MINIAPP_PURCHASE_URL or '').strip()
|
purchase_url = (settings.MINIAPP_PURCHASE_URL or '').strip()
|
||||||
|
|||||||
@@ -287,6 +287,10 @@ async def main():
|
|||||||
daily_subscription_service.set_bot(bot)
|
daily_subscription_service.set_bot(bot)
|
||||||
telegram_notifier.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
|
# Initialize email broadcast service
|
||||||
from app.cabinet.services.email_service import email_service
|
from app.cabinet.services.email_service import email_service
|
||||||
from app.services.broadcast_service import email_broadcast_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
|
payment_builder_module.PaymentRequestBuilder = _FakePaymentRequestBuilder
|
||||||
confirmation_module.ConfirmationType = _FakeConfirmationType
|
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'] = domain_module
|
||||||
sys.modules['yookassa.domain.request'] = request_module
|
sys.modules['yookassa.domain.request'] = request_module
|
||||||
sys.modules['yookassa.domain.request.payment_request_builder'] = payment_builder_module
|
sys.modules['yookassa.domain.request.payment_request_builder'] = payment_builder_module
|
||||||
sys.modules['yookassa.domain.common'] = common_module
|
sys.modules['yookassa.domain.common'] = common_module
|
||||||
sys.modules['yookassa.domain.common.confirmation_type'] = confirmation_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
|
@pytest.fixture
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ async def test_create_yookassa_sbp_payment_success(monkeypatch: pytest.MonkeyPat
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result['confirmation_token'] == 'token123'
|
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'
|
assert captured_args['metadata_json']['type'] == 'balance_topup_sbp'
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user