Files

2891 lines
130 KiB
Python

import html
from collections.abc import Callable
from datetime import UTC, datetime
from typing import Any
import structlog
from aiogram import Bot, Dispatcher, F, types
from aiogram.enums import ParseMode
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.campaign import (
get_campaign_by_id,
get_campaign_by_start_parameter,
)
from app.database.crud.subscription import decrement_subscription_server_counts
from app.database.crud.user import (
create_user,
find_phantom_user_by_username,
get_user_by_referral_code,
get_user_by_telegram_id,
)
from app.database.crud.user_message import get_random_active_message
from app.database.models import GuestPurchase, GuestPurchaseStatus, PinnedMessage, SubscriptionStatus, UserStatus
from app.keyboards.inline import (
get_back_keyboard,
get_language_selection_keyboard,
get_main_menu_keyboard_async,
get_post_registration_keyboard,
get_privacy_policy_keyboard,
get_rules_keyboard,
)
from app.localization.loader import DEFAULT_LANGUAGE
from app.localization.texts import get_privacy_policy, get_rules, get_texts
from app.middlewares.channel_checker import (
delete_pending_payload_from_redis,
get_pending_payload_from_redis,
)
from app.services.admin_notification_service import AdminNotificationService
from app.services.campaign_service import AdvertisingCampaignService
from app.services.channel_subscription_service import channel_subscription_service
from app.services.main_menu_button_service import MainMenuButtonService
from app.services.phantom_service import claim_phantom, merge_phantom_into_user
from app.services.pinned_message_service import (
deliver_pinned_message_to_user,
get_active_pinned_message,
)
from app.services.privacy_policy_service import PrivacyPolicyService
from app.services.referral_service import (
process_referral_registration,
save_pending_campaign,
save_pending_referral,
)
from app.services.subscription_service import SubscriptionService
from app.services.support_settings_service import SupportSettingsService
from app.services.web_auth_service import WEB_AUTH_TOKEN_MIN_LENGTH, link_web_auth_token
from app.states import RegistrationStates
from app.utils.promo_offer import (
build_promo_offer_hint,
build_test_access_hint,
)
from app.utils.timezone import format_local_datetime
from app.utils.user_utils import generate_unique_referral_code
logger = structlog.get_logger(__name__)
async def _activate_pending_gift_after_registration(
db: AsyncSession,
state: FSMContext,
user: 'User',
answer_func: Callable[..., Any],
) -> None:
"""Extract pending_gift_token from FSM state and activate it for the newly registered user.
Must be called BEFORE state.clear() to preserve the token.
"""
gift_token: str | None = None
try:
fresh_state = await state.get_data()
gift_token = fresh_state.get('pending_gift_token')
if not gift_token:
return
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from app.services.guest_purchase_service import activate_purchase as svc_activate
# Support both full token and prefix-based lookup (Telegram truncates long start params)
if len(gift_token) >= 64:
token_filter = GuestPurchase.token == gift_token
else:
token_filter = GuestPurchase.token.startswith(gift_token)
gift_result = await db.execute(
select(GuestPurchase)
.options(selectinload(GuestPurchase.tariff))
.where(token_filter, GuestPurchase.is_gift.is_(True))
.with_for_update()
)
gift_purchase = gift_result.scalars().first()
if not gift_purchase or not gift_purchase.is_gift:
logger.warning('Gift not found for deep link token', token_prefix=gift_token[:5])
return
# Prevent self-activation: buyer cannot activate their own gift
if gift_purchase.buyer_user_id is not None and gift_purchase.buyer_user_id == user.id:
await answer_func(
'⚠️ Нельзя активировать свой собственный подарок.\nОтправьте код другу!',
parse_mode=ParseMode.HTML,
)
return
if gift_purchase.status == GuestPurchaseStatus.DELIVERED.value:
await answer_func(
'ℹ️ Этот подарок уже был активирован.',
parse_mode=ParseMode.HTML,
)
return
activatable_statuses = {
GuestPurchaseStatus.PENDING_ACTIVATION.value,
GuestPurchaseStatus.PAID.value,
}
if gift_purchase.status not in activatable_statuses:
await answer_func(
'❌ Этот подарок невозможно активировать.',
parse_mode=ParseMode.HTML,
)
return
if gift_purchase.user_id is not None and gift_purchase.user_id != user.id:
logger.warning('Gift belongs to another user', token_prefix=gift_token[:5])
return
if gift_purchase.user_id is None:
gift_purchase.user_id = user.id
# Transition PAID → PENDING_ACTIVATION so activate_purchase() accepts it
if gift_purchase.status == GuestPurchaseStatus.PAID.value:
gift_purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
await db.flush()
await svc_activate(db, gift_purchase.token, skip_notification=True)
tariff_name = html.escape(gift_purchase.tariff.name) if gift_purchase.tariff else ''
await answer_func(
f'🎁 <b>Подарок активирован!</b>\n'
f'{tariff_name}{gift_purchase.period_days} дн.\n\n'
f'Ваша подписка обновлена.',
parse_mode=ParseMode.HTML,
)
except Exception:
logger.exception(
'Failed to auto-activate gift after registration',
token_prefix=(gift_token or '')[:5],
)
try:
await answer_func(
'❌ Произошла ошибка при активации подарка. Попробуйте активировать через личный кабинет.',
parse_mode=ParseMode.HTML,
)
except Exception:
pass
async def _claim_phantom_user(
db: AsyncSession,
phantom: 'User',
*,
telegram_id: int,
username: str | None,
first_name: str | None,
last_name: str | None,
language: str,
referrer_id: int | None,
) -> tuple[bool, 'User | None']:
"""Claim a phantom user by backfilling Telegram profile data.
Returns (success, user). On IntegrityError falls back to existing user lookup.
Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
by username only. Since Telegram usernames are changeable and reassignable, this is
inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
purchase time, telegram_id is stored on the user and the phantom path is not used.
"""
from app.utils.validators import sanitize_telegram_name
phantom.telegram_id = telegram_id
phantom.username = username
phantom.first_name = sanitize_telegram_name(first_name)
phantom.last_name = sanitize_telegram_name(last_name)
phantom.language = language
phantom.status = UserStatus.ACTIVE.value
if referrer_id and referrer_id != phantom.id:
phantom.referred_by_id = referrer_id
if not phantom.referral_code:
phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
phantom.updated_at = datetime.now(UTC)
phantom.last_activity = datetime.now(UTC)
try:
await db.commit()
except IntegrityError:
await db.rollback()
logger.warning(
'IntegrityError claiming phantom user, falling back to existing user lookup',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
)
existing = await get_user_by_telegram_id(db, telegram_id)
return False, existing
await db.refresh(phantom, ['subscriptions'])
# SECURITY NOTE: Phantom matched by username only (telegram_id was unknown at purchase time).
# Telegram usernames are changeable/reassignable, so the claimer may not be the intended
# recipient. This is logged at WARNING for admin audit. A confirmation flow would be needed
# to fully prevent username spoofing attacks on phantom claims.
logger.warning(
'Phantom user claimed by username match (verify intended recipient)',
phantom_user_id=phantom.id,
telegram_id=telegram_id,
username=username,
has_subscription=phantom.subscription is not None,
)
# Sync Remnawave panel with updated user data (telegram_id, username, etc.)
phantom_subs = getattr(phantom, 'subscriptions', None) or []
for phantom_sub in phantom_subs:
try:
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, phantom_sub)
except Exception as exc:
logger.warning(
'Failed to update Remnawave panel after phantom claim',
phantom_user_id=phantom.id,
subscription_id=phantom_sub.id,
error=str(exc),
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
if hasattr(phantom_sub, 'id') and hasattr(phantom_sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=phantom_sub.id,
user_id=phantom_sub.user_id,
action='update',
)
return True, phantom
async def _merge_phantom_into_active_user(
db: AsyncSession,
phantom: 'User',
active_user: 'User',
) -> None:
"""Merge a phantom user (created by guest landing purchase) into an existing active user.
Transfers GuestPurchase records and handles subscription conflict.
The phantom is soft-deleted (status=DELETED, username cleared) to preserve
audit trail and avoid CASCADE deletion of payment/transaction records.
"""
from sqlalchemy import update
logger.warning(
'Merging phantom user into active user (audit: username-only match)',
phantom_id=phantom.id,
active_user_id=active_user.id,
active_user_telegram_id=active_user.telegram_id,
phantom_username=phantom.username,
)
# Transfer GuestPurchase.user_id references
await db.execute(update(GuestPurchase).where(GuestPurchase.user_id == phantom.id).values(user_id=active_user.id))
# Transfer GuestPurchase.buyer_user_id references
await db.execute(
update(GuestPurchase).where(GuestPurchase.buyer_user_id == phantom.id).values(buyer_user_id=active_user.id)
)
# Transfer balance
if phantom.balance_kopeks and phantom.balance_kopeks > 0:
active_user.balance_kopeks = (active_user.balance_kopeks or 0) + phantom.balance_kopeks
logger.info('Transferred balance from phantom', amount_kopeks=phantom.balance_kopeks)
# Handle subscriptions
await db.refresh(phantom, ['subscriptions'])
await db.refresh(active_user, ['subscriptions'])
phantom_subs = getattr(phantom, 'subscriptions', None) or []
active_user_subs = getattr(active_user, 'subscriptions', None) or []
if phantom_subs and not active_user_subs:
# Transfer ALL subscriptions from phantom to active user
for sub in phantom_subs:
sub.user_id = active_user.id
# Transfer remnawave_uuid (clear first to avoid unique constraint violation on flush)
if settings.is_multi_tariff_enabled():
# In multi-tariff, transfer user-level UUID only if no subscription-level UUIDs exist
if phantom.remnawave_uuid and not active_user.remnawave_uuid:
phantom_subs = getattr(phantom, 'subscriptions', []) or []
has_sub_uuids = any(getattr(s, 'remnawave_uuid', None) for s in phantom_subs)
if not has_sub_uuids:
uuid_to_transfer = phantom.remnawave_uuid
phantom.remnawave_uuid = None
await db.flush()
active_user.remnawave_uuid = uuid_to_transfer
elif phantom.remnawave_uuid and not active_user.remnawave_uuid:
uuid_to_transfer = phantom.remnawave_uuid
phantom.remnawave_uuid = None
await db.flush()
active_user.remnawave_uuid = uuid_to_transfer
await db.flush()
logger.info(
'Transferred subscriptions from phantom to active user',
subscription_ids=[sub.id for sub in phantom_subs],
)
elif phantom_subs:
# Both have subscriptions — disable phantom's Remnawave user and free server slots
logger.warning(
'Both phantom and active user have subscriptions, disabling phantom',
phantom_subscription_ids=[sub.id for sub in phantom_subs],
active_subscription_ids=[sub.id for sub in active_user_subs],
)
if phantom.remnawave_uuid:
try:
subscription_service = SubscriptionService()
await subscription_service.disable_remnawave_user(phantom.remnawave_uuid)
except Exception as exc:
logger.warning('Failed to disable phantom Remnawave user', error=str(exc))
for sub in phantom_subs:
await decrement_subscription_server_counts(db, sub)
# Soft-delete phantom: clear unique identifiers to prevent future matches
# and constraint violations. Preserve record for audit trail.
phantom.status = UserStatus.DELETED.value
phantom.username = None
phantom.remnawave_uuid = None
phantom.referral_code = None
await db.flush()
logger.info('Phantom user merged and soft-deleted', phantom_id=phantom.id, active_user_id=active_user.id)
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
actual_status = getattr(subscription, 'actual_status', None)
# 'limited' subscriptions are still active (traffic exhausted, but subscription not expired)
has_active_subscription = actual_status in {'active', 'trial', 'limited'}
subscription_is_active = bool(getattr(subscription, 'is_active', False)) or actual_status == 'limited'
return has_active_subscription, subscription_is_active
async def _send_pinned_message(
bot: Bot,
db: AsyncSession,
user,
pinned_message: PinnedMessage | None = None,
) -> None:
try:
await deliver_pinned_message_to_user(bot, db, user, pinned_message)
except Exception as error:
logger.error(
'Не удалось отправить закрепленное сообщение пользователю',
getattr=getattr(user, 'telegram_id', 'unknown'),
error=error,
)
async def _apply_campaign_bonus_if_needed(
db: AsyncSession,
user,
state_data: dict,
texts,
):
campaign_id = state_data.get('campaign_id') if state_data else None
if not campaign_id:
return None
campaign = await get_campaign_by_id(db, campaign_id)
if not campaign or not campaign.is_active:
return None
service = AdvertisingCampaignService()
result = await service.apply_campaign_bonus(db, user, campaign)
if not result.success:
return None
# Bot-flow successfully applied the campaign — clear the Redis pending entry
# (set in cmd_start as a fallback for the cabinet WebApp path) so it isn't
# re-evaluated on a subsequent cabinet login.
try:
from app.services.referral_service import clear_pending_campaign
if getattr(user, 'telegram_id', None):
await clear_pending_campaign(user.telegram_id)
except Exception:
pass
if result.bonus_type == 'balance':
amount_text = texts.format_price(result.balance_kopeks)
return texts.CAMPAIGN_BONUS_BALANCE.format(
amount=amount_text,
name=html.escape(campaign.name),
)
if result.bonus_type == 'subscription':
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
return texts.CAMPAIGN_BONUS_SUBSCRIPTION.format(
name=html.escape(campaign.name),
days=result.subscription_days,
traffic=traffic_text,
devices=result.subscription_device_limit,
)
if result.bonus_type == 'none':
# Ссылка без награды - не показываем сообщение
return None
if result.bonus_type == 'tariff':
traffic_text = texts.format_traffic(result.subscription_traffic_gb or 0)
return texts.t(
'CAMPAIGN_BONUS_TARIFF',
"🎁 Вам выдан тариф '{tariff_name}' на {days} дней!\n📊 Трафик: {traffic}\n📱 Устройств: {devices}",
).format(
tariff_name=result.tariff_name or 'Подарочный',
days=result.tariff_duration_days,
traffic=traffic_text,
devices=result.subscription_device_limit,
)
return None
async def handle_potential_referral_code(message: types.Message, state: FSMContext, db: AsyncSession):
current_state = await state.get_state()
logger.info(
'🔍 REFERRAL/PROMO CHECK: Проверка сообщения в состоянии',
message_text=message.text,
current_state=current_state,
)
if current_state not in [
RegistrationStates.waiting_for_rules_accept.state,
RegistrationStates.waiting_for_privacy_policy_accept.state,
RegistrationStates.waiting_for_referral_code.state,
None,
]:
return False
user = await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status == UserStatus.ACTIVE.value:
return False
data = await state.get_data() or {}
language = data.get('language') or (getattr(user, 'language', None) if user else None) or DEFAULT_LANGUAGE
texts = get_texts(language)
if not message.text:
return False
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
potential_code = message.text.strip()
if len(potential_code) < 3 or len(potential_code) > 50:
return False
# Валидация формата (только буквы, цифры, дефис, подчёркивание)
if not validate_promo_format(potential_code):
return False
# Rate-limit на перебор промокодов
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown)
)
return True
# Сначала проверяем реферальный код
referrer = await get_user_by_referral_code(db, potential_code)
if referrer:
data['referral_code'] = potential_code
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t('REFERRAL_CODE_ACCEPTED', '✅ Реферальный код принят!'))
logger.info(
'✅ Реферальный код применен для пользователя',
potential_code=potential_code,
from_user_id=message.from_user.id,
)
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(rules_text, reply_markup=get_rules_keyboard(language))
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info('📋 Правила отправлены после ввода реферального кода')
else:
await complete_registration(message, state, db)
return True
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, potential_code)
if promocode_check['valid']:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = potential_code
await state.set_data(data)
await message.answer(
texts.t(
'PROMOCODE_ACCEPTED_WILL_ACTIVATE',
'✅ Промокод принят! Он будет активирован после завершения регистрации.',
)
)
logger.info(
'✅ Промокод сохранен для активации для пользователя',
potential_code=potential_code,
from_user_id=message.from_user.id,
)
if current_state != RegistrationStates.waiting_for_referral_code.state:
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
rules_text = await get_rules(language)
await message.answer(rules_text, reply_markup=get_rules_keyboard(language))
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info('📋 Правила отправлены после принятия промокода')
else:
await complete_registration(message, state, db)
return True
# Ни реферальный код, ни промокод не найдены — записываем неудачную попытку
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
await message.answer(
texts.t(
'REFERRAL_OR_PROMO_CODE_INVALID_HELP',
'❌ Неверный реферальный код или промокод.\n\n'
'💡 Если у вас есть реферальный код или промокод, убедитесь что он введен правильно.\n'
'⏭️ Для продолжения регистрации без кода используйте команду /start',
)
)
return True
def _get_language_prompt_text() -> str:
return '🌐 Выберите язык / Choose your language:'
async def _prompt_language_selection(message: types.Message, state: FSMContext) -> None:
logger.info('🌐 LANGUAGE: Запрос выбора языка для пользователя', from_user_id=message.from_user.id)
await state.set_state(RegistrationStates.waiting_for_language)
await message.answer(
_get_language_prompt_text(),
reply_markup=get_language_selection_keyboard(),
)
async def _continue_registration_after_language(
*,
message: types.Message | None,
callback: types.CallbackQuery | None,
state: FSMContext,
db: AsyncSession,
) -> None:
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
target_message = callback.message if callback else message
if not target_message:
logger.warning('⚠️ LANGUAGE: Нет доступного сообщения для продолжения регистрации')
return
async def _complete_registration_wrapper():
if callback:
await complete_registration_from_callback(callback, state, db)
else:
await complete_registration(message, state, db)
if settings.SKIP_RULES_ACCEPT:
logger.info('⚙️ LANGUAGE: SKIP_RULES_ACCEPT включен - пропускаем правила')
if data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info('✅ LANGUAGE: Реферер найден', referrer_id=referrer.id)
if settings.SKIP_REFERRAL_CODE or data.get('referral_code') or data.get('referrer_id'):
await _complete_registration_wrapper()
else:
try:
await target_message.answer(
texts.t(
'REFERRAL_CODE_QUESTION',
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info('🔍 LANGUAGE: Ожидание ввода реферального кода')
except Exception as error:
logger.error('Ошибка при показе вопроса о реферальном коде после выбора языка', error=error)
await _complete_registration_wrapper()
return
rules_text = await get_rules(language)
try:
await target_message.answer(rules_text, reply_markup=get_rules_keyboard(language))
except TelegramForbiddenError:
logger.warning(
'⚠️ Пользователь заблокировал бота, пропускаем отправку правил',
from_user_id=callback.from_user.id if callback else message.from_user.id,
)
return
await state.set_state(RegistrationStates.waiting_for_rules_accept)
logger.info('📋 LANGUAGE: Правила отправлены после выбора языка')
async def cmd_start(message: types.Message, state: FSMContext, db: AsyncSession, db_user=None):
logger.info('🚀 START: Обработка /start от', from_user_id=message.from_user.id)
data = await state.get_data() or {}
# ИСПРАВЛЕНИЕ БАГА: используем .get() вместо .pop() для campaign_notification_sent
# pending_start_payload обрабатывается отдельно ниже
campaign_notification_sent = data.get('campaign_notification_sent', False)
state_needs_update = False
# Получаем payload из state или Redis
pending_start_payload = data.get('pending_start_payload', None)
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(message.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
data['pending_start_payload'] = redis_payload
state_needs_update = True
logger.info(
"📦 START: Payload '' восстановлен из Redis (fallback)", pending_start_payload=pending_start_payload
)
# НЕ удаляем Redis payload здесь - удаление только после успешной регистрации
referral_code = None
campaign = None
start_args = message.text.split()
start_parameter = None
if len(start_args) > 1:
start_parameter = start_args[1]
elif pending_start_payload:
start_parameter = pending_start_payload
logger.info("📦 START: Используем сохраненный payload ''", pending_start_payload=pending_start_payload)
if state_needs_update:
await state.set_data(data)
# Handle gift code deep links: /start GIFT_{token}
if start_parameter and start_parameter.startswith('GIFT_'):
gift_token = start_parameter[5:] # Strip "GIFT_" prefix
if len(gift_token) >= 8:
logger.info(
'Gift code deep link detected',
token_prefix=gift_token[:5],
telegram_id=message.from_user.id,
)
# For new users, gift is auto-activated via
# _activate_pending_gift_after_registration() before state.clear().
await state.update_data(pending_gift_token=gift_token)
start_parameter = None # Don't treat as campaign or referral
# Handle web auth deep links: /start webauth_{token}
if start_parameter and start_parameter.startswith('webauth_'):
web_auth_token = start_parameter.removeprefix('webauth_')
if len(web_auth_token) >= WEB_AUTH_TOKEN_MIN_LENGTH:
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if user and user.status != UserStatus.DELETED.value:
texts = get_texts(user.language)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t('WEB_AUTH_CONFIRM_YES', '✅ Да, войти'),
callback_data=f'webauth_confirm:{web_auth_token}',
),
types.InlineKeyboardButton(
text=texts.t('WEB_AUTH_CONFIRM_NO', '❌ Нет'),
callback_data='webauth_deny',
),
],
]
)
await message.answer(
texts.t(
'WEB_AUTH_CONFIRM_PROMPT',
'🔐 Подтвердите вход в личный кабинет. Если вы не запрашивали вход — нажмите «Нет».',
),
reply_markup=keyboard,
)
else:
logger.warning('Web auth attempt from unregistered user', telegram_id=message.from_user.id)
await message.answer('❌ Сначала зарегистрируйтесь в боте, затем попробуйте войти в кабинет.')
return
start_parameter = None # Invalid token, ignore
if start_parameter:
campaign = await get_campaign_by_start_parameter(
db,
start_parameter,
only_active=True,
)
if campaign:
logger.info(
'📣 Найдена рекламная кампания (start=)',
campaign_id=campaign.id,
start_parameter=campaign.start_parameter,
)
await state.update_data(campaign_id=campaign.id)
# Persist campaign to Redis immediately so it survives if user opens
# miniapp/cabinet (via Telegram menu button) before completing the
# bot registration flow. Mirrors the pending_referral mechanism.
# Only for new users — existing users already had attribution applied.
if not db_user:
try:
await save_pending_campaign(
message.from_user.id,
campaign.start_parameter,
campaign.id,
)
except Exception as exc:
logger.warning(
'Failed to persist pending campaign',
campaign_id=campaign.id,
error=exc,
)
if campaign.partner_user_id:
await state.update_data(referrer_id=campaign.partner_user_id)
logger.info(
'👤 Кампания привязана к партнёру, реферер будет установлен',
campaign_id=campaign.id,
campaign_name=campaign.name,
partner_user_id=campaign.partner_user_id,
)
else:
logger.debug(
'Кампания без партнёра, реферер не устанавливается',
campaign_id=campaign.id,
campaign_name=campaign.name,
)
else:
referral_code = start_parameter
logger.info('🔎 Найден реферальный код', referral_code=referral_code)
if referral_code:
await state.update_data(referral_code=referral_code)
# Persist referral to Redis immediately so it survives if user opens miniapp/cabinet
# Only for new users — existing users don't need pending referral
if not db_user:
try:
referrer = await get_user_by_referral_code(db, referral_code)
if referrer and referrer.telegram_id != message.from_user.id:
await save_pending_referral(message.from_user.id, referral_code, referrer.id)
except Exception as exc:
logger.warning('Failed to persist pending referral', referral_code=referral_code, error=exc)
user = db_user or await get_user_by_telegram_id(db, message.from_user.id)
if campaign and not campaign_notification_sent:
try:
notification_service = AdminNotificationService(message.bot)
await notification_service.send_campaign_link_visit_notification(
db,
message.from_user,
campaign,
user,
)
except Exception as notify_error:
logger.error(
'Ошибка отправки админ уведомления о переходе по кампании',
campaign_id=campaign.id,
notify_error=notify_error,
)
if user and user.status != UserStatus.DELETED.value:
logger.info('✅ Активный пользователь найден', telegram_id=user.telegram_id)
# Check for phantom user created by guest landing purchase and merge
if message.from_user.username:
phantom = await find_phantom_user_by_username(db, message.from_user.username)
if phantom and phantom.id != user.id:
try:
await merge_phantom_into_user(db, phantom, user)
await db.commit()
await db.refresh(user, ['subscriptions'])
except Exception:
await db.rollback()
await db.refresh(user, ['subscriptions'])
logger.exception(
'Failed to merge phantom user',
phantom_id=phantom.id,
active_user_id=user.id,
)
profile_updated = False
if user.username != message.from_user.username:
old_username = user.username
user.username = message.from_user.username
logger.info('📝 Username обновлен', old_username=old_username, username=user.username)
profile_updated = True
if user.first_name != message.from_user.first_name:
old_first_name = user.first_name
user.first_name = message.from_user.first_name
logger.info('📝 Имя обновлено', old_first_name=old_first_name, first_name=user.first_name)
profile_updated = True
if user.last_name != message.from_user.last_name:
old_last_name = user.last_name
user.last_name = message.from_user.last_name
logger.info('📝 Фамилия обновлена', old_last_name=old_last_name, last_name=user.last_name)
profile_updated = True
user.last_activity = datetime.now(UTC)
if profile_updated:
user.updated_at = datetime.now(UTC)
await db.commit()
await db.refresh(user)
logger.info('💾 Профиль пользователя обновлен', telegram_id=user.telegram_id)
else:
await db.commit()
texts = get_texts(user.language)
if referral_code and not user.referred_by_id:
await message.answer(
texts.t(
'ALREADY_REGISTERED_REFERRAL',
'ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.',
)
)
if campaign and not campaign.is_none_bonus:
try:
await message.answer(
texts.t(
'CAMPAIGN_EXISTING_USERL',
'ℹ️ Эта рекламная ссылка доступна только новым пользователям.',
)
)
except Exception as e:
logger.error('Ошибка отправки уведомления о рекламной кампании', error=e)
# Auto-activate pending gift if deep link contained GIFT_
if user:
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.update_data(pending_gift_token=None)
# Refresh user to pick up newly created subscriptions
await db.refresh(user, attribute_names=['subscriptions'])
user_subs_for_flags = getattr(user, 'subscriptions', None) or []
first_sub_for_flags = next(
(s for s in user_subs_for_flags if s.is_active), user_subs_for_flags[0] if user_subs_for_flags else None
)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(first_sub_for_flags)
pinned_message = await get_active_pinned_message(db)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
user_subs = getattr(user, 'subscriptions', None) or []
first_sub = next((s for s in user_subs if s.is_active), user_subs[0] if user_subs else None)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=first_sub,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
await state.clear()
return
if user and user.status == UserStatus.DELETED.value:
logger.info('🔄 Удаленный пользователь начинает повторную регистрацию', telegram_id=user.telegram_id)
try:
from sqlalchemy import delete, update as sa_update
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
KassaAiPayment,
MulenPayPayment,
Pal24Payment,
PlategaPayment,
PromoCodeUse,
ReferralEarning,
SubscriptionServer,
Transaction,
WataPayment,
YooKassaPayment,
)
user_subs = getattr(user, 'subscriptions', None) or []
for sub in user_subs:
await decrement_subscription_server_counts(db, sub)
await db.execute(delete(SubscriptionServer).where(SubscriptionServer.subscription_id == sub.id))
logger.info('Deleted SubscriptionServer records', subscription_id=sub.id)
for sub in user_subs:
await db.delete(sub)
logger.info('Deleted user subscription', subscription_id=sub.id)
await db.execute(delete(PromoCodeUse).where(PromoCodeUse.user_id == user.id))
await db.execute(
sa_update(ReferralEarning)
.where(ReferralEarning.user_id == user.id)
.values(referral_transaction_id=None)
)
await db.execute(
sa_update(ReferralEarning)
.where(ReferralEarning.referral_id == user.id)
.values(referral_transaction_id=None)
)
await db.execute(delete(ReferralEarning).where(ReferralEarning.user_id == user.id))
await db.execute(delete(ReferralEarning).where(ReferralEarning.referral_id == user.id))
# Обнуляем transaction_id во всех таблицах платежей перед удалением транзакций
payment_models = [
YooKassaPayment,
CryptoBotPayment,
HeleketPayment,
MulenPayPayment,
Pal24Payment,
WataPayment,
PlategaPayment,
CloudPaymentsPayment,
FreekassaPayment,
KassaAiPayment,
]
for payment_model in payment_models:
await db.execute(
sa_update(payment_model).where(payment_model.user_id == user.id).values(transaction_id=None)
)
await db.execute(delete(Transaction).where(Transaction.user_id == user.id))
if user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=user.telegram_id,
balance_kopeks=user.balance_kopeks,
)
# Keep status=DELETED so complete_registration properly handles
# referral assignment and status change (not the "already active" branch)
user.balance_kopeks = 0
user.remnawave_uuid = None
user.has_had_paid_subscription = False
user.referred_by_id = None
user.username = message.from_user.username
user.first_name = message.from_user.first_name
user.last_name = message.from_user.last_name
user.updated_at = datetime.now(UTC)
user.last_activity = datetime.now(UTC)
from app.utils.user_utils import generate_unique_referral_code
user.referral_code = await generate_unique_referral_code(db, user.telegram_id)
await db.commit()
logger.info('✅ Пользователь подготовлен к восстановлению', telegram_id=user.telegram_id)
except Exception as e:
logger.error('❌ Ошибка подготовки к восстановлению', error=e)
await db.rollback()
else:
logger.info('🆕 Новый пользователь, начинаем регистрацию')
data = await state.get_data() or {}
if not data.get('language'):
if settings.is_language_selection_enabled():
await _prompt_language_selection(message, state)
return
default_language = (
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
if isinstance(settings.DEFAULT_LANGUAGE, str)
else DEFAULT_LANGUAGE
)
normalized_default = default_language.split('-')[0].lower()
data['language'] = normalized_default
await state.set_data(data)
logger.info(
"🌐 LANGUAGE: выбор языка отключен, устанавливаем язык по умолчанию ''",
normalized_default=normalized_default,
)
await _continue_registration_after_language(
message=message,
callback=None,
state=state,
db=db,
)
async def process_language_selection(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
):
logger.info(
'🌐 LANGUAGE: Пользователь выбрал язык', from_user_id=callback.from_user.id, callback_data=callback.data
)
if not settings.is_language_selection_enabled():
data = await state.get_data() or {}
default_language = (
(settings.DEFAULT_LANGUAGE or DEFAULT_LANGUAGE)
if isinstance(settings.DEFAULT_LANGUAGE, str)
else DEFAULT_LANGUAGE
)
normalized_default = default_language.split('-')[0].lower()
data['language'] = normalized_default
await state.set_data(data)
texts = get_texts(normalized_default)
try:
await callback.message.edit_text(
texts.t(
'LANGUAGE_SELECTION_DISABLED',
'⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.',
)
)
except Exception:
await callback.message.answer(
texts.t(
'LANGUAGE_SELECTION_DISABLED',
'⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.',
)
)
await callback.answer()
await _continue_registration_after_language(
message=None,
callback=callback,
state=state,
db=db,
)
return
selected_raw = (callback.data or '').split(':', 1)[-1]
normalized_selected = selected_raw.strip().lower()
available_map = {
lang.strip().lower(): lang.strip()
for lang in settings.get_available_languages()
if isinstance(lang, str) and lang.strip()
}
if normalized_selected not in available_map:
logger.warning(
'⚠️ LANGUAGE: Выбран недоступный язык пользователем',
normalized_selected=normalized_selected,
from_user_id=callback.from_user.id,
)
await callback.answer('❌ Unsupported language', show_alert=True)
return
resolved_language = available_map[normalized_selected].lower()
data = await state.get_data() or {}
data['language'] = resolved_language
await state.set_data(data)
texts = get_texts(resolved_language)
try:
await callback.message.edit_text(
texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.'),
)
except Exception as error:
logger.warning('⚠️ LANGUAGE: Не удалось обновить сообщение выбора языка', error=error)
await callback.message.answer(
texts.t('LANGUAGE_SELECTED', '🌐 Язык интерфейса обновлен.'),
)
await callback.answer()
await _continue_registration_after_language(
message=None,
callback=callback,
state=state,
db=db,
)
async def _show_privacy_policy_after_rules(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
language: str,
) -> bool:
"""
Показывает политику конфиденциальности после принятия правил.
Возвращает True, если политика была показана, False если её нет или произошла ошибка.
"""
policy = await PrivacyPolicyService.get_policy(db, language, fallback=True)
if not policy or not policy.is_enabled:
logger.info('⚠️ Политика конфиденциальности не включена, пропускаем её показ')
return False
if not policy.content or not policy.content.strip():
privacy_policy_text = get_privacy_policy(language)
if not privacy_policy_text or not privacy_policy_text.strip():
logger.info('⚠️ Политика конфиденциальности включена, но дефолтный текст пустой, пропускаем показ')
return False
logger.info(
'🔒 Используется дефолтный текст политики конфиденциальности из локализации для языка', language=language
)
else:
privacy_policy_text = policy.content
logger.info('🔒 Используется политика конфиденциальности из БД для языка', language=language)
try:
await callback.message.edit_text(
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info('🔒 Политика конфиденциальности отправлена пользователю', from_user_id=callback.from_user.id)
return True
except Exception as e:
logger.error('Ошибка при показе политики конфиденциальности', error=e, exc_info=True)
try:
await callback.message.answer(
privacy_policy_text, reply_markup=get_privacy_policy_keyboard(language), parse_mode='HTML'
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
logger.info(
'🔒 Политика конфиденциальности отправлена новым сообщением пользователю',
from_user_id=callback.from_user.id,
)
return True
except Exception as e2:
logger.error('Критическая ошибка при отправке политики конфиденциальности', e2=e2, exc_info=True)
return False
async def _continue_registration_after_rules(
callback: types.CallbackQuery,
state: FSMContext,
db: AsyncSession,
language: str,
) -> None:
"""
Продолжает регистрацию после принятия правил (реферальный код или завершение).
"""
data = await state.get_data() or {}
texts = get_texts(language)
if data.get('referral_code'):
logger.info('🎫 Найден реферальный код из deep link', data=data['referral_code'])
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info('✅ Реферер найден', referrer_id=referrer.id)
await complete_registration_from_callback(callback, state, db)
elif settings.SKIP_REFERRAL_CODE or data.get('referrer_id'):
logger.info('⚙️ Пропускаем запрос реферального кода')
await complete_registration_from_callback(callback, state, db)
else:
try:
await callback.message.edit_text(
texts.t(
'REFERRAL_CODE_QUESTION',
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
logger.info('🔍 Ожидание ввода реферального кода')
except Exception as e:
logger.error('Ошибка при показе вопроса о реферальном коде', error=e)
await complete_registration_from_callback(callback, state, db)
async def process_rules_accept(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
"""
Обрабатывает принятие или отклонение правил пользователем.
"""
logger.info('📋 RULES: Начало обработки правил')
logger.info('📊 Callback data', callback_data=callback.data)
logger.info('👤 User', from_user_id=callback.from_user.id)
current_state = await state.get_state()
logger.info('📊 Текущее состояние', current_state=current_state)
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
await callback.answer()
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'rules_accept':
logger.info('✅ Правила приняты пользователем', from_user_id=callback.from_user.id)
# Пытаемся показать политику конфиденциальности
policy_shown = await _show_privacy_policy_after_rules(callback, state, db, language)
# Если политика не была показана, продолжаем регистрацию
if not policy_shown:
await _continue_registration_after_rules(callback, state, db, language)
else:
logger.info('❌ Правила отклонены пользователем', from_user_id=callback.from_user.id)
rules_required_text = texts.t(
'RULES_REQUIRED',
'Для использования бота необходимо принять правила сервиса.',
)
try:
await callback.message.edit_text(rules_required_text, reply_markup=get_rules_keyboard(language))
except TelegramBadRequest as e:
if 'message is not modified' in str(e):
pass # Сообщение уже содержит нужный текст
else:
logger.error('Ошибка при показе сообщения об отклонении правил', error=e)
logger.info('✅ Правила обработаны для пользователя', from_user_id=callback.from_user.id)
except Exception as e:
logger.error('❌ Ошибка обработки правил', error=e, exc_info=True)
await callback.answer(
texts.t('ERROR_TRY_AGAIN', '❌ Произошла ошибка. Попробуйте еще раз.'),
show_alert=True,
)
try:
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
await callback.message.answer(
texts.t(
'ERROR_RULES_RETRY',
'Произошла ошибка. Попробуйте принять правила еще раз:',
),
reply_markup=get_rules_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
except Exception:
pass
async def process_privacy_policy_accept(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
logger.info('🔒 PRIVACY POLICY: Начало обработки политики конфиденциальности')
logger.info('📊 Callback data', callback_data=callback.data)
logger.info('👤 User', from_user_id=callback.from_user.id)
current_state = await state.get_state()
logger.info('📊 Текущее состояние', current_state=current_state)
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
await callback.answer()
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
if callback.data == 'privacy_policy_accept':
logger.info('✅ Политика конфиденциальности принята пользователем', from_user_id=callback.from_user.id)
try:
await callback.message.delete()
logger.info('🗑️ Сообщение с политикой конфиденциальности удалено')
except Exception as e:
logger.warning('⚠️ Не удалось удалить сообщение с политикой конфиденциальности', error=e)
try:
await callback.message.edit_text(
texts.t(
'PRIVACY_POLICY_ACCEPTED_PROCESSING',
'✅ Политика конфиденциальности принята! Продолжаем регистрацию...',
),
reply_markup=None,
)
except Exception:
pass
if data.get('referral_code'):
logger.info('🎫 Найден реферальный код из deep link', data=data['referral_code'])
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
logger.info('✅ Реферер найден', referrer_id=referrer.id)
await complete_registration_from_callback(callback, state, db)
elif settings.SKIP_REFERRAL_CODE or data.get('referrer_id'):
logger.info('⚙️ Пропускаем запрос реферального кода')
await complete_registration_from_callback(callback, state, db)
else:
try:
await state.set_data(data)
await state.set_state(RegistrationStates.waiting_for_referral_code)
await callback.bot.send_message(
chat_id=callback.from_user.id,
text=texts.t(
'REFERRAL_CODE_QUESTION',
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language),
)
logger.info('🔍 Ожидание ввода реферального кода')
except Exception as e:
logger.error('Ошибка при показе вопроса о реферальном коде', error=e)
await complete_registration_from_callback(callback, state, db)
else:
logger.info('❌ Политика конфиденциальности отклонена пользователем', from_user_id=callback.from_user.id)
privacy_policy_required_text = texts.t(
'PRIVACY_POLICY_REQUIRED',
'Для использования бота необходимо принять политику конфиденциальности.',
)
try:
await callback.message.edit_text(
privacy_policy_required_text, reply_markup=get_privacy_policy_keyboard(language)
)
except TelegramBadRequest as e:
if 'message is not modified' not in str(e):
logger.warning('Ошибка при показе сообщения об отклонении политики', error=e)
except Exception as e:
logger.warning('Ошибка при показе сообщения об отклонении политики', error=e)
logger.info('✅ Политика конфиденциальности обработана для пользователя', from_user_id=callback.from_user.id)
except Exception as e:
logger.error('❌ Ошибка обработки политики конфиденциальности', error=e, exc_info=True)
await callback.answer(
texts.t('ERROR_TRY_AGAIN', '❌ Произошла ошибка. Попробуйте еще раз.'),
show_alert=True,
)
try:
data = await state.get_data() or {}
language = data.get('language', language)
texts = get_texts(language)
await callback.message.answer(
texts.t(
'ERROR_PRIVACY_POLICY_RETRY',
'Произошла ошибка. Попробуйте принять политику конфиденциальности еще раз:',
),
reply_markup=get_privacy_policy_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_privacy_policy_accept)
except Exception:
pass
async def process_referral_code_input(message: types.Message, state: FSMContext, db: AsyncSession):
logger.info('🎫 REFERRAL/PROMO: Обработка кода', message_text=message.text)
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
if not message.text:
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
return
from app.utils.promo_rate_limiter import promo_limiter, validate_promo_format
code = message.text.strip()
# Валидация формата
if not validate_promo_format(code):
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
return
# Rate-limit на перебор
if promo_limiter.is_blocked(message.from_user.id):
cooldown = promo_limiter.get_block_cooldown(message.from_user.id)
await message.answer(
texts.t(
'PROMO_RATE_LIMITED',
'⏳ Слишком много попыток. Попробуйте через {cooldown} сек.',
).format(cooldown=cooldown)
)
return
# Сначала проверяем, является ли это реферальным кодом
referrer = await get_user_by_referral_code(db, code)
if referrer:
data['referrer_id'] = referrer.id
await state.set_data(data)
await message.answer(texts.t('REFERRAL_CODE_ACCEPTED', '✅ Реферальный код принят!'))
logger.info('✅ Реферальный код применен', code=code)
await complete_registration(message, state, db)
return
# Если реферальный код не найден, проверяем промокод
from app.database.crud.promocode import check_promocode_validity
promocode_check = await check_promocode_validity(db, code)
if promocode_check['valid']:
# Промокод валиден - сохраняем его в state для активации после создания пользователя
data['promocode'] = code
await state.set_data(data)
await message.answer(
texts.t(
'PROMOCODE_ACCEPTED_WILL_ACTIVATE',
'✅ Промокод принят! Он будет активирован после завершения регистрации.',
)
)
logger.info('✅ Промокод сохранен для активации', code=code)
await complete_registration(message, state, db)
return
# Ни реферальный код, ни промокод не найдены — записываем неудачу
promo_limiter.record_failed_attempt(message.from_user.id)
promo_limiter.cleanup()
await message.answer(texts.t('REFERRAL_OR_PROMO_CODE_INVALID', '❌ Неверный реферальный код или промокод'))
logger.info('❌ Неверный код (ни реферальный, ни промокод)', code=code)
return
async def process_referral_code_skip(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
logger.info('⭐️ SKIP: Пропуск реферального кода от пользователя', from_user_id=callback.from_user.id)
await callback.answer()
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
try:
await callback.message.delete()
logger.info('🗑️ Сообщение с вопросом о реферальном коде удалено')
except Exception as e:
logger.warning('⚠️ Не удалось удалить сообщение с вопросом о реферальном коде', error=e)
try:
await callback.message.edit_text(
texts.t('REGISTRATION_COMPLETING', '✅ Завершаем регистрацию...'), reply_markup=None
)
except Exception:
pass
await complete_registration_from_callback(callback, state, db)
async def complete_registration_from_callback(callback: types.CallbackQuery, state: FSMContext, db: AsyncSession):
logger.info('🎯 COMPLETE: Завершение регистрации для пользователя', from_user_id=callback.from_user.id)
existing_user = await get_user_by_telegram_id(db, callback.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning('⚠️ Пользователь уже активен! Показываем главное меню.', from_user_id=callback.from_user.id)
texts = get_texts(existing_user.language)
data = await state.get_data() or {}
if data.get('referral_code') and not existing_user.referred_by_id:
await callback.message.answer(
texts.t(
'ALREADY_REGISTERED_REFERRAL',
'ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.',
)
)
await db.refresh(existing_user, ['subscriptions'])
existing_user_subs = getattr(existing_user, 'subscriptions', None) or []
first_existing_sub = next(
(s for s in existing_user_subs if s.is_active), existing_user_subs[0] if existing_user_subs else None
)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(first_existing_sub)
menu_text = await get_main_menu_text(existing_user, texts, db)
is_admin = settings.is_admin(existing_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(existing_user.telegram_id)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
pinned_message = await get_active_pinned_message(db)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=existing_user,
language=existing_user.language,
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=first_existing_sub,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
await callback.message.answer(
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=html.escape(existing_user.full_name or ''))
)
await state.clear()
return
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referrer_id = data.get('referrer_id')
if not referrer_id and data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
referrer_id = referrer.id
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=callback.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
if existing_user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=existing_user.telegram_id,
balance_kopeks=existing_user.balance_kopeks,
)
existing_user.username = callback.from_user.username
existing_user.first_name = callback.from_user.first_name
existing_user.last_name = callback.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
existing_user.updated_at = datetime.now(UTC)
existing_user.last_activity = datetime.now(UTC)
await db.commit()
await db.refresh(existing_user, ['subscriptions'])
user = existing_user
logger.info('✅ Пользователь восстановлен', from_user_id=callback.from_user.id)
elif not existing_user:
# Check for phantom user created by guest purchase (gift by @username)
phantom = (
await find_phantom_user_by_username(db, callback.from_user.username)
if callback.from_user.username
else None
)
if phantom:
claimed, user = await claim_phantom(
db,
phantom,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referrer_id=referrer_id,
)
if not claimed and user:
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
# Merge phantom's data into the existing user via full account merge service.
if phantom.id != user.id:
try:
await db.refresh(phantom, ['subscriptions'])
await _merge_phantom_into_active_user(db, phantom, user)
await db.commit()
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom into existing user during registration',
phantom_id=phantom.id,
active_user_id=user.id,
)
await db.refresh(user, ['subscriptions'])
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
telegram_id=callback.from_user.id,
phantom_user_id=phantom.id,
)
phantom = None
if not phantom:
logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
referral_code = await generate_unique_referral_code(db, callback.from_user.id)
user = await create_user(
db=db,
telegram_id=callback.from_user.id,
username=callback.from_user.username,
first_name=callback.from_user.first_name,
last_name=callback.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code,
)
await db.refresh(user, ['subscriptions'])
else:
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
existing_user.last_activity = datetime.now(UTC)
await db.commit()
await db.refresh(existing_user, ['subscriptions'])
user = existing_user
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, callback.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
except Exception as e:
logger.error('Ошибка при обработке реферальной регистрации', error=e)
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
'Ошибка обновления данных пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_error=refresh_error,
)
try:
await db.refresh(user, ['subscriptions'])
except Exception as refresh_subscription_error:
logger.error(
'Ошибка обновления подписки пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_subscription_error=refresh_subscription_error,
)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
await delete_pending_payload_from_redis(callback.from_user.id)
logger.info(
'🗑️ COMPLETE_FROM_CALLBACK: Redis payload удален после успешной регистрации пользователя',
telegram_id=user.telegram_id,
)
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, callback.message.answer)
await state.clear()
if campaign_message:
try:
await callback.message.answer(campaign_message)
except Exception as e:
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, callback.from_user)
pinned_message = await get_active_pinned_message(db)
if offer_text:
try:
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
parse_mode='HTML',
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
try:
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
parse_mode=None,
)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
except Exception as fallback_err:
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
else:
logger.error('Ошибка при отправке приветственного сообщения', error=e)
except Exception as e:
logger.error('Ошибка при отправке приветственного сообщения', error=e)
else:
logger.info(
'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя',
telegram_id=user.telegram_id,
)
user_subs_menu = getattr(user, 'subscriptions', None) or []
first_sub_menu = next((s for s in user_subs_menu if s.is_active), user_subs_menu[0] if user_subs_menu else None)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(first_sub_menu)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=first_sub_menu,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(callback.bot, db, user, pinned_message)
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
except Exception as e:
logger.error('Ошибка при показе главного меню', error=e)
await callback.message.answer(
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=html.escape(user.full_name or ''))
)
logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id)
async def complete_registration(message: types.Message, state: FSMContext, db: AsyncSession):
logger.info('🎯 COMPLETE: Завершение регистрации для пользователя', from_user_id=message.from_user.id)
existing_user = await get_user_by_telegram_id(db, message.from_user.id)
if existing_user and existing_user.status == UserStatus.ACTIVE.value:
logger.warning('⚠️ Пользователь уже активен! Показываем главное меню.', from_user_id=message.from_user.id)
texts = get_texts(existing_user.language)
data = await state.get_data() or {}
if data.get('referral_code') and not existing_user.referred_by_id:
await message.answer(
texts.t(
'ALREADY_REGISTERED_REFERRAL',
'ℹ️ Вы уже зарегистрированы в системе. Реферальная ссылка не может быть применена.',
)
)
await db.refresh(existing_user, ['subscriptions'])
existing_user_subs = getattr(existing_user, 'subscriptions', None) or []
first_existing_sub = next(
(s for s in existing_user_subs if s.is_active), existing_user_subs[0] if existing_user_subs else None
)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(first_existing_sub)
menu_text = await get_main_menu_text(existing_user, texts, db)
is_admin = settings.is_admin(existing_user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(existing_user.telegram_id)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
pinned_message = await get_active_pinned_message(db)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=existing_user,
language=existing_user.language,
is_admin=is_admin,
has_had_paid_subscription=existing_user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=existing_user.balance_kopeks,
subscription=first_existing_sub,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, existing_user, pinned_message)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, existing_user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
await message.answer(
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=html.escape(existing_user.full_name or ''))
)
await state.clear()
return
data = await state.get_data() or {}
language = data.get('language', DEFAULT_LANGUAGE)
texts = get_texts(language)
referrer_id = data.get('referrer_id')
if not referrer_id and data.get('referral_code'):
referrer = await get_user_by_referral_code(db, data['referral_code'])
if referrer:
referrer_id = referrer.id
if existing_user and existing_user.status == UserStatus.DELETED.value:
logger.info('🔄 Восстанавливаем удаленного пользователя', from_user_id=message.from_user.id)
# Prevent self-referral when partner re-registers via own campaign link
safe_referrer_id = referrer_id if referrer_id != existing_user.id else None
if existing_user.balance_kopeks > 0:
logger.warning(
'⚠️ DELETED-восстановление: обнуляем ненулевой баланс',
telegram_id=existing_user.telegram_id,
balance_kopeks=existing_user.balance_kopeks,
)
existing_user.username = message.from_user.username
existing_user.first_name = message.from_user.first_name
existing_user.last_name = message.from_user.last_name
existing_user.language = language
existing_user.referred_by_id = safe_referrer_id
existing_user.status = UserStatus.ACTIVE.value
existing_user.balance_kopeks = 0
existing_user.has_had_paid_subscription = False
existing_user.updated_at = datetime.now(UTC)
existing_user.last_activity = datetime.now(UTC)
await db.commit()
await db.refresh(existing_user, ['subscriptions'])
user = existing_user
logger.info('✅ Пользователь восстановлен', from_user_id=message.from_user.id)
elif not existing_user:
# Check for phantom user created by guest purchase (gift by @username)
phantom = (
await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None
)
if phantom:
claimed, user = await claim_phantom(
db,
phantom,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referrer_id=referrer_id,
)
if not claimed and user:
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
# Merge phantom's data into the existing user via full account merge service.
if phantom.id != user.id:
try:
await db.refresh(phantom, ['subscriptions'])
await _merge_phantom_into_active_user(db, phantom, user)
await db.commit()
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom into existing user during registration',
phantom_id=phantom.id,
active_user_id=user.id,
)
await db.refresh(user, ['subscriptions'])
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
telegram_id=message.from_user.id,
phantom_user_id=phantom.id,
)
phantom = None
if not phantom:
logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
referral_code = await generate_unique_referral_code(db, message.from_user.id)
user = await create_user(
db=db,
telegram_id=message.from_user.id,
username=message.from_user.username,
first_name=message.from_user.first_name,
last_name=message.from_user.last_name,
language=language,
referred_by_id=referrer_id,
referral_code=referral_code,
)
await db.refresh(user, ['subscriptions'])
else:
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
existing_user.language = language
if referrer_id and referrer_id != existing_user.id and not existing_user.referred_by_id:
existing_user.referred_by_id = referrer_id
existing_user.updated_at = datetime.now(UTC)
existing_user.last_activity = datetime.now(UTC)
await db.commit()
await db.refresh(existing_user, ['subscriptions'])
user = existing_user
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, message.bot)
logger.info('✅ Реферальная регистрация обработана для', user_id=user.id)
except Exception as e:
logger.error('Ошибка при обработке реферальной регистрации', error=e)
# Активируем промокод если был сохранен в state
promocode_to_activate = data.get('promocode')
if promocode_to_activate:
try:
from app.handlers.promocode import activate_promocode_for_registration
promocode_result = await activate_promocode_for_registration(
db, user.id, promocode_to_activate, message.bot
)
if promocode_result['success']:
await message.answer(
texts.t('PROMOCODE_ACTIVATED_AT_REGISTRATION', '✅ Промокод активирован!\n\n{description}').format(
description=promocode_result['description']
)
)
logger.info(
'✅ Промокод активирован для пользователя',
promocode_to_activate=promocode_to_activate,
user_id=user.id,
)
else:
logger.warning(
'⚠️ Не удалось активировать промокод',
promocode_to_activate=promocode_to_activate,
error=promocode_result.get('error'),
)
except Exception as e:
logger.error('❌ Ошибка при активации промокода', promocode_to_activate=promocode_to_activate, error=e)
campaign_message = await _apply_campaign_bonus_if_needed(db, user, data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
'Ошибка обновления данных пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_error=refresh_error,
)
try:
await db.refresh(user, ['subscriptions'])
except Exception as refresh_subscription_error:
logger.error(
'Ошибка обновления подписки пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_subscription_error=refresh_subscription_error,
)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload после успешной регистрации
await delete_pending_payload_from_redis(message.from_user.id)
logger.info(
'🗑️ COMPLETE: Redis payload удален после успешной регистрации пользователя', telegram_id=user.telegram_id
)
# Auto-activate pending gift for newly registered user (before state.clear() wipes the token)
await _activate_pending_gift_after_registration(db, state, user, message.answer)
await state.clear()
if campaign_message:
try:
await message.answer(campaign_message)
except Exception as e:
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
pinned_message = await get_active_pinned_message(db)
if offer_text:
try:
# Если у пользователя уже есть подписка (например, от промокода), не предлагаем триал
_subs = getattr(user, 'subscriptions', None) or []
user_has_subscription = any(s.is_active for s in _subs)
if user_has_subscription:
keyboard = get_back_keyboard(user.language, callback_data='back_to_menu')
else:
keyboard = get_post_registration_keyboard(user.language)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
await message.answer(
offer_text,
reply_markup=keyboard,
parse_mode='HTML',
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
try:
await message.answer(
offer_text,
reply_markup=keyboard,
parse_mode=None,
)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
except Exception as fallback_err:
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
else:
logger.error('Ошибка при отправке приветственного сообщения', error=e)
except Exception as e:
logger.error('Ошибка при отправке приветственного сообщения', error=e)
else:
logger.info(
'ℹ️ Приветственные сообщения отключены, показываем главное меню для пользователя',
telegram_id=user.telegram_id,
)
user_subs_menu = getattr(user, 'subscriptions', None) or []
first_sub_menu = next((s for s in user_subs_menu if s.is_active), user_subs_menu[0] if user_subs_menu else None)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(first_sub_menu)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
custom_buttons = []
if not settings.is_text_main_menu_mode():
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=first_sub_menu,
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(message.bot, db, user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню', error=e)
await message.answer(
texts.t(
'WELCOME_FALLBACK',
'Добро пожаловать, {user_name}!',
).format(user_name=html.escape(user.full_name or ''))
)
logger.info('✅ Регистрация завершена для пользователя', telegram_id=user.telegram_id)
def _get_subscription_status(user, texts):
_subs = getattr(user, 'subscriptions', None) or [] if user else []
_first_sub = next((s for s in _subs if s.is_active), _subs[0] if _subs else None)
if not user or not _first_sub:
return texts.t('SUBSCRIPTION_NONE', 'Нет активной подписки')
subscription = _first_sub
actual_status = getattr(subscription, 'actual_status', None)
end_date = getattr(subscription, 'end_date', None)
end_date_display = format_local_datetime(end_date, '%d.%m.%Y') if end_date else None
current_time = datetime.now(UTC)
if actual_status == 'disabled':
return texts.t('SUB_STATUS_DISABLED', '⚫ Отключена')
if actual_status == 'limited':
return texts.t('SUB_STATUS_LIMITED', '⚠️ Трафик исчерпан')
if actual_status == 'pending':
return texts.t('SUB_STATUS_PENDING', '⏳ Ожидает активации')
if actual_status == 'expired' or (end_date and end_date <= current_time):
if end_date_display:
return texts.t(
'SUB_STATUS_EXPIRED',
'🔴 Истекла\n📅 {end_date}',
).format(end_date=end_date_display)
return texts.t('SUBSCRIPTION_STATUS_EXPIRED', '🔴 Истекла')
if not end_date:
return texts.t('SUBSCRIPTION_ACTIVE', '✅ Активна')
days_left = (end_date - current_time).days
is_trial = actual_status == 'trial' or getattr(subscription, 'is_trial', False)
if actual_status not in {'active', 'trial', None} and not is_trial:
return texts.t('SUBSCRIPTION_STATUS_UNKNOWN', '❓ Статус неизвестен')
if is_trial:
if days_left > 1 and end_date_display:
return texts.t(
'SUB_STATUS_TRIAL_ACTIVE',
'🎁 Тестовая подписка\n📅 до {end_date} ({days} дн.)',
).format(end_date=end_date_display, days=days_left)
if days_left == 1:
return texts.t(
'SUB_STATUS_TRIAL_TOMORROW',
'🎁 Тестовая подписка\n⚠️ истекает завтра!',
)
return texts.t(
'SUB_STATUS_TRIAL_TODAY',
'🎁 Тестовая подписка\n⚠️ истекает сегодня!',
)
if days_left > 7 and end_date_display:
return texts.t(
'SUB_STATUS_ACTIVE_LONG',
'💎 Активна\n📅 до {end_date} ({days} дн.)',
).format(end_date=end_date_display, days=days_left)
if days_left > 1:
return texts.t(
'SUB_STATUS_ACTIVE_FEW_DAYS',
'💎 Активна\n⚠️ истекает через {days} дн.',
).format(days=days_left)
if days_left == 1:
return texts.t(
'SUB_STATUS_ACTIVE_TOMORROW',
'💎 Активна\n⚠️ истекает завтра!',
)
return texts.t(
'SUB_STATUS_ACTIVE_TODAY',
'💎 Активна\n⚠️ истекает сегодня!',
)
def _get_subscription_status_simple(texts):
return texts.t('SUBSCRIPTION_NONE', 'Нет активной подписки')
def _insert_random_message(base_text: str, random_message: str, action_prompt: str) -> str:
if not random_message:
return base_text
prompt = action_prompt or ''
if prompt and prompt in base_text:
parts = base_text.split(prompt, 1)
if len(parts) == 2:
return f'{parts[0]}\n{random_message}\n\n{prompt}{parts[1]}'
return base_text.replace(prompt, f'\n{random_message}\n\n{prompt}', 1)
return f'{base_text}\n\n{random_message}'
def get_referral_code_keyboard(language: str):
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
texts = get_texts(language)
return InlineKeyboardMarkup(
inline_keyboard=[
[InlineKeyboardButton(text=texts.t('REFERRAL_CODE_SKIP', '⭐️ Пропустить'), callback_data='referral_skip')]
]
)
async def get_main_menu_text(user, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=html.escape(user.full_name or ''), subscription_status=_get_subscription_status(user, texts)
)
action_prompt = texts.t('MAIN_MENU_ACTION_PROMPT', 'Выберите действие:')
info_sections: list[str] = []
try:
promo_hint = await build_promo_offer_hint(db, user, texts)
if promo_hint:
info_sections.append(promo_hint.strip())
except Exception as hint_error:
logger.debug(
'Не удалось построить подсказку промо-предложения для пользователя',
getattr=getattr(user, 'id', None),
hint_error=hint_error,
)
try:
test_access_hint = await build_test_access_hint(db, user, texts)
if test_access_hint:
info_sections.append(test_access_hint.strip())
except Exception as test_error:
logger.debug(
'Не удалось построить подсказку тестового доступа для пользователя',
getattr=getattr(user, 'id', None),
test_error=test_error,
)
if info_sections:
extra_block = '\n\n'.join(section for section in info_sections if section)
if extra_block:
base_text = _insert_random_message(base_text, extra_block, action_prompt)
try:
random_message = await get_random_active_message(db)
if random_message:
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error('Ошибка получения случайного сообщения', error=e)
return base_text
async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
base_text = texts.MAIN_MENU.format(
user_name=html.escape(user_name or ''), subscription_status=_get_subscription_status_simple(texts)
)
action_prompt = texts.t('MAIN_MENU_ACTION_PROMPT', 'Выберите действие:')
try:
random_message = await get_random_active_message(db)
if random_message:
return _insert_random_message(base_text, random_message, action_prompt)
except Exception as e:
logger.error('Ошибка получения случайного сообщения', error=e)
return base_text
async def required_sub_channel_check(
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
):
from app.utils.message_patch import _cache_logo_file_id, caption_exceeds_telegram_limit, get_logo_media
language = DEFAULT_LANGUAGE
texts = get_texts(language)
try:
state_data = await state.get_data() or {}
# Получаем payload БЕЗ удаления - удалим только после успешной проверки подписки
pending_start_payload = state_data.get('pending_start_payload')
# Если в FSM state нет payload, пробуем получить из Redis (резервный механизм)
if not pending_start_payload:
redis_payload = await get_pending_payload_from_redis(query.from_user.id)
if redis_payload:
pending_start_payload = redis_payload
state_data['pending_start_payload'] = redis_payload
logger.info(
"📦 CHANNEL CHECK: Payload '' восстановлен из Redis (fallback)",
pending_start_payload=pending_start_payload,
)
if pending_start_payload:
logger.info("📦 CHANNEL CHECK: Найден сохраненный payload ''", pending_start_payload=pending_start_payload)
user = db_user
if not user:
user = await get_user_by_telegram_id(db, query.from_user.id)
if user and getattr(user, 'language', None):
language = user.language
elif state_data.get('language'):
language = state_data['language']
texts = get_texts(language)
# Ensure bot is set on service
if not channel_subscription_service.bot:
channel_subscription_service.bot = bot
# Invalidate cache for fresh check (user just clicked "I subscribed")
await channel_subscription_service.invalidate_user_cache(query.from_user.id)
is_subscribed = await channel_subscription_service.is_user_subscribed_to_all(query.from_user.id)
if not is_subscribed:
# НЕ удаляем payload - пользователь может попробовать снова после подписки
logger.info(
'CHANNEL CHECK: Подписка не подтверждена, payload сохранён для следующей попытки',
pending_start_payload=pending_start_payload,
)
return await query.answer(
texts.t('CHANNEL_SUBSCRIBE_REQUIRED_ALERT', 'Please subscribe to all required channels first!'),
show_alert=True,
)
# Подписка подтверждена - теперь удаляем payload и обрабатываем его
if pending_start_payload:
# Удаляем из FSM state
state_data.pop('pending_start_payload', None)
# Очищаем Redis после успешной проверки подписки
await delete_pending_payload_from_redis(query.from_user.id)
# Обрабатываем payload только если ещё не обработан
# (проверяем по наличию referral_code или campaign_id в state)
if not state_data.get('referral_code') and not state_data.get('campaign_id'):
campaign = await get_campaign_by_start_parameter(
db,
pending_start_payload,
only_active=True,
)
if campaign:
state_data['campaign_id'] = campaign.id
if campaign.partner_user_id:
state_data['referrer_id'] = campaign.partner_user_id
logger.info(
'📣 CHANNEL CHECK: Кампания восстановлена из payload',
campaign_id=campaign.id,
partner_user_id=campaign.partner_user_id,
)
# Mirror save in Redis so cabinet WebApp auth can pick it up
# if user opens miniapp before completing registration.
try:
await save_pending_campaign(
query.from_user.id,
campaign.start_parameter,
campaign.id,
)
except Exception as exc:
logger.warning(
'Failed to persist pending campaign after channel check',
campaign_id=campaign.id,
error=exc,
)
else:
state_data['referral_code'] = pending_start_payload
logger.info(
'🎯 CHANNEL CHECK: Payload интерпретирован как реферальный код',
pending_start_payload=pending_start_payload,
)
else:
logger.info(
'✅ CHANNEL CHECK: Реферальный код уже сохранен в state',
state_data=state_data.get('referral_code') or f'campaign_id={state_data.get("campaign_id")}',
)
await state.set_data(state_data)
_subs = getattr(user, 'subscriptions', None) or [] if user else []
_restored = False
for subscription in _subs:
if subscription.is_trial and subscription.status == SubscriptionStatus.DISABLED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = datetime.now(UTC)
_restored = True
if _restored:
await db.commit()
logger.info(
'✅ Триальная подписка пользователя восстановлена после подтверждения подписки на канал',
telegram_id=user.telegram_id,
)
try:
subscription_service = SubscriptionService()
for sub in _subs:
if sub.is_trial and sub.status == SubscriptionStatus.ACTIVE.value:
remnawave_uuid = getattr(sub, 'remnawave_uuid', None) or user.remnawave_uuid
if remnawave_uuid:
await subscription_service.update_remnawave_user(db, sub)
else:
await subscription_service.create_remnawave_user(db, sub)
except Exception as api_error:
logger.error(
'❌ Ошибка обновления RemnaWave при восстановлении подписки пользователя',
telegram_id=user.telegram_id if user else query.from_user.id,
api_error=api_error,
)
from app.services.remnawave_retry_queue import remnawave_retry_queue
for sub in _subs:
if sub.is_trial and sub.status == SubscriptionStatus.ACTIVE.value:
if hasattr(sub, 'id') and hasattr(sub, 'user_id'):
remnawave_retry_queue.enqueue(
subscription_id=sub.id,
user_id=sub.user_id,
action='update'
if (getattr(sub, 'remnawave_uuid', None) or user.remnawave_uuid)
else 'create',
)
await query.answer(
texts.t('CHANNEL_SUBSCRIBE_THANKS', '✅ Спасибо за подписку'),
show_alert=True,
)
try:
await query.message.delete()
except Exception as e:
logger.warning('Не удалось удалить сообщение', error=e)
# ИСПРАВЛЕНИЕ БАГА: Очищаем Redis payload ТОЛЬКО после успешной проверки подписки
# и перед показом главного меню или завершением регистрации
if pending_start_payload:
await delete_pending_payload_from_redis(query.from_user.id)
logger.info('🗑️ CHANNEL CHECK: Redis payload удален после успешной проверки подписки')
if user and user.status != UserStatus.DELETED.value:
# Uses primary subscription (multi-tariff compatible via property)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription, # Uses primary subscription (multi-tariff compatible via property)
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
pinned_message = await get_active_pinned_message(db)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
caption=menu_text,
reply_markup=keyboard,
parse_mode='HTML',
)
_cache_logo_file_id(_result)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=menu_text,
reply_markup=keyboard,
parse_mode='HTML',
)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
else:
from app.keyboards.inline import get_rules_keyboard
state_data['language'] = language
await state.set_data(state_data)
if settings.SKIP_RULES_ACCEPT:
if settings.SKIP_REFERRAL_CODE or state_data.get('referral_code') or state_data.get('referrer_id'):
from app.utils.user_utils import generate_unique_referral_code
# Проверяем реферальный код из ссылки или партнёра кампании
referrer_id = state_data.get('referrer_id')
if not referrer_id:
ref_code_from_link = state_data.get('referral_code')
if ref_code_from_link:
referrer = await get_user_by_referral_code(db, ref_code_from_link)
if referrer:
referrer_id = referrer.id
logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id)
# Check for phantom user created by guest purchase (gift by @username)
phantom = (
await find_phantom_user_by_username(db, query.from_user.username)
if query.from_user.username
else None
)
if phantom:
claimed, user = await claim_phantom(
db,
phantom,
telegram_id=query.from_user.id,
username=query.from_user.username,
first_name=query.from_user.first_name,
last_name=query.from_user.last_name,
language=language,
referrer_id=referrer_id,
)
if not claimed and user:
# Phantom claim failed (IntegrityError — user with this telegram_id already exists).
# Merge phantom's data into the existing user via full account merge service.
if phantom.id != user.id:
try:
await db.refresh(phantom, ['subscriptions'])
await _merge_phantom_into_active_user(db, phantom, user)
await db.commit()
except Exception:
await db.rollback()
logger.exception(
'Failed to merge phantom into existing user during registration',
phantom_id=phantom.id,
active_user_id=user.id,
)
await db.refresh(user, ['subscriptions'])
elif not claimed:
logger.critical(
'Phantom claim failed with no fallback user, proceeding to normal registration',
telegram_id=query.from_user.id,
phantom_user_id=phantom.id,
)
phantom = None
if not phantom:
referral_code = await generate_unique_referral_code(db, query.from_user.id)
user = await create_user(
db=db,
telegram_id=query.from_user.id,
username=query.from_user.username,
first_name=query.from_user.first_name,
last_name=query.from_user.last_name,
language=language,
referral_code=referral_code,
referred_by_id=referrer_id,
)
await db.refresh(user, ['subscriptions'])
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
state_data.pop('pending_start_payload', None)
await state.set_data(state_data)
logger.info('✅ CHANNEL CHECK: pending_start_payload удален из state после создания пользователя')
# Обрабатываем реферальную регистрацию
if referrer_id and referrer_id != user.id:
try:
await process_referral_registration(db, user.id, referrer_id, bot)
logger.info('✅ CHANNEL CHECK: Реферальная регистрация обработана для', user_id=user.id)
except Exception as e:
logger.error('Ошибка при обработке реферальной регистрации', error=e)
# Применяем бонус рекламной кампании (record_campaign_registration)
campaign_message = await _apply_campaign_bonus_if_needed(db, user, state_data, texts)
try:
await db.refresh(user)
except Exception as refresh_error:
logger.error(
'Ошибка обновления данных пользователя после бонуса кампании',
telegram_id=user.telegram_id,
refresh_error=refresh_error,
)
try:
await db.refresh(user, ['subscriptions'])
except Exception as refresh_sub_error:
logger.error(
'Ошибка обновления подписки после бонуса кампании',
telegram_id=user.telegram_id,
refresh_sub_error=refresh_sub_error,
)
if campaign_message:
try:
await bot.send_message(
chat_id=query.from_user.id,
text=campaign_message,
)
except Exception as e:
logger.error('Ошибка отправки сообщения о бонусе кампании', error=e)
# Показываем главное меню после создания пользователя
# Uses primary subscription (multi-tariff compatible via property)
has_active_subscription, subscription_is_active = _calculate_subscription_flags(user.subscription)
menu_text = await get_main_menu_text(user, texts, db)
is_admin = settings.is_admin(user.telegram_id)
is_moderator = (not is_admin) and SupportSettingsService.is_moderator(user.telegram_id)
custom_buttons = await MainMenuButtonService.get_buttons_for_user(
db,
is_admin=is_admin,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
)
keyboard = await get_main_menu_keyboard_async(
db=db,
user=user,
language=user.language,
is_admin=is_admin,
has_had_paid_subscription=user.has_had_paid_subscription,
has_active_subscription=has_active_subscription,
subscription_is_active=subscription_is_active,
balance_kopeks=user.balance_kopeks,
subscription=user.subscription, # Uses primary subscription (multi-tariff compatible via property)
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
pinned_message = await get_active_pinned_message(db)
if pinned_message and pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
caption=menu_text,
reply_markup=keyboard,
parse_mode='HTML',
)
_cache_logo_file_id(_result)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=menu_text,
reply_markup=keyboard,
parse_mode='HTML',
)
if pinned_message and not pinned_message.send_before_menu:
await _send_pinned_message(bot, db, user, pinned_message)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=texts.t(
'REFERRAL_CODE_QUESTION',
"У вас есть реферальный код? Введите его или нажмите 'Пропустить'",
),
reply_markup=get_referral_code_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_referral_code)
else:
rules_text = await get_rules(language)
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(rules_text):
_result = await bot.send_photo(
chat_id=query.from_user.id,
photo=get_logo_media(),
caption=rules_text,
reply_markup=get_rules_keyboard(language),
)
_cache_logo_file_id(_result)
else:
await bot.send_message(
chat_id=query.from_user.id,
text=rules_text,
reply_markup=get_rules_keyboard(language),
)
await state.set_state(RegistrationStates.waiting_for_rules_accept)
except TelegramBadRequest as e:
error_msg = str(e).lower()
if 'query is too old' in error_msg or 'query id is invalid' in error_msg:
logger.debug('Устаревший callback в required_sub_channel_check, игнорируем')
else:
logger.error('Ошибка Telegram API в required_sub_channel_check', error=e)
try:
await query.answer(f'{texts.ERROR}!', show_alert=True)
except Exception:
pass
except Exception as e:
logger.error('Ошибка в required_sub_channel_check', error=e)
try:
await query.answer(f'{texts.ERROR}!', show_alert=True)
except Exception:
pass
async def process_webauth_confirm(
callback: types.CallbackQuery,
db: AsyncSession,
):
"""Handle web auth confirmation or denial."""
await callback.answer()
if not isinstance(callback.message, types.Message):
return
if callback.data == 'webauth_deny':
await callback.message.edit_text('❌ Вход отменён.')
return
# Extract token from callback_data: "webauth_confirm:{token}"
token = callback.data.split(':', 1)[1] if ':' in callback.data else ''
if len(token) < WEB_AUTH_TOKEN_MIN_LENGTH:
await callback.message.edit_text('❌ Ошибка: неверный токен.')
return
user = await get_user_by_telegram_id(db, callback.from_user.id)
if not user or user.status != UserStatus.ACTIVE.value:
await callback.message.edit_text('❌ Учётная запись неактивна.')
return
linked = await link_web_auth_token(token, callback.from_user.id, user.id)
texts = get_texts(user.language)
if linked:
await callback.message.edit_text(
texts.t('WEB_AUTH_SUCCESS', '✅ Авторизация в кабинете подтверждена! Вернитесь в браузер.'),
)
else:
await callback.message.edit_text(
texts.t('WEB_AUTH_EXPIRED', '❌ Ссылка для входа истекла. Попробуйте снова.'),
)
def register_handlers(dp: Dispatcher):
logger.debug('=== НАЧАЛО регистрации обработчиков start.py ===')
dp.message.register(cmd_start, Command('start'))
logger.debug('Зарегистрирован cmd_start')
dp.callback_query.register(
process_rules_accept,
F.data.in_(['rules_accept', 'rules_decline']),
StateFilter(RegistrationStates.waiting_for_rules_accept),
)
logger.debug('Зарегистрирован process_rules_accept')
dp.callback_query.register(
process_privacy_policy_accept,
F.data.in_(['privacy_policy_accept', 'privacy_policy_decline']),
StateFilter(RegistrationStates.waiting_for_privacy_policy_accept),
)
logger.debug('Зарегистрирован process_privacy_policy_accept')
dp.callback_query.register(
process_language_selection,
F.data.startswith('language_select:'),
StateFilter(RegistrationStates.waiting_for_language),
)
logger.debug('Зарегистрирован process_language_selection')
dp.callback_query.register(
process_referral_code_skip, F.data == 'referral_skip', StateFilter(RegistrationStates.waiting_for_referral_code)
)
logger.debug('Зарегистрирован process_referral_code_skip')
dp.message.register(process_referral_code_input, StateFilter(RegistrationStates.waiting_for_referral_code))
logger.debug('Зарегистрирован process_referral_code_input')
dp.message.register(
handle_potential_referral_code,
StateFilter(RegistrationStates.waiting_for_rules_accept, RegistrationStates.waiting_for_referral_code),
)
logger.debug('Зарегистрирован handle_potential_referral_code')
dp.callback_query.register(required_sub_channel_check, F.data.in_(['sub_channel_check']))
logger.debug('Зарегистрирован required_sub_channel_check')
dp.callback_query.register(
process_webauth_confirm,
F.data.startswith('webauth_confirm:') | F.data.in_(['webauth_deny']),
)
logger.debug('Зарегистрирован process_webauth_confirm')
logger.debug('=== КОНЕЦ регистрации обработчиков start.py ===')