# ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 from .common import _invalidate_webapp_user_caches def _resolve_telegram_bot_id(bot_token: str) -> Optional[int]: token_prefix = str(bot_token or "").strip().split(":", 1)[0] if not token_prefix.isdigit(): return None try: return int(token_prefix) except ValueError: return None def _resolve_telegram_oauth_client_id(settings: Settings) -> Optional[int]: configured_client_id = getattr(settings, "TELEGRAM_OAUTH_CLIENT_ID", None) if configured_client_id: try: return int(configured_client_id) except (TypeError, ValueError): return None return _resolve_telegram_bot_id(settings.BOT_TOKEN) def _resolve_telegram_oauth_request_access(settings: Settings) -> List[str]: raw_value = str(getattr(settings, "TELEGRAM_OAUTH_REQUEST_ACCESS", "") or "") allowed = {"write", "phone"} scopes = [] for item in raw_value.split(","): value = item.strip().lower() if value in allowed and value not in scopes: scopes.append(value) return scopes def _public_webapp_base_url(settings: Settings, request: web.Request) -> str: configured_url = str(settings.SUBSCRIPTION_MINI_APP_URL or "").strip() if configured_url: parsed_url = urlsplit(configured_url) if parsed_url.scheme and parsed_url.netloc: return f"{parsed_url.scheme}://{parsed_url.netloc}" headers = request.headers if _request_remote_is_trusted_proxy(settings, request): scheme = _first_header_value(headers.get("X-Forwarded-Proto")) or request.scheme host = ( _first_header_value(headers.get("X-Forwarded-Host")) or headers.get("Host") or request.host ) else: scheme = request.scheme host = headers.get("Host") or request.host return f"{scheme}://{host}".rstrip("/") def _first_header_value(value: Optional[str]) -> str: if not value: return "" return value.split(",", 1)[0].strip() def _request_remote_is_trusted_proxy(settings: Settings, request: web.Request) -> bool: try: remote_ip = ipaddress.ip_address(str(request.remote or "").strip()) except ValueError: return False return any(remote_ip in network for network in parse_ip_entries(settings.trusted_proxies)) def _telegram_oauth_callback_url(settings: Settings, request: web.Request) -> str: return f"{_public_webapp_base_url(settings, request)}/auth/telegram/callback" def _telegram_oauth_redirect_url(path: str = "/", *, status: Optional[str] = None) -> str: target_path = path if path.startswith("/") else "/" if target_path not in {"/", "/settings"}: target_path = "/" if not status: return target_path separator = "&" if "?" in target_path else "?" return f"{target_path}{separator}telegram_auth={status}" def _set_telegram_oauth_state_cookie( response: web.StreamResponse, settings: Settings, payload: Dict[str, Any], ) -> None: max_age = max(60, int(settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS)) response.set_cookie( WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME, create_signed_telegram_oauth_state(settings, payload, ttl_seconds=max_age), httponly=True, secure=True, samesite="Lax", path="/auth/telegram", max_age=max_age, ) def _clear_telegram_oauth_state_cookie(response: web.StreamResponse) -> None: response.set_cookie( WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME, "", httponly=True, secure=True, samesite="Lax", path="/auth/telegram", max_age=0, ) def _read_telegram_oauth_state_payload( request: web.Request, state_token: str, ) -> Optional[Dict[str, Any]]: settings: Settings = request.app["settings"] signed_payload = request.cookies.get(WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME, "") payload = verify_signed_telegram_oauth_state(settings, signed_payload) if not payload: return None expected_state = str(payload.get("state") or "") if not expected_state or not hmac.compare_digest(expected_state, state_token): return None return payload def _urlsafe_sha256(value: str) -> str: digest = hashlib.sha256(value.encode("ascii")).digest() return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=") PASSWORD_HASH_ALGORITHM = "pbkdf2_sha256" PASSWORD_HASH_ITERATIONS = 260_000 def _password_hash_b64(value: bytes) -> str: return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=") def _password_hash_unb64(value: str) -> bytes: padding = "=" * (-len(value) % 4) return base64.urlsafe_b64decode((value + padding).encode("ascii")) def _hash_email_password(password: str) -> str: salt = secrets.token_bytes(18) digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, PASSWORD_HASH_ITERATIONS, ) return "$".join( [ PASSWORD_HASH_ALGORITHM, str(PASSWORD_HASH_ITERATIONS), _password_hash_b64(salt), _password_hash_b64(digest), ] ) def _verify_email_password(password: str, stored_hash: Optional[str]) -> bool: if not stored_hash: return False try: algorithm, iterations_raw, salt_raw, digest_raw = stored_hash.split("$", 3) if algorithm != PASSWORD_HASH_ALGORITHM: return False iterations = int(iterations_raw) salt = _password_hash_unb64(salt_raw) expected_digest = _password_hash_unb64(digest_raw) actual_digest = hashlib.pbkdf2_hmac( "sha256", password.encode("utf-8"), salt, iterations, ) except Exception: return False return hmac.compare_digest(actual_digest, expected_digest) async def _exchange_telegram_oauth_code( request: web.Request, *, code: str, code_verifier: str, redirect_uri: str, ) -> Optional[Dict[str, Any]]: settings: Settings = request.app["settings"] client_id = _resolve_telegram_oauth_client_id(settings) client_secret = str(getattr(settings, "TELEGRAM_OAUTH_CLIENT_SECRET", "") or "").strip() if not client_id or not client_secret or not code or not code_verifier: return None credentials = base64.b64encode(f"{client_id}:{client_secret}".encode("utf-8")).decode("ascii") session = await _get_shared_http_session() try: async with session.post( "https://oauth.telegram.org/token", data={ "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": str(client_id), "code_verifier": code_verifier, }, headers={ "Authorization": f"Basic {credentials}", "Content-Type": "application/x-www-form-urlencoded", }, timeout=ClientTimeout(total=15), ) as response: payload = await response.json(content_type=None) if response.status >= 400: logger.warning( "Telegram OAuth token exchange failed with HTTP %s: %s", response.status, payload, ) return None return payload if isinstance(payload, dict) else None except Exception as exc: logger.warning("Telegram OAuth token exchange failed: %s", exc) return None async def telegram_oauth_nonce_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] client_id = _resolve_telegram_oauth_client_id(settings) if not client_id: return _json_error(400, "telegram_oauth_not_configured", "Telegram OAuth is not configured") nonce = create_telegram_oauth_nonce( settings, ttl_seconds=settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS, ) return web.json_response( { "ok": True, "nonce": nonce, "client_id": client_id, "request_access": _resolve_telegram_oauth_request_access(settings), } ) async def telegram_oauth_start_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] client_id = _resolve_telegram_oauth_client_id(settings) client_secret = str(getattr(settings, "TELEGRAM_OAUTH_CLIENT_SECRET", "") or "").strip() if not client_id or not client_secret: raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="not_configured")) purpose = str(request.query.get("purpose") or "login").strip().lower() if purpose not in {"login", "link"}: purpose = "login" current_user_id = _extract_authenticated_user_id(request) if purpose == "link" and not current_user_id: raise web.HTTPFound(_telegram_oauth_redirect_url("/", status="unauthorized")) code_verifier = secrets.token_urlsafe(32) code_challenge = _urlsafe_sha256(code_verifier) nonce = secrets.token_urlsafe(16) state = secrets.token_urlsafe(16) state_payload = { "state": state, "purpose": purpose, "user_id": int(current_user_id) if current_user_id else None, "referral_code": str(request.query.get("referral_code") or "")[:128], "code_verifier": code_verifier, "nonce": nonce, } scopes = ["openid", "profile"] for permission in _resolve_telegram_oauth_request_access(settings): if permission == "phone": scopes.append("phone") elif permission == "write": scopes.append("telegram:bot_access") auth_query = urlencode( { "client_id": str(client_id), "redirect_uri": _telegram_oauth_callback_url(settings, request), "response_type": "code", "scope": " ".join(scopes), "state": state, "nonce": nonce, "code_challenge": code_challenge, "code_challenge_method": "S256", } ) response = web.HTTPFound(f"https://oauth.telegram.org/auth?{auth_query}") _set_telegram_oauth_state_cookie(response, settings, state_payload) raise response async def telegram_oauth_callback_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] def redirect(path: str = "/", status: Optional[str] = None) -> web.HTTPFound: response = web.HTTPFound(_telegram_oauth_redirect_url(path, status=status)) _clear_telegram_oauth_state_cookie(response) return response error = str(request.query.get("error") or "") if error: raise redirect("/", "cancelled") code = str(request.query.get("code") or "") state = _read_telegram_oauth_state_payload(request, str(request.query.get("state") or "")) if not code or not state: raise redirect("/", "invalid_state") token_payload = await _exchange_telegram_oauth_code( request, code=code, code_verifier=str(state.get("code_verifier") or ""), redirect_uri=_telegram_oauth_callback_url(settings, request), ) id_token = str(token_payload.get("id_token") or "") if token_payload else "" telegram_user = await validate_telegram_oauth_id_token( id_token, client_id=int(_resolve_telegram_oauth_client_id(settings) or 0), expected_nonce=str(state.get("nonce") or ""), max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, ) if not telegram_user: raise redirect("/", "invalid_token") purpose = str(state.get("purpose") or "login") redirect_path = "/settings" if purpose == "link" else "/" async_session_factory: sessionmaker = request.app["async_session_factory"] final_user_id: Optional[int] = None async with async_session_factory() as session: try: if purpose == "link": current_user_id = int(state.get("user_id") or 0) db_user = await _link_telegram_to_user( request, session, current_user_id=current_user_id, telegram_user=telegram_user, settings=settings, ) else: db_user = await _ensure_user_from_telegram( session, telegram_user, settings, referral_param=str(state.get("referral_code") or ""), ) referral_applied = await _apply_referral_to_existing_user( request, session, db_user, str(state.get("referral_code") or "") or telegram_user.get("start_param"), ) if getattr(db_user, "_webapp_created", False) or referral_applied: await _apply_referral_welcome_bonus_if_needed( request, session, db_user, str(state.get("referral_code") or "") or telegram_user.get("start_param"), ) if db_user.is_banned: await session.rollback() raise redirect("/", "banned") final_user_id = int(db_user.user_id) await session.commit() except web.HTTPFound: raise except UserMergeConflictError: await session.rollback() raise redirect(redirect_path, "merge_conflict") except Exception: await session.rollback() logger.exception("Telegram OAuth callback failed") raise redirect(redirect_path, "failed") await _invalidate_webapp_user_caches(settings, final_user_id, include_devices=True) token = create_webapp_session_token(settings, int(final_user_id)) response = web.HTTPFound(_telegram_oauth_redirect_url(redirect_path, status="success")) _clear_telegram_oauth_state_cookie(response) _set_webapp_auth_cookies(response, settings, token, secrets.token_hex(32)) raise response async def _validate_telegram_auth_payload( request: web.Request, payload: Dict[str, Any], ) -> Optional[Dict[str, Any]]: settings: Settings = request.app["settings"] init_data = str(payload.get("init_data") or "") if init_data: return validate_telegram_webapp_init_data( init_data, settings.BOT_TOKEN, max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, ) oauth_id_token = str(payload.get("id_token") or "") if oauth_id_token: nonce = str(payload.get("nonce") or "") client_id = _resolve_telegram_oauth_client_id(settings) if not client_id or not verify_telegram_oauth_nonce(settings, nonce): return None return await validate_telegram_oauth_id_token( oauth_id_token, client_id=client_id, expected_nonce=nonce, max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, ) auth_data = payload.get("auth_data") if auth_data is not None: return validate_telegram_login_widget_data( auth_data, settings.BOT_TOKEN, max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS, ) return None async def auth_token_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) referral_param = str(payload.get("referral_code") or payload.get("start_param") or "") telegram_user = await _validate_telegram_auth_payload(request, payload) if not telegram_user: return _json_error(401, "invalid_auth", "Invalid Telegram auth data") rate_limit_response = await _enforce_webapp_rate_limit( request, user_id=int(telegram_user.get("id") or 0), action="auth_token", ) if rate_limit_response: return rate_limit_response async_session_factory: sessionmaker = request.app["async_session_factory"] authenticated_user_id: Optional[int] = None async with async_session_factory() as session: try: db_user = await _ensure_user_from_telegram( session, telegram_user, settings, referral_param=referral_param, ) if db_user.is_banned: await session.rollback() return _json_error(403, "banned", "Access denied") referral_applied = await _apply_referral_to_existing_user( request, session, db_user, referral_param or telegram_user.get("start_param"), ) if getattr(db_user, "_webapp_created", False) or referral_applied: await _apply_referral_welcome_bonus_if_needed( request, session, db_user, referral_param or telegram_user.get("start_param"), ) authenticated_user_id = int(db_user.user_id) await session.commit() except Exception: await session.rollback() logger.exception("WebApp auth failed") return _json_error(500, "auth_failed", "Auth failed") await _invalidate_webapp_user_caches(settings, authenticated_user_id, include_devices=True) token = create_webapp_session_token(settings, int(authenticated_user_id)) return _build_webapp_auth_response(settings, {"ok": True}, token=token) async def logout_route(request: web.Request) -> web.Response: response = web.json_response({"ok": True}) _clear_webapp_auth_cookies(response) return response def _password_login_failure_response( *, status: int = 401, retry_after: Optional[int] = None, ) -> web.Response: payload: Dict[str, Any] = { "ok": False, "error": "password_login_failed", "fallback": "email_code", "message": "Password login failed", } if retry_after is not None: payload["retry_after"] = retry_after return web.json_response(payload, status=status) async def email_password_auth_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] if not settings.email_auth_configured: return _json_error(503, "email_auth_not_configured", "Email auth is not configured") payload = await _read_json(request) password_payload, validation_error = _validate_model_payload( WebAppEmailPasswordPayload, payload, ) if validation_error: return validation_error email = password_payload.email password = str(password_payload.password or "") now = datetime.now(timezone.utc) async_session_factory: sessionmaker = request.app["async_session_factory"] authenticated_user_id: Optional[int] = None authenticated_telegram_id: Optional[int] = None async with async_session_factory() as session: try: throttle = await security_dal.check_throttle( session, scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE, identifier=email, now=now, ) if throttle.locked: await session.commit() return _json_error( 429, "rate_limited", "Too many password attempts", ) db_user = await user_dal.get_user_by_email(session, email) password_ok = bool( db_user and db_user.email_verified_at and db_user.password_hash and _verify_email_password(password, db_user.password_hash) ) if not password_ok: throttle_result = await security_dal.record_throttle_failure( session, scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE, identifier=email, max_failures=settings.BRUTE_FORCE_MAX_FAILURES, window_seconds=settings.BRUTE_FORCE_WINDOW_SECONDS, lock_seconds=settings.BRUTE_FORCE_LOCK_SECONDS, now=now, ) await session.commit() if throttle_result.locked: return _json_error( 429, "rate_limited", "Too many password attempts", ) return _password_login_failure_response() if db_user.is_banned: await session.rollback() return _json_error(403, "banned", "Access denied") await security_dal.clear_throttle_state( session, scope=security_dal.EMAIL_PASSWORD_LOGIN_SCOPE, identifier=email, ) authenticated_user_id = int(db_user.user_id) authenticated_telegram_id = _telegram_id_for_user(db_user) await session.commit() except Exception: await session.rollback() logger.exception("Email password auth failed") return _json_error(500, "auth_failed", "Auth failed") token = create_webapp_session_token(settings, int(authenticated_user_id)) return _build_webapp_auth_response( settings, { "ok": True, "user_id": int(authenticated_user_id), "telegram_id": authenticated_telegram_id, }, token=token, ) async def email_auth_request_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) email_payload, validation_error = _validate_model_payload(WebAppEmailPayload, payload) if validation_error: return validation_error email = email_payload.email lang = _normalize_language(str(payload.get("language") or settings.DEFAULT_LANGUAGE)) return await _request_email_code( request, email=email, purpose="login", language_code=lang, target_user_id=None, ) async def email_auth_verify_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) email_payload, validation_error = _validate_model_payload(WebAppEmailCodePayload, payload) if validation_error: return validation_error email = email_payload.email code = str(email_payload.code or "") referral_param = str(payload.get("referral_code") or payload.get("start_param") or "") email_service: EmailAuthService = request.app["email_auth_service"] async_session_factory: sessionmaker = request.app["async_session_factory"] created_user = False new_user_referrer_id: Optional[int] = None async with async_session_factory() as session: try: verify_result = await email_service.verify_code( session, email=email, purpose="login", code=code, target_user_id=None, ) if not verify_result.ok: await session.commit() status = 429 if verify_result.error == "rate_limited" else 400 return web.json_response( { "ok": False, "error": verify_result.error or "invalid_code", "retry_after": verify_result.retry_after, "message": "Invalid code", }, status=status, ) db_user = await user_dal.get_user_by_email(session, email) if not db_user: referred_by_id = await _resolve_referrer_id( session, referral_param, current_user_id=None, ) db_user, _ = await user_dal.create_email_user( session, email=email, language_code=_normalize_language(settings.DEFAULT_LANGUAGE), email_verified_at=datetime.now(timezone.utc), referred_by_id=referred_by_id, ) created_user = True new_user_referrer_id = referred_by_id elif not db_user.email_verified_at: db_user.email_verified_at = datetime.now(timezone.utc) referral_applied = await _apply_referral_to_existing_user( request, session, db_user, referral_param, ) if created_user or referral_applied: await _apply_referral_welcome_bonus_if_needed( request, session, db_user, referral_param, ) if db_user.is_banned: await session.rollback() return _json_error(403, "banned", "Access denied") await session.commit() except Exception: await session.rollback() logger.exception("Email WebApp auth failed") return _json_error(500, "auth_failed", "Auth failed") await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True) if created_user: try: from bot.services.notification_service import NotificationService bot: Bot = request.app["bot"] notification_service = NotificationService( bot, settings, request.app.get("i18n"), ) await notification_service.notify_new_email_user_registration( user_id=int(db_user.user_id), email=email, referred_by_id=new_user_referrer_id, ) except Exception: logger.exception("Failed to send new email user notification") token = create_webapp_session_token(settings, int(db_user.user_id)) return _build_webapp_auth_response( settings, { "ok": True, "user_id": int(db_user.user_id), "telegram_id": _telegram_id_for_user(db_user), }, token=token, ) async def email_auth_magic_route(request: web.Request) -> web.Response: settings: Settings = request.app["settings"] payload = await _read_json(request) magic_payload, validation_error = _validate_model_payload(WebAppEmailMagicPayload, payload) if validation_error: return validation_error token_value = str(magic_payload.token).strip() referral_param = str(payload.get("referral_code") or payload.get("start_param") or "") email_service: EmailAuthService = request.app["email_auth_service"] async_session_factory: sessionmaker = request.app["async_session_factory"] created_user = False new_user_referrer_id: Optional[int] = None verified_email: Optional[str] = None async with async_session_factory() as session: try: magic_result = await email_service.verify_magic_token( session, token=token_value, purpose="login", target_user_id=None, ) if not magic_result.ok: await session.commit() return web.json_response( { "ok": False, "error": magic_result.error or "invalid_token", "message": "Invalid login link", }, status=400, ) verified_email = magic_result.email or "" db_user = await user_dal.get_user_by_email(session, verified_email) if not db_user: referred_by_id = await _resolve_referrer_id( session, referral_param, current_user_id=None, ) db_user, _ = await user_dal.create_email_user( session, email=verified_email, language_code=_normalize_language(settings.DEFAULT_LANGUAGE), email_verified_at=datetime.now(timezone.utc), referred_by_id=referred_by_id, ) created_user = True new_user_referrer_id = referred_by_id elif not db_user.email_verified_at: db_user.email_verified_at = datetime.now(timezone.utc) referral_applied = await _apply_referral_to_existing_user( request, session, db_user, referral_param, ) if created_user or referral_applied: await _apply_referral_welcome_bonus_if_needed( request, session, db_user, referral_param, ) if db_user.is_banned: await session.rollback() return _json_error(403, "banned", "Access denied") await session.commit() except Exception: await session.rollback() logger.exception("Email magic-link auth failed") return _json_error(500, "auth_failed", "Auth failed") await _invalidate_webapp_user_caches(settings, int(db_user.user_id), include_devices=True) if created_user and verified_email: try: from bot.services.notification_service import NotificationService bot: Bot = request.app["bot"] notification_service = NotificationService( bot, settings, request.app.get("i18n"), ) await notification_service.notify_new_email_user_registration( user_id=int(db_user.user_id), email=verified_email, referred_by_id=new_user_referrer_id, ) except Exception: logger.exception("Failed to send new email user notification") session_token = create_webapp_session_token(settings, int(db_user.user_id)) return _build_webapp_auth_response( settings, { "ok": True, "user_id": int(db_user.user_id), "telegram_id": _telegram_id_for_user(db_user), }, token=session_token, ) def _set_webapp_auth_cookies( response: web.StreamResponse, settings: Settings, session_token: str, csrf_token: str, ) -> None: max_age = max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)) response.set_cookie( WEBAPP_SESSION_COOKIE_NAME, session_token, httponly=True, secure=True, samesite="None", path="/", max_age=max_age, ) response.set_cookie( WEBAPP_CSRF_COOKIE_NAME, csrf_token, httponly=False, secure=True, samesite="None", path="/", max_age=max_age, ) def _clear_webapp_auth_cookies(response: web.StreamResponse) -> None: response.set_cookie( WEBAPP_SESSION_COOKIE_NAME, "", httponly=True, secure=True, samesite="None", path="/", max_age=0, ) response.set_cookie( WEBAPP_CSRF_COOKIE_NAME, "", httponly=False, secure=True, samesite="None", path="/", max_age=0, ) def _build_webapp_auth_response( settings: Settings, payload: Dict[str, Any], *, token: str, csrf_token: Optional[str] = None, ) -> web.Response: response_payload = dict(payload) response_payload["ok"] = True csrf_value = csrf_token or secrets.token_hex(32) response_payload["csrf_token"] = csrf_value response = web.json_response(response_payload) _set_webapp_auth_cookies(response, settings, token, csrf_value) return response def _extract_authenticated_user_id(request: web.Request) -> Optional[int]: from bot.app.web.session import extract_authenticated_user_id return extract_authenticated_user_id(request) def _require_user_id(request: web.Request) -> int: user_id = _extract_authenticated_user_id(request) if not user_id: raise web.HTTPUnauthorized( text=json.dumps({"ok": False, "error": "unauthorized"}), content_type="application/json", ) return user_id async def _request_email_code( request: web.Request, *, email: str, purpose: str, language_code: str, target_user_id: Optional[int], ) -> web.Response: email_service: EmailAuthService = request.app["email_auth_service"] async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: try: result = await email_service.request_code( session, email=email, purpose=purpose, language_code=language_code, target_user_id=target_user_id, ) if not result.ok: await session.rollback() status = 429 if result.error == "rate_limited" else 400 if result.error == "email_auth_not_configured": status = 503 return web.json_response( { "ok": False, "error": result.error, "retry_after": result.retry_after, }, status=status, ) await session.commit() return web.json_response({"ok": True}) except Exception: await session.rollback() logger.exception("Failed to send email verification code") return _json_error(502, "email_send_failed", "Failed to send email") def _telegram_id_for_user(user: User) -> Optional[int]: if user.telegram_id: return int(user.telegram_id) if user.user_id and int(user.user_id) > 0: return int(user.user_id) return None def _panel_description_for_user(user: User) -> str: lines = [ user.email or "", user.username or "", user.first_name or "", user.last_name or "", ] return "\n".join(line for line in lines if line).strip() async def _sync_panel_identity_for_user( request: web.Request, user: User, *, expire_at: Optional[datetime] = None, ) -> bool: if not user.panel_user_uuid: return False subscription_service: SubscriptionService = request.app.get("subscription_service") if not subscription_service or not subscription_service.panel_service: return False payload: Dict[str, Any] = { "description": _panel_description_for_user(user), } telegram_id = _telegram_id_for_user(user) if telegram_id: payload["telegramId"] = telegram_id if user.email: payload["email"] = user.email if expire_at is not None: payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace("+00:00", "Z") try: await subscription_service.panel_service.update_user_details_on_panel( user.panel_user_uuid, payload, log_response=False, ) return True except Exception as exc: logger.warning( "Failed to sync linked identities to panel for user %s: %s", user.user_id, exc, ) return False async def _build_account_merge_notice( session: AsyncSession, *, merged_user: User, source_user_id: int, source_panel_uuid: Optional[str], settings: Settings, ) -> Dict[str, Any]: merged_subscription = None if merged_user.panel_user_uuid: merged_subscription = await subscription_dal.get_active_subscription_by_user_id( session, merged_user.user_id, merged_user.panel_user_uuid, ) if not merged_subscription: merged_subscription = await subscription_dal.get_active_subscription_by_user_id( session, merged_user.user_id, ) final_end_date = merged_subscription.end_date if merged_subscription else None if final_end_date and final_end_date.tzinfo is None: final_end_date = final_end_date.replace(tzinfo=timezone.utc) return { "merged": True, "language": _normalize_language(merged_user.language_code or settings.DEFAULT_LANGUAGE), "primary_user_id": int(merged_user.user_id), "removed_user_id": int(source_user_id), "primary_panel_user_uuid": merged_user.panel_user_uuid, "removed_panel_user_uuid": source_panel_uuid, "final_end_date": final_end_date.isoformat() if final_end_date else None, "final_end_date_text": _format_webapp_datetime(final_end_date), } def _apply_telegram_profile_to_user( user: User, telegram_user: Dict[str, Any], settings: Settings, ) -> None: language_code = ( telegram_user.get("language_code") or user.language_code or settings.DEFAULT_LANGUAGE ) if language_code not in {"ru", "en"}: language_code = user.language_code or settings.DEFAULT_LANGUAGE user.telegram_id = int(telegram_user["id"]) user.username = sanitize_username(telegram_user.get("username")) user.first_name = sanitize_display_name(telegram_user.get("first_name")) user.last_name = sanitize_display_name(telegram_user.get("last_name")) user.language_code = language_code telegram_photo_url = _telegram_photo_url_value(telegram_user) if telegram_photo_url: user.telegram_photo_url = telegram_photo_url async def _link_telegram_to_user( request: web.Request, session: AsyncSession, *, current_user_id: int, telegram_user: Dict[str, Any], settings: Settings, ) -> User: telegram_id = int(telegram_user["id"]) current_user = await user_dal.get_user_by_id(session, current_user_id) if not current_user: raise ValueError("Current user not found.") existing_telegram_user = await user_dal.get_user_by_telegram_id(session, telegram_id) if not existing_telegram_user: existing_telegram_user = await user_dal.get_user_by_id(session, telegram_id) if existing_telegram_user and existing_telegram_user.user_id != current_user.user_id: if ( current_user.email and existing_telegram_user.email and current_user.email != existing_telegram_user.email ): raise UserMergeConflictError("Telegram account is already linked to a different email.") merged_user = await user_dal.merge_users( session, source_user_id=current_user.user_id, target_user_id=existing_telegram_user.user_id, ) _apply_telegram_profile_to_user(merged_user, telegram_user, settings) await session.flush() await _sync_panel_identity_for_user(request, merged_user) return merged_user if not existing_telegram_user and int(current_user.user_id) < 0: language_code = ( telegram_user.get("language_code") or current_user.language_code or settings.DEFAULT_LANGUAGE ) if language_code not in {"ru", "en"}: language_code = current_user.language_code or settings.DEFAULT_LANGUAGE target_user, _ = await user_dal.create_user( session, { "user_id": telegram_id, "telegram_id": telegram_id, "username": sanitize_username(telegram_user.get("username")), "first_name": sanitize_display_name(telegram_user.get("first_name")), "last_name": sanitize_display_name(telegram_user.get("last_name")), "language_code": language_code, "registration_date": current_user.registration_date or datetime.now(timezone.utc), }, ) target_user.referral_code = None await session.flush() merged_user = await user_dal.merge_users( session, source_user_id=current_user.user_id, target_user_id=target_user.user_id, ) _apply_telegram_profile_to_user(merged_user, telegram_user, settings) await session.flush() await _sync_panel_identity_for_user(request, merged_user) return merged_user if current_user.telegram_id and int(current_user.telegram_id) != telegram_id: raise UserMergeConflictError("Current account is already linked to Telegram.") _apply_telegram_profile_to_user(current_user, telegram_user, settings) await session.flush() await _sync_panel_identity_for_user(request, current_user) return current_user def _normalize_referral_param(raw: Optional[str]) -> Optional[str]: value = (raw or "").strip() if not value: return None value_lower = value.lower() if value_lower.startswith("ref_u"): value = value[5:] elif value_lower.startswith("ref_"): value = value[4:] elif value and value[0].lower() == "u" and len(value) == 10: value = value[1:] if not re.fullmatch(r"[A-Za-z0-9]{1,32}", value): return None return value.upper() async def _resolve_referrer_id( session: AsyncSession, raw_referral_param: Optional[str], *, current_user_id: Optional[int], ) -> Optional[int]: normalized = _normalize_referral_param(raw_referral_param) if not normalized: return None ref_user = None if normalized.isdigit(): ref_user = await user_dal.get_user_by_id(session, int(normalized)) if not ref_user: ref_user = await user_dal.get_user_by_referral_code(session, normalized) if not ref_user: return None if current_user_id is not None and int(ref_user.user_id) == int(current_user_id): return None return int(ref_user.user_id) async def _apply_referral_to_existing_user( request: web.Request, session: AsyncSession, user: User, raw_referral_param: Optional[str], ) -> bool: if not raw_referral_param or user.referred_by_id is not None: return False referred_by_id = await _resolve_referrer_id( session, raw_referral_param, current_user_id=int(user.user_id), ) if not referred_by_id: return False subscription_service: SubscriptionService = request.app["subscription_service"] try: is_active_now = await subscription_service.has_active_subscription( session, int(user.user_id), ) except Exception: is_active_now = False if is_active_now: return False user.referred_by_id = referred_by_id await session.flush() return True async def _apply_referral_welcome_bonus_if_needed( request: web.Request, session: AsyncSession, user: User, raw_referral_param: Optional[str], ) -> Optional[datetime]: if not raw_referral_param or not user.referred_by_id: return None settings: Settings = request.app["settings"] referral_welcome_days = max( 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0), ) if referral_welcome_days <= 0: return None subscription_service: SubscriptionService = request.app["subscription_service"] try: if await subscription_service.has_active_subscription(session, int(user.user_id)): return None except Exception: pass return await subscription_service.extend_active_subscription_days( session, int(user.user_id), referral_welcome_days, reason="referral_welcome_bonus", ) async def _ensure_user_from_telegram( session: AsyncSession, telegram_user: Dict[str, Any], settings: Settings, *, referral_param: Optional[str] = None, ) -> User: user_id = int(telegram_user["id"]) language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE if language_code not in {"ru", "en"}: language_code = settings.DEFAULT_LANGUAGE update_data = { "telegram_id": user_id, "username": sanitize_username(telegram_user.get("username")), "first_name": sanitize_display_name(telegram_user.get("first_name")), "last_name": sanitize_display_name(telegram_user.get("last_name")), "language_code": language_code, } telegram_photo_url = _telegram_photo_url_value(telegram_user) if telegram_photo_url: update_data["telegram_photo_url"] = telegram_photo_url db_user = await user_dal.get_user_by_telegram_id(session, user_id) if not db_user: db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: referred_by_id = await _resolve_referrer_id( session, referral_param or telegram_user.get("start_param"), current_user_id=user_id, ) db_user, created = await user_dal.create_user( session, { "user_id": user_id, **update_data, "referred_by_id": referred_by_id, "registration_date": datetime.now(timezone.utc), }, ) setattr(db_user, "_webapp_created", bool(created)) return db_user changed = {key: value for key, value in update_data.items() if getattr(db_user, key) != value} if changed: db_user = await user_dal.update_user(session, db_user.user_id, changed) or db_user return db_user