# ruff: noqa: F401,F403,F405,I001 from ._runtime import * # noqa: F403,F405 async def account_email_request_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) 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 async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: db_user = await user_dal.get_user_by_id(session, user_id) if not db_user or db_user.is_banned: return _json_error(403, "access_denied", "Access denied") if db_user.email == email and db_user.email_verified_at: return web.json_response({"ok": True, "already_linked": True}) lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) return await _request_email_code( request, email=email, purpose="link_email", language_code=lang, target_user_id=user_id, ) async def account_email_verify_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) rate_limit_response = await _enforce_webapp_rate_limit( request, user_id=user_id, action="account_email_verify", ) if rate_limit_response: return rate_limit_response 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 "") email_service: EmailAuthService = request.app["email_auth_service"] settings: Settings = request.app["settings"] async_session_factory: sessionmaker = request.app["async_session_factory"] merge_notice: Optional[Dict[str, Any]] = None source_panel_uuid: Optional[str] = None final_user_id = user_id final_email = email final_telegram_id: Optional[int] = None final_username: Optional[str] = None final_first_name: Optional[str] = None final_panel_uuid: Optional[str] = None should_notify_email_linked = False async with async_session_factory() as session: try: verify_result = await email_service.verify_code( session, email=email, purpose="link_email", code=code, target_user_id=user_id, ) 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, ) current_user = await user_dal.get_user_by_id(session, user_id) if not current_user or current_user.is_banned: await session.rollback() return _json_error(403, "access_denied", "Access denied") should_notify_email_linked = ( bool(_telegram_id_for_user(current_user)) and not current_user.email ) existing_email_user = await user_dal.get_user_by_email(session, email) if existing_email_user and existing_email_user.user_id != current_user.user_id: source_panel_uuid = existing_email_user.panel_user_uuid current_user = await user_dal.merge_users( session, source_user_id=existing_email_user.user_id, target_user_id=current_user.user_id, ) merge_notice = await _build_account_merge_notice( session, merged_user=current_user, source_user_id=existing_email_user.user_id, source_panel_uuid=source_panel_uuid, settings=settings, ) current_user.email = email current_user.email_verified_at = datetime.now(timezone.utc) await _sync_panel_identity_for_user(request, current_user) await session.commit() final_user_id = int(current_user.user_id) final_telegram_id = _telegram_id_for_user(current_user) final_username = current_user.username final_first_name = current_user.first_name final_panel_uuid = current_user.panel_user_uuid if merge_notice: merge_end_date_raw = merge_notice.get("final_end_date") merge_end_date = ( datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None ) await _sync_panel_identity_for_user( request, current_user, expire_at=merge_end_date, ) # Best-effort cleanup of the removed panel account after the DB merge. if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid: subscription_service: SubscriptionService = request.app.get( "subscription_service" ) if subscription_service and subscription_service.panel_service: try: await subscription_service.panel_service.delete_user_from_panel( source_panel_uuid, log_response=False, ) except Exception as exc: logger.warning( "Failed to delete merged source panel user %s: %s", source_panel_uuid, exc, ) email_service: EmailAuthService = request.app.get("email_auth_service") if email_service and final_email: email_content = render_account_merged( settings, language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE, primary_user_id=merge_notice.get("primary_user_id"), removed_user_id=merge_notice.get("removed_user_id"), final_end_date_text=str( merge_notice.get("final_end_date_text") or merge_notice.get("final_end_date") or "" ), ) try: await email_service.send_rendered_email( email=final_email, content=email_content, ) except Exception as exc: logger.warning( "Failed to send account merge email to %s: %s", final_email, exc, ) except UserMergeConflictError as exc: await session.rollback() return _json_error(409, "account_merge_conflict", str(exc)) except Exception: await session.rollback() logger.exception("Email account link failed") return _json_error(500, "link_failed", "Link failed") if should_notify_email_linked: 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_account_email_linked( user_id=int(final_user_id), email=final_email, telegram_id=final_telegram_id, username=final_username, first_name=final_first_name, ) except Exception: logger.exception("Failed to send account email linked notification") token = create_webapp_session_token(settings, int(final_user_id)) response_payload: Dict[str, Any] = {"ok": True} if merge_notice: response_payload["account_merge"] = merge_notice response_payload["user_id"] = final_user_id return _build_webapp_auth_response(settings, response_payload, token=token) async def account_telegram_link_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] payload = await _read_json(request) telegram_user = await _validate_telegram_auth_payload(request, payload) if not telegram_user: return _json_error(401, "invalid_auth", "Invalid Telegram auth data") async_session_factory: sessionmaker = request.app["async_session_factory"] merge_notice: Optional[Dict[str, Any]] = None source_panel_uuid: Optional[str] = None final_user_id = user_id final_telegram_id: Optional[int] = None final_email: Optional[str] = None final_username: Optional[str] = None final_first_name: Optional[str] = None final_panel_uuid: Optional[str] = None should_notify_telegram_linked = False async with async_session_factory() as session: try: current_user_before_link = await user_dal.get_user_by_id(session, user_id) if not current_user_before_link or current_user_before_link.is_banned: await session.rollback() return _json_error(403, "access_denied", "Access denied") should_notify_telegram_linked = bool( current_user_before_link.email ) and not _telegram_id_for_user(current_user_before_link) source_panel_uuid = current_user_before_link.panel_user_uuid db_user = await _link_telegram_to_user( request, session, current_user_id=user_id, telegram_user=telegram_user, settings=settings, ) if db_user.is_banned: await session.rollback() return _json_error(403, "banned", "Access denied") final_user_id = int(db_user.user_id) final_telegram_id = _telegram_id_for_user(db_user) final_email = db_user.email final_username = db_user.username final_first_name = db_user.first_name final_panel_uuid = db_user.panel_user_uuid if final_user_id != user_id: merge_notice = await _build_account_merge_notice( session, merged_user=db_user, source_user_id=user_id, source_panel_uuid=source_panel_uuid, settings=settings, ) await session.commit() if merge_notice: merge_end_date_raw = merge_notice.get("final_end_date") merge_end_date = ( datetime.fromisoformat(merge_end_date_raw) if merge_end_date_raw else None ) await _sync_panel_identity_for_user( request, db_user, expire_at=merge_end_date, ) # Best-effort cleanup of the removed panel account after the DB merge. if source_panel_uuid and final_panel_uuid and source_panel_uuid != final_panel_uuid: subscription_service: SubscriptionService = request.app.get( "subscription_service" ) if subscription_service and subscription_service.panel_service: try: await subscription_service.panel_service.delete_user_from_panel( source_panel_uuid, log_response=False, ) except Exception as exc: logger.warning( "Failed to delete merged source panel user %s: %s", source_panel_uuid, exc, ) email_service: EmailAuthService = request.app.get("email_auth_service") if email_service and final_email: email_content = render_account_merged( settings, language_code=merge_notice.get("language") or settings.DEFAULT_LANGUAGE, primary_user_id=merge_notice.get("primary_user_id"), removed_user_id=merge_notice.get("removed_user_id"), final_end_date_text=str( merge_notice.get("final_end_date_text") or merge_notice.get("final_end_date") or "" ), ) try: await email_service.send_rendered_email( email=final_email, content=email_content, ) except Exception as exc: logger.warning( "Failed to send account merge email to %s: %s", final_email, exc, ) except UserMergeConflictError as exc: await session.rollback() return _json_error(409, "account_merge_conflict", str(exc)) except Exception: await session.rollback() logger.exception("Telegram account link failed") return _json_error(500, "link_failed", "Link failed") if should_notify_telegram_linked and final_telegram_id: 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_account_telegram_linked( user_id=int(final_user_id), email=final_email, telegram_id=int(final_telegram_id), username=final_username, first_name=final_first_name, ) except Exception: logger.exception("Failed to send account Telegram linked notification") token = create_webapp_session_token(settings, int(final_user_id)) response_payload: Dict[str, Any] = { "ok": True, "user_id": int(final_user_id), "telegram_id": final_telegram_id, } if merge_notice: response_payload["account_merge"] = merge_notice return _build_webapp_auth_response(settings, response_payload, token=token) async def me_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) settings: Settings = request.app["settings"] cache_key = redis_key(settings, "cache", "webapp", "me", user_id) cached = await cache_get_json(settings, cache_key) if cached: return web.json_response({"ok": True, **cached}) data = await _build_user_payload(request, user_id) await cache_set_json(settings, cache_key, data, settings.WEBAPP_ME_CACHE_TTL_SECONDS) return web.json_response({"ok": True, **data}) async def account_avatar_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: db_user = await user_dal.get_user_by_id(session, user_id) if not db_user or db_user.is_banned: await session.rollback() return _json_error(403, "access_denied", "Access denied") avatar = await _ensure_cached_telegram_avatar(request, session, db_user) await session.commit() if not avatar: raise web.HTTPNotFound(text="avatar_not_cached") etag = _telegram_avatar_etag(avatar) if etag and request.headers.get("If-None-Match") == etag: return web.Response(status=304, headers={"ETag": etag}) response = web.Response( body=bytes(avatar.image_bytes), content_type=avatar.content_type or "image/jpeg", ) response.headers["Cache-Control"] = "private, max-age=3600" if etag: response.headers["ETag"] = etag return response async def account_language_route(request: web.Request) -> web.Response: user_id = _require_user_id(request) payload = await _read_json(request) language_payload, validation_error = _validate_model_payload(WebAppLanguagePayload, payload) if validation_error: return validation_error language = _normalize_language(str(language_payload.language or "")) async_session_factory: sessionmaker = request.app["async_session_factory"] async with async_session_factory() as session: db_user = await user_dal.get_user_by_id(session, user_id) if not db_user or db_user.is_banned: await session.rollback() return _json_error(403, "access_denied", "Access denied") if _normalize_language(db_user.language_code or "") != language: db_user.language_code = language await session.flush() await session.commit() return web.json_response({"ok": True, "language": language}) def _format_webapp_datetime(value: Optional[datetime]) -> Optional[str]: if not value: return None normalized = value if value.tzinfo else value.replace(tzinfo=timezone.utc) return normalized.strftime("%d.%m.%Y %H:%M") def _telegram_photo_url_value(telegram_user: Dict[str, Any]) -> Optional[str]: raw_value = telegram_user.get("photo_url") if not raw_value: return None value = str(raw_value).strip() return value or None def _telegram_avatar_is_stale(avatar: Optional[UserTelegramAvatar]) -> bool: if not avatar or not avatar.updated_at: return True updated_at = avatar.updated_at if updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=timezone.utc) return ( datetime.now(timezone.utc) - updated_at ).total_seconds() >= WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS def _telegram_avatar_etag(avatar: UserTelegramAvatar) -> str: digest = hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:16] return f'"tg-avatar-{int(avatar.user_id)}-{digest}"' def _telegram_avatar_url(avatar: Optional[UserTelegramAvatar]) -> str: if not avatar: return "" updated_at = avatar.updated_at if updated_at and updated_at.tzinfo is None: updated_at = updated_at.replace(tzinfo=timezone.utc) version = ( int(updated_at.timestamp()) if updated_at else hashlib.sha256(bytes(avatar.image_bytes)).hexdigest()[:8] ) return f"/api/account/avatar?v={version}" def _select_compact_telegram_photo_size(sizes: List[Any]) -> Optional[Any]: if not sizes: return None suitable = [size for size in sizes if int(getattr(size, "width", 0) or 0) >= 160] candidates = suitable or sizes return min( candidates, key=lambda size: ( int(getattr(size, "file_size", 0) or 0) or int(getattr(size, "width", 0) or 0) * int(getattr(size, "height", 0) or 0), int(getattr(size, "width", 0) or 0), ), ) def _telegram_file_content_type(file_path: Optional[str]) -> str: path = str(file_path or "").lower() if path.endswith(".png"): return "image/png" if path.endswith(".webp"): return "image/webp" return "image/jpeg" async def _fetch_compact_telegram_avatar( bot: Bot, telegram_id: int ) -> Optional[Tuple[bytes, str, Optional[str]]]: photos = await bot.get_user_profile_photos(user_id=telegram_id, limit=1) if not photos or not photos.photos: return None photo_size = _select_compact_telegram_photo_size(list(photos.photos[0] or [])) if not photo_size: return None file_info = await bot.get_file(photo_size.file_id) destination = io.BytesIO() await bot.download_file(file_info.file_path, destination=destination) body = destination.getvalue() if not body or len(body) > WEBAPP_TELEGRAM_AVATAR_MAX_BYTES: return None return ( body, _telegram_file_content_type(file_info.file_path), getattr(photo_size, "file_unique_id", None), ) async def _ensure_cached_telegram_avatar( request: web.Request, session: AsyncSession, user: User, ) -> Optional[UserTelegramAvatar]: avatar = await user_dal.get_user_telegram_avatar(session, int(user.user_id)) telegram_id = _telegram_id_for_user(user) if not telegram_id: return avatar if avatar and not _telegram_avatar_is_stale(avatar): return avatar bot: Bot = request.app["bot"] try: fetched = await asyncio.wait_for( _fetch_compact_telegram_avatar(bot, int(telegram_id)), timeout=WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS, ) except Exception as exc: logger.info("Failed to refresh Telegram avatar for user %s: %s", user.user_id, exc) return avatar if not fetched: return avatar body, content_type, file_unique_id = fetched return await user_dal.upsert_user_telegram_avatar( session, user_id=int(user.user_id), file_unique_id=file_unique_id, content_type=content_type, image_bytes=body, )