2891 lines
130 KiB
Python
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 ===')
|