From 74016498416df775e30044e87f97bca09b1937c3 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Wed, 13 May 2026 18:38:10 +0300 Subject: [PATCH] refactor: split backend domains and add API behavior coverage --- bot/app/web/admin_api.py | 2147 +------- bot/app/web/admin_api_impl/__init__.py | 1 + bot/app/web/admin_api_impl/_runtime.py | 65 + bot/app/web/admin_api_impl/ads.py | 69 + bot/app/web/admin_api_impl/auth.py | 58 + bot/app/web/admin_api_impl/broadcast.py | 54 + bot/app/web/admin_api_impl/common.py | 367 ++ bot/app/web/admin_api_impl/logs.py | 36 + bot/app/web/admin_api_impl/panel.py | 35 + bot/app/web/admin_api_impl/payments.py | 95 + bot/app/web/admin_api_impl/promos.py | 96 + bot/app/web/admin_api_impl/routes.py | 57 + bot/app/web/admin_api_impl/settings.py | 74 + bot/app/web/admin_api_impl/stats.py | 89 + bot/app/web/admin_api_impl/sync.py | 23 + bot/app/web/admin_api_impl/tariffs.py | 63 + bot/app/web/admin_api_impl/users.py | 925 ++++ bot/app/web/session.py | 29 + bot/app/web/subscription_webapp.py | 4810 +---------------- bot/app/web/web_server.py | 2 +- bot/app/web/webapp/__init__.py | 1 + bot/app/web/webapp/_runtime.py | 100 + bot/app/web/webapp/account.py | 534 ++ bot/app/web/webapp/application.py | 60 + bot/app/web/webapp/assets.py | 734 +++ bot/app/web/webapp/auth.py | 1136 ++++ bot/app/web/webapp/billing.py | 1191 ++++ bot/app/web/webapp/common.py | 156 + bot/app/web/webapp/devices.py | 169 + bot/app/web/webapp/payloads.py | 59 + bot/app/web/webapp/routes.py | 48 + bot/app/web/webapp/serializers.py | 612 +++ bot/handlers/admin/broadcast.py | 6 +- bot/handlers/admin/common.py | 2 +- bot/handlers/admin/logs_admin.py | 2 +- bot/handlers/admin/payments.py | 2 +- bot/handlers/admin/promo/bulk.py | 2 +- bot/handlers/admin/promo/manage.py | 4 +- bot/handlers/admin/statistics.py | 10 +- bot/handlers/admin/sync_admin.py | 54 +- bot/handlers/admin/user_management.py | 9 +- bot/handlers/user/payment.py | 36 +- bot/handlers/user/promo_user.py | 8 +- bot/handlers/user/referral.py | 2 +- bot/handlers/user/start.py | 11 +- bot/handlers/user/subscription/core.py | 12 +- .../user/subscription/payment_methods.py | 4 +- .../user/subscription/payments_freekassa.py | 6 +- .../user/subscription/payments_platega.py | 6 +- .../user/subscription/payments_severpay.py | 4 +- .../subscription/payments_subscription.py | 4 +- .../user/subscription/payments_yookassa.py | 10 +- bot/handlers/user/trial_handler.py | 3 +- bot/keyboards/inline/user_keyboards.py | 2 +- bot/main_bot.py | 12 +- bot/middlewares/action_logger_middleware.py | 4 +- bot/middlewares/ban_check_middleware.py | 4 +- bot/middlewares/channel_subscription.py | 4 +- bot/middlewares/i18n.py | 14 +- bot/middlewares/profile_sync.py | 6 +- bot/services/email_auth_service.py | 2 +- bot/services/email_templates.py | 42 +- bot/services/freekassa_service.py | 2 +- bot/services/panel_api_service.py | 34 +- bot/services/panel_webhook_service.py | 2 +- bot/services/platega_service.py | 4 +- bot/services/promo_code_service.py | 2 +- bot/services/referral_service.py | 30 +- bot/services/stars_service.py | 2 +- bot/services/subscription_service.py | 2688 +-------- .../subscription_service_impl/__init__.py | 1 + .../subscription_service_impl/_runtime.py | 29 + .../subscription_service_impl/core.py | 34 + .../subscription_service_impl/devices.py | 117 + .../subscription_service_impl/lifecycle.py | 838 +++ .../panel_identity.py | 344 ++ .../subscription_service_impl/payments.py | 111 + .../subscription_service_impl/renewal.py | 67 + .../subscription_service_impl/tariffs.py | 369 ++ .../subscription_service_impl/traffic.py | 693 +++ .../subscription_service_impl/trial.py | 119 + bot/services/tariff_worker.py | 2 +- bot/services/yookassa_service.py | 14 +- bot/utils/__init__.py | 2 +- bot/utils/mini_app_url.py | 2 +- config/settings.py | 54 +- config/tariffs_config.py | 4 +- db/dal/ad_dal.py | 2 +- db/dal/message_log_dal.py | 4 +- db/dal/payment_dal.py | 2 +- db/dal/promo_code_dal.py | 4 +- db/dal/subscription_dal.py | 8 +- db/dal/user_billing_dal.py | 2 +- db/database_setup.py | 4 +- db/migrator.py | 50 +- db/models.py | 4 +- pyproject.toml | 3 - scripts/send_premium_traffic_warning_test.py | 2 +- tests/test_panel_api_service_logging.py | 51 + tests/test_security.py | 3 +- tests/test_subscription_service_behavior.py | 292 + tests/test_webapp_assets.py | 19 +- tests/test_webapp_route_contract.py | 194 + 103 files changed, 10558 insertions(+), 9832 deletions(-) create mode 100644 bot/app/web/admin_api_impl/__init__.py create mode 100644 bot/app/web/admin_api_impl/_runtime.py create mode 100644 bot/app/web/admin_api_impl/ads.py create mode 100644 bot/app/web/admin_api_impl/auth.py create mode 100644 bot/app/web/admin_api_impl/broadcast.py create mode 100644 bot/app/web/admin_api_impl/common.py create mode 100644 bot/app/web/admin_api_impl/logs.py create mode 100644 bot/app/web/admin_api_impl/panel.py create mode 100644 bot/app/web/admin_api_impl/payments.py create mode 100644 bot/app/web/admin_api_impl/promos.py create mode 100644 bot/app/web/admin_api_impl/routes.py create mode 100644 bot/app/web/admin_api_impl/settings.py create mode 100644 bot/app/web/admin_api_impl/stats.py create mode 100644 bot/app/web/admin_api_impl/sync.py create mode 100644 bot/app/web/admin_api_impl/tariffs.py create mode 100644 bot/app/web/admin_api_impl/users.py create mode 100644 bot/app/web/session.py create mode 100644 bot/app/web/webapp/__init__.py create mode 100644 bot/app/web/webapp/_runtime.py create mode 100644 bot/app/web/webapp/account.py create mode 100644 bot/app/web/webapp/application.py create mode 100644 bot/app/web/webapp/assets.py create mode 100644 bot/app/web/webapp/auth.py create mode 100644 bot/app/web/webapp/billing.py create mode 100644 bot/app/web/webapp/common.py create mode 100644 bot/app/web/webapp/devices.py create mode 100644 bot/app/web/webapp/payloads.py create mode 100644 bot/app/web/webapp/routes.py create mode 100644 bot/app/web/webapp/serializers.py create mode 100644 bot/services/subscription_service_impl/__init__.py create mode 100644 bot/services/subscription_service_impl/_runtime.py create mode 100644 bot/services/subscription_service_impl/core.py create mode 100644 bot/services/subscription_service_impl/devices.py create mode 100644 bot/services/subscription_service_impl/lifecycle.py create mode 100644 bot/services/subscription_service_impl/panel_identity.py create mode 100644 bot/services/subscription_service_impl/payments.py create mode 100644 bot/services/subscription_service_impl/renewal.py create mode 100644 bot/services/subscription_service_impl/tariffs.py create mode 100644 bot/services/subscription_service_impl/traffic.py create mode 100644 bot/services/subscription_service_impl/trial.py create mode 100644 tests/test_panel_api_service_logging.py create mode 100644 tests/test_subscription_service_behavior.py create mode 100644 tests/test_webapp_route_contract.py diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py index c8f19ad..8dcb349 100644 --- a/bot/app/web/admin_api.py +++ b/bot/app/web/admin_api.py @@ -1,2111 +1,56 @@ -"""HTTP API powering the admin section of the subscription Mini App. +"""Compatibility facade for the admin Mini App API.""" -All routes require an authenticated webapp session (cookie or Bearer -token) AND the resolved Telegram user id must appear in -``settings.ADMIN_IDS``. Authorization is enforced via the -``_require_admin_user_id`` helper, never trusted from the client. -""" +# ruff: noqa: I001 -from __future__ import annotations - -import csv -import io -import json -import logging -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import parse_qsl, urlsplit, urlunsplit - -from aiohttp import web -from pydantic import ValidationError -from sqlalchemy import Float, and_, case, cast, or_, select -from sqlalchemy import func as sa_func -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import sessionmaker - -from bot.app.web.admin_settings_manifest import ( - manifest_payload, -) -from bot.services.referral_service import ReferralService -from bot.services.settings_override_service import ( - current_value, - update_overrides, -) -from bot.utils import MessageContent, send_message_via_queue -from bot.utils.message_queue import get_queue_manager -from config.settings import Settings -from config.tariffs_config import TariffsConfig -from db.dal import ( - ad_dal, - app_settings_dal, - message_log_dal, - panel_sync_dal, - payment_dal, - promo_code_dal, - subscription_dal, - user_dal, -) -from db.models import ( - AdCampaign, - MessageLog, - Payment, - PromoCode, - Subscription, - User, - UserTelegramAvatar, +from bot.app.web.admin_api_impl import ( + _runtime as _runtime, + ads as _ads, + auth as _auth, + broadcast as _broadcast, + common as _common, + logs as _logs, + panel as _panel, + payments as _payments, + promos as _promos, + routes as _routes, + settings as _settings, + stats as _stats, + sync as _sync, + tariffs as _tariffs, + users as _users, ) -logger = logging.getLogger(__name__) - - -# ─── Auth ────────────────────────────────────────────────────────── - - -def _require_admin_user_id(request: web.Request) -> int: - """Return the authenticated user id, or raise 401/403 for non-admins.""" - - from bot.app.web.subscription_webapp import _extract_authenticated_user_id - - settings: Settings = request.app["settings"] - 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", - ) - - admin_ids = settings.ADMIN_IDS or [] - db_user_telegram_id = request.get("admin_telegram_id") - if db_user_telegram_id is None: - raise web.HTTPForbidden( - text=json.dumps({"ok": False, "error": "forbidden"}), - content_type="application/json", - ) - - if int(db_user_telegram_id) not in {int(x) for x in admin_ids}: - raise web.HTTPForbidden( - text=json.dumps({"ok": False, "error": "forbidden"}), - content_type="application/json", - ) - return int(user_id) - - -@web.middleware -async def admin_auth_middleware(request: web.Request, handler): - """Resolve the Telegram id of the current user and stash it on the request. - - Doing this once per request lets every admin route call - ``_require_admin_user_id`` without re-querying the DB. - """ - - if not request.path.startswith("/api/admin"): - return await handler(request) - - from bot.app.web.subscription_webapp import _extract_authenticated_user_id - - user_id = _extract_authenticated_user_id(request) - if user_id: - 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 db_user and db_user.telegram_id: - request["admin_telegram_id"] = int(db_user.telegram_id) - elif db_user: - # No telegram_id yet (email-only user) — can't be an admin - request["admin_telegram_id"] = None - - return await handler(request) - - -# ─── Helpers ─────────────────────────────────────────────────────── - - -def _ok(payload: Dict[str, Any], **extra) -> web.Response: - body = {"ok": True, **payload, **extra} - return web.json_response(body) - - -def _error(status: int, code: str, message: str = "") -> web.Response: - return web.json_response( - {"ok": False, "error": code, "message": message or code}, - status=status, - ) - - -async def _read_json(request: web.Request) -> Dict[str, Any]: - try: - data = await request.json() - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -def _serialize_user(user: User) -> Dict[str, Any]: - return { - "user_id": int(user.user_id), - "telegram_id": int(user.telegram_id) if user.telegram_id else None, - "telegram_photo_url": user.telegram_photo_url, - "username": user.username, - "first_name": user.first_name, - "last_name": user.last_name, - "email": user.email, - "language_code": user.language_code, - "is_banned": bool(user.is_banned), - "registration_date": user.registration_date.isoformat() if user.registration_date else None, - "panel_user_uuid": user.panel_user_uuid, - "referral_code": user.referral_code, - "referred_by_id": int(user.referred_by_id) if user.referred_by_id else None, - } - - -def _premium_limit_bytes_from_subscription(sub: Subscription) -> int: - premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) - return ( - int(sub.premium_baseline_bytes or 0) - + int(sub.premium_topup_balance_bytes or 0) - + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) - + premium_bonus_bytes - ) - - -def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]: - """Premium traffic column when subscription has a finite premium quota (bytes > 0). - - Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel - routing, not 'tariff includes premium traffic' — do not use it here. - """ - - if sub is None: - return {"state": "none"} - if bool(getattr(sub, "premium_unlimited_override", False)): - return { - "state": "unlimited", - "unlimited": True, - "used_bytes": int(sub.premium_used_bytes or 0), - "limit_bytes": None, - "percent": None, - } - limit_bytes = _premium_limit_bytes_from_subscription(sub) - if limit_bytes <= 0: - return {"state": "none"} - used_bytes = int(sub.premium_used_bytes or 0) - ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0 - pct = int(max(0, min(100, round(ratio * 100)))) - if ratio >= 1.0: - state = "critical" - elif ratio >= 0.85: - state = "warn" - else: - state = "good" - return { - "state": state, - "unlimited": False, - "used_bytes": used_bytes, - "limit_bytes": limit_bytes, - "percent": pct, - } - - -def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: - premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) - regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0) - regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) - premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) - premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) - return { - "subscription_id": int(sub.subscription_id), - "panel_user_uuid": sub.panel_user_uuid, - "panel_subscription_uuid": sub.panel_subscription_uuid, - "start_date": sub.start_date.isoformat() if sub.start_date else None, - "end_date": sub.end_date.isoformat() if sub.end_date else None, - "duration_months": sub.duration_months, - "is_active": bool(sub.is_active), - "status_from_panel": sub.status_from_panel, - "traffic_limit_bytes": sub.traffic_limit_bytes, - "traffic_used_bytes": sub.traffic_used_bytes, - "tier_baseline_bytes": sub.tier_baseline_bytes, - "topup_balance_bytes": sub.topup_balance_bytes, - "premium_used_bytes": sub.premium_used_bytes, - "premium_limit_bytes": premium_limit_bytes, - "premium_baseline_bytes": sub.premium_baseline_bytes, - "premium_topup_balance_bytes": sub.premium_topup_balance_bytes, - "premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0), - "premium_bonus_bytes": premium_bonus_bytes, - "regular_bonus_bytes": regular_bonus_bytes, - "regular_unlimited_override": regular_unlimited_override, - "premium_unlimited_override": premium_unlimited_override, - "premium_is_limited": bool(sub.premium_is_limited), - "tariff_key": sub.tariff_key, - "auto_renew_enabled": bool(sub.auto_renew_enabled), - "provider": sub.provider, - "is_throttled": bool(sub.is_throttled), - } - - -def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]: - """For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None).""" - if payment.purchased_gb is None: - return None, None - try: - gb = float(payment.purchased_gb) - except (TypeError, ValueError): - return None, None - sm = (payment.sale_mode or "").strip() - if not sm: - return None, None - base = sm.split("@", 1)[0].split("|", 1)[0].lower() - if base == "premium_topup": - return None, gb - if base in {"traffic", "traffic_package", "topup"}: - return gb, None - return None, None - - -def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str: - """Human-facing name for payments tables: TG profile name, else email, else user id.""" - if loaded_user is None: - return str(payment_user_id) - tid = getattr(loaded_user, "telegram_id", None) - if tid is not None: - fn = (getattr(loaded_user, "first_name", None) or "").strip() - ln = (getattr(loaded_user, "last_name", None) or "").strip() - full = f"{fn} {ln}".strip() - if full: - return full - un = (getattr(loaded_user, "username", None) or "").strip() - if un: - return un if un.startswith("@") else f"@{un}" - return str(payment_user_id) - email = (getattr(loaded_user, "email", None) or "").strip() - if email: - return email - return str(payment_user_id) - - -def _serialize_payment(payment: Payment) -> Dict[str, Any]: - # Avoid lazy-loading `payment.user` outside an active SQLAlchemy session. - # Some admin routes serialize payments after the session scope is closed. - telegram_id = None - loaded_user = payment.__dict__.get("user") - user_label = _payment_user_display_label(loaded_user, int(payment.user_id)) - if loaded_user is not None: - tid = getattr(loaded_user, "telegram_id", None) - if tid is not None: - try: - telegram_id = int(tid) - except (TypeError, ValueError): - telegram_id = None - reg_gb, prem_gb = _payment_traffic_gb_split(payment) - return { - "payment_id": int(payment.payment_id), - "user_id": int(payment.user_id), - "user_label": user_label, - "telegram_id": telegram_id, - "traffic_regular_gb": reg_gb, - "traffic_premium_gb": prem_gb, - "provider": payment.provider, - "provider_payment_id": payment.provider_payment_id, - "amount": float(payment.amount), - "currency": payment.currency, - "status": payment.status, - "description": payment.description, - "subscription_duration_months": payment.subscription_duration_months, - "sale_mode": payment.sale_mode, - "tariff_key": payment.tariff_key, - "purchased_gb": payment.purchased_gb, - "purchased_hwid_devices": payment.purchased_hwid_devices, - "created_at": payment.created_at.isoformat() if payment.created_at else None, - } - - -def _serialize_promo(promo: PromoCode) -> Dict[str, Any]: - return { - "id": int(promo.promo_code_id), - "code": promo.code, - "bonus_days": int(promo.bonus_days), - "max_activations": int(promo.max_activations), - "current_activations": int(promo.current_activations or 0), - "is_active": bool(promo.is_active), - "valid_until": promo.valid_until.isoformat() if promo.valid_until else None, - "created_at": promo.created_at.isoformat() if promo.created_at else None, - "created_by_admin_id": int(promo.created_by_admin_id) - if promo.created_by_admin_id - else None, - } - - -def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - return { - "id": int(campaign.ad_campaign_id), - "source": campaign.source, - "start_param": campaign.start_param, - "cost": float(campaign.cost or 0), - "is_active": bool(campaign.is_active), - "created_at": campaign.created_at.isoformat() if campaign.created_at else None, - "stats": totals or {}, - } - - -def _serialize_log(entry: MessageLog) -> Dict[str, Any]: - return { - "log_id": int(entry.log_id), - "user_id": int(entry.user_id) if entry.user_id else None, - "telegram_username": entry.telegram_username, - "telegram_first_name": entry.telegram_first_name, - "event_type": entry.event_type, - "content": entry.content, - "is_admin_event": bool(entry.is_admin_event), - "target_user_id": int(entry.target_user_id) if entry.target_user_id else None, - "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, - } - - -def _tariffs_config_path(settings: Settings) -> Path: - return Path(settings.TARIFFS_CONFIG_PATH).expanduser() - - -def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]: - return config.model_dump(mode="json", exclude_none=True) - - -def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: - data = _tariffs_config_payload(config) - path.parent.mkdir(parents=True, exist_ok=True) - tmp_path = path.with_suffix(f"{path.suffix}.tmp") - payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" - try: - tmp_path.write_text(payload, encoding="utf-8") - tmp_path.replace(path) - except PermissionError: - # A docker-compose single-file bind mount can make /app/config - # unwritable while the mounted tariffs.json itself is writable. - # Fall back to updating the existing file in-place. - if tmp_path.exists(): - try: - tmp_path.unlink() - except OSError: - pass - path.write_text(payload, encoding="utf-8") - - -def _panel_node_uuid_key(node: Dict[str, Any]) -> str: - uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") - return str(uid).strip().lower() if uid else "" - - -def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]: - uo = node.get("usersOnline") - if uo is None: - uo = node.get("users_online") - if uo is None: - uo = node.get("onlineUsers") or node.get("online_users") - if uo is None: - mg = node.get("metricGroups") - if isinstance(mg, dict): - uo = mg.get("onlineUsers") or mg.get("online_users") - if uo is None: - return None - try: - return int(uo) - except (TypeError, ValueError): - return None - - -def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]: - """Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload.""" - out: Dict[str, int] = {} - raw_list: Optional[List[Any]] = None - if isinstance(nodes_payload, list): - raw_list = nodes_payload - elif isinstance(nodes_payload, dict): - raw_list = nodes_payload.get("nodes") - if raw_list is None: - raw_list = nodes_payload.get("items") or nodes_payload.get("data") - if not isinstance(raw_list, list): - return out - for n in raw_list: - if not isinstance(n, dict): - continue - key = _panel_node_uuid_key(n) - if not key: - continue - online = _panel_node_users_online(n) - if online is not None: - out[key] = online - return out - - -def _enrich_bandwidth_nodes_with_online( - bw: Any, - online_by_uuid: Dict[str, int], - online_by_name: Optional[Dict[str, int]] = None, -) -> None: - """Attach usersOnline to topNodes/series (UUID and optional node name).""" - if not isinstance(bw, dict): - return - if not online_by_uuid and not online_by_name: - return - for key in ("topNodes", "series"): - arr = bw.get(key) - if not isinstance(arr, list): - continue - for item in arr: - if not isinstance(item, dict): - continue - if item.get("usersOnline") is not None: - continue - uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid") - if uid and online_by_uuid: - hit = online_by_uuid.get(str(uid).strip().lower()) - if hit is not None: - item["usersOnline"] = hit - continue - if online_by_name: - nm = item.get("name") - if nm and isinstance(nm, str): - hitn = online_by_name.get(nm.strip().lower()) - if hitn is not None: - item["usersOnline"] = hitn - - -# ─── Routes ──────────────────────────────────────────────────────── - - -async def admin_me_route(request: web.Request) -> web.Response: - user_id = _require_admin_user_id(request) - settings: Settings = request.app["settings"] - return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or [])) - - -async def admin_stats_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - - async with async_session_factory() as session: - user_stats = await user_dal.get_enhanced_user_statistics(session) - financial_stats = await payment_dal.get_financial_statistics(session) - sync_status = await panel_sync_dal.get_panel_sync_status(session) - recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10) - - payload = { - "users": user_stats, - "financial": financial_stats, - "panel_sync": { - "status": sync_status.status if sync_status else "never_run", - "last_sync_time": sync_status.last_sync_time.isoformat() - if sync_status and sync_status.last_sync_time - else None, - "details": sync_status.details if sync_status else None, - "users_processed": sync_status.users_processed_from_panel if sync_status else 0, - "subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0, - }, - "recent_payments": [_serialize_payment(p) for p in recent_payments], - } - - panel_service = request.app.get("panel_service") - if panel_service is not None: - try: - system = await panel_service.get_system_stats() - bandwidth = await panel_service.get_bandwidth_stats() - panel_body: Dict[str, Any] = { - "system": system or {}, - "bandwidth": bandwidth or {}, - } - try: - nodes = await panel_service.get_nodes_statistics() - panel_body["nodes"] = nodes or {} - except Exception as exc_nodes: # pragma: no cover - optional endpoint - logger.debug("Panel nodes stats unavailable: %s", exc_nodes) - panel_body["nodes"] = {} - try: - today = datetime.now(timezone.utc).date() - start_d = today - timedelta(days=7) - nodes_bw = await panel_service.get_nodes_bandwidth_usage( - start=start_d.isoformat(), - end=today.isoformat(), - top_nodes_limit=64, - ) - panel_body["nodes_bandwidth"] = nodes_bw or {} - except Exception as exc_nb: # pragma: no cover - optional endpoint - logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb) - panel_body["nodes_bandwidth"] = {} - try: - online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes")) - lookups = await panel_service.get_nodes_online_lookups() - for k, v in lookups.get("byUuid", {}).items(): - online_map[k] = v - _enrich_bandwidth_nodes_with_online( - panel_body.get("nodes_bandwidth"), - online_map, - lookups.get("byName") or {}, - ) - except Exception as exc_merge: # pragma: no cover - logger.debug("Panel nodes online merge skipped: %s", exc_merge) - payload["panel"] = panel_body - except Exception as exc: - logger.debug("Panel stats unavailable: %s", exc) - payload["panel"] = {"error": "unavailable"} - - queue_manager = get_queue_manager() - if queue_manager: - try: - payload["queue"] = queue_manager.get_queue_stats() - except Exception: # pragma: no cover - defensive - payload["queue"] = None - - payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" - return _ok(payload) - - -# ─── Users ───────────────────────────────────────────────────────── - - -async def admin_users_list_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - async_session_factory: sessionmaker = request.app["async_session_factory"] - - page = max(0, int(request.query.get("page", 0) or 0)) - page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) - query = (request.query.get("q") or "").strip() - filter_value = (request.query.get("filter") or "all").lower() - panel_status = (request.query.get("panel_status") or "all").lower() - premium_traffic = (request.query.get("premium_traffic") or "all").lower() - sort_value = (request.query.get("sort") or "registered_desc").lower() - - async with async_session_factory() as session: - users, total = await _filter_and_sort_users( - session, - query=query, - filter_value=filter_value, - panel_status=panel_status, - premium_traffic=premium_traffic, - sort_value=sort_value, - page=page, - page_size=page_size, - ) - - statuses = await _bulk_user_statuses(session, [u.user_id for u in users]) - cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users]) - active_subs = await _bulk_active_subscriptions_for_users( - session, [u.user_id for u in users] - ) - - serialized = [] - for user in users: - payload = _serialize_user(user) - status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None} - payload["panel_status"] = status_payload.get("status") - if status_payload.get("status") == "expired" and status_payload.get("end_date"): - payload["panel_status_expired_at"] = status_payload["end_date"] - payload["avatar_url"] = ( - f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}" - if user.user_id in cached_avatar_ids - else None - ) - payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id)) - serialized.append(payload) - - return _ok( +_MODULES = ( + _runtime, + _auth, + _common, + _stats, + _users, + _payments, + _promos, + _logs, + _broadcast, + _sync, + _ads, + _settings, + _tariffs, + _panel, + _routes, +) + +_NAMESPACE = {} +for _module in _MODULES: + _NAMESPACE.update( { - "users": serialized, - "page": page, - "page_size": page_size, - "total": total, + _name: _value + for _name, _value in vars(_module).items() + if not _name.startswith("__") and _name != "annotations" } ) +for _module in _MODULES: + vars(_module).update(_NAMESPACE) -async def _bulk_user_statuses( - session: AsyncSession, user_ids: List[int] -) -> Dict[int, Dict[str, Optional[str]]]: - """Return active subscription status for a batch of users. +globals().update(_NAMESPACE) - Returns the panel status (active/expired/limited/disabled) when an active - subscription exists, otherwise ``"bot_only"`` when the user is in the bot - but has no panel subscription. - """ - - if not user_ids: - return {} - stmt = ( - select( - Subscription.user_id, - Subscription.status_from_panel, - Subscription.is_active, - Subscription.end_date, - ) - .where(Subscription.user_id.in_(user_ids)) - .order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast()) - ) - rows = (await session.execute(stmt)).all() - out: Dict[int, Dict[str, Optional[str]]] = {} - for uid, panel_status, is_active, end_date in rows: - if uid in out: - continue - if is_active: - status = (panel_status or "active").lower() - else: - status = (panel_status or "expired").lower() - out[uid] = { - "status": status, - "end_date": end_date.isoformat() if end_date else None, - } - for uid in user_ids: - if uid not in out: - out[uid] = {"status": "bot_only", "end_date": None} - return out - - -async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]: - """Return ``{user_id: cache_key}`` for users with a cached Telegram avatar. - - The cache key is the row's ``updated_at`` timestamp — used as a - cache-buster query param so the browser refetches when the avatar - changes. - """ - - if not user_ids: - return {} - stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where( - UserTelegramAvatar.user_id.in_(user_ids) - ) - rows = (await session.execute(stmt)).all() - return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows} - - -async def admin_user_avatar_route(request: web.Request) -> web.Response: - """Serve the cached Telegram avatar for any user (admin-only). - - Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL - and uses admin auth. Only the cached blob from - ``user_telegram_avatars`` is served — refreshing from Telegram is the - job of the user-facing endpoint, so the admin list never blocks on a - Telegram round-trip. - """ - - _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - avatar = await session.get(UserTelegramAvatar, target_id) - - if not avatar: - raise web.HTTPNotFound(text="avatar_not_cached") - - etag = ( - f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"' - if avatar.updated_at - else None - ) - 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 - - -def _ranked_active_subscriptions_sq(now: datetime): - """Latest active subscription per user (same ordering as subscription_dal).""" - - rn = sa_func.row_number().over( - partition_by=Subscription.user_id, - order_by=( - Subscription.end_date.desc(), - Subscription.subscription_id.desc(), - ), - ) - inner = ( - select( - Subscription.user_id, - Subscription.subscription_id, - Subscription.premium_used_bytes, - Subscription.premium_baseline_bytes, - Subscription.premium_topup_balance_bytes, - Subscription.premium_topup_used_bytes, - Subscription.premium_bonus_bytes, - Subscription.premium_unlimited_override, - rn.label("rn"), - ) - .where( - Subscription.is_active.is_(True), - Subscription.end_date > now, - ) - .subquery() - ) - return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub") - - -async def _bulk_active_subscriptions_for_users( - session: AsyncSession, user_ids: List[int] -) -> Dict[int, Subscription]: - """Active subscription row per user (for admin list premium traffic column).""" - - if not user_ids: - return {} - now = datetime.now(timezone.utc) - stmt = ( - select(Subscription) - .where( - Subscription.user_id.in_(user_ids), - Subscription.is_active.is_(True), - Subscription.end_date > now, - ) - .order_by( - Subscription.user_id.asc(), - Subscription.end_date.desc(), - Subscription.subscription_id.desc(), - ) - ) - rows = (await session.execute(stmt)).scalars().all() - out: Dict[int, Subscription] = {} - for sub in rows: - uid = int(sub.user_id) - if uid not in out: - out[uid] = sub - return out - - -async def _filter_and_sort_users( - session: AsyncSession, - *, - query: str = "", - filter_value: str, - panel_status: str = "all", - premium_traffic: str = "all", - sort_value: str, - page: int, - page_size: int, -) -> tuple[List[User], int]: - """Return paginated users with optional search, filter and sort applied.""" - - now = datetime.now(timezone.utc) - sort_key = (sort_value or "registered_desc").lower() - pt_filter = (premium_traffic or "all").lower() - needs_premium_sq = pt_filter != "all" or sort_key in { - "premium_ratio_asc", - "premium_ratio_desc", - } - - stmt = select(User) - count_stmt = select(sa_func.count(User.user_id)) - - sq = None - ratio_expr = None - plim_expr = None - pu_expr = None - - if needs_premium_sq: - sq = _ranked_active_subscriptions_sq(now) - stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id) - count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id) - pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0) - plim_expr = ( - sa_func.coalesce(sq.c.premium_baseline_bytes, 0) - + sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0) - + sa_func.coalesce(sq.c.premium_topup_used_bytes, 0) - + pb - ) - pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0) - ratio_expr = case( - (sq.c.user_id.is_(None), None), - (sq.c.premium_unlimited_override.is_(True), None), - (plim_expr <= 0, None), - else_=cast(pu_expr, Float) / cast(plim_expr, Float), - ) - - search_cond = _user_search_condition(query) - if search_cond is not None: - stmt = stmt.where(search_cond) - count_stmt = count_stmt.where(search_cond) - - f = (filter_value or "all").lower() - if f == "banned": - cond = User.is_banned.is_(True) - elif f == "active": - cond = User.is_banned.is_(False) - elif f == "tg_linked": - cond = User.telegram_id.is_not(None) - elif f == "no_tg": - cond = User.telegram_id.is_(None) - elif f == "email_linked": - cond = User.email.is_not(None) - elif f == "no_email": - cond = User.email.is_(None) - elif f == "panel_linked": - cond = User.panel_user_uuid.is_not(None) - else: - cond = None - - if cond is not None: - stmt = stmt.where(cond) - count_stmt = count_stmt.where(cond) - - panel_cond = _user_panel_status_condition(panel_status) - if panel_cond is not None: - stmt = stmt.where(panel_cond) - count_stmt = count_stmt.where(panel_cond) - - if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None: - if pt_filter == "none": - premium_cond = or_( - sq.c.user_id.is_(None), - and_( - sq.c.premium_unlimited_override.is_(False), - plim_expr <= 0, - ), - ) - stmt = stmt.where(premium_cond) - count_stmt = count_stmt.where(premium_cond) - elif pt_filter == "unlimited": - premium_cond = and_( - sq.c.user_id.isnot(None), - sq.c.premium_unlimited_override.is_(True), - ) - stmt = stmt.where(premium_cond) - count_stmt = count_stmt.where(premium_cond) - elif pt_filter == "good": - premium_cond = and_( - sq.c.user_id.isnot(None), - sq.c.premium_unlimited_override.is_(False), - plim_expr > 0, - (100 * pu_expr) < (85 * plim_expr), - ) - stmt = stmt.where(premium_cond) - count_stmt = count_stmt.where(premium_cond) - elif pt_filter == "warn": - premium_cond = and_( - sq.c.user_id.isnot(None), - sq.c.premium_unlimited_override.is_(False), - plim_expr > 0, - (100 * pu_expr) >= (85 * plim_expr), - pu_expr < plim_expr, - ) - stmt = stmt.where(premium_cond) - count_stmt = count_stmt.where(premium_cond) - elif pt_filter == "critical": - premium_cond = and_( - sq.c.user_id.isnot(None), - sq.c.premium_unlimited_override.is_(False), - plim_expr > 0, - pu_expr >= plim_expr, - ) - stmt = stmt.where(premium_cond) - count_stmt = count_stmt.where(premium_cond) - - sort_map = { - "registered_desc": User.registration_date.desc().nullslast(), - "registered_asc": User.registration_date.asc().nullslast(), - "name_asc": ( - sa_func.coalesce(User.first_name, User.username, User.email).asc(), - User.user_id.asc(), - ), - "name_desc": ( - sa_func.coalesce(User.first_name, User.username, User.email).desc(), - User.user_id.desc(), - ), - "id_asc": User.user_id.asc(), - "id_desc": User.user_id.desc(), - } - - if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc": - stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc()) - elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc": - stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc()) - else: - order = sort_map.get(sort_key, sort_map["registered_desc"]) - if isinstance(order, tuple): - stmt = stmt.order_by(*order) - else: - stmt = stmt.order_by(order) - - stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1)) - - users = (await session.execute(stmt)).scalars().all() - total = (await session.execute(count_stmt)).scalar_one() - return users, int(total) - - -def _user_panel_status_condition(panel_status: str): - status = (panel_status or "all").lower() - if status not in {"active", "expired", "limited"}: - return None - - normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, "")) - blank_status = or_( - Subscription.status_from_panel.is_(None), Subscription.status_from_panel == "" - ) - if status == "active": - status_cond = or_( - normalized_status == "active", blank_status & Subscription.is_active.is_(True) - ) - elif status == "expired": - status_cond = or_( - normalized_status == "expired", blank_status & Subscription.is_active.is_(False) - ) - else: - status_cond = normalized_status == "limited" - - return ( - select(Subscription.subscription_id) - .where(Subscription.user_id == User.user_id, status_cond) - .exists() - ) - - -def _user_search_condition(query: str): - raw = (query or "").strip().lstrip("@") - if not raw: - return None - - like = f"%{raw}%" - conditions = [ - User.username.ilike(like), - User.first_name.ilike(like), - User.last_name.ilike(like), - User.email.ilike(like), - ] - if raw.isdigit(): - numeric = int(raw) - conditions.extend([User.user_id == numeric, User.telegram_id == numeric]) - - return or_(*conditions) - - -async def admin_user_detail_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - async_session_factory: sessionmaker = request.app["async_session_factory"] - settings: Settings = request.app["settings"] - - async with async_session_factory() as session: - user = await user_dal.get_user_by_id(session, target_id) - if not user: - return _error(404, "not_found", "User not found") - - active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - latest_subs_stmt = ( - select(Subscription) - .where(Subscription.user_id == target_id) - .order_by(Subscription.start_date.desc().nullslast()) - .limit(20) - ) - latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() - total_paid = await payment_dal.get_user_total_paid(session, target_id) - recent_payments_stmt = ( - select(Payment) - .where(Payment.user_id == target_id) - .order_by(Payment.created_at.desc()) - .limit(20) - ) - recent_payments = (await session.execute(recent_payments_stmt)).scalars().all() - log_count = await message_log_dal.count_user_message_logs(session, target_id) - avatar_keys = await _bulk_user_avatar_keys(session, [target_id]) - - # Referral links — both the bot deep-link and the webapp deep-link. - referral_code: Optional[str] = None - try: - referral_code = await user_dal.ensure_referral_code(session, user) - await session.commit() - except Exception as exc_ref: # pragma: no cover — defensive - logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref) - await session.rollback() - - referral_service: Optional[ReferralService] = request.app.get("referral_service") - bot_username = request.app.get("bot_username") or "" - referral_bot_link: Optional[str] = None - if referral_service and bot_username and referral_code: - try: - async with async_session_factory() as session: - referral_bot_link = await referral_service.generate_referral_link( - session, bot_username, target_id - ) - except Exception as exc_link: # pragma: no cover - logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link) - referral_webapp_link = _build_admin_webapp_referral_link( - getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None), - referral_code, - ) - - # Subscription page URL — the raw panel `subscriptionUrl` that the user - # imports into their VPN client. May be missing if the user has never - # been provisioned on the panel. - subscription_url: Optional[str] = None - panel_uuid = getattr(user, "panel_user_uuid", None) - if panel_uuid: - subscription_service = request.app.get("subscription_service") - panel_service = getattr(subscription_service, "panel_service", None) - if panel_service is not None: - try: - panel_data = await panel_service.get_user_by_uuid(panel_uuid) - if panel_data: - subscription_url = panel_data.get("subscriptionUrl") or None - except Exception as exc_panel: # pragma: no cover - logger.warning( - "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", - target_id, - panel_uuid, - exc_panel, - ) - - serialized_user = _serialize_user(user) - serialized_user["avatar_url"] = ( - f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}" - if target_id in avatar_keys - else None - ) - - return _ok( - { - "user": serialized_user, - "active_subscription": _serialize_subscription(active_sub) if active_sub else None, - "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], - "total_paid": float(total_paid), - "recent_payments": [_serialize_payment(p) for p in recent_payments], - "log_count": int(log_count or 0), - "subscription_url": subscription_url, - "referral": { - "code": referral_code, - "bot_link": referral_bot_link, - "webapp_link": referral_webapp_link, - }, - } - ) - - -def _build_admin_webapp_referral_link( - base_url: Optional[str], referral_code: Optional[str] -) -> Optional[str]: - """Mirror of ``subscription_webapp._build_webapp_referral_link``. - - Kept local to avoid a cross-module import cycle (subscription_webapp - imports admin_api). - """ - if not base_url or not referral_code: - return None - parts = urlsplit(base_url) - query = dict(parse_qsl(parts.query, keep_blank_values=True)) - query["ref"] = f"u{referral_code}" - new_query = "&".join(f"{k}={v}" for k, v in query.items()) - return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) - - -async def admin_user_ban_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - desired = bool(payload.get("banned")) - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - user = await user_dal.get_user_by_id(session, target_id) - if not user: - return _error(404, "not_found") - user.is_banned = bool(desired) - await session.commit() - await session.refresh(user) - return _ok({"user": _serialize_user(user)}) - - -async def admin_user_message_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - text = str(payload.get("text") or "").strip() - if not text: - return _error(400, "empty_text") - - queue_manager = get_queue_manager() - if not queue_manager: - return _error(503, "queue_unavailable") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - target_user = await user_dal.get_user_by_id(session, target_id) - if not target_user or not target_user.telegram_id: - return _error(404, "no_telegram_account") - - try: - await send_message_via_queue( - queue_manager, - int(target_user.telegram_id), - MessageContent(content_type="text", text=text), - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception as exc: - logger.warning("Admin direct message failed: %s", exc) - return _error(502, "send_failed", str(exc)) - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_direct_message_webapp", - "content": text[:4000], - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - - return _ok({}) - - -async def admin_user_message_preview_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - admin_telegram_id = request.get("admin_telegram_id") - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - text = str(payload.get("text") or "").strip() - if not text: - return _error(400, "empty_text") - if not admin_telegram_id: - return _error(403, "admin_telegram_unavailable") - - queue_manager = get_queue_manager() - if not queue_manager: - return _error(503, "queue_unavailable") - - try: - await send_message_via_queue( - queue_manager, - int(admin_telegram_id), - MessageContent(content_type="text", text=text), - parse_mode="HTML", - disable_web_page_preview=True, - ) - except Exception as exc: - logger.warning("Admin direct message preview failed: %s", exc) - return _error(502, "preview_failed", str(exc)) - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_direct_message_preview_webapp", - "content": text[:4000], - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - - return _ok({}) - - -async def admin_user_delete_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - ok = await user_dal.delete_user_and_relations(session, target_id) - if not ok: - await session.rollback() - return _error(404, "not_found") - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_delete_user_webapp", - "content": f"Deleted user_id={target_id}", - "is_admin_event": True, - }, - ) - await session.commit() - return _ok({}) - - -async def admin_user_reset_trial_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - panel_service = request.app.get("panel_service") - subscription_service = request.app.get("subscription_service") - if panel_service is None or subscription_service is None: - return _error(503, "service_unavailable") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - user = await user_dal.get_user_by_id(session, target_id) - if not user: - return _error(404, "not_found") - - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if active: - await session.delete(active) - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_reset_trial_webapp", - "content": f"Reset trial for user_id={target_id}", - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - await session.commit() - return _ok({}) - - -async def admin_user_premium_override_route(request: web.Request) -> web.Response: - """Premium-squad traffic overrides only (unlimited toggle + bonus GB).""" - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - subscription_service = request.app.get("subscription_service") - - unlimited = bool(payload.get("unlimited")) - bonus_bytes_raw = payload.get("bonus_bytes") - bonus_gb_raw = payload.get("bonus_gb") - if bonus_bytes_raw is None and bonus_gb_raw is None: - bonus_bytes = 0 - elif bonus_bytes_raw is not None: - try: - bonus_bytes = int(bonus_bytes_raw) - except (TypeError, ValueError): - return _error(400, "invalid_bonus", "bonus_bytes must be an integer") - else: - try: - bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3))) - except (TypeError, ValueError): - return _error(400, "invalid_bonus", "bonus_gb must be a number") - - if bonus_bytes < 0: - return _error(400, "invalid_bonus", "bonus must be non-negative") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if not active: - return _error(404, "no_active_subscription") - - active.premium_unlimited_override = bool(unlimited) - active.premium_bonus_bytes = int(bonus_bytes) - if active.premium_unlimited_override: - active.premium_is_limited = False - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_premium_override_webapp", - "content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"), - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - await session.commit() - await session.refresh(active) - - if subscription_service is not None: - await subscription_service.sync_premium_squad_access_to_panel(session, target_id) - await session.commit() - await session.refresh(active) - - return _ok({"subscription": _serialize_subscription(active)}) - - -async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response: - """Main (regular) traffic: unlimited-style ceiling + admin bonus GB.""" - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - - unlimited = bool(payload.get("unlimited")) - regular_bonus_bytes_raw = payload.get("regular_bonus_bytes") - regular_bonus_gb_raw = payload.get("regular_bonus_gb") - if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None: - regular_bonus_bytes = 0 - elif regular_bonus_bytes_raw is not None: - try: - regular_bonus_bytes = int(regular_bonus_bytes_raw) - except (TypeError, ValueError): - return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer") - else: - try: - regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3))) - except (TypeError, ValueError): - return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number") - - if regular_bonus_bytes < 0: - return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative") - - subscription_service = request.app.get("subscription_service") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if not active: - return _error(404, "no_active_subscription") - - active.regular_unlimited_override = bool(unlimited) - active.regular_bonus_bytes = int(regular_bonus_bytes) - - if subscription_service is not None: - await subscription_service.sync_main_traffic_limit_to_panel(session, target_id) - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_regular_traffic_override_webapp", - "content": ( - f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}" - ), - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - await session.commit() - await session.refresh(active) - - return _ok({"subscription": _serialize_subscription(active)}) - - -async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: - """Credit regular or premium traffic to a user without a payment. - - Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively - ``"bytes": int``). Mirrors the same effect as a user-purchased top-up: - the chosen balance grows, panel limit/squads are refreshed, and an entry - is added to ``traffic_topups`` with ``kind="admin_topup"`` or - ``kind="admin_premium_topup"`` and ``payment_id=NULL``. - """ - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - - kind = str(payload.get("kind") or "regular").strip().lower() - if kind not in {"regular", "premium"}: - return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'") - - bytes_raw = payload.get("bytes") - gb_raw = payload.get("gb") - if bytes_raw is None and gb_raw is None: - return _error(400, "missing_amount", "either 'gb' or 'bytes' is required") - try: - if bytes_raw is not None: - grant_bytes = int(bytes_raw) - gb_value = grant_bytes / (1024**3) - else: - gb_value = float(gb_raw) - grant_bytes = int(round(gb_value * (1024**3))) - except (TypeError, ValueError): - return _error(400, "invalid_amount", "amount must be a positive number") - if gb_value <= 0 or grant_bytes <= 0: - return _error(400, "invalid_amount", "amount must be positive") - - subscription_service = request.app.get("subscription_service") - if subscription_service is None: - return _error(503, "subscription_service_unavailable") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - if not active: - return _error(404, "no_active_subscription") - - if kind == "regular": - result = await subscription_service.admin_grant_topup(session, target_id, gb_value) - else: - result = await subscription_service.admin_grant_premium_topup( - session, target_id, gb_value - ) - if not result: - await session.rollback() - return _error( - 422, - "grant_failed", - "Unable to credit traffic (missing tariff/squads or panel error)", - ) - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_traffic_grant_webapp", - "content": f"kind={kind} bytes={grant_bytes}", - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - await session.commit() - - refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - - return _ok( - { - "subscription": _serialize_subscription(refreshed) if refreshed else None, - "grant": { - "kind": kind, - "granted_bytes": grant_bytes, - "granted_gb": gb_value, - }, - } - ) - - -async def admin_user_extend_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - target_id = int(request.match_info["user_id"]) - payload = await _read_json(request) - try: - days = int(payload.get("days") or 0) - except (TypeError, ValueError): - return _error(400, "invalid_days") - if days <= 0: - return _error(400, "invalid_days") - - subscription_service = request.app.get("subscription_service") - if subscription_service is None: - return _error(503, "subscription_service_unavailable") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - new_end = await subscription_service.extend_active_subscription_days( - session, - target_id, - days, - "admin_extend_subscription_webapp", - ) - if not new_end: - await session.rollback() - return _error(500, "extend_failed") - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_extend_subscription_webapp", - "content": f"+{days}d -> {new_end.isoformat()}", - "is_admin_event": True, - "target_user_id": target_id, - }, - ) - await session.commit() - - refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id) - - return _ok( - { - "subscription": _serialize_subscription(refreshed) if refreshed else None, - } - ) - - -# ─── Payments ────────────────────────────────────────────────────── - - -async def admin_payments_list_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - async_session_factory: sessionmaker = request.app["async_session_factory"] - - page = max(0, int(request.query.get("page", 0) or 0)) - page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) - - async with async_session_factory() as session: - from sqlalchemy.orm import selectinload - - stmt = ( - select(Payment) - .options(selectinload(Payment.user)) - .order_by(Payment.created_at.desc()) - .offset(page * page_size) - .limit(page_size) - ) - rows = (await session.execute(stmt)).scalars().all() - total = await payment_dal.get_payments_count(session) - - return _ok( - { - "payments": [_serialize_payment(p) for p in rows], - "page": page, - "page_size": page_size, - "total": int(total or 0), - } - ) - - -async def admin_payments_export_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - async_session_factory: sessionmaker = request.app["async_session_factory"] - - async with async_session_factory() as session: - from sqlalchemy.orm import selectinload - - stmt = ( - select(Payment) - .options(selectinload(Payment.user)) - .order_by(Payment.created_at.desc()) - .limit(10000) - ) - rows = (await session.execute(stmt)).scalars().all() - - buffer = io.StringIO() - writer = csv.writer(buffer) - writer.writerow( - [ - "payment_id", - "user_id", - "user_label", - "provider", - "provider_payment_id", - "amount", - "currency", - "status", - "description", - "duration_months", - "sale_mode", - "tariff_key", - "created_at", - ] - ) - for p in rows: - label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id) - writer.writerow( - [ - p.payment_id, - p.user_id, - label, - p.provider, - p.provider_payment_id or "", - p.amount, - p.currency, - p.status, - p.description or "", - p.subscription_duration_months or "", - p.sale_mode or "", - p.tariff_key or "", - p.created_at.isoformat() if p.created_at else "", - ] - ) - - response = web.Response( - body=buffer.getvalue().encode("utf-8-sig"), - content_type="text/csv", - charset="utf-8", - ) - response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"' - return response - - -# ─── Promo codes ─────────────────────────────────────────────────── - - -async def admin_promos_list_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - async_session_factory: sessionmaker = request.app["async_session_factory"] - page = max(0, int(request.query.get("page", 0) or 0)) - page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) - async with async_session_factory() as session: - promos = await promo_code_dal.get_all_promo_codes_with_details( - session, limit=page_size, offset=page * page_size - ) - total = await promo_code_dal.get_promo_codes_count(session) - return _ok( - { - "promos": [_serialize_promo(p) for p in promos], - "page": page, - "page_size": page_size, - "total": int(total or 0), - } - ) - - -async def admin_promo_create_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - payload = await _read_json(request) - code = str(payload.get("code") or "").strip().upper() - bonus_days = int(payload.get("bonus_days") or 0) - max_activations = int(payload.get("max_activations") or 0) - valid_days = payload.get("valid_days") - if not code or bonus_days <= 0 or max_activations <= 0: - return _error(400, "invalid_payload") - - valid_until = None - if valid_days: - try: - valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days)) - except (TypeError, ValueError): - return _error(400, "invalid_valid_days") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - existing = await promo_code_dal.get_promo_code_by_code(session, code) - if existing: - return _error(409, "duplicate_code") - promo = await promo_code_dal.create_promo_code( - session, - { - "code": code, - "bonus_days": bonus_days, - "max_activations": max_activations, - "valid_until": valid_until, - "created_by_admin_id": actor_id, - "is_active": True, - }, - ) - await session.commit() - return _ok({"promo": _serialize_promo(promo)}) - - -async def admin_promo_update_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - promo_id = int(request.match_info["promo_id"]) - payload = await _read_json(request) - update_data: Dict[str, Any] = {} - if "is_active" in payload: - update_data["is_active"] = bool(payload["is_active"]) - if "bonus_days" in payload and payload["bonus_days"] is not None: - update_data["bonus_days"] = int(payload["bonus_days"]) - if "max_activations" in payload and payload["max_activations"] is not None: - update_data["max_activations"] = int(payload["max_activations"]) - - if not update_data: - return _error(400, "no_changes") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - promo = await promo_code_dal.update_promo_code(session, promo_id, update_data) - if not promo: - return _error(404, "not_found") - await session.commit() - await session.refresh(promo) - return _ok({"promo": _serialize_promo(promo)}) - - -async def admin_promo_delete_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - promo_id = int(request.match_info["promo_id"]) - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - promo = await promo_code_dal.delete_promo_code(session, promo_id) - if not promo: - return _error(404, "not_found") - await session.commit() - return _ok({}) - - -# ─── Logs ────────────────────────────────────────────────────────── - - -async def admin_logs_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - async_session_factory: sessionmaker = request.app["async_session_factory"] - - page = max(0, int(request.query.get("page", 0) or 0)) - page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50))) - user_filter = request.query.get("user_id") - - async with async_session_factory() as session: - if user_filter: - try: - user_id = int(user_filter) - except (TypeError, ValueError): - return _error(400, "invalid_user_id") - entries = await message_log_dal.get_user_message_logs( - session, user_id, page_size, page * page_size - ) - total = await message_log_dal.count_user_message_logs(session, user_id) - else: - entries = await message_log_dal.get_all_message_logs( - session, page_size, page * page_size - ) - total = await message_log_dal.count_all_message_logs(session) - - return _ok( - { - "logs": [_serialize_log(entry) for entry in entries], - "page": page, - "page_size": page_size, - "total": int(total or 0), - } - ) - - -# ─── Broadcast ───────────────────────────────────────────────────── - - -async def admin_broadcast_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - payload = await _read_json(request) - text = str(payload.get("text") or "").strip() - target = str(payload.get("target") or "all").strip().lower() - if not text: - return _error(400, "empty_text") - if target not in {"all", "active", "inactive"}: - target = "all" - - queue_manager = get_queue_manager() - if not queue_manager: - return _error(503, "queue_unavailable") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - if target == "active": - user_ids = await user_dal.get_user_ids_with_active_subscription(session) - elif target == "inactive": - user_ids = await user_dal.get_user_ids_without_active_subscription(session) - else: - user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) - - sent = 0 - failed = 0 - for uid in user_ids: - try: - await send_message_via_queue( - queue_manager, - int(uid), - MessageContent(content_type="text", text=text), - parse_mode="HTML", - disable_web_page_preview=True, - ) - sent += 1 - except Exception as exc: - failed += 1 - logger.debug("Broadcast queue failed for %s: %s", uid, exc) - - await message_log_dal.create_message_log( - session, - { - "user_id": actor_id, - "event_type": "admin_broadcast_webapp", - "content": f"target={target} sent={sent} failed={failed} text={text[:120]}", - "is_admin_event": True, - }, - ) - - return _ok({"queued": sent, "failed": failed, "target": target}) - - -# ─── Sync ────────────────────────────────────────────────────────── - - -async def admin_sync_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - panel_service = request.app.get("panel_service") - if panel_service is None: - return _error(503, "panel_unavailable") - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - i18n = request.app.get("i18n") - - from bot.handlers.admin.sync_admin import perform_sync - - async with async_session_factory() as session: - result = await perform_sync( - panel_service=panel_service, - session=session, - settings=settings, - i18n_instance=i18n, - ) - return _ok({"result": result or {}}) - - -# ─── Ad campaigns ────────────────────────────────────────────────── - - -async def admin_ads_list_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - campaigns = await ad_dal.list_campaigns(session) - totals = await ad_dal.get_totals(session) - results = [] - for campaign in campaigns: - try: - stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id) - except Exception: - stats = {} - results.append(_serialize_ad(campaign, stats)) - return _ok({"campaigns": results, "totals": totals}) - - -async def admin_ad_create_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - payload = await _read_json(request) - source = str(payload.get("source") or "").strip() - start_param = str(payload.get("start_param") or "").strip() - cost = float(payload.get("cost") or 0.0) - if not source or not start_param: - return _error(400, "invalid_payload") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - existing = await ad_dal.get_campaign_by_start_param(session, start_param) - if existing: - return _error(409, "duplicate_start_param") - campaign = await ad_dal.create_campaign( - session, - source=source, - start_param=start_param, - cost=cost, - ) - await session.commit() - await session.refresh(campaign) - return _ok({"campaign": _serialize_ad(campaign)}) - - -async def admin_ad_toggle_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - campaign_id = int(request.match_info["campaign_id"]) - payload = await _read_json(request) - is_active = bool(payload.get("is_active", True)) - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active) - if not ok: - return _error(404, "not_found") - await session.commit() - return _ok({}) - - -async def admin_ad_delete_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - campaign_id = int(request.match_info["campaign_id"]) - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - ok = await ad_dal.delete_campaign(session, campaign_id) - if not ok: - return _error(404, "not_found") - await session.commit() - return _ok({}) - - -# ─── Settings (manifest + overrides) ─────────────────────────────── - - -async def admin_settings_get_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - - async with async_session_factory() as session: - overrides = await app_settings_dal.get_overrides_with_meta(session) - - overrides_by_key = {entry["key"]: entry for entry in overrides} - - fields = manifest_payload() - sections: Dict[str, Dict[str, Any]] = {} - for field in fields: - key = field["key"] - section_id = field["section"] - if section_id not in sections: - sections[section_id] = { - "id": section_id, - "order": field["section_order"], - "fields": [], - } - override = overrides_by_key.get(key) - value = current_value(settings, key) - is_secret = bool(field.get("secret")) - response_field = { - **field, - "value": "" if is_secret else value, - "overridden": bool(override), - "updated_at": override.get("updated_at") if override else None, - } - if is_secret: - response_field["has_value"] = bool(value) - sections[section_id]["fields"].append(response_field) - - ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) - return _ok({"sections": ordered_sections}) - - -async def admin_settings_patch_route(request: web.Request) -> web.Response: - actor_id = _require_admin_user_id(request) - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - payload = await _read_json(request) - updates = payload.get("updates") or {} - deletes = payload.get("deletes") or [] - if not isinstance(updates, dict): - return _error(400, "invalid_updates") - if not isinstance(deletes, list): - return _error(400, "invalid_deletes") - - result = await update_overrides( - settings, - async_session_factory, - updates=updates, - deletes=deletes, - actor_id=actor_id, - ) - if not result.get("ok"): - return web.json_response( - {"ok": False, "error": "validation_failed", "errors": result.get("errors", {})}, - status=400, - ) - - # Bust the public webapp settings cache so users see new values immediately. - cache = request.app.get("webapp_settings_cache") - if isinstance(cache, dict): - cache["ts"] = 0.0 - cache["data"] = {} - - return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) - - -# ─── Tariffs catalog ──────────────────────────────────────────────── - - -async def admin_tariffs_get_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - settings: Settings = request.app["settings"] - path = _tariffs_config_path(settings) - - try: - config = settings.tariffs_config - except Exception as exc: - logger.warning("Invalid tariffs config requested from admin UI: %s", exc) - return _error(400, "invalid_tariffs_config", str(exc)) - - if config is None: - return _ok( - { - "exists": path.exists(), - "path": str(path), - "catalog": { - "default_tariff": "", - "topup_packages_default": {"rub": [], "stars": []}, - "tariffs": [], - }, - } - ) - - return _ok( - { - "exists": True, - "path": str(path), - "catalog": _tariffs_config_payload(config), - } - ) - - -async def admin_tariffs_save_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - settings: Settings = request.app["settings"] - payload = await _read_json(request) - catalog = payload.get("catalog") if "catalog" in payload else payload - if not isinstance(catalog, dict): - return _error(400, "invalid_payload", "catalog must be an object") - - try: - config = TariffsConfig.model_validate(catalog) - except (ValidationError, ValueError) as exc: - return _error(400, "invalid_tariffs_config", str(exc)) - - path = _tariffs_config_path(settings) - try: - _write_tariffs_config_file(path, config) - except OSError as exc: - logger.exception("Failed to write tariffs config to %s", path) - return _error(500, "write_failed", str(exc)) - - cache = request.app.get("webapp_settings_cache") - if isinstance(cache, dict): - cache["ts"] = 0.0 - cache["data"] = {} - - return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) - - -async def admin_panel_internal_squads_route(request: web.Request) -> web.Response: - _require_admin_user_id(request) - panel_service = request.app.get("panel_service") - if panel_service is None: - return _error(503, "panel_unavailable", "Panel service unavailable") - try: - squads = await panel_service.get_internal_squads() - except Exception as exc: - logger.exception("Failed to load internal squads from panel") - return _error(502, "panel_request_failed", str(exc)) - if squads is None: - return _error(502, "panel_request_failed", "Unable to load internal squads") - items = [] - for squad in squads: - if not isinstance(squad, dict): - continue - uuid = squad.get("uuid") or squad.get("id") - if not uuid: - continue - items.append( - { - "uuid": str(uuid), - "name": squad.get("name") or squad.get("title") or str(uuid), - "members_count": squad.get("membersCount") - or squad.get("usersCount") - or squad.get("members_count"), - "active_inbounds_count": squad.get("activeInboundsCount") - or squad.get("active_inbounds_count"), - } - ) - return _ok({"squads": items}) - - -# ─── Router setup ────────────────────────────────────────────────── - - -def setup_admin_routes(app: web.Application) -> None: - router = app.router - router.add_get("/api/admin/me", admin_me_route) - router.add_get("/api/admin/stats", admin_stats_route) - - router.add_get("/api/admin/users", admin_users_list_route) - router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route) - router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route) - router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route) - router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route) - router.add_post( - "/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route - ) - router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) - router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) - router.add_post( - "/api/admin/users/{user_id:-?\\d+}/premium-override", - admin_user_premium_override_route, - ) - router.add_post( - "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", - admin_user_regular_traffic_override_route, - ) - router.add_post( - "/api/admin/users/{user_id:-?\\d+}/traffic-grant", - admin_user_traffic_grant_route, - ) - router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route) - - router.add_get("/api/admin/payments", admin_payments_list_route) - router.add_get("/api/admin/payments/export.csv", admin_payments_export_route) - - router.add_get("/api/admin/promos", admin_promos_list_route) - router.add_post("/api/admin/promos", admin_promo_create_route) - router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route) - router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route) - - router.add_get("/api/admin/logs", admin_logs_route) - - router.add_post("/api/admin/broadcast", admin_broadcast_route) - router.add_post("/api/admin/sync", admin_sync_route) - - router.add_get("/api/admin/ads", admin_ads_list_route) - router.add_post("/api/admin/ads", admin_ad_create_route) - router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route) - router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route) - - router.add_get("/api/admin/settings", admin_settings_get_route) - router.add_patch("/api/admin/settings", admin_settings_patch_route) - - router.add_get("/api/admin/tariffs", admin_tariffs_get_route) - router.add_put("/api/admin/tariffs", admin_tariffs_save_route) - router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) +__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__")) diff --git a/bot/app/web/admin_api_impl/__init__.py b/bot/app/web/admin_api_impl/__init__.py new file mode 100644 index 0000000..9755cce --- /dev/null +++ b/bot/app/web/admin_api_impl/__init__.py @@ -0,0 +1 @@ +"""Domain modules for the admin Mini App API.""" diff --git a/bot/app/web/admin_api_impl/_runtime.py b/bot/app/web/admin_api_impl/_runtime.py new file mode 100644 index 0000000..560aef7 --- /dev/null +++ b/bot/app/web/admin_api_impl/_runtime.py @@ -0,0 +1,65 @@ +# ruff: noqa: F401,F403,F405,I001 +"""HTTP API powering the admin section of the subscription Mini App. + +All routes require an authenticated webapp session (cookie or Bearer +token) AND the resolved Telegram user id must appear in +``settings.ADMIN_IDS``. Authorization is enforced via the +``_require_admin_user_id`` helper, never trusted from the client. +""" + +from __future__ import annotations + +import csv +import io +import json +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlsplit, urlunsplit + +from aiohttp import web +from pydantic import ValidationError +from sqlalchemy import Float, and_, case, cast, or_, select +from sqlalchemy import func as sa_func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.app.web.admin_settings_manifest import ( + manifest_payload, +) +from bot.services.referral_service import ReferralService +from bot.services.settings_override_service import ( + current_value, + update_overrides, +) +from bot.utils import MessageContent, send_message_via_queue +from bot.utils.message_queue import get_queue_manager +from config.settings import Settings +from config.tariffs_config import TariffsConfig +from db.dal import ( + ad_dal, + app_settings_dal, + message_log_dal, + panel_sync_dal, + payment_dal, + promo_code_dal, + subscription_dal, + user_dal, +) +from db.models import ( + AdCampaign, + MessageLog, + Payment, + PromoCode, + Subscription, + User, + UserTelegramAvatar, +) + +logger = logging.getLogger(__name__) + + +# ─── Auth ────────────────────────────────────────────────────────── + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/bot/app/web/admin_api_impl/ads.py b/bot/app/web/admin_api_impl/ads.py new file mode 100644 index 0000000..a84fa62 --- /dev/null +++ b/bot/app/web/admin_api_impl/ads.py @@ -0,0 +1,69 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_ads_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + campaigns = await ad_dal.list_campaigns(session) + totals = await ad_dal.get_totals(session) + results = [] + for campaign in campaigns: + try: + stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id) + except Exception: + stats = {} + results.append(_serialize_ad(campaign, stats)) + return _ok({"campaigns": results, "totals": totals}) + + +async def admin_ad_create_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + payload = await _read_json(request) + source = str(payload.get("source") or "").strip() + start_param = str(payload.get("start_param") or "").strip() + cost = float(payload.get("cost") or 0.0) + if not source or not start_param: + return _error(400, "invalid_payload") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + existing = await ad_dal.get_campaign_by_start_param(session, start_param) + if existing: + return _error(409, "duplicate_start_param") + campaign = await ad_dal.create_campaign( + session, + source=source, + start_param=start_param, + cost=cost, + ) + await session.commit() + await session.refresh(campaign) + return _ok({"campaign": _serialize_ad(campaign)}) + + +async def admin_ad_toggle_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + campaign_id = int(request.match_info["campaign_id"]) + payload = await _read_json(request) + is_active = bool(payload.get("is_active", True)) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active) + if not ok: + return _error(404, "not_found") + await session.commit() + return _ok({}) + + +async def admin_ad_delete_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + campaign_id = int(request.match_info["campaign_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + ok = await ad_dal.delete_campaign(session, campaign_id) + if not ok: + return _error(404, "not_found") + await session.commit() + return _ok({}) diff --git a/bot/app/web/admin_api_impl/auth.py b/bot/app/web/admin_api_impl/auth.py new file mode 100644 index 0000000..3e256c3 --- /dev/null +++ b/bot/app/web/admin_api_impl/auth.py @@ -0,0 +1,58 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +def _require_admin_user_id(request: web.Request) -> int: + """Return the authenticated user id, or raise 401/403 for non-admins.""" + + from bot.app.web.session import extract_authenticated_user_id + + settings: Settings = request.app["settings"] + 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", + ) + + admin_ids = settings.ADMIN_IDS or [] + db_user_telegram_id = request.get("admin_telegram_id") + if db_user_telegram_id is None: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "forbidden"}), + content_type="application/json", + ) + + if int(db_user_telegram_id) not in {int(x) for x in admin_ids}: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "forbidden"}), + content_type="application/json", + ) + return int(user_id) + + +@web.middleware +async def admin_auth_middleware(request: web.Request, handler): + """Resolve the Telegram id of the current user and stash it on the request. + + Doing this once per request lets every admin route call + ``_require_admin_user_id`` without re-querying the DB. + """ + + if not request.path.startswith("/api/admin"): + return await handler(request) + + from bot.app.web.session import extract_authenticated_user_id + + user_id = extract_authenticated_user_id(request) + if user_id: + 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 db_user and db_user.telegram_id: + request["admin_telegram_id"] = int(db_user.telegram_id) + elif db_user: + # No telegram_id yet (email-only user) — can't be an admin + request["admin_telegram_id"] = None + + return await handler(request) diff --git a/bot/app/web/admin_api_impl/broadcast.py b/bot/app/web/admin_api_impl/broadcast.py new file mode 100644 index 0000000..97f972e --- /dev/null +++ b/bot/app/web/admin_api_impl/broadcast.py @@ -0,0 +1,54 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_broadcast_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + payload = await _read_json(request) + text = str(payload.get("text") or "").strip() + target = str(payload.get("target") or "all").strip().lower() + if not text: + return _error(400, "empty_text") + if target not in {"all", "active", "inactive"}: + target = "all" + + queue_manager = get_queue_manager() + if not queue_manager: + return _error(503, "queue_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + if target == "active": + user_ids = await user_dal.get_user_ids_with_active_subscription(session) + elif target == "inactive": + user_ids = await user_dal.get_user_ids_without_active_subscription(session) + else: + user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) + + sent = 0 + failed = 0 + for uid in user_ids: + try: + await send_message_via_queue( + queue_manager, + int(uid), + MessageContent(content_type="text", text=text), + parse_mode="HTML", + disable_web_page_preview=True, + ) + sent += 1 + except Exception as exc: + failed += 1 + logger.debug("Broadcast queue failed for %s: %s", uid, exc) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_broadcast_webapp", + "content": f"target={target} sent={sent} failed={failed} text={text[:120]}", + "is_admin_event": True, + }, + ) + + return _ok({"queued": sent, "failed": failed, "target": target}) diff --git a/bot/app/web/admin_api_impl/common.py b/bot/app/web/admin_api_impl/common.py new file mode 100644 index 0000000..7a337c6 --- /dev/null +++ b/bot/app/web/admin_api_impl/common.py @@ -0,0 +1,367 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +def _ok(payload: Dict[str, Any], **extra) -> web.Response: + body = {"ok": True, **payload, **extra} + return web.json_response(body) + + +def _error(status: int, code: str, message: str = "") -> web.Response: + return web.json_response( + {"ok": False, "error": code, "message": message or code}, + status=status, + ) + + +async def _read_json(request: web.Request) -> Dict[str, Any]: + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _serialize_user(user: User) -> Dict[str, Any]: + return { + "user_id": int(user.user_id), + "telegram_id": int(user.telegram_id) if user.telegram_id else None, + "telegram_photo_url": user.telegram_photo_url, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "language_code": user.language_code, + "is_banned": bool(user.is_banned), + "registration_date": user.registration_date.isoformat() if user.registration_date else None, + "panel_user_uuid": user.panel_user_uuid, + "referral_code": user.referral_code, + "referred_by_id": int(user.referred_by_id) if user.referred_by_id else None, + } + + +def _premium_limit_bytes_from_subscription(sub: Subscription) -> int: + premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) + return ( + int(sub.premium_baseline_bytes or 0) + + int(sub.premium_topup_balance_bytes or 0) + + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + + premium_bonus_bytes + ) + + +def _premium_traffic_list_payload(sub: Optional[Subscription]) -> Dict[str, Any]: + """Premium traffic column when subscription has a finite premium quota (bytes > 0). + + Note: ``Subscription.premium_is_limited`` in the DB means *quota exhausted* for panel + routing, not 'tariff includes premium traffic' — do not use it here. + """ + + if sub is None: + return {"state": "none"} + if bool(getattr(sub, "premium_unlimited_override", False)): + return { + "state": "unlimited", + "unlimited": True, + "used_bytes": int(sub.premium_used_bytes or 0), + "limit_bytes": None, + "percent": None, + } + limit_bytes = _premium_limit_bytes_from_subscription(sub) + if limit_bytes <= 0: + return {"state": "none"} + used_bytes = int(sub.premium_used_bytes or 0) + ratio = float(used_bytes) / float(limit_bytes) if limit_bytes else 0.0 + pct = int(max(0, min(100, round(ratio * 100)))) + if ratio >= 1.0: + state = "critical" + elif ratio >= 0.85: + state = "warn" + else: + state = "good" + return { + "state": state, + "unlimited": False, + "used_bytes": used_bytes, + "limit_bytes": limit_bytes, + "percent": pct, + } + + +def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: + premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) + regular_bonus_bytes = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + regular_unlimited_override = bool(getattr(sub, "regular_unlimited_override", False)) + premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) + premium_limit_bytes = _premium_limit_bytes_from_subscription(sub) + return { + "subscription_id": int(sub.subscription_id), + "panel_user_uuid": sub.panel_user_uuid, + "panel_subscription_uuid": sub.panel_subscription_uuid, + "start_date": sub.start_date.isoformat() if sub.start_date else None, + "end_date": sub.end_date.isoformat() if sub.end_date else None, + "duration_months": sub.duration_months, + "is_active": bool(sub.is_active), + "status_from_panel": sub.status_from_panel, + "traffic_limit_bytes": sub.traffic_limit_bytes, + "traffic_used_bytes": sub.traffic_used_bytes, + "tier_baseline_bytes": sub.tier_baseline_bytes, + "topup_balance_bytes": sub.topup_balance_bytes, + "premium_used_bytes": sub.premium_used_bytes, + "premium_limit_bytes": premium_limit_bytes, + "premium_baseline_bytes": sub.premium_baseline_bytes, + "premium_topup_balance_bytes": sub.premium_topup_balance_bytes, + "premium_topup_used_bytes": getattr(sub, "premium_topup_used_bytes", 0), + "premium_bonus_bytes": premium_bonus_bytes, + "regular_bonus_bytes": regular_bonus_bytes, + "regular_unlimited_override": regular_unlimited_override, + "premium_unlimited_override": premium_unlimited_override, + "premium_is_limited": bool(sub.premium_is_limited), + "tariff_key": sub.tariff_key, + "auto_renew_enabled": bool(sub.auto_renew_enabled), + "provider": sub.provider, + "is_throttled": bool(sub.is_throttled), + } + + +def _payment_traffic_gb_split(payment: Payment) -> Tuple[Optional[float], Optional[float]]: + """For traffic purchases: ``(regular_gb, premium_gb)``. Other payments → (None, None).""" + if payment.purchased_gb is None: + return None, None + try: + gb = float(payment.purchased_gb) + except (TypeError, ValueError): + return None, None + sm = (payment.sale_mode or "").strip() + if not sm: + return None, None + base = sm.split("@", 1)[0].split("|", 1)[0].lower() + if base == "premium_topup": + return None, gb + if base in {"traffic", "traffic_package", "topup"}: + return gb, None + return None, None + + +def _payment_user_display_label(loaded_user: Any, payment_user_id: int) -> str: + """Human-facing name for payments tables: TG profile name, else email, else user id.""" + if loaded_user is None: + return str(payment_user_id) + tid = getattr(loaded_user, "telegram_id", None) + if tid is not None: + fn = (getattr(loaded_user, "first_name", None) or "").strip() + ln = (getattr(loaded_user, "last_name", None) or "").strip() + full = f"{fn} {ln}".strip() + if full: + return full + un = (getattr(loaded_user, "username", None) or "").strip() + if un: + return un if un.startswith("@") else f"@{un}" + return str(payment_user_id) + email = (getattr(loaded_user, "email", None) or "").strip() + if email: + return email + return str(payment_user_id) + + +def _serialize_payment(payment: Payment) -> Dict[str, Any]: + # Avoid lazy-loading `payment.user` outside an active SQLAlchemy session. + # Some admin routes serialize payments after the session scope is closed. + telegram_id = None + loaded_user = payment.__dict__.get("user") + user_label = _payment_user_display_label(loaded_user, int(payment.user_id)) + if loaded_user is not None: + tid = getattr(loaded_user, "telegram_id", None) + if tid is not None: + try: + telegram_id = int(tid) + except (TypeError, ValueError): + telegram_id = None + reg_gb, prem_gb = _payment_traffic_gb_split(payment) + return { + "payment_id": int(payment.payment_id), + "user_id": int(payment.user_id), + "user_label": user_label, + "telegram_id": telegram_id, + "traffic_regular_gb": reg_gb, + "traffic_premium_gb": prem_gb, + "provider": payment.provider, + "provider_payment_id": payment.provider_payment_id, + "amount": float(payment.amount), + "currency": payment.currency, + "status": payment.status, + "description": payment.description, + "subscription_duration_months": payment.subscription_duration_months, + "sale_mode": payment.sale_mode, + "tariff_key": payment.tariff_key, + "purchased_gb": payment.purchased_gb, + "purchased_hwid_devices": payment.purchased_hwid_devices, + "created_at": payment.created_at.isoformat() if payment.created_at else None, + } + + +def _serialize_promo(promo: PromoCode) -> Dict[str, Any]: + return { + "id": int(promo.promo_code_id), + "code": promo.code, + "bonus_days": int(promo.bonus_days), + "max_activations": int(promo.max_activations), + "current_activations": int(promo.current_activations or 0), + "is_active": bool(promo.is_active), + "valid_until": promo.valid_until.isoformat() if promo.valid_until else None, + "created_at": promo.created_at.isoformat() if promo.created_at else None, + "created_by_admin_id": int(promo.created_by_admin_id) + if promo.created_by_admin_id + else None, + } + + +def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return { + "id": int(campaign.ad_campaign_id), + "source": campaign.source, + "start_param": campaign.start_param, + "cost": float(campaign.cost or 0), + "is_active": bool(campaign.is_active), + "created_at": campaign.created_at.isoformat() if campaign.created_at else None, + "stats": totals or {}, + } + + +def _serialize_log(entry: MessageLog) -> Dict[str, Any]: + return { + "log_id": int(entry.log_id), + "user_id": int(entry.user_id) if entry.user_id else None, + "telegram_username": entry.telegram_username, + "telegram_first_name": entry.telegram_first_name, + "event_type": entry.event_type, + "content": entry.content, + "is_admin_event": bool(entry.is_admin_event), + "target_user_id": int(entry.target_user_id) if entry.target_user_id else None, + "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, + } + + +def _tariffs_config_path(settings: Settings) -> Path: + return Path(settings.TARIFFS_CONFIG_PATH).expanduser() + + +def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]: + return config.model_dump(mode="json", exclude_none=True) + + +def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: + data = _tariffs_config_payload(config) + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(f"{path.suffix}.tmp") + payload = json.dumps(data, ensure_ascii=False, indent=2) + "\n" + try: + tmp_path.write_text(payload, encoding="utf-8") + tmp_path.replace(path) + except PermissionError: + # A docker-compose single-file bind mount can make /app/config + # unwritable while the mounted tariffs.json itself is writable. + # Fall back to updating the existing file in-place. + if tmp_path.exists(): + try: + tmp_path.unlink() + except OSError: + pass + path.write_text(payload, encoding="utf-8") + + +def _panel_node_uuid_key(node: Dict[str, Any]) -> str: + uid = node.get("nodeUuid") or node.get("node_uuid") or node.get("uuid") or node.get("id") + return str(uid).strip().lower() if uid else "" + + +def _panel_node_users_online(node: Dict[str, Any]) -> Optional[int]: + uo = node.get("usersOnline") + if uo is None: + uo = node.get("users_online") + if uo is None: + uo = node.get("onlineUsers") or node.get("online_users") + if uo is None: + mg = node.get("metricGroups") + if isinstance(mg, dict): + uo = mg.get("onlineUsers") or mg.get("online_users") + if uo is None: + return None + try: + return int(uo) + except (TypeError, ValueError): + return None + + +def _panel_nodes_online_by_uuid(nodes_payload: Any) -> Dict[str, int]: + """Build node_uuid(lower) -> usersOnline from GET /system/stats/nodes payload.""" + out: Dict[str, int] = {} + raw_list: Optional[List[Any]] = None + if isinstance(nodes_payload, list): + raw_list = nodes_payload + elif isinstance(nodes_payload, dict): + raw_list = nodes_payload.get("nodes") + if raw_list is None: + raw_list = nodes_payload.get("items") or nodes_payload.get("data") + if not isinstance(raw_list, list): + return out + for n in raw_list: + if not isinstance(n, dict): + continue + key = _panel_node_uuid_key(n) + if not key: + continue + online = _panel_node_users_online(n) + if online is not None: + out[key] = online + return out + + +def _enrich_bandwidth_nodes_with_online( + bw: Any, + online_by_uuid: Dict[str, int], + online_by_name: Optional[Dict[str, int]] = None, +) -> None: + """Attach usersOnline to topNodes/series (UUID and optional node name).""" + if not isinstance(bw, dict): + return + if not online_by_uuid and not online_by_name: + return + for key in ("topNodes", "series"): + arr = bw.get(key) + if not isinstance(arr, list): + continue + for item in arr: + if not isinstance(item, dict): + continue + if item.get("usersOnline") is not None: + continue + uid = item.get("uuid") or item.get("nodeUuid") or item.get("node_uuid") + if uid and online_by_uuid: + hit = online_by_uuid.get(str(uid).strip().lower()) + if hit is not None: + item["usersOnline"] = hit + continue + if online_by_name: + nm = item.get("name") + if nm and isinstance(nm, str): + hitn = online_by_name.get(nm.strip().lower()) + if hitn is not None: + item["usersOnline"] = hitn + + +def _build_admin_webapp_referral_link( + base_url: Optional[str], referral_code: Optional[str] +) -> Optional[str]: + """Mirror of ``subscription_webapp._build_webapp_referral_link``. + + Kept local to avoid a cross-module import cycle (subscription_webapp + imports admin_api). + """ + if not base_url or not referral_code: + return None + parts = urlsplit(base_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["ref"] = f"u{referral_code}" + new_query = "&".join(f"{k}={v}" for k, v in query.items()) + return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment)) diff --git a/bot/app/web/admin_api_impl/logs.py b/bot/app/web/admin_api_impl/logs.py new file mode 100644 index 0000000..7a564a6 --- /dev/null +++ b/bot/app/web/admin_api_impl/logs.py @@ -0,0 +1,36 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_logs_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50))) + user_filter = request.query.get("user_id") + + async with async_session_factory() as session: + if user_filter: + try: + user_id = int(user_filter) + except (TypeError, ValueError): + return _error(400, "invalid_user_id") + entries = await message_log_dal.get_user_message_logs( + session, user_id, page_size, page * page_size + ) + total = await message_log_dal.count_user_message_logs(session, user_id) + else: + entries = await message_log_dal.get_all_message_logs( + session, page_size, page * page_size + ) + total = await message_log_dal.count_all_message_logs(session) + + return _ok( + { + "logs": [_serialize_log(entry) for entry in entries], + "page": page, + "page_size": page_size, + "total": int(total or 0), + } + ) diff --git a/bot/app/web/admin_api_impl/panel.py b/bot/app/web/admin_api_impl/panel.py new file mode 100644 index 0000000..ec1646e --- /dev/null +++ b/bot/app/web/admin_api_impl/panel.py @@ -0,0 +1,35 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_panel_internal_squads_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + panel_service = request.app.get("panel_service") + if panel_service is None: + return _error(503, "panel_unavailable", "Panel service unavailable") + try: + squads = await panel_service.get_internal_squads() + except Exception as exc: + logger.exception("Failed to load internal squads from panel") + return _error(502, "panel_request_failed", str(exc)) + if squads is None: + return _error(502, "panel_request_failed", "Unable to load internal squads") + items = [] + for squad in squads: + if not isinstance(squad, dict): + continue + uuid = squad.get("uuid") or squad.get("id") + if not uuid: + continue + items.append( + { + "uuid": str(uuid), + "name": squad.get("name") or squad.get("title") or str(uuid), + "members_count": squad.get("membersCount") + or squad.get("usersCount") + or squad.get("members_count"), + "active_inbounds_count": squad.get("activeInboundsCount") + or squad.get("active_inbounds_count"), + } + ) + return _ok({"squads": items}) diff --git a/bot/app/web/admin_api_impl/payments.py b/bot/app/web/admin_api_impl/payments.py new file mode 100644 index 0000000..d554b5c --- /dev/null +++ b/bot/app/web/admin_api_impl/payments.py @@ -0,0 +1,95 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_payments_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + + async with async_session_factory() as session: + from sqlalchemy.orm import selectinload + + stmt = ( + select(Payment) + .options(selectinload(Payment.user)) + .order_by(Payment.created_at.desc()) + .offset(page * page_size) + .limit(page_size) + ) + rows = (await session.execute(stmt)).scalars().all() + total = await payment_dal.get_payments_count(session) + + return _ok( + { + "payments": [_serialize_payment(p) for p in rows], + "page": page, + "page_size": page_size, + "total": int(total or 0), + } + ) + + +async def admin_payments_export_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + from sqlalchemy.orm import selectinload + + stmt = ( + select(Payment) + .options(selectinload(Payment.user)) + .order_by(Payment.created_at.desc()) + .limit(10000) + ) + rows = (await session.execute(stmt)).scalars().all() + + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + [ + "payment_id", + "user_id", + "user_label", + "provider", + "provider_payment_id", + "amount", + "currency", + "status", + "description", + "duration_months", + "sale_mode", + "tariff_key", + "created_at", + ] + ) + for p in rows: + label = _payment_user_display_label(p.user, int(p.user_id)) if p.user else str(p.user_id) + writer.writerow( + [ + p.payment_id, + p.user_id, + label, + p.provider, + p.provider_payment_id or "", + p.amount, + p.currency, + p.status, + p.description or "", + p.subscription_duration_months or "", + p.sale_mode or "", + p.tariff_key or "", + p.created_at.isoformat() if p.created_at else "", + ] + ) + + response = web.Response( + body=buffer.getvalue().encode("utf-8-sig"), + content_type="text/csv", + charset="utf-8", + ) + response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"' + return response diff --git a/bot/app/web/admin_api_impl/promos.py b/bot/app/web/admin_api_impl/promos.py new file mode 100644 index 0000000..3d40ceb --- /dev/null +++ b/bot/app/web/admin_api_impl/promos.py @@ -0,0 +1,96 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_promos_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + async with async_session_factory() as session: + promos = await promo_code_dal.get_all_promo_codes_with_details( + session, limit=page_size, offset=page * page_size + ) + total = await promo_code_dal.get_promo_codes_count(session) + return _ok( + { + "promos": [_serialize_promo(p) for p in promos], + "page": page, + "page_size": page_size, + "total": int(total or 0), + } + ) + + +async def admin_promo_create_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + payload = await _read_json(request) + code = str(payload.get("code") or "").strip().upper() + bonus_days = int(payload.get("bonus_days") or 0) + max_activations = int(payload.get("max_activations") or 0) + valid_days = payload.get("valid_days") + if not code or bonus_days <= 0 or max_activations <= 0: + return _error(400, "invalid_payload") + + valid_until = None + if valid_days: + try: + valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days)) + except (TypeError, ValueError): + return _error(400, "invalid_valid_days") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + existing = await promo_code_dal.get_promo_code_by_code(session, code) + if existing: + return _error(409, "duplicate_code") + promo = await promo_code_dal.create_promo_code( + session, + { + "code": code, + "bonus_days": bonus_days, + "max_activations": max_activations, + "valid_until": valid_until, + "created_by_admin_id": actor_id, + "is_active": True, + }, + ) + await session.commit() + return _ok({"promo": _serialize_promo(promo)}) + + +async def admin_promo_update_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + promo_id = int(request.match_info["promo_id"]) + payload = await _read_json(request) + update_data: Dict[str, Any] = {} + if "is_active" in payload: + update_data["is_active"] = bool(payload["is_active"]) + if "bonus_days" in payload and payload["bonus_days"] is not None: + update_data["bonus_days"] = int(payload["bonus_days"]) + if "max_activations" in payload and payload["max_activations"] is not None: + update_data["max_activations"] = int(payload["max_activations"]) + + if not update_data: + return _error(400, "no_changes") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + promo = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if not promo: + return _error(404, "not_found") + await session.commit() + await session.refresh(promo) + return _ok({"promo": _serialize_promo(promo)}) + + +async def admin_promo_delete_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + promo_id = int(request.match_info["promo_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + promo = await promo_code_dal.delete_promo_code(session, promo_id) + if not promo: + return _error(404, "not_found") + await session.commit() + return _ok({}) diff --git a/bot/app/web/admin_api_impl/routes.py b/bot/app/web/admin_api_impl/routes.py new file mode 100644 index 0000000..ad2c89d --- /dev/null +++ b/bot/app/web/admin_api_impl/routes.py @@ -0,0 +1,57 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +def setup_admin_routes(app: web.Application) -> None: + router = app.router + router.add_get("/api/admin/me", admin_me_route) + router.add_get("/api/admin/stats", admin_stats_route) + + router.add_get("/api/admin/users", admin_users_list_route) + router.add_get("/api/admin/users/{user_id:-?\\d+}", admin_user_detail_route) + router.add_get("/api/admin/users/{user_id:-?\\d+}/avatar", admin_user_avatar_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/ban", admin_user_ban_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/message", admin_user_message_route) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/message/preview", admin_user_message_preview_route + ) + router.add_post("/api/admin/users/{user_id:-?\\d+}/reset-trial", admin_user_reset_trial_route) + router.add_post("/api/admin/users/{user_id:-?\\d+}/extend", admin_user_extend_route) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/premium-override", + admin_user_premium_override_route, + ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/regular-traffic-override", + admin_user_regular_traffic_override_route, + ) + router.add_post( + "/api/admin/users/{user_id:-?\\d+}/traffic-grant", + admin_user_traffic_grant_route, + ) + router.add_delete("/api/admin/users/{user_id:-?\\d+}", admin_user_delete_route) + + router.add_get("/api/admin/payments", admin_payments_list_route) + router.add_get("/api/admin/payments/export.csv", admin_payments_export_route) + + router.add_get("/api/admin/promos", admin_promos_list_route) + router.add_post("/api/admin/promos", admin_promo_create_route) + router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route) + router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route) + + router.add_get("/api/admin/logs", admin_logs_route) + + router.add_post("/api/admin/broadcast", admin_broadcast_route) + router.add_post("/api/admin/sync", admin_sync_route) + + router.add_get("/api/admin/ads", admin_ads_list_route) + router.add_post("/api/admin/ads", admin_ad_create_route) + router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route) + router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route) + + router.add_get("/api/admin/settings", admin_settings_get_route) + router.add_patch("/api/admin/settings", admin_settings_patch_route) + + router.add_get("/api/admin/tariffs", admin_tariffs_get_route) + router.add_put("/api/admin/tariffs", admin_tariffs_save_route) + router.add_get("/api/admin/panel/internal-squads", admin_panel_internal_squads_route) diff --git a/bot/app/web/admin_api_impl/settings.py b/bot/app/web/admin_api_impl/settings.py new file mode 100644 index 0000000..787778f --- /dev/null +++ b/bot/app/web/admin_api_impl/settings.py @@ -0,0 +1,74 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_settings_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + overrides = await app_settings_dal.get_overrides_with_meta(session) + + overrides_by_key = {entry["key"]: entry for entry in overrides} + + fields = manifest_payload() + sections: Dict[str, Dict[str, Any]] = {} + for field in fields: + key = field["key"] + section_id = field["section"] + if section_id not in sections: + sections[section_id] = { + "id": section_id, + "order": field["section_order"], + "fields": [], + } + override = overrides_by_key.get(key) + value = current_value(settings, key) + is_secret = bool(field.get("secret")) + response_field = { + **field, + "value": "" if is_secret else value, + "overridden": bool(override), + "updated_at": override.get("updated_at") if override else None, + } + if is_secret: + response_field["has_value"] = bool(value) + sections[section_id]["fields"].append(response_field) + + ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) + return _ok({"sections": ordered_sections}) + + +async def admin_settings_patch_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + payload = await _read_json(request) + updates = payload.get("updates") or {} + deletes = payload.get("deletes") or [] + if not isinstance(updates, dict): + return _error(400, "invalid_updates") + if not isinstance(deletes, list): + return _error(400, "invalid_deletes") + + result = await update_overrides( + settings, + async_session_factory, + updates=updates, + deletes=deletes, + actor_id=actor_id, + ) + if not result.get("ok"): + return web.json_response( + {"ok": False, "error": "validation_failed", "errors": result.get("errors", {})}, + status=400, + ) + + # Bust the public webapp settings cache so users see new values immediately. + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) diff --git a/bot/app/web/admin_api_impl/stats.py b/bot/app/web/admin_api_impl/stats.py new file mode 100644 index 0000000..289f82f --- /dev/null +++ b/bot/app/web/admin_api_impl/stats.py @@ -0,0 +1,89 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_me_route(request: web.Request) -> web.Response: + user_id = _require_admin_user_id(request) + settings: Settings = request.app["settings"] + return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or [])) + + +async def admin_stats_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + user_stats = await user_dal.get_enhanced_user_statistics(session) + financial_stats = await payment_dal.get_financial_statistics(session) + sync_status = await panel_sync_dal.get_panel_sync_status(session) + recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10) + + payload = { + "users": user_stats, + "financial": financial_stats, + "panel_sync": { + "status": sync_status.status if sync_status else "never_run", + "last_sync_time": sync_status.last_sync_time.isoformat() + if sync_status and sync_status.last_sync_time + else None, + "details": sync_status.details if sync_status else None, + "users_processed": sync_status.users_processed_from_panel if sync_status else 0, + "subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0, + }, + "recent_payments": [_serialize_payment(p) for p in recent_payments], + } + + panel_service = request.app.get("panel_service") + if panel_service is not None: + try: + system = await panel_service.get_system_stats() + bandwidth = await panel_service.get_bandwidth_stats() + panel_body: Dict[str, Any] = { + "system": system or {}, + "bandwidth": bandwidth or {}, + } + try: + nodes = await panel_service.get_nodes_statistics() + panel_body["nodes"] = nodes or {} + except Exception as exc_nodes: # pragma: no cover - optional endpoint + logger.debug("Panel nodes stats unavailable: %s", exc_nodes) + panel_body["nodes"] = {} + try: + today = datetime.now(timezone.utc).date() + start_d = today - timedelta(days=7) + nodes_bw = await panel_service.get_nodes_bandwidth_usage( + start=start_d.isoformat(), + end=today.isoformat(), + top_nodes_limit=64, + ) + panel_body["nodes_bandwidth"] = nodes_bw or {} + except Exception as exc_nb: # pragma: no cover - optional endpoint + logger.debug("Panel nodes bandwidth range unavailable: %s", exc_nb) + panel_body["nodes_bandwidth"] = {} + try: + online_map = _panel_nodes_online_by_uuid(panel_body.get("nodes")) + lookups = await panel_service.get_nodes_online_lookups() + for k, v in lookups.get("byUuid", {}).items(): + online_map[k] = v + _enrich_bandwidth_nodes_with_online( + panel_body.get("nodes_bandwidth"), + online_map, + lookups.get("byName") or {}, + ) + except Exception as exc_merge: # pragma: no cover + logger.debug("Panel nodes online merge skipped: %s", exc_merge) + payload["panel"] = panel_body + except Exception as exc: + logger.debug("Panel stats unavailable: %s", exc) + payload["panel"] = {"error": "unavailable"} + + queue_manager = get_queue_manager() + if queue_manager: + try: + payload["queue"] = queue_manager.get_queue_stats() + except Exception: # pragma: no cover - defensive + payload["queue"] = None + + payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + return _ok(payload) diff --git a/bot/app/web/admin_api_impl/sync.py b/bot/app/web/admin_api_impl/sync.py new file mode 100644 index 0000000..ec62cac --- /dev/null +++ b/bot/app/web/admin_api_impl/sync.py @@ -0,0 +1,23 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_sync_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + panel_service = request.app.get("panel_service") + if panel_service is None: + return _error(503, "panel_unavailable") + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + i18n = request.app.get("i18n") + + from bot.handlers.admin.sync_admin import perform_sync + + async with async_session_factory() as session: + result = await perform_sync( + panel_service=panel_service, + session=session, + settings=settings, + i18n_instance=i18n, + ) + return _ok({"result": result or {}}) diff --git a/bot/app/web/admin_api_impl/tariffs.py b/bot/app/web/admin_api_impl/tariffs.py new file mode 100644 index 0000000..16b22cf --- /dev/null +++ b/bot/app/web/admin_api_impl/tariffs.py @@ -0,0 +1,63 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_tariffs_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + path = _tariffs_config_path(settings) + + try: + config = settings.tariffs_config + except Exception as exc: + logger.warning("Invalid tariffs config requested from admin UI: %s", exc) + return _error(400, "invalid_tariffs_config", str(exc)) + + if config is None: + return _ok( + { + "exists": path.exists(), + "path": str(path), + "catalog": { + "default_tariff": "", + "topup_packages_default": {"rub": [], "stars": []}, + "tariffs": [], + }, + } + ) + + return _ok( + { + "exists": True, + "path": str(path), + "catalog": _tariffs_config_payload(config), + } + ) + + +async def admin_tariffs_save_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + catalog = payload.get("catalog") if "catalog" in payload else payload + if not isinstance(catalog, dict): + return _error(400, "invalid_payload", "catalog must be an object") + + try: + config = TariffsConfig.model_validate(catalog) + except (ValidationError, ValueError) as exc: + return _error(400, "invalid_tariffs_config", str(exc)) + + path = _tariffs_config_path(settings) + try: + _write_tariffs_config_file(path, config) + except OSError as exc: + logger.exception("Failed to write tariffs config to %s", path) + return _error(500, "write_failed", str(exc)) + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) diff --git a/bot/app/web/admin_api_impl/users.py b/bot/app/web/admin_api_impl/users.py new file mode 100644 index 0000000..3c933d0 --- /dev/null +++ b/bot/app/web/admin_api_impl/users.py @@ -0,0 +1,925 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def admin_users_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + query = (request.query.get("q") or "").strip() + filter_value = (request.query.get("filter") or "all").lower() + panel_status = (request.query.get("panel_status") or "all").lower() + premium_traffic = (request.query.get("premium_traffic") or "all").lower() + sort_value = (request.query.get("sort") or "registered_desc").lower() + + async with async_session_factory() as session: + users, total = await _filter_and_sort_users( + session, + query=query, + filter_value=filter_value, + panel_status=panel_status, + premium_traffic=premium_traffic, + sort_value=sort_value, + page=page, + page_size=page_size, + ) + + statuses = await _bulk_user_statuses(session, [u.user_id for u in users]) + cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users]) + active_subs = await _bulk_active_subscriptions_for_users( + session, [u.user_id for u in users] + ) + + serialized = [] + for user in users: + payload = _serialize_user(user) + status_payload = statuses.get(user.user_id) or {"status": "bot_only", "end_date": None} + payload["panel_status"] = status_payload.get("status") + if status_payload.get("status") == "expired" and status_payload.get("end_date"): + payload["panel_status_expired_at"] = status_payload["end_date"] + payload["avatar_url"] = ( + f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}" + if user.user_id in cached_avatar_ids + else None + ) + payload["premium_traffic"] = _premium_traffic_list_payload(active_subs.get(user.user_id)) + serialized.append(payload) + + return _ok( + { + "users": serialized, + "page": page, + "page_size": page_size, + "total": total, + } + ) + + +async def _bulk_user_statuses( + session: AsyncSession, user_ids: List[int] +) -> Dict[int, Dict[str, Optional[str]]]: + """Return active subscription status for a batch of users. + + Returns the panel status (active/expired/limited/disabled) when an active + subscription exists, otherwise ``"bot_only"`` when the user is in the bot + but has no panel subscription. + """ + + if not user_ids: + return {} + stmt = ( + select( + Subscription.user_id, + Subscription.status_from_panel, + Subscription.is_active, + Subscription.end_date, + ) + .where(Subscription.user_id.in_(user_ids)) + .order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast()) + ) + rows = (await session.execute(stmt)).all() + out: Dict[int, Dict[str, Optional[str]]] = {} + for uid, panel_status, is_active, end_date in rows: + if uid in out: + continue + if is_active: + status = (panel_status or "active").lower() + else: + status = (panel_status or "expired").lower() + out[uid] = { + "status": status, + "end_date": end_date.isoformat() if end_date else None, + } + for uid in user_ids: + if uid not in out: + out[uid] = {"status": "bot_only", "end_date": None} + return out + + +async def _bulk_user_avatar_keys(session: AsyncSession, user_ids: List[int]) -> Dict[int, str]: + """Return ``{user_id: cache_key}`` for users with a cached Telegram avatar. + + The cache key is the row's ``updated_at`` timestamp — used as a + cache-buster query param so the browser refetches when the avatar + changes. + """ + + if not user_ids: + return {} + stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where( + UserTelegramAvatar.user_id.in_(user_ids) + ) + rows = (await session.execute(stmt)).all() + return {int(uid): (updated_at.isoformat() if updated_at else "") for uid, updated_at in rows} + + +async def admin_user_avatar_route(request: web.Request) -> web.Response: + """Serve the cached Telegram avatar for any user (admin-only). + + Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL + and uses admin auth. Only the cached blob from + ``user_telegram_avatars`` is served — refreshing from Telegram is the + job of the user-facing endpoint, so the admin list never blocks on a + Telegram round-trip. + """ + + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + avatar = await session.get(UserTelegramAvatar, target_id) + + if not avatar: + raise web.HTTPNotFound(text="avatar_not_cached") + + etag = ( + f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"' + if avatar.updated_at + else None + ) + 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 + + +def _ranked_active_subscriptions_sq(now: datetime): + """Latest active subscription per user (same ordering as subscription_dal).""" + + rn = sa_func.row_number().over( + partition_by=Subscription.user_id, + order_by=( + Subscription.end_date.desc(), + Subscription.subscription_id.desc(), + ), + ) + inner = ( + select( + Subscription.user_id, + Subscription.subscription_id, + Subscription.premium_used_bytes, + Subscription.premium_baseline_bytes, + Subscription.premium_topup_balance_bytes, + Subscription.premium_topup_used_bytes, + Subscription.premium_bonus_bytes, + Subscription.premium_unlimited_override, + rn.label("rn"), + ) + .where( + Subscription.is_active.is_(True), + Subscription.end_date > now, + ) + .subquery() + ) + return select(inner).where(inner.c.rn == 1).subquery(name="ranked_active_sub") + + +async def _bulk_active_subscriptions_for_users( + session: AsyncSession, user_ids: List[int] +) -> Dict[int, Subscription]: + """Active subscription row per user (for admin list premium traffic column).""" + + if not user_ids: + return {} + now = datetime.now(timezone.utc) + stmt = ( + select(Subscription) + .where( + Subscription.user_id.in_(user_ids), + Subscription.is_active.is_(True), + Subscription.end_date > now, + ) + .order_by( + Subscription.user_id.asc(), + Subscription.end_date.desc(), + Subscription.subscription_id.desc(), + ) + ) + rows = (await session.execute(stmt)).scalars().all() + out: Dict[int, Subscription] = {} + for sub in rows: + uid = int(sub.user_id) + if uid not in out: + out[uid] = sub + return out + + +async def _filter_and_sort_users( + session: AsyncSession, + *, + query: str = "", + filter_value: str, + panel_status: str = "all", + premium_traffic: str = "all", + sort_value: str, + page: int, + page_size: int, +) -> tuple[List[User], int]: + """Return paginated users with optional search, filter and sort applied.""" + + now = datetime.now(timezone.utc) + sort_key = (sort_value or "registered_desc").lower() + pt_filter = (premium_traffic or "all").lower() + needs_premium_sq = pt_filter != "all" or sort_key in { + "premium_ratio_asc", + "premium_ratio_desc", + } + + stmt = select(User) + count_stmt = select(sa_func.count(User.user_id)) + + sq = None + ratio_expr = None + plim_expr = None + pu_expr = None + + if needs_premium_sq: + sq = _ranked_active_subscriptions_sq(now) + stmt = stmt.outerjoin(sq, User.user_id == sq.c.user_id) + count_stmt = count_stmt.outerjoin(sq, User.user_id == sq.c.user_id) + pb = sa_func.coalesce(sq.c.premium_bonus_bytes, 0) + plim_expr = ( + sa_func.coalesce(sq.c.premium_baseline_bytes, 0) + + sa_func.coalesce(sq.c.premium_topup_balance_bytes, 0) + + sa_func.coalesce(sq.c.premium_topup_used_bytes, 0) + + pb + ) + pu_expr = sa_func.coalesce(sq.c.premium_used_bytes, 0) + ratio_expr = case( + (sq.c.user_id.is_(None), None), + (sq.c.premium_unlimited_override.is_(True), None), + (plim_expr <= 0, None), + else_=cast(pu_expr, Float) / cast(plim_expr, Float), + ) + + search_cond = _user_search_condition(query) + if search_cond is not None: + stmt = stmt.where(search_cond) + count_stmt = count_stmt.where(search_cond) + + f = (filter_value or "all").lower() + if f == "banned": + cond = User.is_banned.is_(True) + elif f == "active": + cond = User.is_banned.is_(False) + elif f == "tg_linked": + cond = User.telegram_id.is_not(None) + elif f == "no_tg": + cond = User.telegram_id.is_(None) + elif f == "email_linked": + cond = User.email.is_not(None) + elif f == "no_email": + cond = User.email.is_(None) + elif f == "panel_linked": + cond = User.panel_user_uuid.is_not(None) + else: + cond = None + + if cond is not None: + stmt = stmt.where(cond) + count_stmt = count_stmt.where(cond) + + panel_cond = _user_panel_status_condition(panel_status) + if panel_cond is not None: + stmt = stmt.where(panel_cond) + count_stmt = count_stmt.where(panel_cond) + + if needs_premium_sq and sq is not None and plim_expr is not None and pu_expr is not None: + if pt_filter == "none": + premium_cond = or_( + sq.c.user_id.is_(None), + and_( + sq.c.premium_unlimited_override.is_(False), + plim_expr <= 0, + ), + ) + stmt = stmt.where(premium_cond) + count_stmt = count_stmt.where(premium_cond) + elif pt_filter == "unlimited": + premium_cond = and_( + sq.c.user_id.isnot(None), + sq.c.premium_unlimited_override.is_(True), + ) + stmt = stmt.where(premium_cond) + count_stmt = count_stmt.where(premium_cond) + elif pt_filter == "good": + premium_cond = and_( + sq.c.user_id.isnot(None), + sq.c.premium_unlimited_override.is_(False), + plim_expr > 0, + (100 * pu_expr) < (85 * plim_expr), + ) + stmt = stmt.where(premium_cond) + count_stmt = count_stmt.where(premium_cond) + elif pt_filter == "warn": + premium_cond = and_( + sq.c.user_id.isnot(None), + sq.c.premium_unlimited_override.is_(False), + plim_expr > 0, + (100 * pu_expr) >= (85 * plim_expr), + pu_expr < plim_expr, + ) + stmt = stmt.where(premium_cond) + count_stmt = count_stmt.where(premium_cond) + elif pt_filter == "critical": + premium_cond = and_( + sq.c.user_id.isnot(None), + sq.c.premium_unlimited_override.is_(False), + plim_expr > 0, + pu_expr >= plim_expr, + ) + stmt = stmt.where(premium_cond) + count_stmt = count_stmt.where(premium_cond) + + sort_map = { + "registered_desc": User.registration_date.desc().nullslast(), + "registered_asc": User.registration_date.asc().nullslast(), + "name_asc": ( + sa_func.coalesce(User.first_name, User.username, User.email).asc(), + User.user_id.asc(), + ), + "name_desc": ( + sa_func.coalesce(User.first_name, User.username, User.email).desc(), + User.user_id.desc(), + ), + "id_asc": User.user_id.asc(), + "id_desc": User.user_id.desc(), + } + + if needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_asc": + stmt = stmt.order_by(ratio_expr.asc().nullslast(), User.user_id.asc()) + elif needs_premium_sq and ratio_expr is not None and sort_key == "premium_ratio_desc": + stmt = stmt.order_by(ratio_expr.desc().nullslast(), User.user_id.desc()) + else: + order = sort_map.get(sort_key, sort_map["registered_desc"]) + if isinstance(order, tuple): + stmt = stmt.order_by(*order) + else: + stmt = stmt.order_by(order) + + stmt = stmt.offset(max(page, 0) * max(page_size, 1)).limit(max(page_size, 1)) + + users = (await session.execute(stmt)).scalars().all() + total = (await session.execute(count_stmt)).scalar_one() + return users, int(total) + + +def _user_panel_status_condition(panel_status: str): + status = (panel_status or "all").lower() + if status not in {"active", "expired", "limited"}: + return None + + normalized_status = sa_func.lower(sa_func.coalesce(Subscription.status_from_panel, "")) + blank_status = or_( + Subscription.status_from_panel.is_(None), Subscription.status_from_panel == "" + ) + if status == "active": + status_cond = or_( + normalized_status == "active", blank_status & Subscription.is_active.is_(True) + ) + elif status == "expired": + status_cond = or_( + normalized_status == "expired", blank_status & Subscription.is_active.is_(False) + ) + else: + status_cond = normalized_status == "limited" + + return ( + select(Subscription.subscription_id) + .where(Subscription.user_id == User.user_id, status_cond) + .exists() + ) + + +def _user_search_condition(query: str): + raw = (query or "").strip().lstrip("@") + if not raw: + return None + + like = f"%{raw}%" + conditions = [ + User.username.ilike(like), + User.first_name.ilike(like), + User.last_name.ilike(like), + User.email.ilike(like), + ] + if raw.isdigit(): + numeric = int(raw) + conditions.extend([User.user_id == numeric, User.telegram_id == numeric]) + + return or_(*conditions) + + +async def admin_user_detail_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + settings: Settings = request.app["settings"] + + async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found", "User not found") + + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + latest_subs_stmt = ( + select(Subscription) + .where(Subscription.user_id == target_id) + .order_by(Subscription.start_date.desc().nullslast()) + .limit(20) + ) + latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + total_paid = await payment_dal.get_user_total_paid(session, target_id) + recent_payments_stmt = ( + select(Payment) + .where(Payment.user_id == target_id) + .order_by(Payment.created_at.desc()) + .limit(20) + ) + recent_payments = (await session.execute(recent_payments_stmt)).scalars().all() + log_count = await message_log_dal.count_user_message_logs(session, target_id) + avatar_keys = await _bulk_user_avatar_keys(session, [target_id]) + + # Referral links — both the bot deep-link and the webapp deep-link. + referral_code: Optional[str] = None + try: + referral_code = await user_dal.ensure_referral_code(session, user) + await session.commit() + except Exception as exc_ref: # pragma: no cover — defensive + logger.warning("Failed to ensure referral code for user %s: %s", target_id, exc_ref) + await session.rollback() + + referral_service: Optional[ReferralService] = request.app.get("referral_service") + bot_username = request.app.get("bot_username") or "" + referral_bot_link: Optional[str] = None + if referral_service and bot_username and referral_code: + try: + async with async_session_factory() as session: + referral_bot_link = await referral_service.generate_referral_link( + session, bot_username, target_id + ) + except Exception as exc_link: # pragma: no cover + logger.warning("Failed to build bot referral link for %s: %s", target_id, exc_link) + referral_webapp_link = _build_admin_webapp_referral_link( + getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None), + referral_code, + ) + + # Subscription page URL — the raw panel `subscriptionUrl` that the user + # imports into their VPN client. May be missing if the user has never + # been provisioned on the panel. + subscription_url: Optional[str] = None + panel_uuid = getattr(user, "panel_user_uuid", None) + if panel_uuid: + subscription_service = request.app.get("subscription_service") + panel_service = getattr(subscription_service, "panel_service", None) + if panel_service is not None: + try: + panel_data = await panel_service.get_user_by_uuid(panel_uuid) + if panel_data: + subscription_url = panel_data.get("subscriptionUrl") or None + except Exception as exc_panel: # pragma: no cover + logger.warning( + "Failed to fetch subscriptionUrl for user %s (uuid=%s): %s", + target_id, + panel_uuid, + exc_panel, + ) + + serialized_user = _serialize_user(user) + serialized_user["avatar_url"] = ( + f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}" + if target_id in avatar_keys + else None + ) + + return _ok( + { + "user": serialized_user, + "active_subscription": _serialize_subscription(active_sub) if active_sub else None, + "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "total_paid": float(total_paid), + "recent_payments": [_serialize_payment(p) for p in recent_payments], + "log_count": int(log_count or 0), + "subscription_url": subscription_url, + "referral": { + "code": referral_code, + "bot_link": referral_bot_link, + "webapp_link": referral_webapp_link, + }, + } + ) + + +async def admin_user_ban_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + desired = bool(payload.get("banned")) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found") + user.is_banned = bool(desired) + await session.commit() + await session.refresh(user) + return _ok({"user": _serialize_user(user)}) + + +async def admin_user_message_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + text = str(payload.get("text") or "").strip() + if not text: + return _error(400, "empty_text") + + queue_manager = get_queue_manager() + if not queue_manager: + return _error(503, "queue_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + target_user = await user_dal.get_user_by_id(session, target_id) + if not target_user or not target_user.telegram_id: + return _error(404, "no_telegram_account") + + try: + await send_message_via_queue( + queue_manager, + int(target_user.telegram_id), + MessageContent(content_type="text", text=text), + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logger.warning("Admin direct message failed: %s", exc) + return _error(502, "send_failed", str(exc)) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_direct_message_webapp", + "content": text[:4000], + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + + return _ok({}) + + +async def admin_user_message_preview_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + admin_telegram_id = request.get("admin_telegram_id") + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + text = str(payload.get("text") or "").strip() + if not text: + return _error(400, "empty_text") + if not admin_telegram_id: + return _error(403, "admin_telegram_unavailable") + + queue_manager = get_queue_manager() + if not queue_manager: + return _error(503, "queue_unavailable") + + try: + await send_message_via_queue( + queue_manager, + int(admin_telegram_id), + MessageContent(content_type="text", text=text), + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logger.warning("Admin direct message preview failed: %s", exc) + return _error(502, "preview_failed", str(exc)) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_direct_message_preview_webapp", + "content": text[:4000], + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + + return _ok({}) + + +async def admin_user_delete_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + ok = await user_dal.delete_user_and_relations(session, target_id) + if not ok: + await session.rollback() + return _error(404, "not_found") + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_delete_user_webapp", + "content": f"Deleted user_id={target_id}", + "is_admin_event": True, + }, + ) + await session.commit() + return _ok({}) + + +async def admin_user_reset_trial_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + panel_service = request.app.get("panel_service") + subscription_service = request.app.get("subscription_service") + if panel_service is None or subscription_service is None: + return _error(503, "service_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found") + + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if active: + await session.delete(active) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_reset_trial_webapp", + "content": f"Reset trial for user_id={target_id}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + return _ok({}) + + +async def admin_user_premium_override_route(request: web.Request) -> web.Response: + """Premium-squad traffic overrides only (unlimited toggle + bonus GB).""" + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + subscription_service = request.app.get("subscription_service") + + unlimited = bool(payload.get("unlimited")) + bonus_bytes_raw = payload.get("bonus_bytes") + bonus_gb_raw = payload.get("bonus_gb") + if bonus_bytes_raw is None and bonus_gb_raw is None: + bonus_bytes = 0 + elif bonus_bytes_raw is not None: + try: + bonus_bytes = int(bonus_bytes_raw) + except (TypeError, ValueError): + return _error(400, "invalid_bonus", "bonus_bytes must be an integer") + else: + try: + bonus_bytes = int(round(float(bonus_gb_raw) * (1024**3))) + except (TypeError, ValueError): + return _error(400, "invalid_bonus", "bonus_gb must be a number") + + if bonus_bytes < 0: + return _error(400, "invalid_bonus", "bonus must be non-negative") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.premium_unlimited_override = bool(unlimited) + active.premium_bonus_bytes = int(bonus_bytes) + if active.premium_unlimited_override: + active.premium_is_limited = False + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_premium_override_webapp", + "content": (f"unlimited={bool(unlimited)} bonus_bytes={int(bonus_bytes)}"), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + if subscription_service is not None: + await subscription_service.sync_premium_squad_access_to_panel(session, target_id) + await session.commit() + await session.refresh(active) + + return _ok({"subscription": _serialize_subscription(active)}) + + +async def admin_user_regular_traffic_override_route(request: web.Request) -> web.Response: + """Main (regular) traffic: unlimited-style ceiling + admin bonus GB.""" + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + + unlimited = bool(payload.get("unlimited")) + regular_bonus_bytes_raw = payload.get("regular_bonus_bytes") + regular_bonus_gb_raw = payload.get("regular_bonus_gb") + if regular_bonus_bytes_raw is None and regular_bonus_gb_raw is None: + regular_bonus_bytes = 0 + elif regular_bonus_bytes_raw is not None: + try: + regular_bonus_bytes = int(regular_bonus_bytes_raw) + except (TypeError, ValueError): + return _error(400, "invalid_regular_bonus", "regular_bonus_bytes must be an integer") + else: + try: + regular_bonus_bytes = int(round(float(regular_bonus_gb_raw) * (1024**3))) + except (TypeError, ValueError): + return _error(400, "invalid_regular_bonus", "regular_bonus_gb must be a number") + + if regular_bonus_bytes < 0: + return _error(400, "invalid_regular_bonus", "regular bonus must be non-negative") + + subscription_service = request.app.get("subscription_service") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + active.regular_unlimited_override = bool(unlimited) + active.regular_bonus_bytes = int(regular_bonus_bytes) + + if subscription_service is not None: + await subscription_service.sync_main_traffic_limit_to_panel(session, target_id) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_regular_traffic_override_webapp", + "content": ( + f"unlimited={bool(unlimited)} regular_bonus_bytes={int(regular_bonus_bytes)}" + ), + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + + return _ok({"subscription": _serialize_subscription(active)}) + + +async def admin_user_traffic_grant_route(request: web.Request) -> web.Response: + """Credit regular or premium traffic to a user without a payment. + + Body: ``{"kind": "regular" | "premium", "gb": float}`` (alternatively + ``"bytes": int``). Mirrors the same effect as a user-purchased top-up: + the chosen balance grows, panel limit/squads are refreshed, and an entry + is added to ``traffic_topups`` with ``kind="admin_topup"`` or + ``kind="admin_premium_topup"`` and ``payment_id=NULL``. + """ + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + + kind = str(payload.get("kind") or "regular").strip().lower() + if kind not in {"regular", "premium"}: + return _error(400, "invalid_kind", "kind must be 'regular' or 'premium'") + + bytes_raw = payload.get("bytes") + gb_raw = payload.get("gb") + if bytes_raw is None and gb_raw is None: + return _error(400, "missing_amount", "either 'gb' or 'bytes' is required") + try: + if bytes_raw is not None: + grant_bytes = int(bytes_raw) + gb_value = grant_bytes / (1024**3) + else: + gb_value = float(gb_raw) + grant_bytes = int(round(gb_value * (1024**3))) + except (TypeError, ValueError): + return _error(400, "invalid_amount", "amount must be a positive number") + if gb_value <= 0 or grant_bytes <= 0: + return _error(400, "invalid_amount", "amount must be positive") + + subscription_service = request.app.get("subscription_service") + if subscription_service is None: + return _error(503, "subscription_service_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + if kind == "regular": + result = await subscription_service.admin_grant_topup(session, target_id, gb_value) + else: + result = await subscription_service.admin_grant_premium_topup( + session, target_id, gb_value + ) + if not result: + await session.rollback() + return _error( + 422, + "grant_failed", + "Unable to credit traffic (missing tariff/squads or panel error)", + ) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_traffic_grant_webapp", + "content": f"kind={kind} bytes={grant_bytes}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + + refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + + return _ok( + { + "subscription": _serialize_subscription(refreshed) if refreshed else None, + "grant": { + "kind": kind, + "granted_bytes": grant_bytes, + "granted_gb": gb_value, + }, + } + ) + + +async def admin_user_extend_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + try: + days = int(payload.get("days") or 0) + except (TypeError, ValueError): + return _error(400, "invalid_days") + if days <= 0: + return _error(400, "invalid_days") + + subscription_service = request.app.get("subscription_service") + if subscription_service is None: + return _error(503, "subscription_service_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + new_end = await subscription_service.extend_active_subscription_days( + session, + target_id, + days, + "admin_extend_subscription_webapp", + ) + if not new_end: + await session.rollback() + return _error(500, "extend_failed") + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_extend_subscription_webapp", + "content": f"+{days}d -> {new_end.isoformat()}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + + refreshed = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + + return _ok( + { + "subscription": _serialize_subscription(refreshed) if refreshed else None, + } + ) diff --git a/bot/app/web/session.py b/bot/app/web/session.py new file mode 100644 index 0000000..23d46fa --- /dev/null +++ b/bot/app/web/session.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from typing import Optional + +from aiohttp import web + +from bot.app.web.webapp_auth import verify_webapp_session_token +from config.settings import Settings + +WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session" + + +def extract_authenticated_user_id(request: web.Request) -> Optional[int]: + settings: Settings = request.app["settings"] + + auth_header = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + user_id = verify_webapp_session_token( + settings, + auth_header.removeprefix("Bearer ").strip(), + ) + if user_id: + return user_id + + session_cookie = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME) + if session_cookie: + return verify_webapp_session_token(settings, session_cookie) + + return None diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 62b5702..60dce0f 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -1,4784 +1,48 @@ -import asyncio -import base64 -import hashlib -import hmac -import io -import ipaddress -import json -import logging -import os -import re -import secrets -import socket -import subprocess -import time -from collections import deque -from datetime import datetime, timezone -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple -from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit +"""Compatibility facade for the subscription Mini App backend.""" -from aiogram import Bot, Dispatcher -from aiogram.types import LabeledPrice -from aiohttp import ClientSession, ClientTimeout, web -from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy.orm import sessionmaker +# ruff: noqa: I001 -from bot.app.web.admin_api import ( - admin_auth_middleware, - setup_admin_routes, +from bot.app.web.webapp import ( + _runtime as _runtime, + account as _account, + application as _application, + assets as _assets, + auth as _auth, + billing as _billing, + common as _common, + devices as _devices, + payloads as _payloads, + routes as _routes, + serializers as _serializers, ) -from bot.app.web.webapp_auth import ( - create_signed_telegram_oauth_state, - create_telegram_oauth_nonce, - create_webapp_session_token, - validate_telegram_login_widget_data, - validate_telegram_oauth_id_token, - validate_telegram_webapp_init_data, - verify_signed_telegram_oauth_state, - verify_telegram_oauth_nonce, - verify_webapp_session_token, + +_MODULES = ( + _runtime, + _payloads, + _common, + _assets, + _auth, + _account, + _serializers, + _billing, + _devices, + _routes, + _application, ) -from bot.services.crypto_pay_service import CryptoPayService -from bot.services.email_auth_service import EmailAuthService, normalize_email -from bot.services.email_templates import render_account_merged -from bot.services.freekassa_service import FreeKassaService -from bot.services.platega_service import PlategaService -from bot.services.promo_code_service import PromoCodeService -from bot.services.referral_service import ReferralService -from bot.services.severpay_service import SeverPayService -from bot.services.subscription_service import SubscriptionService -from bot.services.yookassa_service import YooKassaService -from bot.utils.config_link import prepare_config_links -from bot.utils.request_security import parse_ip_entries, request_client_ip -from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username -from config.settings import Settings -from db.dal import payment_dal, subscription_dal, user_dal -from db.dal.user_dal import UserMergeConflictError -from db.models import Payment, User, UserTelegramAvatar -logger = logging.getLogger(__name__) - -TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html" -ASSET_DIR = TEMPLATE_PATH.parent -WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" -WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-logo" -WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[3] / "data" / "webapp-emoji" -WEBAPP_CONFIG_PLACEHOLDER = "" -WEBAPP_I18N_PLACEHOLDER = "" -WEBAPP_JS_PLACEHOLDER = "" -APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop" -DEV_MOCK_START_MARKER = "" -DEV_MOCK_END_MARKER = "" -WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 -WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 -WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 -WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 -WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024 -WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 -WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 -WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session" -WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf" -WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state" -WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" -WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} -_APP_VERSION_CACHE: Optional[str] = None -WEBAPP_CSRF_EXEMPT_PATHS = { - "/api/auth/telegram/nonce", - "/api/auth/token", - "/api/auth/email/request", - "/api/auth/email/verify", - "/api/auth/email/magic", - "/api/auth/logout", -} - - -class WebAppEmailPayload(BaseModel): - model_config = ConfigDict(extra="ignore") - - email: EmailStr - - @field_validator("email") - @classmethod - def _normalize_and_limit_email(cls, value: EmailStr) -> str: - normalized = normalize_email(str(value)) - if len(normalized) > 254: - raise ValueError("email_too_long") - return normalized - - -class WebAppEmailCodePayload(WebAppEmailPayload): - code: str = "" - - -class WebAppEmailMagicPayload(BaseModel): - model_config = ConfigDict(extra="ignore") - - token: constr(min_length=8, max_length=512) - - -class WebAppPaymentCreatePayload(BaseModel): - model_config = ConfigDict(extra="ignore") - - method: str = "" - months: Any = None - traffic_gb: Any = None - device_count: Any = None - tariff_key: Optional[constr(max_length=128)] = None - sale_mode: Optional[constr(max_length=64)] = None - description: Optional[constr(max_length=4096)] = None - comment: Optional[constr(max_length=4096)] = None - note: Optional[constr(max_length=4096)] = None - - -class WebAppTariffChangePayload(BaseModel): - model_config = ConfigDict(extra="ignore") - - tariff_key: constr(min_length=1, max_length=128) - mode: constr(min_length=1, max_length=64) - - -class WebAppLanguagePayload(BaseModel): - model_config = ConfigDict(extra="ignore") - - language: constr(min_length=2, max_length=16) - - -class WebAppDeviceDisconnectPayload(BaseModel): - model_config = ConfigDict(extra="ignore") - - token: constr(min_length=8, max_length=128) - - -_SHARED_HTTP_SESSION: Optional[ClientSession] = None -_SHARED_HTTP_SESSION_LOCK = asyncio.Lock() - - -def create_subscription_webapp_application( - dp: Dispatcher, - bot: Bot, - settings: Settings, - async_session_factory: sessionmaker, -) -> web.Application: - app = web.Application( - middlewares=[ - _security_headers_middleware, - _csrf_protection_middleware, - admin_auth_middleware, - ] - ) - app["bot"] = bot - app["dp"] = dp - app["settings"] = settings - app["async_session_factory"] = async_session_factory - app["i18n"] = dp.get("i18n_instance") - app["email_auth_service"] = EmailAuthService(settings) - app["webapp_logo_cache"] = None - app["webapp_logo_cache_lock"] = asyncio.Lock() - app["webapp_settings_cache"] = {"ts": 0.0, "data": {}} - app["webapp_rate_limit_buckets"] = {} - app["webapp_rate_limit_lock"] = asyncio.Lock() - - async def _startup(app_obj: web.Application) -> None: - await _ensure_shared_http_session() - await _warm_webapp_logo_cache(app_obj) - await _warm_webapp_animated_emoji_cache(app_obj) - - async def _shutdown(app_obj: web.Application) -> None: - await _close_shared_http_session() - - app.on_startup.append(_startup) - app.on_shutdown.append(_shutdown) - - for key in ( - "subscription_service", - "yookassa_service", - "freekassa_service", - "cryptopay_service", - "platega_service", - "severpay_service", - "promo_code_service", - "referral_service", - "panel_service", - ): - if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] - app[key] = dp.workflow_data[key] # type: ignore[index] - - if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined] - app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index] - - setup_subscription_webapp_routes(app) - return app - - -def setup_subscription_webapp_routes(app: web.Application) -> None: - app.router.add_get("/", index_route) - app.router.add_get("/home", index_route) - app.router.add_get("/invite", index_route) - app.router.add_get("/devices", index_route) - app.router.add_get("/settings", index_route) - app.router.add_get("/admin", index_route) - app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) - app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) - app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) - app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) - app.router.add_get("/health", health_route) - app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) - app.router.add_get( - r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", - webapp_animated_emoji_route, - ) - app.router.add_get("/subscription_webapp.css", css_asset_route) - app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) - app.router.add_get("/subscription_webapp.js", js_asset_route) - app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) - app.router.add_post("/api/auth/token", auth_token_route) - app.router.add_post("/api/auth/email/request", email_auth_request_route) - app.router.add_post("/api/auth/email/verify", email_auth_verify_route) - app.router.add_post("/api/auth/email/magic", email_auth_magic_route) - app.router.add_post("/api/auth/logout", logout_route) - app.router.add_get("/api/me", me_route) - app.router.add_get("/api/account/avatar", account_avatar_route) - app.router.add_post("/api/account/language", account_language_route) - app.router.add_post("/api/account/email/request", account_email_request_route) - app.router.add_post("/api/account/email/verify", account_email_verify_route) - app.router.add_post("/api/account/telegram/link", account_telegram_link_route) - app.router.add_post("/api/promo/apply", apply_promo_route) - app.router.add_post("/api/trial/activate", activate_trial_route) - app.router.add_get("/api/devices", devices_route) - app.router.add_post("/api/devices/disconnect", disconnect_device_route) - app.router.add_get("/api/devices/topup-options", device_topup_options_route) - app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route) - app.router.add_get("/api/tariffs/change-options", tariff_change_options_route) - app.router.add_post("/api/tariffs/change", tariff_change_route) - app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route) - app.router.add_post("/api/payments", create_payment_route) - app.router.add_get("/api/payments/{payment_id}", payment_status_route) - setup_admin_routes(app) - - -async def health_route(request: web.Request) -> web.Response: - return web.json_response({"ok": True}) - - -async def css_asset_route(request: web.Request) -> web.Response: - return await _serve_template_asset(request, "subscription_webapp.css", "text/css") - - -def _resolve_webapp_logo_url(settings: Settings) -> str: - raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() - if not raw_logo_url: - return "" - - parsed_logo_url = urlsplit(raw_logo_url) - if parsed_logo_url.scheme == "https": - cache_key = hashlib.sha256(raw_logo_url.encode("utf-8")).hexdigest()[:12] - return f"{WEBAPP_LOGO_PROXY_PATH}?v={cache_key}" - if parsed_logo_url.scheme in {"http", "data"}: - return raw_logo_url - if raw_logo_url.startswith("/"): - return raw_logo_url - return "" - - -def _webapp_logo_cache_key(logo_url: str) -> str: - return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() - - -def _webapp_logo_disk_paths(logo_url: str) -> Tuple[Path, Path]: - cache_key = _webapp_logo_cache_key(logo_url) - return WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.bin", WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.json" - - -def _is_proxyable_webapp_logo_url(logo_url: str) -> bool: - parsed_logo_url = urlsplit(logo_url) - return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname) - - -def _emoji_to_codepoints(value: str) -> str: - return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) - - -def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path: - return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}" - - -def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str: - return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}" - - -def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: - codepoints = _emoji_to_codepoints(emoji) - if not codepoints or ext not in {"gif", "webp"}: - return "" - return f"/webapp-emoji/{codepoints}/512.{ext}" - - -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("=") - - -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 webapp_logo_route(request: web.Request) -> web.Response: - settings: Settings = request.app["settings"] - raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() - if not raw_logo_url: - raise web.HTTPNotFound(text="webapp_logo_not_configured") - - if not _is_proxyable_webapp_logo_url(raw_logo_url): - raise web.HTTPNotFound(text="webapp_logo_not_proxied") - - parsed_logo_url = urlsplit(raw_logo_url) - if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname): - raise web.HTTPNotFound(text="webapp_logo_not_proxied") - - source_logo_url = raw_logo_url - logo_cache: Optional[Tuple[str, bytes, str]] = request.app.get("webapp_logo_cache") - if logo_cache is None or logo_cache[0] != source_logo_url: - cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"] - async with cache_lock: - logo_cache = request.app.get("webapp_logo_cache") - if logo_cache is None or logo_cache[0] != source_logo_url: - fetched_logo = await _load_or_fetch_webapp_logo(source_logo_url) - logo_cache = ( - (source_logo_url, fetched_logo[0], fetched_logo[1]) if fetched_logo else None - ) - request.app["webapp_logo_cache"] = logo_cache - - if not logo_cache: - raise web.HTTPNotFound(text="webapp_logo_unavailable") - - _, body, content_type = logo_cache - response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response - - -async def webapp_animated_emoji_route(request: web.Request) -> web.Response: - codepoints = str(request.match_info.get("codepoints") or "").strip().lower() - ext = str(request.match_info.get("ext") or "").strip().lower() - if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}: - raise web.HTTPNotFound(text="webapp_emoji_not_found") - - emoji_cache_key = f"{codepoints}:{ext}" - emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {}) - emoji_cache = emoji_caches.get(emoji_cache_key) - if emoji_cache is None: - cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) - async with cache_lock: - emoji_cache = emoji_caches.get(emoji_cache_key) - if emoji_cache is None: - emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) - if emoji_cache: - emoji_caches[emoji_cache_key] = emoji_cache - - if not emoji_cache: - raise web.HTTPNotFound(text="webapp_emoji_unavailable") - - body, content_type = emoji_cache - response = web.Response(body=body, content_type=content_type) - response.headers["Cache-Control"] = "public, max-age=31536000, immutable" - return response - - -async def _warm_webapp_logo_cache(app: web.Application) -> None: - settings: Settings = app["settings"] - raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() - if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): - return - - parsed_logo_url = urlsplit(raw_logo_url) - if not parsed_logo_url.hostname or not await _hostname_resolves_to_public_address( - parsed_logo_url.hostname - ): - return - - cache_lock: asyncio.Lock = app["webapp_logo_cache_lock"] - async with cache_lock: - logo_cache: Optional[Tuple[str, bytes, str]] = app.get("webapp_logo_cache") - if logo_cache and logo_cache[0] == raw_logo_url: - return - loaded_logo = await _load_or_fetch_webapp_logo(raw_logo_url) - app["webapp_logo_cache"] = ( - (raw_logo_url, loaded_logo[0], loaded_logo[1]) if loaded_logo else None - ) - - -async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: - settings: Settings = app["settings"] - if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": - return - - codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI) - if not codepoints: - return - - app.setdefault("webapp_emoji_cache", {}) - app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) - emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"] - - for ext in ("gif", "webp"): - emoji_cache_key = f"{codepoints}:{ext}" - if emoji_cache_key in emoji_caches: - continue - loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) - if loaded_emoji: - emoji_caches[emoji_cache_key] = loaded_emoji - if ext == "gif": - return - - -async def _load_or_fetch_webapp_animated_emoji( - codepoints: str, ext: str -) -> Optional[Tuple[bytes, str]]: - disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext) - if disk_emoji: - return disk_emoji - - fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext) - if fetched_emoji: - await asyncio.to_thread( - _write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji - ) - return fetched_emoji - - -def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: - path = _webapp_emoji_disk_path(codepoints, ext) - try: - body = path.read_bytes() - except OSError: - return None - - if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: - return None - return body, "image/gif" if ext == "gif" else "image/webp" - - -def _write_webapp_animated_emoji_to_disk( - codepoints: str, ext: str, emoji: Tuple[bytes, str] -) -> None: - body, _content_type = emoji - if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: - return - - path = _webapp_emoji_disk_path(codepoints, ext) - try: - WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True) - path.write_bytes(body) - except OSError as exc: - logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc) - - -async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: - try: - session = await _get_shared_http_session() - timeout = ClientTimeout(total=4) - source_url = _webapp_animated_emoji_source_url(codepoints, ext) - async with session.get( - source_url, - allow_redirects=False, - headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"}, - timeout=timeout, - ) as response: - if response.status != 200: - return None - - content_type = ( - (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() - ) - expected_content_type = "image/gif" if ext == "gif" else "image/webp" - if content_type and content_type != expected_content_type: - return None - - body = bytearray() - async for chunk in response.content.iter_chunked(64 * 1024): - body.extend(chunk) - if len(body) > WEBAPP_EMOJI_MAX_BYTES: - logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.") - return None - - if not body: - return None - - return bytes(body), expected_content_type - except Exception as exc: - logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc) - return None - - -async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: - disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url) - if disk_logo: - return disk_logo - - fetched_logo = await _fetch_webapp_logo(logo_url) - if fetched_logo: - await asyncio.to_thread(_write_webapp_logo_to_disk, logo_url, fetched_logo) - return fetched_logo - - -def _read_webapp_logo_from_disk(logo_url: str) -> Optional[Tuple[bytes, str]]: - body_path, meta_path = _webapp_logo_disk_paths(logo_url) - try: - metadata = json.loads(meta_path.read_text(encoding="utf-8")) - if metadata.get("source_url") != logo_url: - return None - content_type = str(metadata.get("content_type") or "").strip().lower() - if not content_type.startswith("image/"): - return None - body = body_path.read_bytes() - except (OSError, json.JSONDecodeError): - return None - - if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: - return None - return body, content_type - - -def _write_webapp_logo_to_disk(logo_url: str, logo: Tuple[bytes, str]) -> None: - body, content_type = logo - if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: - return - - body_path, meta_path = _webapp_logo_disk_paths(logo_url) - try: - WEBAPP_LOGO_CACHE_DIR.mkdir(parents=True, exist_ok=True) - body_path.write_bytes(body) - meta_path.write_text( - json.dumps( - { - "source_url": logo_url, - "content_type": content_type, - "cached_at": datetime.now(timezone.utc).isoformat(), - "bytes": len(body), - }, - ensure_ascii=False, - separators=(",", ":"), - ), - encoding="utf-8", - ) - except OSError as exc: - logger.warning("Failed to write WEBAPP_LOGO_URL cache: %s", exc) - - -async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: - """Fetch and cache the configured logo on the server side.""" - try: - session = await _get_shared_http_session() - timeout = ClientTimeout(total=3) - async with session.get( - logo_url, - allow_redirects=False, - headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, - timeout=timeout, - ) as response: - if response.status != 200: - logger.warning( - "WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.", - response.status, - ) - return None - - content_type = ( - (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() - ) - if content_type and not content_type.startswith("image/"): - logger.warning( - "WEBAPP_LOGO_URL returned non-image content type %s; keeping the logo hidden.", - content_type, - ) - return None - - body = bytearray() - async for chunk in response.content.iter_chunked(64 * 1024): - body.extend(chunk) - if len(body) > WEBAPP_LOGO_MAX_BYTES: - logger.warning("WEBAPP_LOGO_URL exceeded the 2 MiB limit.") - return None - - if not body: - logger.warning("WEBAPP_LOGO_URL returned an empty response body.") - return None - - return bytes(body), content_type or "image/png" - except Exception as exc: - logger.warning("Failed to fetch WEBAPP_LOGO_URL: %s", exc) - return None - - -async def _get_shared_http_session() -> ClientSession: - global _SHARED_HTTP_SESSION - async with _SHARED_HTTP_SESSION_LOCK: - if _SHARED_HTTP_SESSION is None or _SHARED_HTTP_SESSION.closed: - _SHARED_HTTP_SESSION = ClientSession( - timeout=ClientTimeout(total=30), - headers={ - "User-Agent": "Mozilla/5.0", - "Accept": "*/*", - }, - ) - return _SHARED_HTTP_SESSION - - -async def _ensure_shared_http_session() -> None: - await _get_shared_http_session() - - -async def _close_shared_http_session() -> None: - global _SHARED_HTTP_SESSION - async with _SHARED_HTTP_SESSION_LOCK: - if _SHARED_HTTP_SESSION and not _SHARED_HTTP_SESSION.closed: - await _SHARED_HTTP_SESSION.close() - _SHARED_HTTP_SESSION = None - - -async def _hostname_resolves_to_public_address(hostname: str) -> bool: - if not hostname: - return False - - try: - ip_obj = ipaddress.ip_address(hostname) - return not ( - ip_obj.is_private - or ip_obj.is_loopback - or ip_obj.is_link_local - or ip_obj.is_unspecified - or ip_obj.is_reserved - ) - except ValueError: - pass - - loop = asyncio.get_running_loop() - try: - resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) - except Exception: - return False - - found_public_ip = False - for entry in resolved: - sockaddr = entry[4] - if not sockaddr: - continue - candidate = sockaddr[0] - try: - ip_obj = ipaddress.ip_address(candidate) - except ValueError: - continue - if ( - ip_obj.is_private - or ip_obj.is_loopback - or ip_obj.is_link_local - or ip_obj.is_unspecified - or ip_obj.is_reserved - ): - return False - found_public_ip = True - - return found_public_ip - - -@web.middleware -async def _security_headers_middleware(request: web.Request, handler): - request["csp_nonce"] = secrets.token_urlsafe(16) - try: - response = await handler(request) - except web.HTTPException as exc: - response = exc - nonce = request.get("csp_nonce", "") - response.headers.setdefault( - "Content-Security-Policy", - ( - "default-src 'self'; " - f"script-src 'self' 'nonce-{nonce}' https://telegram.org; " - "frame-src https://oauth.telegram.org; " - "frame-ancestors https://web.telegram.org https://t.me; " - "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " - "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " - "img-src 'self' data: https:; " - "connect-src 'self' https://oauth.telegram.org; " - "object-src 'none'; " - "base-uri 'self'; " - "form-action 'self'" - ), - ) - response.headers.setdefault("Referrer-Policy", "no-referrer") - response.headers.setdefault("X-Content-Type-Options", "nosniff") - response.headers.setdefault( - "Permissions-Policy", - ( - "accelerometer=(), autoplay=(), camera=(), display-capture=(), " - "encrypted-media=(), geolocation=(), gyroscope=(), magnetometer=(), " - "microphone=(), midi=(), payment=(), usb=()" - ), - ) - return response - - -@web.middleware -async def _csrf_protection_middleware(request: web.Request, handler): - settings: Settings = request.app["settings"] - header = request.headers.get("Authorization", "") - prefix = "Bearer " - if header.startswith(prefix): - if verify_webapp_session_token(settings, header[len(prefix) :].strip()): - return await handler(request) - - if ( - request.method in WEBAPP_STATE_CHANGING_METHODS - and request.path not in WEBAPP_CSRF_EXEMPT_PATHS - and request.cookies.get(WEBAPP_SESSION_COOKIE_NAME) - ): - csrf_cookie = request.cookies.get(WEBAPP_CSRF_COOKIE_NAME, "") - csrf_header = request.headers.get(WEBAPP_CSRF_HEADER_NAME, "") - if not csrf_cookie or not csrf_header or not hmac.compare_digest(csrf_header, csrf_cookie): - return _json_error(403, "csrf_failed", "Invalid CSRF token") - - return await handler(request) - - -def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: - settings: Settings = request.app["settings"] - cache = request.app["webapp_settings_cache"] - now = time.monotonic() - if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): - cache["data"] = { - "logo_url": _resolve_webapp_logo_url(settings), - "subscription_options": settings.subscription_options, - "stars_subscription_options": settings.stars_subscription_options, - "traffic_packages": settings.traffic_packages, - "stars_traffic_packages": settings.stars_traffic_packages, - "support_url": settings.SUPPORT_LINK or "", - "terms_url": settings.TERMS_OF_SERVICE_URL or "", - "privacy_policy_url": settings.PRIVACY_POLICY_URL or "", - "user_agreement_url": settings.USER_AGREEMENT_URL or "", - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "email_auth_enabled": settings.email_auth_configured, - "language": _normalize_language(settings.DEFAULT_LANGUAGE), - } - cache["ts"] = now - return cache["data"] - - -def _run_git_command(*args: str) -> str: - repo_root = Path(__file__).resolve().parents[3] - try: - result = subprocess.run( - ["git", *args], - cwd=repo_root, - check=True, - capture_output=True, - text=True, - timeout=1.5, - ) - except (OSError, subprocess.SubprocessError): - return "" - return result.stdout.strip() - - -def _resolve_app_version() -> str: - global _APP_VERSION_CACHE - if _APP_VERSION_CACHE: - return _APP_VERSION_CACHE - - env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip() - if env_version: - _APP_VERSION_CACHE = env_version - return env_version - - build_version_path = Path(__file__).resolve().parents[3] / ".build-version" - try: - build_version = build_version_path.read_text(encoding="utf-8").strip() - except OSError: - build_version = "" - if build_version: - _APP_VERSION_CACHE = build_version - return build_version - - tag = _run_git_command("describe", "--tags", "--abbrev=0") - sha = _run_git_command("rev-parse", "--short", "HEAD") - dirty = bool(_run_git_command("status", "--porcelain")) - - if tag and sha: - commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count") - if commits_since_tag and commits_since_tag != "0": - version = f"{tag}+{commits_since_tag}.g{sha}" - else: - version = tag - elif sha: - version = f"dev+g{sha}" - else: - version = "dev+unknown" - - if dirty: - version = f"{version}-dirty" - - _APP_VERSION_CACHE = version - return version - - -async def _enforce_webapp_rate_limit( - request: web.Request, - *, - user_id: int, - action: str, -) -> Optional[web.Response]: - settings: Settings = request.app["settings"] - ip_address = ( - request_client_ip(request, trusted_proxies=settings.trusted_proxies) - or request.remote - or "unknown" - ) - key = f"{action}:{ip_address}:{int(user_id)}" - buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"] - lock: asyncio.Lock = request.app["webapp_rate_limit_lock"] - now = time.monotonic() - - async with lock: - bucket = buckets.setdefault(key, deque()) - while bucket and now - bucket[0] >= WEBAPP_RATE_LIMIT_WINDOW_SECONDS: - bucket.popleft() - if not bucket: - buckets.pop(key, None) - bucket = buckets.setdefault(key, deque()) - if len(bucket) >= WEBAPP_RATE_LIMIT_MAX_REQUESTS: - retry_after = ( - max( - 1, - int(WEBAPP_RATE_LIMIT_WINDOW_SECONDS - (now - bucket[0])), - ) - if bucket - else WEBAPP_RATE_LIMIT_WINDOW_SECONDS - ) - return web.json_response( - { - "ok": False, - "error": "rate_limited", - "retry_after": retry_after, - }, - status=429, - headers={"Retry-After": str(retry_after)}, - ) - bucket.append(now) - - return None - - -async def js_asset_route(request: web.Request) -> web.Response: - asset_hash = request.match_info.get("asset_hash") - filename = ( - f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js" - ) - response = await _serve_template_asset( - request, - filename, - "application/javascript", - strip_dev_mock=not asset_hash, - ) - response.headers["Cache-Control"] = ( - "public, max-age=31536000, immutable" if asset_hash else "no-cache" - ) - return response - - -async def index_route(request: web.Request) -> web.Response: - settings: Settings = request.app["settings"] - if not settings.WEBAPP_ENABLED: - raise web.HTTPNotFound(text="webapp_disabled") - - html = TEMPLATE_PATH.read_text(encoding="utf-8") - cached = _get_cached_webapp_settings(request) - config = { - "title": settings.WEBAPP_TITLE, - "primaryColor": settings.WEBAPP_PRIMARY_COLOR, - "logoUrl": cached["logo_url"], - "logoEmoji": settings.WEBAPP_LOGO_EMOJI, - "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, - "apiBase": "/api", - "telegramLoginBotUsername": request.app.get("bot_username") or "", - "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, - "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, - "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), - "supportUrl": cached["support_url"], - "termsUrl": cached["terms_url"], - "privacyPolicyUrl": cached["privacy_policy_url"], - "userAgreementUrl": cached["user_agreement_url"], - "currency": cached["currency"], - "language": cached["language"], - "emailAuthEnabled": cached["email_auth_enabled"], - "appVersion": _resolve_app_version(), - "appRepositoryUrl": APP_REPOSITORY_URL, - } - html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) - i18n_instance: Optional[object] = request.app.get("i18n") - i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} - nonce = request.get("csp_nonce", "") - html = html.replace( - WEBAPP_CONFIG_PLACEHOLDER, - ( - f'" - ), - ) - html = html.replace( - WEBAPP_I18N_PLACEHOLDER, - ( - f'" - ), - ) - html = html.replace( - WEBAPP_JS_PLACEHOLDER, - f'', - ) - brand_asset_url = cached["logo_url"] - if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": - brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) - if brand_asset_url: - html = html.replace( - '', - f'', - ) - else: - html = html.replace( - '', - "", - ) - return web.Response(text=html, content_type="text/html", charset="utf-8") - - -async def _serve_template_asset( - request: web.Request, - filename: str, - content_type: str, - *, - strip_dev_mock: bool = False, -) -> web.Response: - settings: Settings = request.app["settings"] - if not settings.WEBAPP_ENABLED: - raise web.HTTPNotFound(text="webapp_disabled") - - path = ASSET_DIR / filename - text = path.read_text(encoding="utf-8") - if strip_dev_mock: - text = _strip_marked_block( - text, - "/* WEBAPP_DEV_MOCK_START */", - "/* WEBAPP_DEV_MOCK_END */", - ) - return web.Response(text=text, content_type=content_type, charset="utf-8") - - -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( +_NAMESPACE = {} +for _module in _MODULES: + _NAMESPACE.update( { - "ok": True, - "nonce": nonce, - "client_id": client_id, - "request_access": _resolve_telegram_oauth_request_access(settings), + _name: _value + for _name, _value in vars(_module).items() + if not _name.startswith("__") and _name != "annotations" } ) +for _module in _MODULES: + vars(_module).update(_NAMESPACE) -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")) +globals().update(_NAMESPACE) - 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") - - 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 - - -def _resolve_webapp_js_asset_name() -> str: - minified_assets = [] - for path in ASSET_DIR.glob("subscription_webapp.min.*.js"): - try: - minified_assets.append((path.stat().st_mtime, path.name)) - except OSError: - continue - if minified_assets: - minified_assets.sort(reverse=True) - return minified_assets[0][1] - return "subscription_webapp.js" - - -def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: - start = html.find(start_marker) - if start == -1: - return html - end = html.find(end_marker, start) - if end == -1: - return html[:start] - return html[:start] + html[end + len(end_marker) :] - - -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") - - 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 - - -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") - - 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") - - 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, - ) - - -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) - data = await _build_user_payload(request, user_id) - 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}) - - -async def apply_promo_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - payload = await _read_json(request) - code = str(payload.get("code") or "").strip() - if not code: - return _json_error(400, "empty_code", "Promo code is empty") - - settings: Settings = request.app["settings"] - promo_code_service: PromoCodeService = request.app.get("promo_code_service") - if not promo_code_service: - return _json_error(503, "service_unavailable", "Promo service unavailable") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - try: - 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") - lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) - success, result = await promo_code_service.apply_promo_code( - session, - user_id, - code, - lang, - ) - if not success: - await session.commit() - return _json_error(400, "promo_apply_failed", str(result)) - await session.commit() - end_date = result if isinstance(result, datetime) else None - return web.json_response( - { - "ok": True, - "end_date": end_date.isoformat() if end_date else None, - "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else None, - } - ) - except Exception: - await session.rollback() - logger.exception("WebApp promo apply failed") - return _json_error(500, "promo_apply_failed", "Promo apply failed") - - -async def create_payment_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="payments_create", - ) - if rate_limit_response: - return rate_limit_response - - payload = await _read_json(request) - payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload) - if validation_error: - return validation_error - method = str(payment_payload.method or "").strip().lower() - settings: Settings = request.app["settings"] - cached = _get_cached_webapp_settings(request) - tariffs_config = settings.tariffs_config - traffic_mode = bool(settings.traffic_sale_mode) - sale_mode = "subscription" - traffic_gb_for_payment: Optional[float] = None - requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) - - if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}: - tariff_key = str(payment_payload.tariff_key or "").strip() - if not tariff_key: - return _json_error(400, "invalid_plan", "Tariff is not selected") - try: - tariff = tariffs_config.require(tariff_key) - except Exception: - return _json_error(400, "invalid_plan", "Tariff is not available") - try: - device_count = int( - float( - payment_payload.device_count - if payment_payload.device_count is not None - else payment_payload.months - ) - ) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid device package") - packages = tariff.hwid_device_packages - rub_packages = { - int(package.count): float(package.price) - for package in (packages.rub if packages else []) - } - stars_packages = { - int(package.count): int(float(package.price)) - for package in (packages.stars if packages else []) - } - price = rub_packages.get(device_count) - stars_price = stars_packages.get(device_count) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Device package is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") - payment_units = device_count - sale_mode = f"hwid_devices@{tariff.key}" - elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}: - tariff_key = str(payment_payload.tariff_key or "").strip() - if not tariff_key: - return _json_error(400, "invalid_plan", "Tariff is not selected") - try: - tariff = tariffs_config.require(tariff_key) - except Exception: - return _json_error(400, "invalid_plan", "Tariff is not available") - try: - traffic_gb = float( - payment_payload.traffic_gb - if payment_payload.traffic_gb is not None - else payment_payload.months - ) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid traffic package") - packages = ( - tariff.premium_topup_packages - if requested_sale_mode == "premium_topup" - else tariffs_config.topup_packages_for(tariff) - ) - rub_packages = { - float(package.gb): float(package.price) - for package in (packages.rub if packages else []) - } - stars_packages = { - float(package.gb): int(float(package.price)) - for package in (packages.stars if packages else []) - } - package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) - stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) - price = rub_packages.get(package_key) if package_key is not None else None - stars_price = ( - stars_packages.get(stars_package_key) if stars_package_key is not None else None - ) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Traffic package is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") - payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb - traffic_gb_for_payment = float(payment_units) - sale_mode = f"{requested_sale_mode}@{tariff.key}" - elif tariffs_config: - tariff_key = str(payment_payload.tariff_key or "").strip() - if not tariff_key: - return _json_error(400, "invalid_plan", "Tariff is not selected") - try: - tariff = tariffs_config.require(tariff_key) - except Exception: - return _json_error(400, "invalid_plan", "Tariff is not available") - - if tariff.billing_model == "traffic": - try: - traffic_gb = float( - payment_payload.traffic_gb - if payment_payload.traffic_gb is not None - else payment_payload.months - ) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid traffic package") - if traffic_gb <= 0: - return _json_error(400, "invalid_plan", "Invalid traffic package") - rub_packages = { - float(package.gb): float(package.price) - for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) - } - stars_packages = { - float(package.gb): int(float(package.price)) - for package in (tariff.traffic_packages.stars if tariff.traffic_packages else []) - } - package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) - stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) - price = rub_packages.get(package_key) if package_key is not None else None - stars_price = ( - stars_packages.get(stars_package_key) if stars_package_key is not None else None - ) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Traffic package is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") - payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb - traffic_gb_for_payment = float(payment_units) - sale_mode = f"traffic_package@{tariff.key}" - else: - try: - months = int(float(payment_payload.months)) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid subscription period") - if months not in tariff.enabled_periods: - return _json_error(400, "invalid_plan", "Subscription period is not available") - price = tariff.period_price(months, "rub") - stars_price_raw = tariff.period_price(months, "stars") - stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Subscription period is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") - payment_units = months - sale_mode = f"subscription@{tariff.key}" - elif traffic_mode: - try: - traffic_gb = float( - payment_payload.traffic_gb - if payment_payload.traffic_gb is not None - else payment_payload.months - ) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid traffic package") - if traffic_gb <= 0: - return _json_error(400, "invalid_plan", "Invalid traffic package") - package_key = _resolve_numeric_option_key(cached["traffic_packages"], traffic_gb) - stars_package_key = _resolve_numeric_option_key( - cached["stars_traffic_packages"], traffic_gb - ) - price = cached["traffic_packages"].get(package_key) if package_key is not None else None - stars_price = ( - cached["stars_traffic_packages"].get(stars_package_key) - if stars_package_key is not None - else None - ) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Traffic package is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") - payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb - traffic_gb_for_payment = float(payment_units) - sale_mode = "traffic" - else: - try: - months = int(float(payment_payload.months)) - except (TypeError, ValueError): - return _json_error(400, "invalid_plan", "Invalid subscription period") - price = cached["subscription_options"].get(months) - stars_price = cached["stars_subscription_options"].get(months) - if price is None and method != "stars": - return _json_error(400, "invalid_plan", "Subscription period is not available") - if method == "stars" and (stars_price is None or int(stars_price) <= 0): - return _json_error(400, "invalid_plan", "Stars price is not configured") - payment_units = months - sale_mode = "subscription" - - 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") - lang = db_user.language_code or settings.DEFAULT_LANGUAGE - return await _create_subscription_payment( - request=request, - session=session, - user_id=user_id, - method=method, - months=payment_units, - price=float(price or 0), - stars_price=stars_price, - lang=lang, - sale_mode=sale_mode, - traffic_gb=traffic_gb_for_payment, - ) - - -async def activate_trial_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="trial_activate", - ) - if rate_limit_response: - return rate_limit_response - - settings: Settings = request.app["settings"] - if not settings.TRIAL_ENABLED or settings.TRIAL_DURATION_DAYS <= 0: - return _json_error(400, "trial_unavailable", "Trial is not available") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - - activation_result = await subscription_service.activate_trial_subscription(session, user_id) - if not activation_result or not activation_result.get("activated"): - await session.rollback() - message_key = ( - activation_result.get("message_key", "trial_activation_failed") - if activation_result - else "trial_activation_failed" - ) - status = 400 if message_key != "trial_activation_failed_panel_update" else 502 - return _json_error(status, message_key, message_key) - - end_date = activation_result.get("end_date") - config_link, connect_url = await prepare_config_links( - settings, - activation_result.get("subscription_url"), - ) - - i18n_instance = request.app.get("i18n") - if settings.LOG_TRIAL_ACTIVATIONS and i18n_instance: - try: - from bot.services.notification_service import NotificationService - - notification_service = NotificationService( - request.app["bot"], settings, i18n_instance - ) - await notification_service.notify_trial_activation(user_id, end_date) - except Exception: - logger.exception("Failed to send WebApp trial activation notification") - - try: - from db.dal import ad_dal as _ad_dal - - await _ad_dal.mark_trial_activated(session, user_id) - await session.commit() - except Exception: - await session.rollback() - logger.exception("Failed to mark WebApp trial activation for ad attribution") - - return web.json_response( - { - "ok": True, - "activated": True, - "days": activation_result.get("days", settings.TRIAL_DURATION_DAYS), - "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None, - "end_date_text": _format_webapp_datetime(end_date) - if isinstance(end_date, datetime) - else None, - "traffic_gb": activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB), - "config_link": config_link, - "connect_url": connect_url or config_link, - } - ) - - -async def tariff_topup_options_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - settings: Settings = request.app["settings"] - config = settings.tariffs_config - topup_kind = str(request.query.get("kind") or "all").strip().lower() - if topup_kind not in {"all", "regular", "premium"}: - return _json_error(400, "invalid_topup_kind", "Invalid topup kind") - if not config: - return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") - - 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") - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub or not sub.tariff_key: - return _json_error( - 400, "subscription_required", "Active tariff subscription is required" - ) - lang = db_user.language_code or settings.DEFAULT_LANGUAGE - tariff = config.require(sub.tariff_key) - plans = ( - _serialize_topup_packages(settings, tariff, config.topup_packages_for(tariff), lang) - if topup_kind in {"all", "regular"} - else [] - ) - premium_plans = ( - _serialize_topup_packages( - settings, - tariff, - tariff.premium_topup_packages, - lang, - sale_mode="premium_topup", - title_prefix=f"{tariff.premium_name(lang)} ", - ) - if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids - else [] - ) - premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) - premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) - premium_limit_bytes = ( - int(sub.premium_baseline_bytes or 0) - + int(sub.premium_topup_balance_bytes or 0) - + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) - + premium_bonus_bytes - ) - premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff) - return web.json_response( - { - "ok": True, - "tariff_key": tariff.key, - "tariff_name": tariff.name(lang), - "topup_kind": topup_kind, - "premium_title": tariff.premium_name(lang), - "traffic_percent": _traffic_percent( - sub.traffic_used_bytes, sub.traffic_limit_bytes - ), - "premium_traffic_percent": 0 - if premium_unlimited_override - else _traffic_percent( - sub.premium_used_bytes, - premium_limit_bytes, - ), - "premium_limit_bytes": premium_limit_bytes, - "premium_used_bytes": int(sub.premium_used_bytes or 0), - "premium_baseline_bytes": int(sub.premium_baseline_bytes or 0), - "premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0), - "premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0), - "premium_bonus_bytes": premium_bonus_bytes, - "premium_unlimited_override": premium_unlimited_override, - "premium_is_limited": bool(sub.premium_is_limited), - "premium_squad_labels": premium_access.get("squad_labels") or [], - "premium_node_labels": premium_access.get("node_labels") or [], - "warning_levels": settings.tariff_traffic_warning_levels, - "plans": plans + premium_plans, - } - ) - - -async def tariff_change_options_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - settings: Settings = request.app["settings"] - config = settings.tariffs_config - if not config: - return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub or not sub.tariff_key: - return _json_error( - 400, "subscription_required", "Active tariff subscription is required" - ) - lang = db_user.language_code or settings.DEFAULT_LANGUAGE - current = config.require(sub.tariff_key) - targets = [] - for tariff in config.enabled_tariffs: - if tariff.key == current.key: - continue - options = subscription_service.calculate_tariff_switch_options(sub, tariff) - targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang)) - return web.json_response( - { - "ok": True, - "current": { - "tariff_key": current.key, - "title": current.name(lang), - "description": current.description(lang), - "billing_model": current.billing_model, - }, - "targets": targets, - } - ) - - -async def tariff_change_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - payload = await _read_json(request) - change_payload, validation_error = _validate_model_payload(WebAppTariffChangePayload, payload) - if validation_error: - return validation_error - mode = str(change_payload.mode or "").strip() - if mode not in {"recalc_days", "convert_days_to_gb"}: - return _json_error(400, "invalid_change_mode", "This tariff change requires payment") - - settings: Settings = request.app["settings"] - if not settings.tariffs_config: - return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - result = await subscription_service.switch_tariff_without_payment( - session, - user_id, - str(change_payload.tariff_key), - mode, - ) - if not result: - await session.rollback() - return _json_error(400, "change_failed", "Tariff change failed") - await session.commit() - return web.json_response({"ok": True, **result}) - - -async def tariff_change_payment_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - payload = await _read_json(request) - payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload) - if validation_error: - return validation_error - method = str(payment_payload.method or "").strip().lower() - tariff_key = str(payment_payload.tariff_key or "").strip() - settings: Settings = request.app["settings"] - config = settings.tariffs_config - if not config: - return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") - if not tariff_key: - return _json_error(400, "invalid_plan", "Tariff is not selected") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return _json_error( - 400, "subscription_required", "Active tariff subscription is required" - ) - target = config.require(tariff_key) - options = subscription_service.calculate_tariff_switch_options(sub, target) - price = float(options.get("paid_diff_rub") or 0) - if price <= 0: - return _json_error( - 400, "payment_not_required", "Payment is not required for this tariff change" - ) - return await _create_subscription_payment( - request=request, - session=session, - user_id=user_id, - method=method, - months=1, - price=price, - stars_price=None, - lang=db_user.language_code or settings.DEFAULT_LANGUAGE, - sale_mode=f"tariff_upgrade@{target.key}", - ) - - -async def devices_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - settings: Settings = request.app["settings"] - if not settings.MY_DEVICES_SECTION_ENABLED: - return _json_error(404, "devices_disabled", "Devices section is disabled") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - - active = await subscription_service.get_active_subscription_details(session, user_id) - panel_user_uuid = active.get("user_id") if active else None - if not panel_user_uuid: - return _json_error(400, "subscription_not_active", "Subscription is not active") - - panel_service = getattr(subscription_service, "panel_service", None) - if not panel_service: - return _json_error(503, "panel_unavailable", "Panel service unavailable") - - try: - devices_response = await panel_service.get_user_devices(panel_user_uuid) - except Exception: - logger.exception("Failed to load WebApp devices for user %s", user_id) - return _json_error(502, "devices_load_failed", "Failed to load devices") - - devices = _normalize_devices_response(devices_response) - max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None - return web.json_response( - { - "ok": True, - "enabled": True, - "current_devices": len(devices), - "max_devices": max_devices, - "max_devices_label": _format_devices_limit(max_devices), - "devices": [ - _serialize_device(device, index) for index, device in enumerate(devices, start=1) - ], - } - ) - - -async def disconnect_device_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="devices_disconnect", - ) - if rate_limit_response: - return rate_limit_response - - settings: Settings = request.app["settings"] - if not settings.MY_DEVICES_SECTION_ENABLED: - return _json_error(404, "devices_disabled", "Devices section is disabled") - - payload = await _read_json(request) - disconnect_payload, validation_error = _validate_model_payload( - WebAppDeviceDisconnectPayload, payload - ) - if validation_error: - return validation_error - token = str(disconnect_payload.token or "").strip() - - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - - active = await subscription_service.get_active_subscription_details(session, user_id) - panel_user_uuid = active.get("user_id") if active else None - if not panel_user_uuid: - return _json_error(400, "subscription_not_active", "Subscription is not active") - - panel_service = getattr(subscription_service, "panel_service", None) - if not panel_service: - return _json_error(503, "panel_unavailable", "Panel service unavailable") - - try: - devices_response = await panel_service.get_user_devices(panel_user_uuid) - except Exception: - logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id) - return _json_error(502, "devices_load_failed", "Failed to load devices") - - target_hwid = None - for device in _normalize_devices_response(devices_response): - hwid = str(device.get("hwid") or "").strip() - if hwid and hmac.compare_digest(_device_hwid_token(hwid), token): - target_hwid = hwid - break - - if not target_hwid: - return _json_error(404, "device_not_found", "Device not found") - - success = await panel_service.disconnect_device(panel_user_uuid, target_hwid) - if not success: - return _json_error(502, "device_disconnect_failed", "Failed to disconnect device") - await session.commit() - - return web.json_response({"ok": True}) - - -async def device_topup_options_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - settings: Settings = request.app["settings"] - config = settings.tariffs_config - if not settings.MY_DEVICES_SECTION_ENABLED: - return _json_error(404, "devices_disabled", "Devices section is disabled") - if not config: - return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - 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") - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub or not sub.tariff_key: - return _json_error( - 400, "subscription_required", "Active tariff subscription is required" - ) - tariff = config.require(sub.tariff_key) - active = await subscription_service.get_active_subscription_details(session, user_id) - plans = _serialize_hwid_device_packages( - settings, - tariff, - tariff.hwid_device_packages, - db_user.language_code or settings.DEFAULT_LANGUAGE, - ) - return web.json_response( - { - "ok": True, - "tariff_key": tariff.key, - "tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE), - "current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None, - "extra_hwid_devices": int(sub.extra_hwid_devices or 0), - "plans": plans, - } - ) - - -async def payment_status_route(request: web.Request) -> web.Response: - user_id = _require_user_id(request) - try: - payment_id = int(request.match_info["payment_id"]) - except (TypeError, ValueError): - return _json_error(400, "invalid_payment", "Invalid payment id") - - async_session_factory: sessionmaker = request.app["async_session_factory"] - async with async_session_factory() as session: - payment = await payment_dal.get_payment_by_db_id(session, payment_id) - if not payment or payment.user_id != user_id: - return _json_error(404, "not_found", "Payment not found") - return web.json_response( - { - "ok": True, - "payment_id": payment.payment_id, - "status": payment.status, - "paid": payment.status == "succeeded", - } - ) - - -async def _read_json(request: web.Request) -> Dict[str, Any]: - try: - data = await request.json() - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -def _json_error(status: int, code: str, message: str) -> web.Response: - return web.json_response( - {"ok": False, "error": code, "message": message}, - status=status, - ) - - -def _validation_error_response(exc: ValidationError) -> web.Response: - for error in exc.errors(): - loc = error.get("loc") or () - field = str(loc[0]) if loc else "" - error_type = str(error.get("type") or "") - message = str(error.get("msg") or "") - message_lower = message.lower() - - if field == "email": - if ( - "too_long" in message_lower - or "too long" in message_lower - or error_type == "string_too_long" - ): - return _json_error(400, "email_too_long", "Email is too long") - return _json_error(400, "invalid_email", "Invalid email") - - if field in {"description", "comment", "note"} and error_type == "string_too_long": - return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long") - - if error_type == "string_too_long": - return _json_error(400, "text_too_long", "Text is too long") - - return _json_error(400, "invalid_request", "Invalid request") - - -def _validate_model_payload( - model_cls: type[BaseModel], - payload: Dict[str, Any], -) -> tuple[Optional[BaseModel], Optional[web.Response]]: - try: - return model_cls.model_validate(payload), None - except ValidationError as exc: - return None, _validation_error_response(exc) - - -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]: - settings: Settings = request.app["settings"] - header = request.headers.get("Authorization", "") - prefix = "Bearer " - if header.startswith(prefix): - user_id = verify_webapp_session_token(settings, header[len(prefix) :].strip()) - if user_id: - return user_id - - cookie_token = request.cookies.get(WEBAPP_SESSION_COOKIE_NAME, "") - if cookie_token: - return verify_webapp_session_token(settings, cookie_token) - return None - - -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 - - -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") - - -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 _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, - ) - - -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 - - -async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: - settings: Settings = request.app["settings"] - async_session_factory: sessionmaker = request.app["async_session_factory"] - subscription_service: SubscriptionService = request.app["subscription_service"] - cached = _get_cached_webapp_settings(request) - - 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: - raise web.HTTPForbidden( - text=json.dumps({"ok": False, "error": "access_denied"}), - content_type="application/json", - ) - - active = await subscription_service.get_active_subscription_details(session, user_id) - referral_code = await user_dal.ensure_referral_code(session, db_user) - referral_service: Optional[ReferralService] = request.app.get("referral_service") - bot_username = request.app.get("bot_username") or "" - referral_link = None - if referral_service and bot_username: - referral_link = await referral_service.generate_referral_link( - session, - bot_username, - user_id, - ) - webapp_referral_link = _build_webapp_referral_link( - request.app["settings"].SUBSCRIPTION_MINI_APP_URL, - referral_code, - ) - referral_stats = ( - await referral_service.get_referral_stats(session, user_id) - if referral_service - else {"invited_count": 0, "purchased_count": 0} - ) - local_sub = ( - await subscription_dal.get_active_subscription_by_user_id( - session, - user_id, - db_user.panel_user_uuid, - ) - if db_user.panel_user_uuid - else None - ) - trial_available = bool( - settings.TRIAL_ENABLED - and settings.TRIAL_DURATION_DAYS > 0 - and not await subscription_service.has_had_any_subscription(session, user_id) - ) - avatar = await _ensure_cached_telegram_avatar(request, session, db_user) - try: - await session.commit() - except Exception: - await session.rollback() - - lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) - admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} - is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) - return { - "user": { - "id": user_id, - "username": db_user.username, - "email": db_user.email, - "email_verified": bool(db_user.email_verified_at), - "telegram_id": db_user.telegram_id, - "telegram_linked": bool(_telegram_id_for_user(db_user)), - "telegram_photo_url": _telegram_avatar_url(avatar), - "first_name": db_user.first_name, - "language_code": lang, - "is_admin": is_admin, - }, - "subscription": _serialize_subscription(settings, active, local_sub, lang), - "referral": { - "code": referral_code, - "bot_link": referral_link, - "webapp_link": webapp_referral_link, - "invited_count": referral_stats.get("invited_count", 0), - "purchased_count": referral_stats.get("purchased_count", 0), - "welcome_bonus_days": max( - 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0) - ), - "one_bonus_per_referee": bool( - getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False) - ), - "bonus_details": _serialize_referral_bonus_details(settings, lang), - }, - "plans": _serialize_plans( - settings, - lang, - subscription_options=cached["subscription_options"], - stars_subscription_options=cached["stars_subscription_options"], - traffic_packages=cached["traffic_packages"], - stars_traffic_packages=cached["stars_traffic_packages"], - ), - "payment_methods": _serialize_payment_methods(settings, request.app), - "settings": { - "support_url": settings.SUPPORT_LINK, - "traffic_mode": bool(settings.traffic_sale_mode), - "my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED), - "user_hwid_device_limit": ( - int(settings.USER_HWID_DEVICE_LIMIT) - if settings.USER_HWID_DEVICE_LIMIT is not None - else None - ), - "trial_enabled": bool(settings.TRIAL_ENABLED), - "trial_available": trial_available, - "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), - "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), - "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), - "email_auth_enabled": settings.email_auth_configured, - }, - } - - -def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]: - if getattr(settings, "traffic_sale_mode", False): - return [] - - details: List[Dict[str, Any]] = [] - for months, _price in sorted(settings.subscription_options.items()): - inviter_days = settings.referral_bonus_inviter.get(months) - friend_days = settings.referral_bonus_referee.get(months) - if inviter_days is None and friend_days is None: - continue - details.append( - { - "months": int(months), - "title": _format_months_title(int(months), lang), - "inviter_days": int(inviter_days or 0), - "friend_days": int(friend_days or 0), - } - ) - return details - - -def _build_webapp_referral_link( - base_url: Optional[str], - referral_code: Optional[str], -) -> Optional[str]: - if not base_url or not referral_code: - return None - parts = urlsplit(base_url) - query = dict(parse_qsl(parts.query, keep_blank_values=True)) - query["ref"] = f"u{referral_code}" - return urlunsplit( - ( - parts.scheme, - parts.netloc, - parts.path or "/", - urlencode(query), - parts.fragment, - ) - ) - - -def _serialize_subscription( - settings: Settings, - active: Optional[Dict[str, Any]], - local_sub: Optional[Any], - lang: str, -) -> Dict[str, Any]: - if not active: - return { - "active": False, - "status": "INACTIVE", - "remaining_text": _format_remaining(0, lang), - "days_left": 0, - "config_link": None, - "connect_url": None, - } - - end_date = active.get("end_date") - if end_date and end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=timezone.utc) - - seconds_left = 0 - if end_date: - seconds_left = max( - 0, - int((end_date - datetime.now(timezone.utc)).total_seconds()), - ) - - can_topup_regular_traffic = False - can_topup_premium_traffic = False - can_topup_traffic = False - if settings.tariffs_config and active.get("tariff_key"): - try: - tariff = settings.tariffs_config.require(str(active.get("tariff_key"))) - packages = settings.tariffs_config.topup_packages_for(tariff) - can_topup_regular_traffic = bool(packages and packages.has_any()) - can_topup_premium_traffic = bool( - tariff.premium_squad_uuids - and tariff.premium_topup_packages - and tariff.premium_topup_packages.has_any() - ) - can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) - except Exception: - can_topup_regular_traffic = False - can_topup_premium_traffic = False - can_topup_traffic = False - - return { - "active": seconds_left > 0, - "status": active.get("status_from_panel") or "UNKNOWN", - "end_date": end_date.isoformat() if end_date else None, - "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A", - "days_left": seconds_left // 86400, - "remaining_text": _format_remaining(seconds_left, lang), - "config_link": active.get("config_link"), - "connect_url": active.get("connect_button_url") or active.get("config_link"), - "traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True), - "traffic_used": _format_bytes(active.get("traffic_used_bytes")), - "traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")), - "traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")), - "tariff_key": active.get("tariff_key"), - "tariff_name": active.get("tariff_name"), - "tariff_description": active.get("tariff_description"), - "premium_title": active.get("premium_title"), - "billing_model": active.get("billing_model"), - "traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""), - "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")), - "topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")), - "premium_limit": _format_bytes(active.get("premium_limit_bytes"), zero_as_unlimited=True), - "premium_used": _format_bytes(active.get("premium_used_bytes")), - "premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")), - "premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")), - "premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")), - "premium_topup_balance_bytes": _coerce_int_or_none( - active.get("premium_topup_balance_bytes") - ), - "premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")), - "premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0, - "regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0, - "regular_unlimited_override": bool(active.get("regular_unlimited_override")), - "premium_unlimited_override": bool(active.get("premium_unlimited_override")), - "premium_is_limited": bool(active.get("premium_is_limited")), - "premium_squad_labels": list(active.get("premium_squad_labels") or []), - "premium_node_labels": list(active.get("premium_node_labels") or []), - "can_topup_traffic": can_topup_traffic, - "can_topup_regular_traffic": can_topup_regular_traffic, - "can_topup_premium_traffic": can_topup_premium_traffic, - "period_start_at": active.get("period_start_at").isoformat() - if active.get("period_start_at") - else None, - "is_throttled": bool(active.get("is_throttled")), - "max_devices": _coerce_int_or_none(active.get("max_devices")), - "base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")), - "extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0, - "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), - "provider": getattr(local_sub, "provider", None), - } - - -def _serialize_plans( - settings: Settings, - lang: str, - *, - subscription_options: Optional[Dict[int, float]] = None, - stars_subscription_options: Optional[Dict[int, int]] = None, - traffic_packages: Optional[Dict[float, float]] = None, - stars_traffic_packages: Optional[Dict[float, int]] = None, -) -> List[Dict[str, Any]]: - tariffs_config = settings.tariffs_config - if tariffs_config: - plans: List[Dict[str, Any]] = [] - for tariff in tariffs_config.enabled_tariffs: - common = { - "tariff_key": tariff.key, - "tariff_name": tariff.name(lang), - "billing_model": tariff.billing_model, - "description": tariff.description(lang), - "squad_uuids": tariff.squad_uuids, - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "hwid_device_limit": tariff.hwid_device_limit, - "hwid_device_packages": _serialize_hwid_device_packages( - settings, - tariff, - tariff.hwid_device_packages, - lang, - ), - } - if tariff.billing_model == "period": - for months in sorted(tariff.enabled_periods): - price = tariff.period_price(int(months), "rub") - stars_price = tariff.period_price(int(months), "stars") - if price is None and (stars_price is None or int(stars_price) <= 0): - continue - plan = { - **common, - "id": f"{tariff.key}:period:{int(months)}", - "sale_mode": "subscription", - "months": int(months), - "price": float(price or 0), - "title": tariff.name(lang), - "subtitle": _format_months_title(int(months), lang), - "monthly_gb": tariff.monthly_gb, - } - if stars_price is not None and int(stars_price) > 0: - plan["stars_price"] = int(stars_price) - plans.append(plan) - else: - rub_packages = { - float(package.gb): float(package.price) - for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) - } - stars_packages = { - float(package.gb): int(float(package.price)) - for package in ( - tariff.traffic_packages.stars if tariff.traffic_packages else [] - ) - } - for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): - price = rub_packages.get(traffic_gb) - stars_price = stars_packages.get(traffic_gb) - if price is None and (stars_price is None or int(stars_price) <= 0): - continue - traffic_value = float(traffic_gb) - plan = { - **common, - "id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}", - "sale_mode": "traffic_package", - "months": int(traffic_value) - if traffic_value.is_integer() - else traffic_value, - "traffic_gb": traffic_value, - "price": float(price or 0), - "title": tariff.name(lang), - "subtitle": _format_traffic_title(traffic_value, lang), - } - if stars_price is not None and int(stars_price) > 0: - plan["stars_price"] = int(stars_price) - plans.append(plan) - return plans - - if getattr(settings, "traffic_sale_mode", False): - active_traffic_packages = traffic_packages or settings.traffic_packages - active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages - traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages)) - plans: List[Dict[str, Any]] = [] - for traffic_gb in traffic_units: - price = active_traffic_packages.get(traffic_gb) - stars_price = active_stars_traffic_packages.get(traffic_gb) - if price is None and (stars_price is None or int(stars_price) <= 0): - continue - traffic_value = float(traffic_gb) - plan = { - "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, - "traffic_gb": traffic_value, - "price": float(price or 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "title": _format_traffic_title(traffic_value, lang), - "sale_mode": "traffic", - } - if stars_price is not None and int(stars_price) > 0: - plan["stars_price"] = int(stars_price) - plans.append(plan) - return plans - - active_subscription_options = subscription_options or settings.subscription_options - active_stars_subscription_options = ( - stars_subscription_options or settings.stars_subscription_options - ) - plans: List[Dict[str, Any]] = [] - for months in sorted(set(active_subscription_options) | set(active_stars_subscription_options)): - price = active_subscription_options.get(months) - stars_price = active_stars_subscription_options.get(months) - if price is None and (stars_price is None or int(stars_price) <= 0): - continue - plan = { - "months": int(months), - "price": float(price or 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "title": _format_months_title(int(months), lang), - "sale_mode": "subscription", - } - if stars_price is not None and int(stars_price) > 0: - plan["stars_price"] = int(stars_price) - plans.append(plan) - return plans - - -def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int: - used_val = int(used or 0) - limit_val = int(limit or 0) - if limit_val <= 0: - return 0 - return max(0, min(100, round((used_val / limit_val) * 100))) - - -def _serialize_topup_packages( - settings: Settings, - tariff: Any, - packages: Optional[Any], - lang: str, - *, - sale_mode: str = "topup", - title_prefix: str = "", -) -> List[Dict[str, Any]]: - rub_packages = { - float(package.gb): float(package.price) for package in (packages.rub if packages else []) - } - stars_packages = { - float(package.gb): int(float(package.price)) - for package in (packages.stars if packages else []) - } - plans: List[Dict[str, Any]] = [] - for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): - price = rub_packages.get(traffic_gb) - stars_price = stars_packages.get(traffic_gb) - if price is None and (stars_price is None or int(stars_price) <= 0): - continue - traffic_value = float(traffic_gb) - plan: Dict[str, Any] = { - "id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}", - "tariff_key": tariff.key, - "tariff_name": tariff.name(lang), - "billing_model": tariff.billing_model, - "sale_mode": sale_mode, - "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, - "traffic_gb": traffic_value, - "price": float(price or 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}", - "subtitle": tariff.premium_name(lang) - if sale_mode == "premium_topup" - else tariff.name(lang), - } - if stars_price is not None and int(stars_price) > 0: - plan["stars_price"] = int(stars_price) - plans.append(plan) - return plans - - -def _serialize_hwid_device_packages( - settings: Settings, - tariff: Any, - packages: Optional[Any], - lang: str, -) -> List[Dict[str, Any]]: - rub_packages = { - int(package.count): float(package.price) for package in (packages.rub if packages else []) - } - stars_packages = { - int(package.count): int(float(package.price)) - for package in (packages.stars if packages else []) - } - plans: List[Dict[str, Any]] = [] - for count in sorted(set(rub_packages) | set(stars_packages)): - price = rub_packages.get(count) - stars_price = stars_packages.get(count) - if price is None and (stars_price is None or int(stars_price) <= 0): - continue - plan: Dict[str, Any] = { - "id": f"{tariff.key}:hwid:{count}", - "tariff_key": tariff.key, - "tariff_name": tariff.name(lang), - "billing_model": tariff.billing_model, - "sale_mode": "hwid_devices", - "months": int(count), - "device_count": int(count), - "price": float(price or 0), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - "title": f"+{count}", - "subtitle": tariff.name(lang), - } - if stars_price is not None and int(stars_price) > 0: - plan["stars_price"] = int(stars_price) - plans.append(plan) - return plans - - -def _serialize_tariff_change_target( - settings: Settings, - config: Any, - tariff: Any, - options: Dict[str, Any], - lang: str, -) -> Dict[str, Any]: - actions: List[Dict[str, Any]] = [] - mode = str(options.get("mode") or "") - if mode == "period_to_period": - actions.append( - { - "mode": "recalc_days", - "kind": "free", - "title": "recalc_days", - "days_after": int(options.get("recalc_days") or 0), - "remaining_days": int(options.get("remaining_days") or 0), - } - ) - paid_diff = float(options.get("paid_diff_rub") or 0) - if paid_diff > 0: - actions.append( - { - "mode": "paid_diff", - "kind": "payment", - "title": "paid_diff", - "price": paid_diff, - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - } - ) - elif mode == "period_to_traffic": - actions.append( - { - "mode": "convert_days_to_gb", - "kind": "free", - "title": "convert_days_to_gb", - "converted_gb": float(options.get("converted_gb") or 0), - "remaining_days": int(options.get("remaining_days") or 0), - } - ) - actions.extend( - { - "mode": "buy_package", - "kind": "payment", - "title": f"+{package.gb:g} GB", - "traffic_gb": float(package.gb), - "price": float(package.price), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - } - for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) - ) - else: - for months in tariff.enabled_periods: - price = tariff.period_price(int(months), "rub") - if price: - actions.append( - { - "mode": "buy_period", - "kind": "payment", - "months": int(months), - "title": _format_months_title(int(months), lang), - "price": float(price), - "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - } - ) - return { - "tariff_key": tariff.key, - "title": tariff.name(lang), - "description": tariff.description(lang), - "billing_model": tariff.billing_model, - "monthly_gb": tariff.monthly_gb, - "options": options, - "actions": actions, - } - - -def _serialize_payment_methods( - settings: Settings, - app: web.Application, -) -> List[Dict[str, Any]]: - labels = { - "severpay": "SeverPay", - "freekassa": "FreeKassa / СБП", - "platega_sbp": "Platega · СБП", - "platega_crypto": "Platega · Crypto", - "yookassa": "Банковская карта", - "stars": "Telegram Stars", - "cryptopay": "CryptoPay", - } - methods: List[Dict[str, Any]] = [] - for method in settings.payment_methods_order: - method = method.lower() - if ( - method == "severpay" - and settings.SEVERPAY_ENABLED - and _service_configured(app, "severpay_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "freekassa" - and settings.FREEKASSA_ENABLED - and _service_configured(app, "freekassa_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "platega_sbp" - and settings.PLATEGA_ENABLED - and settings.PLATEGA_SBP_ENABLED - and _service_configured(app, "platega_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "platega_crypto" - and settings.PLATEGA_ENABLED - and settings.PLATEGA_CRYPTO_ENABLED - and _service_configured(app, "platega_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "yookassa" - and settings.YOOKASSA_ENABLED - and _service_configured(app, "yookassa_service") - ): - methods.append({"id": method, "name": labels[method]}) - elif method == "stars" and settings.STARS_ENABLED: - methods.append({"id": method, "name": labels[method]}) - elif ( - method == "cryptopay" - and settings.CRYPTOPAY_ENABLED - and _service_configured(app, "cryptopay_service") - ): - methods.append({"id": method, "name": labels[method]}) - return methods - - -def _service_configured(app: web.Application, key: str) -> bool: - service = app.get(key) - return bool(service and getattr(service, "configured", False)) - - -def _sale_mode_base(sale_mode: str) -> str: - return str(sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0] - - -def _sale_mode_tariff_key(sale_mode: str) -> Optional[str]: - if "@" not in str(sale_mode or ""): - return None - return str(sale_mode).split("@", 1)[1].split("|", 1)[0] or None - - -def _sale_mode_is_traffic(sale_mode: str) -> bool: - return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"} - - -def _sale_mode_is_hwid_devices(sale_mode: str) -> bool: - return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"} - - -async def _create_subscription_payment( - *, - request: web.Request, - session: AsyncSession, - user_id: int, - method: str, - months: Any, - price: float, - stars_price: Optional[int], - lang: str, - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - settings: Settings = request.app["settings"] - sale_mode = str(sale_mode or "subscription") - traffic_sale = _sale_mode_is_traffic(sale_mode) - hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) - description = ( - _traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang) - if traffic_sale - else _hwid_devices_payment_description(int(float(months)), lang) - if hwid_devices_sale - else _payment_description(int(months), lang) - ) - - if method == "yookassa": - if not settings.YOOKASSA_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_yookassa_payment( - request, - session, - user_id, - months, - price, - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method == "freekassa": - if not settings.FREEKASSA_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_freekassa_payment( - request, - session, - user_id, - months, - price, - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method in ("platega", "platega_sbp", "platega_crypto"): - if not settings.PLATEGA_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_platega_payment( - request, - session, - user_id, - months, - price, - description, - variant=method, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method == "severpay": - if not settings.SEVERPAY_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_severpay_payment( - request, - session, - user_id, - months, - price, - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - if method == "cryptopay": - service: CryptoPayService = request.app["cryptopay_service"] - if not settings.CRYPTOPAY_ENABLED or not service or not service.configured: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - url = await service.create_invoice( - session=session, - user_id=user_id, - months=months, - amount=price, - description=description, - sale_mode=sale_mode, - url_kind="web", - ) - if not url: - return _json_error(502, "payment_failed", "Failed to create payment") - return web.json_response( - {"ok": True, "action": "open_link", "payment_url": url, "payment_id": None} - ) - if method == "stars": - if not settings.STARS_ENABLED or stars_price is None: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - return await _create_stars_payment( - request, - session, - user_id, - months, - int(stars_price), - description, - sale_mode=sale_mode, - traffic_gb=traffic_gb, - ) - - return _json_error(400, "payment_unavailable", "Payment method unavailable") - - -async def _create_base_payment_record( - session: AsyncSession, - *, - user_id: int, - amount: float, - currency: str, - status: str, - description: str, - months: int, - provider: str, - sale_mode: Optional[str] = None, - tariff_key: Optional[str] = None, - purchased_gb: Optional[float] = None, - purchased_hwid_devices: Optional[int] = None, -) -> Payment: - payment = await payment_dal.create_payment_record( - session, - { - "user_id": user_id, - "amount": amount, - "currency": currency, - "status": status, - "description": description, - "subscription_duration_months": months, - "provider": provider, - "sale_mode": sale_mode, - "tariff_key": tariff_key, - "purchased_gb": purchased_gb, - "purchased_hwid_devices": purchased_hwid_devices, - }, - ) - await session.commit() - return payment - - -async def _create_yookassa_payment( - request: web.Request, - session: AsyncSession, - user_id: int, - months: Any, - price: float, - description: str, - *, - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - settings: Settings = request.app["settings"] - service: YooKassaService = request.app["yookassa_service"] - if not service or not service.configured: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - - try: - traffic_sale = _sale_mode_is_traffic(sale_mode) - hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) - payment = await _create_base_payment_record( - session, - user_id=user_id, - amount=price, - currency="RUB", - status="pending_yookassa", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="yookassa", - sale_mode=sale_mode, - tariff_key=_sale_mode_tariff_key(sale_mode), - purchased_gb=float(traffic_gb or months) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, - ) - metadata = { - "user_id": str(user_id), - "subscription_months": str( - int(float(months)) if not traffic_sale and not hwid_devices_sale else 0 - ), - "payment_db_id": str(payment.payment_id), - "sale_mode": sale_mode, - "source": "webapp", - } - if traffic_sale: - metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months) - if hwid_devices_sale: - metadata["hwid_devices"] = str(int(float(months))) - if _sale_mode_tariff_key(sale_mode): - metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode) - response = await service.create_payment( - amount=price, - currency="RUB", - description=description, - metadata=metadata, - receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, - save_payment_method=bool( - settings.yookassa_autopayments_active - and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING - ), - ) - payment_url = response.get("confirmation_url") if response else None - if not payment_url: - await payment_dal.update_payment_status_by_db_id( - session, payment.payment_id, "failed_creation" - ) - await session.commit() - return _json_error(502, "payment_failed", "Failed to create payment") - - await payment_dal.update_payment_status_by_db_id( - session, - payment.payment_id, - response.get("status", "pending"), - yk_payment_id=response.get("id"), - ) - await session.commit() - return web.json_response( - { - "ok": True, - "action": "open_link", - "payment_url": payment_url, - "payment_id": payment.payment_id, - } - ) - except Exception: - await session.rollback() - logger.exception("YooKassa WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_freekassa_payment( - request: web.Request, - session: AsyncSession, - user_id: int, - months: Any, - price: float, - description: str, - *, - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - settings: Settings = request.app["settings"] - service: FreeKassaService = request.app["freekassa_service"] - if not service or not service.configured or not service.payment_method_id: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - - try: - traffic_sale = _sale_mode_is_traffic(sale_mode) - hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) - payment = await _create_base_payment_record( - session, - user_id=user_id, - amount=price, - currency=service.default_currency, - status="pending_freekassa", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="freekassa", - sale_mode=sale_mode, - tariff_key=_sale_mode_tariff_key(sale_mode), - purchased_gb=float(traffic_gb or months) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, - ) - success, response_data = await service.create_order( - payment_db_id=payment.payment_id, - user_id=user_id, - months=months, - amount=price, - currency=service.default_currency, - payment_method_id=service.payment_method_id, - ip_address=service.server_ip, - extra_params={"us_method": service.payment_method_id}, - ) - payment_url = response_data.get("location") if success else None - provider_id = response_data.get("orderHash") or response_data.get("orderId") - if provider_id: - await payment_dal.update_provider_payment_and_status( - session, payment.payment_id, str(provider_id), payment.status - ) - await session.commit() - if not payment_url: - await payment_dal.update_payment_status_by_db_id( - session, payment.payment_id, "failed_creation" - ) - await session.commit() - return _json_error(502, "payment_failed", "Failed to create payment") - return web.json_response( - { - "ok": True, - "action": "open_link", - "payment_url": payment_url, - "payment_id": payment.payment_id, - } - ) - except Exception: - await session.rollback() - logger.exception("FreeKassa WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_platega_payment( - request: web.Request, - session: AsyncSession, - user_id: int, - months: Any, - price: float, - description: str, - variant: str = "platega_sbp", - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - settings: Settings = request.app["settings"] - service: PlategaService = request.app["platega_service"] - if not service or not service.configured: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - if variant == "platega_crypto": - if not settings.PLATEGA_CRYPTO_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - platega_method_id = settings.PLATEGA_CRYPTO_METHOD - else: - if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - platega_method_id = settings.platega_sbp_method_resolved - - try: - traffic_sale = _sale_mode_is_traffic(sale_mode) - hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) - payment = await _create_base_payment_record( - session, - user_id=user_id, - amount=price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - status="pending_platega", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="platega", - sale_mode=sale_mode, - tariff_key=_sale_mode_tariff_key(sale_mode), - purchased_gb=float(traffic_gb or months) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, - ) - months_for_provider = ( - int(float(months)) if not traffic_sale else int(float(traffic_gb or months)) - ) - payload = json.dumps( - { - "payment_db_id": payment.payment_id, - "user_id": user_id, - "months": months_for_provider if not traffic_sale else 0, - "sale_mode": sale_mode, - "traffic_gb": _format_number_for_payload(traffic_gb or months) - if traffic_sale - else None, - "hwid_devices": int(float(months)) if hwid_devices_sale else None, - "source": "webapp", - "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", - } - ) - success, response_data = await service.create_transaction( - payment_db_id=payment.payment_id, - user_id=user_id, - months=months_for_provider, - amount=price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - description=description, - payload=payload, - payment_method=platega_method_id, - ) - payment_url = ( - ( - response_data.get("redirect") - or response_data.get("url") - or response_data.get("paymentUrl") - ) - if success - else None - ) - provider_id = response_data.get("transactionId") or response_data.get("id") - if provider_id: - await payment_dal.update_provider_payment_and_status( - session, - payment.payment_id, - str(provider_id), - str(response_data.get("status", payment.status)), - ) - await session.commit() - if not payment_url: - await payment_dal.update_payment_status_by_db_id( - session, payment.payment_id, "failed_creation" - ) - await session.commit() - return _json_error(502, "payment_failed", "Failed to create payment") - return web.json_response( - { - "ok": True, - "action": "open_link", - "payment_url": payment_url, - "payment_id": payment.payment_id, - } - ) - except Exception: - await session.rollback() - logger.exception("Platega WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_severpay_payment( - request: web.Request, - session: AsyncSession, - user_id: int, - months: Any, - price: float, - description: str, - *, - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - settings: Settings = request.app["settings"] - service: SeverPayService = request.app["severpay_service"] - if not service or not service.configured: - return _json_error(400, "payment_unavailable", "Payment method unavailable") - - try: - traffic_sale = _sale_mode_is_traffic(sale_mode) - hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) - payment = await _create_base_payment_record( - session, - user_id=user_id, - amount=price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - status="pending_severpay", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="severpay", - sale_mode=sale_mode, - tariff_key=_sale_mode_tariff_key(sale_mode), - purchased_gb=float(traffic_gb or months) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, - ) - success, response_data = await service.create_payment( - payment_db_id=payment.payment_id, - user_id=user_id, - months=months, - amount=price, - currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", - description=description, - ) - payment_url = ( - ( - response_data.get("url") - or response_data.get("payment_url") - or response_data.get("paymentUrl") - ) - if success - else None - ) - provider_id = response_data.get("id") or response_data.get("uid") - if provider_id: - await payment_dal.update_provider_payment_and_status( - session, payment.payment_id, str(provider_id), payment.status - ) - await session.commit() - if not payment_url: - await payment_dal.update_payment_status_by_db_id( - session, payment.payment_id, "failed_creation" - ) - await session.commit() - return _json_error(502, "payment_failed", "Failed to create payment") - return web.json_response( - { - "ok": True, - "action": "open_link", - "payment_url": payment_url, - "payment_id": payment.payment_id, - } - ) - except Exception: - await session.rollback() - logger.exception("SeverPay WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create payment") - - -async def _create_stars_payment( - request: web.Request, - session: AsyncSession, - user_id: int, - months: Any, - stars_price: int, - description: str, - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, -) -> web.Response: - bot: Bot = request.app["bot"] - try: - traffic_sale = _sale_mode_is_traffic(sale_mode) - hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) - payment = await _create_base_payment_record( - session, - user_id=user_id, - amount=float(stars_price), - currency="XTR", - status="pending_stars", - description=description, - months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), - provider="telegram_stars", - sale_mode=sale_mode, - tariff_key=_sale_mode_tariff_key(sale_mode), - purchased_gb=float(traffic_gb or months) if traffic_sale else None, - purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, - ) - payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months - payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}" - prices = [LabeledPrice(label=description, amount=stars_price)] - create_invoice_link = getattr(bot, "create_invoice_link", None) - if callable(create_invoice_link): - invoice_url = await create_invoice_link( - title=description, - description=description, - payload=payload, - provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. - currency="XTR", - prices=prices, - ) - return web.json_response( - { - "ok": True, - "action": "open_invoice", - "payment_url": invoice_url, - "payment_id": payment.payment_id, - } - ) - - await bot.send_invoice( - chat_id=user_id, - title=description, - description=description, - payload=payload, - provider_token="", - currency="XTR", - prices=prices, - ) - return web.json_response( - { - "ok": True, - "action": "invoice_sent", - "payment_id": payment.payment_id, - } - ) - except Exception: - await session.rollback() - logger.exception("Stars WebApp payment failed") - return _json_error(502, "payment_failed", "Failed to create invoice") - - -def _normalize_language(lang: Optional[str]) -> str: - value = (lang or "ru").split("-")[0].lower() - return value if value in {"ru", "en"} else "ru" - - -def _format_remaining(seconds: int, lang: str) -> str: - if seconds <= 0: - if lang == "en": - return "Subscription inactive" - return "Подписка не активна" - days, rem = divmod(seconds, 86400) - hours, rem = divmod(rem, 3600) - minutes = rem // 60 - if lang == "en": - if days > 0: - return f"{days} d. {hours} h." - if hours > 0: - return f"{hours} h. {minutes} min." - return f"{max(1, minutes)} min." - if days > 0: - return f"{days} д. {hours} ч." - if hours > 0: - return f"{hours} ч. {minutes} мин." - return f"{max(1, minutes)} мин." - - -def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]: - if value is None: - return None - try: - return int(value) - except (TypeError, ValueError): - return None - - -def _format_bytes(value: Optional[Any], *, zero_as_unlimited: bool = False) -> str: - if value is None: - return "N/A" - try: - size = float(value) - except (TypeError, ValueError): - return str(value) - if size <= 0 and zero_as_unlimited: - return "∞" - if size <= 0: - size = 0 - units = ["B", "KB", "MB", "GB", "TB"] - index = 0 - while size >= 1024 and index < len(units) - 1: - size /= 1024 - index += 1 - return f"{size:.2f} {units[index]}" - - -def _device_hwid_token(hwid: str) -> str: - return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32] - - -def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str: - value = str(hwid or "").strip() - if len(value) <= max_length: - return value - return f"{value[:8]}...{value[-6:]}" - - -def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]: - if isinstance(devices_response, dict): - devices = devices_response.get("devices") or [] - else: - devices = devices_response or [] - if not isinstance(devices, list): - return [] - return [device for device in devices if isinstance(device, dict)] - - -def _format_devices_limit(max_devices: Optional[int]) -> str: - if max_devices in (None, 0): - return "Unlimited" - return str(max_devices) - - -def _format_device_datetime(value: Any) -> str: - if not value: - return "" - text = str(value) - try: - normalized = datetime.fromisoformat(text.replace("Z", "+00:00")) - return normalized.strftime("%d.%m.%Y %H:%M") - except Exception: - return text - - -def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]: - hwid = str(device.get("hwid") or "").strip() - model = str(device.get("deviceModel") or "").strip() - platform = str(device.get("platform") or "").strip() - os_version = str(device.get("osVersion") or "").strip() - user_agent = str(device.get("userAgent") or "").strip() - display_name = model or platform or f"Device {index}" - platform_label = " ".join(part for part in (platform, os_version) if part).strip() - return { - "index": index, - "display_name": display_name, - "platform": platform, - "os_version": os_version, - "platform_label": platform_label, - "user_agent": user_agent, - "created_at": device.get("createdAt"), - "created_at_text": _format_device_datetime(device.get("createdAt")), - "hwid_short": _shorten_hwid_for_display(hwid), - "token": _device_hwid_token(hwid) if hwid else "", - "can_disconnect": bool(hwid), - } - - -def _format_months_title(months: int, lang: str) -> str: - if lang == "en": - if months == 1: - return "1 month" - return f"{months} months" - if months == 1: - return "1 месяц" - if 2 <= months <= 4: - return f"{months} месяца" - return f"{months} месяцев" - - -def _format_number_for_payload(value: Any) -> str: - numeric = float(value or 0) - return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}" - - -def _format_traffic_title(traffic_gb: float, lang: str) -> str: - return f"{_format_number_for_payload(traffic_gb)} GB" - - -def _traffic_payment_description(traffic_gb: float, lang: str) -> str: - if lang == "en": - return f"Traffic package {_format_traffic_title(traffic_gb, lang)}" - return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}" - - -def _hwid_devices_payment_description(device_count: int, lang: str) -> str: - if lang == "en": - return f"HWID device package +{device_count}" - return f"Докупка устройств HWID +{device_count}" - - -def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]: - for key in options: - try: - if abs(float(key) - float(target)) < 0.000001: - return key - except (TypeError, ValueError): - continue - return None - - -def _payment_description(months: int, lang: str) -> str: - if lang == "en": - return f"Subscription for {_format_months_title(months, lang)}" - return f"Подписка на {_format_months_title(months, lang)}" +__all__ = sorted(_name for _name in _NAMESPACE if not _name.startswith("__")) diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index ba231b1..cf3400a 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -72,7 +72,7 @@ async def build_and_start_web_app( secret_token=settings.WEBHOOK_SECRET_TOKEN, ).register(app, path=telegram_webhook_path) logging.info( - f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" + f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501 ) from bot.handlers.user.payment import yookassa_webhook_route diff --git a/bot/app/web/webapp/__init__.py b/bot/app/web/webapp/__init__.py new file mode 100644 index 0000000..3efd4ef --- /dev/null +++ b/bot/app/web/webapp/__init__.py @@ -0,0 +1 @@ +"""Domain modules for the subscription Mini App backend.""" diff --git a/bot/app/web/webapp/_runtime.py b/bot/app/web/webapp/_runtime.py new file mode 100644 index 0000000..e9b4ee0 --- /dev/null +++ b/bot/app/web/webapp/_runtime.py @@ -0,0 +1,100 @@ +# ruff: noqa: F401,F403,F405,I001 +import asyncio +import base64 +import hashlib +import hmac +import io +import ipaddress +import json +import logging +import os +import re +import secrets +import socket +import subprocess +import time +from collections import deque +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +from aiogram import Bot, Dispatcher +from aiogram.types import LabeledPrice +from aiohttp import ClientSession, ClientTimeout, web +from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.app.web.admin_api import ( + admin_auth_middleware, + setup_admin_routes, +) +from bot.app.web.webapp_auth import ( + create_signed_telegram_oauth_state, + create_telegram_oauth_nonce, + create_webapp_session_token, + validate_telegram_login_widget_data, + validate_telegram_oauth_id_token, + validate_telegram_webapp_init_data, + verify_signed_telegram_oauth_state, + verify_telegram_oauth_nonce, + verify_webapp_session_token, +) +from bot.services.crypto_pay_service import CryptoPayService +from bot.services.email_auth_service import EmailAuthService, normalize_email +from bot.services.email_templates import render_account_merged +from bot.services.freekassa_service import FreeKassaService +from bot.services.platega_service import PlategaService +from bot.services.promo_code_service import PromoCodeService +from bot.services.referral_service import ReferralService +from bot.services.severpay_service import SeverPayService +from bot.services.subscription_service import SubscriptionService +from bot.services.yookassa_service import YooKassaService +from bot.utils.config_link import prepare_config_links +from bot.utils.request_security import parse_ip_entries, request_client_ip +from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username +from config.settings import Settings +from db.dal import payment_dal, subscription_dal, user_dal +from db.dal.user_dal import UserMergeConflictError +from db.models import Payment, User, UserTelegramAvatar + +logger = logging.getLogger(__name__) + +TEMPLATE_PATH = Path(__file__).resolve().parents[1] / "templates" / "subscription_webapp.html" +ASSET_DIR = TEMPLATE_PATH.parent +WEBAPP_LOGO_PROXY_PATH = "/webapp-logo" +WEBAPP_LOGO_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-logo" +WEBAPP_EMOJI_CACHE_DIR = Path(__file__).resolve().parents[4] / "data" / "webapp-emoji" +WEBAPP_CONFIG_PLACEHOLDER = "" +WEBAPP_I18N_PLACEHOLDER = "" +WEBAPP_JS_PLACEHOLDER = "" +APP_REPOSITORY_URL = "https://github.com/3252a8/remnawave-minishop" +DEV_MOCK_START_MARKER = "" +DEV_MOCK_END_MARKER = "" +WEBAPP_RATE_LIMIT_WINDOW_SECONDS = 60 +WEBAPP_RATE_LIMIT_MAX_REQUESTS = 30 +WEBAPP_LOGO_MAX_BYTES = 2 * 1024 * 1024 +WEBAPP_EMOJI_MAX_BYTES = 4 * 1024 * 1024 +WEBAPP_TELEGRAM_AVATAR_MAX_BYTES = 128 * 1024 +WEBAPP_TELEGRAM_AVATAR_REFRESH_SECONDS = 24 * 60 * 60 +WEBAPP_TELEGRAM_AVATAR_FETCH_TIMEOUT_SECONDS = 4 +WEBAPP_SESSION_COOKIE_NAME = "rw_webapp_session" +WEBAPP_CSRF_COOKIE_NAME = "rw_webapp_csrf" +WEBAPP_TELEGRAM_OAUTH_STATE_COOKIE_NAME = "rw_tg_oauth_state" +WEBAPP_CSRF_HEADER_NAME = "X-CSRF-Token" +WEBAPP_STATE_CHANGING_METHODS = {"POST", "PUT", "PATCH", "DELETE"} +_APP_VERSION_CACHE: Optional[str] = None +WEBAPP_CSRF_EXEMPT_PATHS = { + "/api/auth/telegram/nonce", + "/api/auth/token", + "/api/auth/email/request", + "/api/auth/email/verify", + "/api/auth/email/magic", + "/api/auth/logout", +} + +_SHARED_HTTP_SESSION: Optional[ClientSession] = None +_SHARED_HTTP_SESSION_LOCK = asyncio.Lock() + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/bot/app/web/webapp/account.py b/bot/app/web/webapp/account.py new file mode 100644 index 0000000..0471d38 --- /dev/null +++ b/bot/app/web/webapp/account.py @@ -0,0 +1,534 @@ +# 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) + data = await _build_user_payload(request, user_id) + 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, + ) diff --git a/bot/app/web/webapp/application.py b/bot/app/web/webapp/application.py new file mode 100644 index 0000000..150f865 --- /dev/null +++ b/bot/app/web/webapp/application.py @@ -0,0 +1,60 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +def create_subscription_webapp_application( + dp: Dispatcher, + bot: Bot, + settings: Settings, + async_session_factory: sessionmaker, +) -> web.Application: + app = web.Application( + middlewares=[ + _security_headers_middleware, + _csrf_protection_middleware, + admin_auth_middleware, + ] + ) + app["bot"] = bot + app["dp"] = dp + app["settings"] = settings + app["async_session_factory"] = async_session_factory + app["i18n"] = dp.get("i18n_instance") + app["email_auth_service"] = EmailAuthService(settings) + app["webapp_logo_cache"] = None + app["webapp_logo_cache_lock"] = asyncio.Lock() + app["webapp_settings_cache"] = {"ts": 0.0, "data": {}} + app["webapp_rate_limit_buckets"] = {} + app["webapp_rate_limit_lock"] = asyncio.Lock() + + async def _startup(app_obj: web.Application) -> None: + await _ensure_shared_http_session() + await _warm_webapp_logo_cache(app_obj) + await _warm_webapp_animated_emoji_cache(app_obj) + + async def _shutdown(app_obj: web.Application) -> None: + await _close_shared_http_session() + + app.on_startup.append(_startup) + app.on_shutdown.append(_shutdown) + + for key in ( + "subscription_service", + "yookassa_service", + "freekassa_service", + "cryptopay_service", + "platega_service", + "severpay_service", + "promo_code_service", + "referral_service", + "panel_service", + ): + if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] + app[key] = dp.workflow_data[key] # type: ignore[index] + + # type: ignore[attr-defined] + if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: + app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index] + + setup_subscription_webapp_routes(app) + return app diff --git a/bot/app/web/webapp/assets.py b/bot/app/web/webapp/assets.py new file mode 100644 index 0000000..f879a30 --- /dev/null +++ b/bot/app/web/webapp/assets.py @@ -0,0 +1,734 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def health_route(request: web.Request) -> web.Response: + return web.json_response({"ok": True}) + + +async def css_asset_route(request: web.Request) -> web.Response: + return await _serve_template_asset(request, "subscription_webapp.css", "text/css") + + +def _resolve_webapp_logo_url(settings: Settings) -> str: + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() + if not raw_logo_url: + return "" + + parsed_logo_url = urlsplit(raw_logo_url) + if parsed_logo_url.scheme == "https": + cache_key = hashlib.sha256(raw_logo_url.encode("utf-8")).hexdigest()[:12] + return f"{WEBAPP_LOGO_PROXY_PATH}?v={cache_key}" + if parsed_logo_url.scheme in {"http", "data"}: + return raw_logo_url + if raw_logo_url.startswith("/"): + return raw_logo_url + return "" + + +def _webapp_logo_cache_key(logo_url: str) -> str: + return hashlib.sha256(logo_url.encode("utf-8")).hexdigest() + + +def _webapp_logo_disk_paths(logo_url: str) -> Tuple[Path, Path]: + cache_key = _webapp_logo_cache_key(logo_url) + return WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.bin", WEBAPP_LOGO_CACHE_DIR / f"{cache_key}.json" + + +def _is_proxyable_webapp_logo_url(logo_url: str) -> bool: + parsed_logo_url = urlsplit(logo_url) + return parsed_logo_url.scheme == "https" and bool(parsed_logo_url.hostname) + + +def _emoji_to_codepoints(value: str) -> str: + return "_".join(f"{ord(char):x}" for char in str(value or "").strip()) + + +def _webapp_emoji_disk_path(codepoints: str, ext: str) -> Path: + return WEBAPP_EMOJI_CACHE_DIR / f"{codepoints}.512.{ext}" + + +def _webapp_animated_emoji_source_url(codepoints: str, ext: str) -> str: + return f"https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoints}/512.{ext}" + + +def _webapp_animated_emoji_asset_path(emoji: str, ext: str = "gif") -> str: + codepoints = _emoji_to_codepoints(emoji) + if not codepoints or ext not in {"gif", "webp"}: + return "" + return f"/webapp-emoji/{codepoints}/512.{ext}" + + +async def webapp_logo_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() + if not raw_logo_url: + raise web.HTTPNotFound(text="webapp_logo_not_configured") + + if not _is_proxyable_webapp_logo_url(raw_logo_url): + raise web.HTTPNotFound(text="webapp_logo_not_proxied") + + parsed_logo_url = urlsplit(raw_logo_url) + if not await _hostname_resolves_to_public_address(parsed_logo_url.hostname): + raise web.HTTPNotFound(text="webapp_logo_not_proxied") + + source_logo_url = raw_logo_url + logo_cache: Optional[Tuple[str, bytes, str]] = request.app.get("webapp_logo_cache") + if logo_cache is None or logo_cache[0] != source_logo_url: + cache_lock: asyncio.Lock = request.app["webapp_logo_cache_lock"] + async with cache_lock: + logo_cache = request.app.get("webapp_logo_cache") + if logo_cache is None or logo_cache[0] != source_logo_url: + fetched_logo = await _load_or_fetch_webapp_logo(source_logo_url) + logo_cache = ( + (source_logo_url, fetched_logo[0], fetched_logo[1]) if fetched_logo else None + ) + request.app["webapp_logo_cache"] = logo_cache + + if not logo_cache: + raise web.HTTPNotFound(text="webapp_logo_unavailable") + + _, body, content_type = logo_cache + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + +async def webapp_animated_emoji_route(request: web.Request) -> web.Response: + codepoints = str(request.match_info.get("codepoints") or "").strip().lower() + ext = str(request.match_info.get("ext") or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]+(?:_[0-9a-f]+)*", codepoints) or ext not in {"gif", "webp"}: + raise web.HTTPNotFound(text="webapp_emoji_not_found") + + emoji_cache_key = f"{codepoints}:{ext}" + emoji_caches: Dict[str, Tuple[bytes, str]] = request.app.setdefault("webapp_emoji_cache", {}) + emoji_cache = emoji_caches.get(emoji_cache_key) + if emoji_cache is None: + cache_lock: asyncio.Lock = request.app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) + async with cache_lock: + emoji_cache = emoji_caches.get(emoji_cache_key) + if emoji_cache is None: + emoji_cache = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) + if emoji_cache: + emoji_caches[emoji_cache_key] = emoji_cache + + if not emoji_cache: + raise web.HTTPNotFound(text="webapp_emoji_unavailable") + + body, content_type = emoji_cache + response = web.Response(body=body, content_type=content_type) + response.headers["Cache-Control"] = "public, max-age=31536000, immutable" + return response + + +async def _warm_webapp_logo_cache(app: web.Application) -> None: + settings: Settings = app["settings"] + raw_logo_url = (settings.WEBAPP_LOGO_URL or "").strip() + if not raw_logo_url or not _is_proxyable_webapp_logo_url(raw_logo_url): + return + + parsed_logo_url = urlsplit(raw_logo_url) + if not parsed_logo_url.hostname or not await _hostname_resolves_to_public_address( + parsed_logo_url.hostname + ): + return + + cache_lock: asyncio.Lock = app["webapp_logo_cache_lock"] + async with cache_lock: + logo_cache: Optional[Tuple[str, bytes, str]] = app.get("webapp_logo_cache") + if logo_cache and logo_cache[0] == raw_logo_url: + return + loaded_logo = await _load_or_fetch_webapp_logo(raw_logo_url) + app["webapp_logo_cache"] = ( + (raw_logo_url, loaded_logo[0], loaded_logo[1]) if loaded_logo else None + ) + + +async def _warm_webapp_animated_emoji_cache(app: web.Application) -> None: + settings: Settings = app["settings"] + if str(settings.WEBAPP_LOGO_EMOJI_FONT or "").strip() != "noto-color-animated": + return + + codepoints = _emoji_to_codepoints(settings.WEBAPP_LOGO_EMOJI) + if not codepoints: + return + + app.setdefault("webapp_emoji_cache", {}) + app.setdefault("webapp_emoji_cache_lock", asyncio.Lock()) + emoji_caches: Dict[str, Tuple[bytes, str]] = app["webapp_emoji_cache"] + + for ext in ("gif", "webp"): + emoji_cache_key = f"{codepoints}:{ext}" + if emoji_cache_key in emoji_caches: + continue + loaded_emoji = await _load_or_fetch_webapp_animated_emoji(codepoints, ext) + if loaded_emoji: + emoji_caches[emoji_cache_key] = loaded_emoji + if ext == "gif": + return + + +async def _load_or_fetch_webapp_animated_emoji( + codepoints: str, ext: str +) -> Optional[Tuple[bytes, str]]: + disk_emoji = await asyncio.to_thread(_read_webapp_animated_emoji_from_disk, codepoints, ext) + if disk_emoji: + return disk_emoji + + fetched_emoji = await _fetch_webapp_animated_emoji(codepoints, ext) + if fetched_emoji: + await asyncio.to_thread( + _write_webapp_animated_emoji_to_disk, codepoints, ext, fetched_emoji + ) + return fetched_emoji + + +def _read_webapp_animated_emoji_from_disk(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: + path = _webapp_emoji_disk_path(codepoints, ext) + try: + body = path.read_bytes() + except OSError: + return None + + if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: + return None + return body, "image/gif" if ext == "gif" else "image/webp" + + +def _write_webapp_animated_emoji_to_disk( + codepoints: str, ext: str, emoji: Tuple[bytes, str] +) -> None: + body, _content_type = emoji + if not body or len(body) > WEBAPP_EMOJI_MAX_BYTES: + return + + path = _webapp_emoji_disk_path(codepoints, ext) + try: + WEBAPP_EMOJI_CACHE_DIR.mkdir(parents=True, exist_ok=True) + path.write_bytes(body) + except OSError as exc: + logger.warning("Failed to write WEBAPP animated emoji cache: %s", exc) + + +async def _fetch_webapp_animated_emoji(codepoints: str, ext: str) -> Optional[Tuple[bytes, str]]: + try: + session = await _get_shared_http_session() + timeout = ClientTimeout(total=4) + source_url = _webapp_animated_emoji_source_url(codepoints, ext) + async with session.get( + source_url, + allow_redirects=False, + headers={"Accept": "image/gif,image/webp,image/*,*/*;q=0.8"}, + timeout=timeout, + ) as response: + if response.status != 200: + return None + + content_type = ( + (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + ) + expected_content_type = "image/gif" if ext == "gif" else "image/webp" + if content_type and content_type != expected_content_type: + return None + + body = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + body.extend(chunk) + if len(body) > WEBAPP_EMOJI_MAX_BYTES: + logger.warning("WEBAPP animated emoji exceeded the 4 MiB limit.") + return None + + if not body: + return None + + return bytes(body), expected_content_type + except Exception as exc: + logger.warning("Failed to fetch WEBAPP animated emoji: %s", exc) + return None + + +async def _load_or_fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: + disk_logo = await asyncio.to_thread(_read_webapp_logo_from_disk, logo_url) + if disk_logo: + return disk_logo + + fetched_logo = await _fetch_webapp_logo(logo_url) + if fetched_logo: + await asyncio.to_thread(_write_webapp_logo_to_disk, logo_url, fetched_logo) + return fetched_logo + + +def _read_webapp_logo_from_disk(logo_url: str) -> Optional[Tuple[bytes, str]]: + body_path, meta_path = _webapp_logo_disk_paths(logo_url) + try: + metadata = json.loads(meta_path.read_text(encoding="utf-8")) + if metadata.get("source_url") != logo_url: + return None + content_type = str(metadata.get("content_type") or "").strip().lower() + if not content_type.startswith("image/"): + return None + body = body_path.read_bytes() + except (OSError, json.JSONDecodeError): + return None + + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + return None + return body, content_type + + +def _write_webapp_logo_to_disk(logo_url: str, logo: Tuple[bytes, str]) -> None: + body, content_type = logo + if not body or len(body) > WEBAPP_LOGO_MAX_BYTES: + return + + body_path, meta_path = _webapp_logo_disk_paths(logo_url) + try: + WEBAPP_LOGO_CACHE_DIR.mkdir(parents=True, exist_ok=True) + body_path.write_bytes(body) + meta_path.write_text( + json.dumps( + { + "source_url": logo_url, + "content_type": content_type, + "cached_at": datetime.now(timezone.utc).isoformat(), + "bytes": len(body), + }, + ensure_ascii=False, + separators=(",", ":"), + ), + encoding="utf-8", + ) + except OSError as exc: + logger.warning("Failed to write WEBAPP_LOGO_URL cache: %s", exc) + + +async def _fetch_webapp_logo(logo_url: str) -> Optional[Tuple[bytes, str]]: + """Fetch and cache the configured logo on the server side.""" + try: + session = await _get_shared_http_session() + timeout = ClientTimeout(total=3) + async with session.get( + logo_url, + allow_redirects=False, + headers={"Accept": "image/avif,image/webp,image/svg+xml,image/png,image/*,*/*;q=0.8"}, + timeout=timeout, + ) as response: + if response.status != 200: + logger.warning( + "WEBAPP_LOGO_URL returned HTTP %s; keeping the logo hidden.", + response.status, + ) + return None + + content_type = ( + (response.headers.get("Content-Type") or "").split(";", 1)[0].strip().lower() + ) + if content_type and not content_type.startswith("image/"): + logger.warning( + "WEBAPP_LOGO_URL returned non-image content type %s; keeping the logo hidden.", + content_type, + ) + return None + + body = bytearray() + async for chunk in response.content.iter_chunked(64 * 1024): + body.extend(chunk) + if len(body) > WEBAPP_LOGO_MAX_BYTES: + logger.warning("WEBAPP_LOGO_URL exceeded the 2 MiB limit.") + return None + + if not body: + logger.warning("WEBAPP_LOGO_URL returned an empty response body.") + return None + + return bytes(body), content_type or "image/png" + except Exception as exc: + logger.warning("Failed to fetch WEBAPP_LOGO_URL: %s", exc) + return None + + +async def _get_shared_http_session() -> ClientSession: + global _SHARED_HTTP_SESSION + async with _SHARED_HTTP_SESSION_LOCK: + if _SHARED_HTTP_SESSION is None or _SHARED_HTTP_SESSION.closed: + _SHARED_HTTP_SESSION = ClientSession( + timeout=ClientTimeout(total=30), + headers={ + "User-Agent": "Mozilla/5.0", + "Accept": "*/*", + }, + ) + return _SHARED_HTTP_SESSION + + +async def _ensure_shared_http_session() -> None: + await _get_shared_http_session() + + +async def _close_shared_http_session() -> None: + global _SHARED_HTTP_SESSION + async with _SHARED_HTTP_SESSION_LOCK: + if _SHARED_HTTP_SESSION and not _SHARED_HTTP_SESSION.closed: + await _SHARED_HTTP_SESSION.close() + _SHARED_HTTP_SESSION = None + + +async def _hostname_resolves_to_public_address(hostname: str) -> bool: + if not hostname: + return False + + try: + ip_obj = ipaddress.ip_address(hostname) + return not ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ) + except ValueError: + pass + + loop = asyncio.get_running_loop() + try: + resolved = await loop.getaddrinfo(hostname, None, type=socket.SOCK_STREAM) + except Exception: + return False + + found_public_ip = False + for entry in resolved: + sockaddr = entry[4] + if not sockaddr: + continue + candidate = sockaddr[0] + try: + ip_obj = ipaddress.ip_address(candidate) + except ValueError: + continue + if ( + ip_obj.is_private + or ip_obj.is_loopback + or ip_obj.is_link_local + or ip_obj.is_unspecified + or ip_obj.is_reserved + ): + return False + found_public_ip = True + + return found_public_ip + + +@web.middleware +async def _security_headers_middleware(request: web.Request, handler): + request["csp_nonce"] = secrets.token_urlsafe(16) + try: + response = await handler(request) + except web.HTTPException as exc: + response = exc + nonce = request.get("csp_nonce", "") + response.headers.setdefault( + "Content-Security-Policy", + ( + "default-src 'self'; " + f"script-src 'self' 'nonce-{nonce}' https://telegram.org; " + "frame-src https://oauth.telegram.org; " + "frame-ancestors https://web.telegram.org https://t.me; " + "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net; " # noqa: E501 + "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net data:; " + "img-src 'self' data: https:; " + "connect-src 'self' https://oauth.telegram.org; " + "object-src 'none'; " + "base-uri 'self'; " + "form-action 'self'" + ), + ) + response.headers.setdefault("Referrer-Policy", "no-referrer") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault( + "Permissions-Policy", + ( + "accelerometer=(), autoplay=(), camera=(), display-capture=(), " + "encrypted-media=(), geolocation=(), gyroscope=(), magnetometer=(), " + "microphone=(), midi=(), payment=(), usb=()" + ), + ) + return response + + +@web.middleware +async def _csrf_protection_middleware(request: web.Request, handler): + settings: Settings = request.app["settings"] + header = request.headers.get("Authorization", "") + prefix = "Bearer " + if header.startswith(prefix): + if verify_webapp_session_token(settings, header[len(prefix) :].strip()): + return await handler(request) + + if ( + request.method in WEBAPP_STATE_CHANGING_METHODS + and request.path not in WEBAPP_CSRF_EXEMPT_PATHS + and request.cookies.get(WEBAPP_SESSION_COOKIE_NAME) + ): + csrf_cookie = request.cookies.get(WEBAPP_CSRF_COOKIE_NAME, "") + csrf_header = request.headers.get(WEBAPP_CSRF_HEADER_NAME, "") + if not csrf_cookie or not csrf_header or not hmac.compare_digest(csrf_header, csrf_cookie): + return _json_error(403, "csrf_failed", "Invalid CSRF token") + + return await handler(request) + + +def _get_cached_webapp_settings(request: web.Request) -> Dict[str, Any]: + settings: Settings = request.app["settings"] + cache = request.app["webapp_settings_cache"] + now = time.monotonic() + if now - float(cache.get("ts", 0.0)) >= 60 or not cache.get("data"): + cache["data"] = { + "logo_url": _resolve_webapp_logo_url(settings), + "subscription_options": settings.subscription_options, + "stars_subscription_options": settings.stars_subscription_options, + "traffic_packages": settings.traffic_packages, + "stars_traffic_packages": settings.stars_traffic_packages, + "support_url": settings.SUPPORT_LINK or "", + "terms_url": settings.TERMS_OF_SERVICE_URL or "", + "privacy_policy_url": settings.PRIVACY_POLICY_URL or "", + "user_agreement_url": settings.USER_AGREEMENT_URL or "", + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "email_auth_enabled": settings.email_auth_configured, + "language": _normalize_language(settings.DEFAULT_LANGUAGE), + } + cache["ts"] = now + return cache["data"] + + +def _run_git_command(*args: str) -> str: + repo_root = Path(__file__).resolve().parents[3] + try: + result = subprocess.run( + ["git", *args], + cwd=repo_root, + check=True, + capture_output=True, + text=True, + timeout=1.5, + ) + except (OSError, subprocess.SubprocessError): + return "" + return result.stdout.strip() + + +def _resolve_app_version() -> str: + global _APP_VERSION_CACHE + if _APP_VERSION_CACHE: + return _APP_VERSION_CACHE + + env_version = os.getenv("REMNAWAVE_MINISHOP_VERSION", "").strip() + if env_version: + _APP_VERSION_CACHE = env_version + return env_version + + build_version_path = Path(__file__).resolve().parents[3] / ".build-version" + try: + build_version = build_version_path.read_text(encoding="utf-8").strip() + except OSError: + build_version = "" + if build_version: + _APP_VERSION_CACHE = build_version + return build_version + + tag = _run_git_command("describe", "--tags", "--abbrev=0") + sha = _run_git_command("rev-parse", "--short", "HEAD") + dirty = bool(_run_git_command("status", "--porcelain")) + + if tag and sha: + commits_since_tag = _run_git_command("rev-list", f"{tag}..HEAD", "--count") + if commits_since_tag and commits_since_tag != "0": + version = f"{tag}+{commits_since_tag}.g{sha}" + else: + version = tag + elif sha: + version = f"dev+g{sha}" + else: + version = "dev+unknown" + + if dirty: + version = f"{version}-dirty" + + _APP_VERSION_CACHE = version + return version + + +async def _enforce_webapp_rate_limit( + request: web.Request, + *, + user_id: int, + action: str, +) -> Optional[web.Response]: + settings: Settings = request.app["settings"] + ip_address = ( + request_client_ip(request, trusted_proxies=settings.trusted_proxies) + or request.remote + or "unknown" + ) + key = f"{action}:{ip_address}:{int(user_id)}" + buckets: Dict[str, deque[float]] = request.app["webapp_rate_limit_buckets"] + lock: asyncio.Lock = request.app["webapp_rate_limit_lock"] + now = time.monotonic() + + async with lock: + bucket = buckets.setdefault(key, deque()) + while bucket and now - bucket[0] >= WEBAPP_RATE_LIMIT_WINDOW_SECONDS: + bucket.popleft() + if not bucket: + buckets.pop(key, None) + bucket = buckets.setdefault(key, deque()) + if len(bucket) >= WEBAPP_RATE_LIMIT_MAX_REQUESTS: + retry_after = ( + max( + 1, + int(WEBAPP_RATE_LIMIT_WINDOW_SECONDS - (now - bucket[0])), + ) + if bucket + else WEBAPP_RATE_LIMIT_WINDOW_SECONDS + ) + return web.json_response( + { + "ok": False, + "error": "rate_limited", + "retry_after": retry_after, + }, + status=429, + headers={"Retry-After": str(retry_after)}, + ) + bucket.append(now) + + return None + + +async def js_asset_route(request: web.Request) -> web.Response: + asset_hash = request.match_info.get("asset_hash") + filename = ( + f"subscription_webapp.min.{asset_hash}.js" if asset_hash else "subscription_webapp.js" + ) + response = await _serve_template_asset( + request, + filename, + "application/javascript", + strip_dev_mock=not asset_hash, + ) + response.headers["Cache-Control"] = ( + "public, max-age=31536000, immutable" if asset_hash else "no-cache" + ) + return response + + +async def index_route(request: web.Request) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + html = TEMPLATE_PATH.read_text(encoding="utf-8") + cached = _get_cached_webapp_settings(request) + config = { + "title": settings.WEBAPP_TITLE, + "primaryColor": settings.WEBAPP_PRIMARY_COLOR, + "logoUrl": cached["logo_url"], + "logoEmoji": settings.WEBAPP_LOGO_EMOJI, + "logoEmojiFont": settings.WEBAPP_LOGO_EMOJI_FONT, + "apiBase": "/api", + "telegramLoginBotUsername": request.app.get("bot_username") or "", + "telegramLoginBotId": _resolve_telegram_bot_id(settings.BOT_TOKEN) or 0, + "telegramOAuthClientId": _resolve_telegram_oauth_client_id(settings) or 0, + "telegramOAuthRequestAccess": _resolve_telegram_oauth_request_access(settings), + "supportUrl": cached["support_url"], + "termsUrl": cached["terms_url"], + "privacyPolicyUrl": cached["privacy_policy_url"], + "userAgreementUrl": cached["user_agreement_url"], + "currency": cached["currency"], + "language": cached["language"], + "emailAuthEnabled": cached["email_auth_enabled"], + "appVersion": _resolve_app_version(), + "appRepositoryUrl": APP_REPOSITORY_URL, + } + html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER) + i18n_instance: Optional[object] = request.app.get("i18n") + i18n_payload = getattr(i18n_instance, "locales_data", {}) if i18n_instance else {} + nonce = request.get("csp_nonce", "") + html = html.replace( + WEBAPP_CONFIG_PLACEHOLDER, + ( + f'" + ), + ) + html = html.replace( + WEBAPP_I18N_PLACEHOLDER, + ( + f'" + ), + ) + html = html.replace( + WEBAPP_JS_PLACEHOLDER, + f'', + ) + brand_asset_url = cached["logo_url"] + if not brand_asset_url and settings.WEBAPP_LOGO_EMOJI_FONT == "noto-color-animated": + brand_asset_url = _webapp_animated_emoji_asset_path(settings.WEBAPP_LOGO_EMOJI) + if brand_asset_url: + html = html.replace( + '', # noqa: E501 + f'', # noqa: E501 + ) + else: + html = html.replace( + '', # noqa: E501 + "", + ) + return web.Response(text=html, content_type="text/html", charset="utf-8") + + +async def _serve_template_asset( + request: web.Request, + filename: str, + content_type: str, + *, + strip_dev_mock: bool = False, +) -> web.Response: + settings: Settings = request.app["settings"] + if not settings.WEBAPP_ENABLED: + raise web.HTTPNotFound(text="webapp_disabled") + + path = ASSET_DIR / filename + text = path.read_text(encoding="utf-8") + if strip_dev_mock: + text = _strip_marked_block( + text, + "/* WEBAPP_DEV_MOCK_START */", + "/* WEBAPP_DEV_MOCK_END */", + ) + return web.Response(text=text, content_type=content_type, charset="utf-8") + + +def _resolve_webapp_js_asset_name() -> str: + minified_assets = [] + for path in ASSET_DIR.glob("subscription_webapp.min.*.js"): + try: + minified_assets.append((path.stat().st_mtime, path.name)) + except OSError: + continue + if minified_assets: + minified_assets.sort(reverse=True) + return minified_assets[0][1] + return "subscription_webapp.js" + + +def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str: + start = html.find(start_marker) + if start == -1: + return html + end = html.find(end_marker, start) + if end == -1: + return html[:start] + return html[:start] + html[end + len(end_marker) :] diff --git a/bot/app/web/webapp/auth.py b/bot/app/web/webapp/auth.py new file mode 100644 index 0000000..cc8db5d --- /dev/null +++ b/bot/app/web/webapp/auth.py @@ -0,0 +1,1136 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +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("=") + + +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") + + 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") + + 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 + + +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") + + 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") + + 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 diff --git a/bot/app/web/webapp/billing.py b/bot/app/web/webapp/billing.py new file mode 100644 index 0000000..5a4fe94 --- /dev/null +++ b/bot/app/web/webapp/billing.py @@ -0,0 +1,1191 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def apply_promo_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + code = str(payload.get("code") or "").strip() + if not code: + return _json_error(400, "empty_code", "Promo code is empty") + + settings: Settings = request.app["settings"] + promo_code_service: PromoCodeService = request.app.get("promo_code_service") + if not promo_code_service: + return _json_error(503, "service_unavailable", "Promo service unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + try: + 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") + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + success, result = await promo_code_service.apply_promo_code( + session, + user_id, + code, + lang, + ) + if not success: + await session.commit() + return _json_error(400, "promo_apply_failed", str(result)) + await session.commit() + end_date = result if isinstance(result, datetime) else None + return web.json_response( + { + "ok": True, + "end_date": end_date.isoformat() if end_date else None, + "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else None, + } + ) + except Exception: + await session.rollback() + logger.exception("WebApp promo apply failed") + return _json_error(500, "promo_apply_failed", "Promo apply failed") + + +async def create_payment_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="payments_create", + ) + if rate_limit_response: + return rate_limit_response + + payload = await _read_json(request) + payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload) + if validation_error: + return validation_error + method = str(payment_payload.method or "").strip().lower() + settings: Settings = request.app["settings"] + cached = _get_cached_webapp_settings(request) + tariffs_config = settings.tariffs_config + traffic_mode = bool(settings.traffic_sale_mode) + sale_mode = "subscription" + traffic_gb_for_payment: Optional[float] = None + requested_sale_mode = _sale_mode_base(str(payment_payload.sale_mode or "")) + + if tariffs_config and requested_sale_mode in {"hwid_device", "hwid_devices"}: + tariff_key = str(payment_payload.tariff_key or "").strip() + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + try: + tariff = tariffs_config.require(tariff_key) + except Exception: + return _json_error(400, "invalid_plan", "Tariff is not available") + try: + device_count = int( + float( + payment_payload.device_count + if payment_payload.device_count is not None + else payment_payload.months + ) + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid device package") + packages = tariff.hwid_device_packages + rub_packages = { + int(package.count): float(package.price) + for package in (packages.rub if packages else []) + } + stars_packages = { + int(package.count): int(float(package.price)) + for package in (packages.stars if packages else []) + } + price = rub_packages.get(device_count) + stars_price = stars_packages.get(device_count) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Device package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = device_count + sale_mode = f"hwid_devices@{tariff.key}" + elif tariffs_config and requested_sale_mode in {"topup", "premium_topup"}: + tariff_key = str(payment_payload.tariff_key or "").strip() + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + try: + tariff = tariffs_config.require(tariff_key) + except Exception: + return _json_error(400, "invalid_plan", "Tariff is not available") + try: + traffic_gb = float( + payment_payload.traffic_gb + if payment_payload.traffic_gb is not None + else payment_payload.months + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid traffic package") + packages = ( + tariff.premium_topup_packages + if requested_sale_mode == "premium_topup" + else tariffs_config.topup_packages_for(tariff) + ) + rub_packages = { + float(package.gb): float(package.price) + for package in (packages.rub if packages else []) + } + stars_packages = { + float(package.gb): int(float(package.price)) + for package in (packages.stars if packages else []) + } + package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) + stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) + price = rub_packages.get(package_key) if package_key is not None else None + stars_price = ( + stars_packages.get(stars_package_key) if stars_package_key is not None else None + ) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Traffic package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb + traffic_gb_for_payment = float(payment_units) + sale_mode = f"{requested_sale_mode}@{tariff.key}" + elif tariffs_config: + tariff_key = str(payment_payload.tariff_key or "").strip() + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + try: + tariff = tariffs_config.require(tariff_key) + except Exception: + return _json_error(400, "invalid_plan", "Tariff is not available") + + if tariff.billing_model == "traffic": + try: + traffic_gb = float( + payment_payload.traffic_gb + if payment_payload.traffic_gb is not None + else payment_payload.months + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid traffic package") + if traffic_gb <= 0: + return _json_error(400, "invalid_plan", "Invalid traffic package") + rub_packages = { + float(package.gb): float(package.price) + for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + } + stars_packages = { + float(package.gb): int(float(package.price)) + for package in (tariff.traffic_packages.stars if tariff.traffic_packages else []) + } + package_key = _resolve_numeric_option_key(rub_packages, traffic_gb) + stars_package_key = _resolve_numeric_option_key(stars_packages, traffic_gb) + price = rub_packages.get(package_key) if package_key is not None else None + stars_price = ( + stars_packages.get(stars_package_key) if stars_package_key is not None else None + ) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Traffic package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb + traffic_gb_for_payment = float(payment_units) + sale_mode = f"traffic_package@{tariff.key}" + else: + try: + months = int(float(payment_payload.months)) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid subscription period") + if months not in tariff.enabled_periods: + return _json_error(400, "invalid_plan", "Subscription period is not available") + price = tariff.period_price(months, "rub") + stars_price_raw = tariff.period_price(months, "stars") + stars_price = int(stars_price_raw) if stars_price_raw and stars_price_raw > 0 else None + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Subscription period is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = months + sale_mode = f"subscription@{tariff.key}" + elif traffic_mode: + try: + traffic_gb = float( + payment_payload.traffic_gb + if payment_payload.traffic_gb is not None + else payment_payload.months + ) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid traffic package") + if traffic_gb <= 0: + return _json_error(400, "invalid_plan", "Invalid traffic package") + package_key = _resolve_numeric_option_key(cached["traffic_packages"], traffic_gb) + stars_package_key = _resolve_numeric_option_key( + cached["stars_traffic_packages"], traffic_gb + ) + price = cached["traffic_packages"].get(package_key) if package_key is not None else None + stars_price = ( + cached["stars_traffic_packages"].get(stars_package_key) + if stars_package_key is not None + else None + ) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Traffic package is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = int(traffic_gb) if float(traffic_gb).is_integer() else traffic_gb + traffic_gb_for_payment = float(payment_units) + sale_mode = "traffic" + else: + try: + months = int(float(payment_payload.months)) + except (TypeError, ValueError): + return _json_error(400, "invalid_plan", "Invalid subscription period") + price = cached["subscription_options"].get(months) + stars_price = cached["stars_subscription_options"].get(months) + if price is None and method != "stars": + return _json_error(400, "invalid_plan", "Subscription period is not available") + if method == "stars" and (stars_price is None or int(stars_price) <= 0): + return _json_error(400, "invalid_plan", "Stars price is not configured") + payment_units = months + sale_mode = "subscription" + + 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") + lang = db_user.language_code or settings.DEFAULT_LANGUAGE + return await _create_subscription_payment( + request=request, + session=session, + user_id=user_id, + method=method, + months=payment_units, + price=float(price or 0), + stars_price=stars_price, + lang=lang, + sale_mode=sale_mode, + traffic_gb=traffic_gb_for_payment, + ) + + +async def activate_trial_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="trial_activate", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + if not settings.TRIAL_ENABLED or settings.TRIAL_DURATION_DAYS <= 0: + return _json_error(400, "trial_unavailable", "Trial is not available") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + + activation_result = await subscription_service.activate_trial_subscription(session, user_id) + if not activation_result or not activation_result.get("activated"): + await session.rollback() + message_key = ( + activation_result.get("message_key", "trial_activation_failed") + if activation_result + else "trial_activation_failed" + ) + status = 400 if message_key != "trial_activation_failed_panel_update" else 502 + return _json_error(status, message_key, message_key) + + end_date = activation_result.get("end_date") + config_link, connect_url = await prepare_config_links( + settings, + activation_result.get("subscription_url"), + ) + + i18n_instance = request.app.get("i18n") + if settings.LOG_TRIAL_ACTIVATIONS and i18n_instance: + try: + from bot.services.notification_service import NotificationService + + notification_service = NotificationService( + request.app["bot"], settings, i18n_instance + ) + await notification_service.notify_trial_activation(user_id, end_date) + except Exception: + logger.exception("Failed to send WebApp trial activation notification") + + try: + from db.dal import ad_dal as _ad_dal + + await _ad_dal.mark_trial_activated(session, user_id) + await session.commit() + except Exception: + await session.rollback() + logger.exception("Failed to mark WebApp trial activation for ad attribution") + + return web.json_response( + { + "ok": True, + "activated": True, + "days": activation_result.get("days", settings.TRIAL_DURATION_DAYS), + "end_date": end_date.isoformat() if isinstance(end_date, datetime) else None, + "end_date_text": _format_webapp_datetime(end_date) + if isinstance(end_date, datetime) + else None, + "traffic_gb": activation_result.get("traffic_gb", settings.TRIAL_TRAFFIC_LIMIT_GB), + "config_link": config_link, + "connect_url": connect_url or config_link, + } + ) + + +async def tariff_topup_options_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + config = settings.tariffs_config + topup_kind = str(request.query.get("kind") or "all").strip().lower() + if topup_kind not in {"all", "regular", "premium"}: + return _json_error(400, "invalid_topup_kind", "Invalid topup kind") + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + + 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") + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub or not sub.tariff_key: + return _json_error( + 400, "subscription_required", "Active tariff subscription is required" + ) + lang = db_user.language_code or settings.DEFAULT_LANGUAGE + tariff = config.require(sub.tariff_key) + plans = ( + _serialize_topup_packages(settings, tariff, config.topup_packages_for(tariff), lang) + if topup_kind in {"all", "regular"} + else [] + ) + premium_plans = ( + _serialize_topup_packages( + settings, + tariff, + tariff.premium_topup_packages, + lang, + sale_mode="premium_topup", + title_prefix=f"{tariff.premium_name(lang)} ", + ) + if topup_kind in {"all", "premium"} and tariff.premium_squad_uuids + else [] + ) + premium_bonus_bytes = int(getattr(sub, "premium_bonus_bytes", 0) or 0) + premium_unlimited_override = bool(getattr(sub, "premium_unlimited_override", False)) + premium_limit_bytes = ( + int(sub.premium_baseline_bytes or 0) + + int(sub.premium_topup_balance_bytes or 0) + + int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + + premium_bonus_bytes + ) + premium_access = await request.app["subscription_service"].premium_access_for_tariff(tariff) + return web.json_response( + { + "ok": True, + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "topup_kind": topup_kind, + "premium_title": tariff.premium_name(lang), + "traffic_percent": _traffic_percent( + sub.traffic_used_bytes, sub.traffic_limit_bytes + ), + "premium_traffic_percent": 0 + if premium_unlimited_override + else _traffic_percent( + sub.premium_used_bytes, + premium_limit_bytes, + ), + "premium_limit_bytes": premium_limit_bytes, + "premium_used_bytes": int(sub.premium_used_bytes or 0), + "premium_baseline_bytes": int(sub.premium_baseline_bytes or 0), + "premium_topup_balance_bytes": int(sub.premium_topup_balance_bytes or 0), + "premium_topup_used_bytes": int(getattr(sub, "premium_topup_used_bytes", 0) or 0), + "premium_bonus_bytes": premium_bonus_bytes, + "premium_unlimited_override": premium_unlimited_override, + "premium_is_limited": bool(sub.premium_is_limited), + "premium_squad_labels": premium_access.get("squad_labels") or [], + "premium_node_labels": premium_access.get("node_labels") or [], + "warning_levels": settings.tariff_traffic_warning_levels, + "plans": plans + premium_plans, + } + ) + + +async def tariff_change_options_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub or not sub.tariff_key: + return _json_error( + 400, "subscription_required", "Active tariff subscription is required" + ) + lang = db_user.language_code or settings.DEFAULT_LANGUAGE + current = config.require(sub.tariff_key) + targets = [] + for tariff in config.enabled_tariffs: + if tariff.key == current.key: + continue + options = subscription_service.calculate_tariff_switch_options(sub, tariff) + targets.append(_serialize_tariff_change_target(settings, config, tariff, options, lang)) + return web.json_response( + { + "ok": True, + "current": { + "tariff_key": current.key, + "title": current.name(lang), + "description": current.description(lang), + "billing_model": current.billing_model, + }, + "targets": targets, + } + ) + + +async def tariff_change_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + change_payload, validation_error = _validate_model_payload(WebAppTariffChangePayload, payload) + if validation_error: + return validation_error + mode = str(change_payload.mode or "").strip() + if mode not in {"recalc_days", "convert_days_to_gb"}: + return _json_error(400, "invalid_change_mode", "This tariff change requires payment") + + settings: Settings = request.app["settings"] + if not settings.tariffs_config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + result = await subscription_service.switch_tariff_without_payment( + session, + user_id, + str(change_payload.tariff_key), + mode, + ) + if not result: + await session.rollback() + return _json_error(400, "change_failed", "Tariff change failed") + await session.commit() + return web.json_response({"ok": True, **result}) + + +async def tariff_change_payment_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + payload = await _read_json(request) + payment_payload, validation_error = _validate_model_payload(WebAppPaymentCreatePayload, payload) + if validation_error: + return validation_error + method = str(payment_payload.method or "").strip().lower() + tariff_key = str(payment_payload.tariff_key or "").strip() + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + if not tariff_key: + return _json_error(400, "invalid_plan", "Tariff is not selected") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return _json_error( + 400, "subscription_required", "Active tariff subscription is required" + ) + target = config.require(tariff_key) + options = subscription_service.calculate_tariff_switch_options(sub, target) + price = float(options.get("paid_diff_rub") or 0) + if price <= 0: + return _json_error( + 400, "payment_not_required", "Payment is not required for this tariff change" + ) + return await _create_subscription_payment( + request=request, + session=session, + user_id=user_id, + method=method, + months=1, + price=price, + stars_price=None, + lang=db_user.language_code or settings.DEFAULT_LANGUAGE, + sale_mode=f"tariff_upgrade@{target.key}", + ) + + +async def device_topup_options_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + config = settings.tariffs_config + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + if not config: + return _json_error(404, "tariffs_unavailable", "Tariffs are not configured") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub or not sub.tariff_key: + return _json_error( + 400, "subscription_required", "Active tariff subscription is required" + ) + tariff = config.require(sub.tariff_key) + active = await subscription_service.get_active_subscription_details(session, user_id) + plans = _serialize_hwid_device_packages( + settings, + tariff, + tariff.hwid_device_packages, + db_user.language_code or settings.DEFAULT_LANGUAGE, + ) + return web.json_response( + { + "ok": True, + "tariff_key": tariff.key, + "tariff_name": tariff.name(db_user.language_code or settings.DEFAULT_LANGUAGE), + "current_limit": _coerce_int_or_none(active.get("max_devices")) if active else None, + "extra_hwid_devices": int(sub.extra_hwid_devices or 0), + "plans": plans, + } + ) + + +async def payment_status_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + try: + payment_id = int(request.match_info["payment_id"]) + except (TypeError, ValueError): + return _json_error(400, "invalid_payment", "Invalid payment id") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + payment = await payment_dal.get_payment_by_db_id(session, payment_id) + if not payment or payment.user_id != user_id: + return _json_error(404, "not_found", "Payment not found") + return web.json_response( + { + "ok": True, + "payment_id": payment.payment_id, + "status": payment.status, + "paid": payment.status == "succeeded", + } + ) + + +def _sale_mode_base(sale_mode: str) -> str: + return str(sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0] + + +def _sale_mode_tariff_key(sale_mode: str) -> Optional[str]: + if "@" not in str(sale_mode or ""): + return None + return str(sale_mode).split("@", 1)[1].split("|", 1)[0] or None + + +def _sale_mode_is_traffic(sale_mode: str) -> bool: + return _sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"} + + +def _sale_mode_is_hwid_devices(sale_mode: str) -> bool: + return _sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"} + + +async def _create_subscription_payment( + *, + request: web.Request, + session: AsyncSession, + user_id: int, + method: str, + months: Any, + price: float, + stars_price: Optional[int], + lang: str, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, +) -> web.Response: + settings: Settings = request.app["settings"] + sale_mode = str(sale_mode or "subscription") + traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) + description = ( + _traffic_payment_description(float(traffic_gb if traffic_gb is not None else months), lang) + if traffic_sale + else _hwid_devices_payment_description(int(float(months)), lang) + if hwid_devices_sale + else _payment_description(int(months), lang) + ) + + if method == "yookassa": + if not settings.YOOKASSA_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + return await _create_yookassa_payment( + request, + session, + user_id, + months, + price, + description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + ) + if method == "freekassa": + if not settings.FREEKASSA_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + return await _create_freekassa_payment( + request, + session, + user_id, + months, + price, + description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + ) + if method in ("platega", "platega_sbp", "platega_crypto"): + if not settings.PLATEGA_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + return await _create_platega_payment( + request, + session, + user_id, + months, + price, + description, + variant=method, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + ) + if method == "severpay": + if not settings.SEVERPAY_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + return await _create_severpay_payment( + request, + session, + user_id, + months, + price, + description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + ) + if method == "cryptopay": + service: CryptoPayService = request.app["cryptopay_service"] + if not settings.CRYPTOPAY_ENABLED or not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + url = await service.create_invoice( + session=session, + user_id=user_id, + months=months, + amount=price, + description=description, + sale_mode=sale_mode, + url_kind="web", + ) + if not url: + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + {"ok": True, "action": "open_link", "payment_url": url, "payment_id": None} + ) + if method == "stars": + if not settings.STARS_ENABLED or stars_price is None: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + return await _create_stars_payment( + request, + session, + user_id, + months, + int(stars_price), + description, + sale_mode=sale_mode, + traffic_gb=traffic_gb, + ) + + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + +async def _create_base_payment_record( + session: AsyncSession, + *, + user_id: int, + amount: float, + currency: str, + status: str, + description: str, + months: int, + provider: str, + sale_mode: Optional[str] = None, + tariff_key: Optional[str] = None, + purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, +) -> Payment: + payment = await payment_dal.create_payment_record( + session, + { + "user_id": user_id, + "amount": amount, + "currency": currency, + "status": status, + "description": description, + "subscription_duration_months": months, + "provider": provider, + "sale_mode": sale_mode, + "tariff_key": tariff_key, + "purchased_gb": purchased_gb, + "purchased_hwid_devices": purchased_hwid_devices, + }, + ) + await session.commit() + return payment + + +async def _create_yookassa_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: Any, + price: float, + description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, +) -> web.Response: + settings: Settings = request.app["settings"] + service: YooKassaService = request.app["yookassa_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency="RUB", + status="pending_yookassa", + description=description, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), + provider="yookassa", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + ) + metadata = { + "user_id": str(user_id), + "subscription_months": str( + int(float(months)) if not traffic_sale and not hwid_devices_sale else 0 + ), + "payment_db_id": str(payment.payment_id), + "sale_mode": sale_mode, + "source": "webapp", + } + if traffic_sale: + metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months) + if hwid_devices_sale: + metadata["hwid_devices"] = str(int(float(months))) + if _sale_mode_tariff_key(sale_mode): + metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode) + response = await service.create_payment( + amount=price, + currency="RUB", + description=description, + metadata=metadata, + receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL, + save_payment_method=bool( + settings.yookassa_autopayments_active + and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING + ), + ) + payment_url = response.get("confirmation_url") if response else None + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + + await payment_dal.update_payment_status_by_db_id( + session, + payment.payment_id, + response.get("status", "pending"), + yk_payment_id=response.get("id"), + ) + await session.commit() + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception: + await session.rollback() + logger.exception("YooKassa WebApp payment failed") + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_freekassa_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: Any, + price: float, + description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, +) -> web.Response: + request.app["settings"] + service: FreeKassaService = request.app["freekassa_service"] + if not service or not service.configured or not service.payment_method_id: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency=service.default_currency, + status="pending_freekassa", + description=description, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), + provider="freekassa", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + ) + success, response_data = await service.create_order( + payment_db_id=payment.payment_id, + user_id=user_id, + months=months, + amount=price, + currency=service.default_currency, + payment_method_id=service.payment_method_id, + ip_address=service.server_ip, + extra_params={"us_method": service.payment_method_id}, + ) + payment_url = response_data.get("location") if success else None + provider_id = response_data.get("orderHash") or response_data.get("orderId") + if provider_id: + await payment_dal.update_provider_payment_and_status( + session, payment.payment_id, str(provider_id), payment.status + ) + await session.commit() + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception: + await session.rollback() + logger.exception("FreeKassa WebApp payment failed") + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_platega_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: Any, + price: float, + description: str, + variant: str = "platega_sbp", + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, +) -> web.Response: + settings: Settings = request.app["settings"] + service: PlategaService = request.app["platega_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + if variant == "platega_crypto": + if not settings.PLATEGA_CRYPTO_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + platega_method_id = settings.PLATEGA_CRYPTO_METHOD + else: + if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + platega_method_id = settings.platega_sbp_method_resolved + + try: + traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + status="pending_platega", + description=description, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), + provider="platega", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + ) + months_for_provider = ( + int(float(months)) if not traffic_sale else int(float(traffic_gb or months)) + ) + payload = json.dumps( + { + "payment_db_id": payment.payment_id, + "user_id": user_id, + "months": months_for_provider if not traffic_sale else 0, + "sale_mode": sale_mode, + "traffic_gb": _format_number_for_payload(traffic_gb or months) + if traffic_sale + else None, + "hwid_devices": int(float(months)) if hwid_devices_sale else None, + "source": "webapp", + "platega_variant": "crypto" if variant == "platega_crypto" else "sbp", + } + ) + success, response_data = await service.create_transaction( + payment_db_id=payment.payment_id, + user_id=user_id, + months=months_for_provider, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + description=description, + payload=payload, + payment_method=platega_method_id, + ) + payment_url = ( + ( + response_data.get("redirect") + or response_data.get("url") + or response_data.get("paymentUrl") + ) + if success + else None + ) + provider_id = response_data.get("transactionId") or response_data.get("id") + if provider_id: + await payment_dal.update_provider_payment_and_status( + session, + payment.payment_id, + str(provider_id), + str(response_data.get("status", payment.status)), + ) + await session.commit() + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception: + await session.rollback() + logger.exception("Platega WebApp payment failed") + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_severpay_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: Any, + price: float, + description: str, + *, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, +) -> web.Response: + settings: Settings = request.app["settings"] + service: SeverPayService = request.app["severpay_service"] + if not service or not service.configured: + return _json_error(400, "payment_unavailable", "Payment method unavailable") + + try: + traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + status="pending_severpay", + description=description, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), + provider="severpay", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + ) + success, response_data = await service.create_payment( + payment_db_id=payment.payment_id, + user_id=user_id, + months=months, + amount=price, + currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + description=description, + ) + payment_url = ( + ( + response_data.get("url") + or response_data.get("payment_url") + or response_data.get("paymentUrl") + ) + if success + else None + ) + provider_id = response_data.get("id") or response_data.get("uid") + if provider_id: + await payment_dal.update_provider_payment_and_status( + session, payment.payment_id, str(provider_id), payment.status + ) + await session.commit() + if not payment_url: + await payment_dal.update_payment_status_by_db_id( + session, payment.payment_id, "failed_creation" + ) + await session.commit() + return _json_error(502, "payment_failed", "Failed to create payment") + return web.json_response( + { + "ok": True, + "action": "open_link", + "payment_url": payment_url, + "payment_id": payment.payment_id, + } + ) + except Exception: + await session.rollback() + logger.exception("SeverPay WebApp payment failed") + return _json_error(502, "payment_failed", "Failed to create payment") + + +async def _create_stars_payment( + request: web.Request, + session: AsyncSession, + user_id: int, + months: Any, + stars_price: int, + description: str, + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, +) -> web.Response: + bot: Bot = request.app["bot"] + try: + traffic_sale = _sale_mode_is_traffic(sale_mode) + hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode) + payment = await _create_base_payment_record( + session, + user_id=user_id, + amount=float(stars_price), + currency="XTR", + status="pending_stars", + description=description, + months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)), + provider="telegram_stars", + sale_mode=sale_mode, + tariff_key=_sale_mode_tariff_key(sale_mode), + purchased_gb=float(traffic_gb or months) if traffic_sale else None, + purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None, + ) + payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months + payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}" + prices = [LabeledPrice(label=description, amount=stars_price)] + create_invoice_link = getattr(bot, "create_invoice_link", None) + if callable(create_invoice_link): + invoice_url = await create_invoice_link( + title=description, + description=description, + payload=payload, + # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. + provider_token="", + currency="XTR", + prices=prices, + ) + return web.json_response( + { + "ok": True, + "action": "open_invoice", + "payment_url": invoice_url, + "payment_id": payment.payment_id, + } + ) + + await bot.send_invoice( + chat_id=user_id, + title=description, + description=description, + payload=payload, + provider_token="", + currency="XTR", + prices=prices, + ) + return web.json_response( + { + "ok": True, + "action": "invoice_sent", + "payment_id": payment.payment_id, + } + ) + except Exception: + await session.rollback() + logger.exception("Stars WebApp payment failed") + return _json_error(502, "payment_failed", "Failed to create invoice") diff --git a/bot/app/web/webapp/common.py b/bot/app/web/webapp/common.py new file mode 100644 index 0000000..3d24921 --- /dev/null +++ b/bot/app/web/webapp/common.py @@ -0,0 +1,156 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def _read_json(request: web.Request) -> Dict[str, Any]: + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _json_error(status: int, code: str, message: str) -> web.Response: + return web.json_response( + {"ok": False, "error": code, "message": message}, + status=status, + ) + + +def _validation_error_response(exc: ValidationError) -> web.Response: + for error in exc.errors(): + loc = error.get("loc") or () + field = str(loc[0]) if loc else "" + error_type = str(error.get("type") or "") + message = str(error.get("msg") or "") + message_lower = message.lower() + + if field == "email": + if ( + "too_long" in message_lower + or "too long" in message_lower + or error_type == "string_too_long" + ): + return _json_error(400, "email_too_long", "Email is too long") + return _json_error(400, "invalid_email", "Invalid email") + + if field in {"description", "comment", "note"} and error_type == "string_too_long": + return _json_error(400, f"{field}_too_long", f"{field.capitalize()} is too long") + + if error_type == "string_too_long": + return _json_error(400, "text_too_long", "Text is too long") + + return _json_error(400, "invalid_request", "Invalid request") + + +def _validate_model_payload( + model_cls: type[BaseModel], + payload: Dict[str, Any], +) -> tuple[Optional[BaseModel], Optional[web.Response]]: + try: + return model_cls.model_validate(payload), None + except ValidationError as exc: + return None, _validation_error_response(exc) + + +def _normalize_language(lang: Optional[str]) -> str: + value = (lang or "ru").split("-")[0].lower() + return value if value in {"ru", "en"} else "ru" + + +def _format_remaining(seconds: int, lang: str) -> str: + if seconds <= 0: + if lang == "en": + return "Subscription inactive" + return "Подписка не активна" + days, rem = divmod(seconds, 86400) + hours, rem = divmod(rem, 3600) + minutes = rem // 60 + if lang == "en": + if days > 0: + return f"{days} d. {hours} h." + if hours > 0: + return f"{hours} h. {minutes} min." + return f"{max(1, minutes)} min." + if days > 0: + return f"{days} д. {hours} ч." + if hours > 0: + return f"{hours} ч. {minutes} мин." + return f"{max(1, minutes)} мин." + + +def _coerce_int_or_none(value: Optional[Any]) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _format_bytes(value: Optional[Any], *, zero_as_unlimited: bool = False) -> str: + if value is None: + return "N/A" + try: + size = float(value) + except (TypeError, ValueError): + return str(value) + if size <= 0 and zero_as_unlimited: + return "∞" + if size <= 0: + size = 0 + units = ["B", "KB", "MB", "GB", "TB"] + index = 0 + while size >= 1024 and index < len(units) - 1: + size /= 1024 + index += 1 + return f"{size:.2f} {units[index]}" + + +def _format_months_title(months: int, lang: str) -> str: + if lang == "en": + if months == 1: + return "1 month" + return f"{months} months" + if months == 1: + return "1 месяц" + if 2 <= months <= 4: + return f"{months} месяца" + return f"{months} месяцев" + + +def _format_number_for_payload(value: Any) -> str: + numeric = float(value or 0) + return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}" + + +def _format_traffic_title(traffic_gb: float, lang: str) -> str: + return f"{_format_number_for_payload(traffic_gb)} GB" + + +def _traffic_payment_description(traffic_gb: float, lang: str) -> str: + if lang == "en": + return f"Traffic package {_format_traffic_title(traffic_gb, lang)}" + return f"Пакет трафика {_format_traffic_title(traffic_gb, lang)}" + + +def _hwid_devices_payment_description(device_count: int, lang: str) -> str: + if lang == "en": + return f"HWID device package +{device_count}" + return f"Докупка устройств HWID +{device_count}" + + +def _resolve_numeric_option_key(options: Dict[Any, Any], target: float) -> Optional[Any]: + for key in options: + try: + if abs(float(key) - float(target)) < 0.000001: + return key + except (TypeError, ValueError): + continue + return None + + +def _payment_description(months: int, lang: str) -> str: + if lang == "en": + return f"Subscription for {_format_months_title(months, lang)}" + return f"Подписка на {_format_months_title(months, lang)}" diff --git a/bot/app/web/webapp/devices.py b/bot/app/web/webapp/devices.py new file mode 100644 index 0000000..9760f71 --- /dev/null +++ b/bot/app/web/webapp/devices.py @@ -0,0 +1,169 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def devices_route(request: web.Request) -> web.Response: + user_id = _require_user_id(request) + settings: Settings = request.app["settings"] + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + + active = await subscription_service.get_active_subscription_details(session, user_id) + panel_user_uuid = active.get("user_id") if active else None + if not panel_user_uuid: + return _json_error(400, "subscription_not_active", "Subscription is not active") + + panel_service = getattr(subscription_service, "panel_service", None) + if not panel_service: + return _json_error(503, "panel_unavailable", "Panel service unavailable") + + try: + devices_response = await panel_service.get_user_devices(panel_user_uuid) + except Exception: + logger.exception("Failed to load WebApp devices for user %s", user_id) + return _json_error(502, "devices_load_failed", "Failed to load devices") + + devices = _normalize_devices_response(devices_response) + max_devices = _coerce_int_or_none(active.get("max_devices")) if active else None + return web.json_response( + { + "ok": True, + "enabled": True, + "current_devices": len(devices), + "max_devices": max_devices, + "max_devices_label": _format_devices_limit(max_devices), + "devices": [ + _serialize_device(device, index) for index, device in enumerate(devices, start=1) + ], + } + ) + + +async def disconnect_device_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="devices_disconnect", + ) + if rate_limit_response: + return rate_limit_response + + settings: Settings = request.app["settings"] + if not settings.MY_DEVICES_SECTION_ENABLED: + return _json_error(404, "devices_disabled", "Devices section is disabled") + + payload = await _read_json(request) + disconnect_payload, validation_error = _validate_model_payload( + WebAppDeviceDisconnectPayload, payload + ) + if validation_error: + return validation_error + token = str(disconnect_payload.token or "").strip() + + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + 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") + + active = await subscription_service.get_active_subscription_details(session, user_id) + panel_user_uuid = active.get("user_id") if active else None + if not panel_user_uuid: + return _json_error(400, "subscription_not_active", "Subscription is not active") + + panel_service = getattr(subscription_service, "panel_service", None) + if not panel_service: + return _json_error(503, "panel_unavailable", "Panel service unavailable") + + try: + devices_response = await panel_service.get_user_devices(panel_user_uuid) + except Exception: + logger.exception("Failed to load WebApp devices before disconnect for user %s", user_id) + return _json_error(502, "devices_load_failed", "Failed to load devices") + + target_hwid = None + for device in _normalize_devices_response(devices_response): + hwid = str(device.get("hwid") or "").strip() + if hwid and hmac.compare_digest(_device_hwid_token(hwid), token): + target_hwid = hwid + break + + if not target_hwid: + return _json_error(404, "device_not_found", "Device not found") + + success = await panel_service.disconnect_device(panel_user_uuid, target_hwid) + if not success: + return _json_error(502, "device_disconnect_failed", "Failed to disconnect device") + await session.commit() + + return web.json_response({"ok": True}) + + +def _device_hwid_token(hwid: str) -> str: + return hashlib.sha256(str(hwid or "").encode()).hexdigest()[:32] + + +def _shorten_hwid_for_display(hwid: Optional[str], max_length: int = 24) -> str: + value = str(hwid or "").strip() + if len(value) <= max_length: + return value + return f"{value[:8]}...{value[-6:]}" + + +def _normalize_devices_response(devices_response: Any) -> List[Dict[str, Any]]: + if isinstance(devices_response, dict): + devices = devices_response.get("devices") or [] + else: + devices = devices_response or [] + if not isinstance(devices, list): + return [] + return [device for device in devices if isinstance(device, dict)] + + +def _format_devices_limit(max_devices: Optional[int]) -> str: + if max_devices in (None, 0): + return "Unlimited" + return str(max_devices) + + +def _format_device_datetime(value: Any) -> str: + if not value: + return "" + text = str(value) + try: + normalized = datetime.fromisoformat(text.replace("Z", "+00:00")) + return normalized.strftime("%d.%m.%Y %H:%M") + except Exception: + return text + + +def _serialize_device(device: Dict[str, Any], index: int) -> Dict[str, Any]: + hwid = str(device.get("hwid") or "").strip() + model = str(device.get("deviceModel") or "").strip() + platform = str(device.get("platform") or "").strip() + os_version = str(device.get("osVersion") or "").strip() + user_agent = str(device.get("userAgent") or "").strip() + display_name = model or platform or f"Device {index}" + platform_label = " ".join(part for part in (platform, os_version) if part).strip() + return { + "index": index, + "display_name": display_name, + "platform": platform, + "os_version": os_version, + "platform_label": platform_label, + "user_agent": user_agent, + "created_at": device.get("createdAt"), + "created_at_text": _format_device_datetime(device.get("createdAt")), + "hwid_short": _shorten_hwid_for_display(hwid), + "token": _device_hwid_token(hwid) if hwid else "", + "can_disconnect": bool(hwid), + } diff --git a/bot/app/web/webapp/payloads.py b/bot/app/web/webapp/payloads.py new file mode 100644 index 0000000..3e7b44b --- /dev/null +++ b/bot/app/web/webapp/payloads.py @@ -0,0 +1,59 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class WebAppEmailPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + email: EmailStr + + @field_validator("email") + @classmethod + def _normalize_and_limit_email(cls, value: EmailStr) -> str: + normalized = normalize_email(str(value)) + if len(normalized) > 254: + raise ValueError("email_too_long") + return normalized + + +class WebAppEmailCodePayload(WebAppEmailPayload): + code: str = "" + + +class WebAppEmailMagicPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: constr(min_length=8, max_length=512) + + +class WebAppPaymentCreatePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + method: str = "" + months: Any = None + traffic_gb: Any = None + device_count: Any = None + tariff_key: Optional[constr(max_length=128)] = None + sale_mode: Optional[constr(max_length=64)] = None + description: Optional[constr(max_length=4096)] = None + comment: Optional[constr(max_length=4096)] = None + note: Optional[constr(max_length=4096)] = None + + +class WebAppTariffChangePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + tariff_key: constr(min_length=1, max_length=128) + mode: constr(min_length=1, max_length=64) + + +class WebAppLanguagePayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + language: constr(min_length=2, max_length=16) + + +class WebAppDeviceDisconnectPayload(BaseModel): + model_config = ConfigDict(extra="ignore") + + token: constr(min_length=8, max_length=128) diff --git a/bot/app/web/webapp/routes.py b/bot/app/web/webapp/routes.py new file mode 100644 index 0000000..ee78959 --- /dev/null +++ b/bot/app/web/webapp/routes.py @@ -0,0 +1,48 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +def setup_subscription_webapp_routes(app: web.Application) -> None: + app.router.add_get("/", index_route) + app.router.add_get("/home", index_route) + app.router.add_get("/invite", index_route) + app.router.add_get("/devices", index_route) + app.router.add_get("/settings", index_route) + app.router.add_get("/admin", index_route) + app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) + app.router.add_get("/admin/users/{user_id:-?[0-9]+}", index_route) + app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) + app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) + app.router.add_get("/health", health_route) + app.router.add_get(WEBAPP_LOGO_PROXY_PATH, webapp_logo_route) + app.router.add_get( + r"/webapp-emoji/{codepoints:[0-9a-f_]+}/512.{ext:gif|webp}", + webapp_animated_emoji_route, + ) + app.router.add_get("/subscription_webapp.css", css_asset_route) + app.router.add_get("/subscription_webapp.min.{asset_hash}.js", js_asset_route) + app.router.add_get("/subscription_webapp.js", js_asset_route) + app.router.add_post("/api/auth/telegram/nonce", telegram_oauth_nonce_route) + app.router.add_post("/api/auth/token", auth_token_route) + app.router.add_post("/api/auth/email/request", email_auth_request_route) + app.router.add_post("/api/auth/email/verify", email_auth_verify_route) + app.router.add_post("/api/auth/email/magic", email_auth_magic_route) + app.router.add_post("/api/auth/logout", logout_route) + app.router.add_get("/api/me", me_route) + app.router.add_get("/api/account/avatar", account_avatar_route) + app.router.add_post("/api/account/language", account_language_route) + app.router.add_post("/api/account/email/request", account_email_request_route) + app.router.add_post("/api/account/email/verify", account_email_verify_route) + app.router.add_post("/api/account/telegram/link", account_telegram_link_route) + app.router.add_post("/api/promo/apply", apply_promo_route) + app.router.add_post("/api/trial/activate", activate_trial_route) + app.router.add_get("/api/devices", devices_route) + app.router.add_post("/api/devices/disconnect", disconnect_device_route) + app.router.add_get("/api/devices/topup-options", device_topup_options_route) + app.router.add_get("/api/tariffs/topup-options", tariff_topup_options_route) + app.router.add_get("/api/tariffs/change-options", tariff_change_options_route) + app.router.add_post("/api/tariffs/change", tariff_change_route) + app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route) + app.router.add_post("/api/payments", create_payment_route) + app.router.add_get("/api/payments/{payment_id}", payment_status_route) + setup_admin_routes(app) diff --git a/bot/app/web/webapp/serializers.py b/bot/app/web/webapp/serializers.py new file mode 100644 index 0000000..174cd53 --- /dev/null +++ b/bot/app/web/webapp/serializers.py @@ -0,0 +1,612 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]: + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + subscription_service: SubscriptionService = request.app["subscription_service"] + cached = _get_cached_webapp_settings(request) + + 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: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "access_denied"}), + content_type="application/json", + ) + + active = await subscription_service.get_active_subscription_details(session, user_id) + referral_code = await user_dal.ensure_referral_code(session, db_user) + referral_service: Optional[ReferralService] = request.app.get("referral_service") + bot_username = request.app.get("bot_username") or "" + referral_link = None + if referral_service and bot_username: + referral_link = await referral_service.generate_referral_link( + session, + bot_username, + user_id, + ) + webapp_referral_link = _build_webapp_referral_link( + request.app["settings"].SUBSCRIPTION_MINI_APP_URL, + referral_code, + ) + referral_stats = ( + await referral_service.get_referral_stats(session, user_id) + if referral_service + else {"invited_count": 0, "purchased_count": 0} + ) + local_sub = ( + await subscription_dal.get_active_subscription_by_user_id( + session, + user_id, + db_user.panel_user_uuid, + ) + if db_user.panel_user_uuid + else None + ) + trial_available = bool( + settings.TRIAL_ENABLED + and settings.TRIAL_DURATION_DAYS > 0 + and not await subscription_service.has_had_any_subscription(session, user_id) + ) + avatar = await _ensure_cached_telegram_avatar(request, session, db_user) + try: + await session.commit() + except Exception: + await session.rollback() + + lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} + is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) + return { + "user": { + "id": user_id, + "username": db_user.username, + "email": db_user.email, + "email_verified": bool(db_user.email_verified_at), + "telegram_id": db_user.telegram_id, + "telegram_linked": bool(_telegram_id_for_user(db_user)), + "telegram_photo_url": _telegram_avatar_url(avatar), + "first_name": db_user.first_name, + "language_code": lang, + "is_admin": is_admin, + }, + "subscription": _serialize_subscription(settings, active, local_sub, lang), + "referral": { + "code": referral_code, + "bot_link": referral_link, + "webapp_link": webapp_referral_link, + "invited_count": referral_stats.get("invited_count", 0), + "purchased_count": referral_stats.get("purchased_count", 0), + "welcome_bonus_days": max( + 0, int(getattr(settings, "REFERRAL_WELCOME_BONUS_DAYS", 0) or 0) + ), + "one_bonus_per_referee": bool( + getattr(settings, "REFERRAL_ONE_BONUS_PER_REFEREE", False) + ), + "bonus_details": _serialize_referral_bonus_details(settings, lang), + }, + "plans": _serialize_plans( + settings, + lang, + subscription_options=cached["subscription_options"], + stars_subscription_options=cached["stars_subscription_options"], + traffic_packages=cached["traffic_packages"], + stars_traffic_packages=cached["stars_traffic_packages"], + ), + "payment_methods": _serialize_payment_methods(settings, request.app), + "settings": { + "support_url": settings.SUPPORT_LINK, + "traffic_mode": bool(settings.traffic_sale_mode), + "my_devices_enabled": bool(settings.MY_DEVICES_SECTION_ENABLED), + "user_hwid_device_limit": ( + int(settings.USER_HWID_DEVICE_LIMIT) + if settings.USER_HWID_DEVICE_LIMIT is not None + else None + ), + "trial_enabled": bool(settings.TRIAL_ENABLED), + "trial_available": trial_available, + "trial_duration_days": int(settings.TRIAL_DURATION_DAYS or 0), + "trial_traffic_limit_gb": float(settings.TRIAL_TRAFFIC_LIMIT_GB or 0), + "trial_traffic_strategy": getattr(settings, "TRIAL_TRAFFIC_STRATEGY", "NO_RESET"), + "email_auth_enabled": settings.email_auth_configured, + }, + } + + +def _serialize_referral_bonus_details(settings: Settings, lang: str) -> List[Dict[str, Any]]: + if getattr(settings, "traffic_sale_mode", False): + return [] + + details: List[Dict[str, Any]] = [] + for months, _price in sorted(settings.subscription_options.items()): + inviter_days = settings.referral_bonus_inviter.get(months) + friend_days = settings.referral_bonus_referee.get(months) + if inviter_days is None and friend_days is None: + continue + details.append( + { + "months": int(months), + "title": _format_months_title(int(months), lang), + "inviter_days": int(inviter_days or 0), + "friend_days": int(friend_days or 0), + } + ) + return details + + +def _build_webapp_referral_link( + base_url: Optional[str], + referral_code: Optional[str], +) -> Optional[str]: + if not base_url or not referral_code: + return None + parts = urlsplit(base_url) + query = dict(parse_qsl(parts.query, keep_blank_values=True)) + query["ref"] = f"u{referral_code}" + return urlunsplit( + ( + parts.scheme, + parts.netloc, + parts.path or "/", + urlencode(query), + parts.fragment, + ) + ) + + +def _serialize_subscription( + settings: Settings, + active: Optional[Dict[str, Any]], + local_sub: Optional[Any], + lang: str, +) -> Dict[str, Any]: + if not active: + return { + "active": False, + "status": "INACTIVE", + "remaining_text": _format_remaining(0, lang), + "days_left": 0, + "config_link": None, + "connect_url": None, + } + + end_date = active.get("end_date") + if end_date and end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=timezone.utc) + + seconds_left = 0 + if end_date: + seconds_left = max( + 0, + int((end_date - datetime.now(timezone.utc)).total_seconds()), + ) + + can_topup_regular_traffic = False + can_topup_premium_traffic = False + can_topup_traffic = False + if settings.tariffs_config and active.get("tariff_key"): + try: + tariff = settings.tariffs_config.require(str(active.get("tariff_key"))) + packages = settings.tariffs_config.topup_packages_for(tariff) + can_topup_regular_traffic = bool(packages and packages.has_any()) + can_topup_premium_traffic = bool( + tariff.premium_squad_uuids + and tariff.premium_topup_packages + and tariff.premium_topup_packages.has_any() + ) + can_topup_traffic = bool(can_topup_regular_traffic or can_topup_premium_traffic) + except Exception: + can_topup_regular_traffic = False + can_topup_premium_traffic = False + can_topup_traffic = False + + return { + "active": seconds_left > 0, + "status": active.get("status_from_panel") or "UNKNOWN", + "end_date": end_date.isoformat() if end_date else None, + "end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A", + "days_left": seconds_left // 86400, + "remaining_text": _format_remaining(seconds_left, lang), + "config_link": active.get("config_link"), + "connect_url": active.get("connect_button_url") or active.get("config_link"), + "traffic_limit": _format_bytes(active.get("traffic_limit_bytes"), zero_as_unlimited=True), + "traffic_used": _format_bytes(active.get("traffic_used_bytes")), + "traffic_limit_bytes": _coerce_int_or_none(active.get("traffic_limit_bytes")), + "traffic_used_bytes": _coerce_int_or_none(active.get("traffic_used_bytes")), + "tariff_key": active.get("tariff_key"), + "tariff_name": active.get("tariff_name"), + "tariff_description": active.get("tariff_description"), + "premium_title": active.get("premium_title"), + "billing_model": active.get("billing_model"), + "traffic_limit_strategy": str(active.get("traffic_limit_strategy") or ""), + "tier_baseline_bytes": _coerce_int_or_none(active.get("tier_baseline_bytes")), + "topup_balance_bytes": _coerce_int_or_none(active.get("topup_balance_bytes")), + "premium_limit": _format_bytes(active.get("premium_limit_bytes"), zero_as_unlimited=True), + "premium_used": _format_bytes(active.get("premium_used_bytes")), + "premium_limit_bytes": _coerce_int_or_none(active.get("premium_limit_bytes")), + "premium_used_bytes": _coerce_int_or_none(active.get("premium_used_bytes")), + "premium_baseline_bytes": _coerce_int_or_none(active.get("premium_baseline_bytes")), + "premium_topup_balance_bytes": _coerce_int_or_none( + active.get("premium_topup_balance_bytes") + ), + "premium_topup_used_bytes": _coerce_int_or_none(active.get("premium_topup_used_bytes")), + "premium_bonus_bytes": _coerce_int_or_none(active.get("premium_bonus_bytes")) or 0, + "regular_bonus_bytes": _coerce_int_or_none(active.get("regular_bonus_bytes")) or 0, + "regular_unlimited_override": bool(active.get("regular_unlimited_override")), + "premium_unlimited_override": bool(active.get("premium_unlimited_override")), + "premium_is_limited": bool(active.get("premium_is_limited")), + "premium_squad_labels": list(active.get("premium_squad_labels") or []), + "premium_node_labels": list(active.get("premium_node_labels") or []), + "can_topup_traffic": can_topup_traffic, + "can_topup_regular_traffic": can_topup_regular_traffic, + "can_topup_premium_traffic": can_topup_premium_traffic, + "period_start_at": active.get("period_start_at").isoformat() + if active.get("period_start_at") + else None, + "is_throttled": bool(active.get("is_throttled")), + "max_devices": _coerce_int_or_none(active.get("max_devices")), + "base_hwid_device_limit": _coerce_int_or_none(active.get("base_hwid_device_limit")), + "extra_hwid_devices": _coerce_int_or_none(active.get("extra_hwid_devices")) or 0, + "auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)), + "provider": getattr(local_sub, "provider", None), + } + + +def _serialize_plans( + settings: Settings, + lang: str, + *, + subscription_options: Optional[Dict[int, float]] = None, + stars_subscription_options: Optional[Dict[int, int]] = None, + traffic_packages: Optional[Dict[float, float]] = None, + stars_traffic_packages: Optional[Dict[float, int]] = None, +) -> List[Dict[str, Any]]: + tariffs_config = settings.tariffs_config + if tariffs_config: + plans: List[Dict[str, Any]] = [] + for tariff in tariffs_config.enabled_tariffs: + common = { + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "billing_model": tariff.billing_model, + "description": tariff.description(lang), + "squad_uuids": tariff.squad_uuids, + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "hwid_device_limit": tariff.hwid_device_limit, + "hwid_device_packages": _serialize_hwid_device_packages( + settings, + tariff, + tariff.hwid_device_packages, + lang, + ), + } + if tariff.billing_model == "period": + for months in sorted(tariff.enabled_periods): + price = tariff.period_price(int(months), "rub") + stars_price = tariff.period_price(int(months), "stars") + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + plan = { + **common, + "id": f"{tariff.key}:period:{int(months)}", + "sale_mode": "subscription", + "months": int(months), + "price": float(price or 0), + "title": tariff.name(lang), + "subtitle": _format_months_title(int(months), lang), + "monthly_gb": tariff.monthly_gb, + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + else: + rub_packages = { + float(package.gb): float(package.price) + for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + } + stars_packages = { + float(package.gb): int(float(package.price)) + for package in ( + tariff.traffic_packages.stars if tariff.traffic_packages else [] + ) + } + for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): + price = rub_packages.get(traffic_gb) + stars_price = stars_packages.get(traffic_gb) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + traffic_value = float(traffic_gb) + plan = { + **common, + "id": f"{tariff.key}:traffic:{_format_number_for_payload(traffic_value)}", + "sale_mode": "traffic_package", + "months": int(traffic_value) + if traffic_value.is_integer() + else traffic_value, + "traffic_gb": traffic_value, + "price": float(price or 0), + "title": tariff.name(lang), + "subtitle": _format_traffic_title(traffic_value, lang), + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + if getattr(settings, "traffic_sale_mode", False): + active_traffic_packages = traffic_packages or settings.traffic_packages + active_stars_traffic_packages = stars_traffic_packages or settings.stars_traffic_packages + traffic_units = sorted(set(active_traffic_packages) | set(active_stars_traffic_packages)) + plans: List[Dict[str, Any]] = [] + for traffic_gb in traffic_units: + price = active_traffic_packages.get(traffic_gb) + stars_price = active_stars_traffic_packages.get(traffic_gb) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + traffic_value = float(traffic_gb) + plan = { + "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, + "traffic_gb": traffic_value, + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": _format_traffic_title(traffic_value, lang), + "sale_mode": "traffic", + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + active_subscription_options = subscription_options or settings.subscription_options + active_stars_subscription_options = ( + stars_subscription_options or settings.stars_subscription_options + ) + plans: List[Dict[str, Any]] = [] + for months in sorted(set(active_subscription_options) | set(active_stars_subscription_options)): + price = active_subscription_options.get(months) + stars_price = active_stars_subscription_options.get(months) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + plan = { + "months": int(months), + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": _format_months_title(int(months), lang), + "sale_mode": "subscription", + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + +def _traffic_percent(used: Optional[int], limit: Optional[int]) -> int: + used_val = int(used or 0) + limit_val = int(limit or 0) + if limit_val <= 0: + return 0 + return max(0, min(100, round((used_val / limit_val) * 100))) + + +def _serialize_topup_packages( + settings: Settings, + tariff: Any, + packages: Optional[Any], + lang: str, + *, + sale_mode: str = "topup", + title_prefix: str = "", +) -> List[Dict[str, Any]]: + rub_packages = { + float(package.gb): float(package.price) for package in (packages.rub if packages else []) + } + stars_packages = { + float(package.gb): int(float(package.price)) + for package in (packages.stars if packages else []) + } + plans: List[Dict[str, Any]] = [] + for traffic_gb in sorted(set(rub_packages) | set(stars_packages)): + price = rub_packages.get(traffic_gb) + stars_price = stars_packages.get(traffic_gb) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + traffic_value = float(traffic_gb) + plan: Dict[str, Any] = { + "id": f"{tariff.key}:{sale_mode}:{_format_number_for_payload(traffic_value)}", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "billing_model": tariff.billing_model, + "sale_mode": sale_mode, + "months": int(traffic_value) if traffic_value.is_integer() else traffic_value, + "traffic_gb": traffic_value, + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": f"{title_prefix}{_format_traffic_title(traffic_value, lang)}", + "subtitle": tariff.premium_name(lang) + if sale_mode == "premium_topup" + else tariff.name(lang), + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + +def _serialize_hwid_device_packages( + settings: Settings, + tariff: Any, + packages: Optional[Any], + lang: str, +) -> List[Dict[str, Any]]: + rub_packages = { + int(package.count): float(package.price) for package in (packages.rub if packages else []) + } + stars_packages = { + int(package.count): int(float(package.price)) + for package in (packages.stars if packages else []) + } + plans: List[Dict[str, Any]] = [] + for count in sorted(set(rub_packages) | set(stars_packages)): + price = rub_packages.get(count) + stars_price = stars_packages.get(count) + if price is None and (stars_price is None or int(stars_price) <= 0): + continue + plan: Dict[str, Any] = { + "id": f"{tariff.key}:hwid:{count}", + "tariff_key": tariff.key, + "tariff_name": tariff.name(lang), + "billing_model": tariff.billing_model, + "sale_mode": "hwid_devices", + "months": int(count), + "device_count": int(count), + "price": float(price or 0), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + "title": f"+{count}", + "subtitle": tariff.name(lang), + } + if stars_price is not None and int(stars_price) > 0: + plan["stars_price"] = int(stars_price) + plans.append(plan) + return plans + + +def _serialize_tariff_change_target( + settings: Settings, + config: Any, + tariff: Any, + options: Dict[str, Any], + lang: str, +) -> Dict[str, Any]: + actions: List[Dict[str, Any]] = [] + mode = str(options.get("mode") or "") + if mode == "period_to_period": + actions.append( + { + "mode": "recalc_days", + "kind": "free", + "title": "recalc_days", + "days_after": int(options.get("recalc_days") or 0), + "remaining_days": int(options.get("remaining_days") or 0), + } + ) + paid_diff = float(options.get("paid_diff_rub") or 0) + if paid_diff > 0: + actions.append( + { + "mode": "paid_diff", + "kind": "payment", + "title": "paid_diff", + "price": paid_diff, + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + ) + elif mode == "period_to_traffic": + actions.append( + { + "mode": "convert_days_to_gb", + "kind": "free", + "title": "convert_days_to_gb", + "converted_gb": float(options.get("converted_gb") or 0), + "remaining_days": int(options.get("remaining_days") or 0), + } + ) + actions.extend( + { + "mode": "buy_package", + "kind": "payment", + "title": f"+{package.gb:g} GB", + "traffic_gb": float(package.gb), + "price": float(package.price), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + for package in (tariff.traffic_packages.rub if tariff.traffic_packages else []) + ) + else: + for months in tariff.enabled_periods: + price = tariff.period_price(int(months), "rub") + if price: + actions.append( + { + "mode": "buy_period", + "kind": "payment", + "months": int(months), + "title": _format_months_title(int(months), lang), + "price": float(price), + "currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB", + } + ) + return { + "tariff_key": tariff.key, + "title": tariff.name(lang), + "description": tariff.description(lang), + "billing_model": tariff.billing_model, + "monthly_gb": tariff.monthly_gb, + "options": options, + "actions": actions, + } + + +def _serialize_payment_methods( + settings: Settings, + app: web.Application, +) -> List[Dict[str, Any]]: + labels = { + "severpay": "SeverPay", + "freekassa": "FreeKassa / СБП", + "platega_sbp": "Platega · СБП", + "platega_crypto": "Platega · Crypto", + "yookassa": "Банковская карта", + "stars": "Telegram Stars", + "cryptopay": "CryptoPay", + } + methods: List[Dict[str, Any]] = [] + for method in settings.payment_methods_order: + method = method.lower() + if ( + method == "severpay" + and settings.SEVERPAY_ENABLED + and _service_configured(app, "severpay_service") + ): + methods.append({"id": method, "name": labels[method]}) + elif ( + method == "freekassa" + and settings.FREEKASSA_ENABLED + and _service_configured(app, "freekassa_service") + ): + methods.append({"id": method, "name": labels[method]}) + elif ( + method == "platega_sbp" + and settings.PLATEGA_ENABLED + and settings.PLATEGA_SBP_ENABLED + and _service_configured(app, "platega_service") + ): + methods.append({"id": method, "name": labels[method]}) + elif ( + method == "platega_crypto" + and settings.PLATEGA_ENABLED + and settings.PLATEGA_CRYPTO_ENABLED + and _service_configured(app, "platega_service") + ): + methods.append({"id": method, "name": labels[method]}) + elif ( + method == "yookassa" + and settings.YOOKASSA_ENABLED + and _service_configured(app, "yookassa_service") + ): + methods.append({"id": method, "name": labels[method]}) + elif method == "stars" and settings.STARS_ENABLED: + methods.append({"id": method, "name": labels[method]}) + elif ( + method == "cryptopay" + and settings.CRYPTOPAY_ENABLED + and _service_configured(app, "cryptopay_service") + ): + methods.append({"id": method, "name": labels[method]}) + return methods + + +def _service_configured(app: web.Application, key: str) -> bool: + service = app.get(key) + return bool(service and getattr(service, "configured", False)) diff --git a/bot/handlers/admin/broadcast.py b/bot/handlers/admin/broadcast.py index c40d45f..73b3f31 100644 --- a/bot/handlers/admin/broadcast.py +++ b/bot/handlers/admin/broadcast.py @@ -160,7 +160,7 @@ async def change_broadcast_target_handler( return await state.update_data(broadcast_target=new_target) - user_fsm_data = await state.get_data() + await state.get_data() _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) confirmation_prompt = _("admin_broadcast_confirm_prompt_short") try: @@ -254,7 +254,7 @@ async def confirm_broadcast_callback_handler( failed_count = 0 admin_user = callback.from_user logging.info( - f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." + f"Admin {admin_user.id} broadcasting '{(content.text or '')[:50]}...' to {len(user_ids)} users." # noqa: E501 ) # Get message queue manager @@ -297,7 +297,7 @@ async def confirm_broadcast_callback_handler( "telegram_username": admin_user.username, "telegram_first_name": admin_user.first_name, "event_type": "admin_broadcast_queued", - "content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", + "content": f"To user {uid}: [{content.content_type}] {(content.text or '')[:70]}...", # noqa: E501 "is_admin_event": True, "target_user_id": uid, }, diff --git a/bot/handlers/admin/common.py b/bot/handlers/admin/common.py index 2f01e6f..19794c3 100644 --- a/bot/handlers/admin/common.py +++ b/bot/handlers/admin/common.py @@ -78,7 +78,7 @@ async def admin_panel_actions_callback_handler( if not callback.message: logging.error( - f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}" + f"CallbackQuery {callback.id} from {callback.from_user.id} has no message for admin_action {action}" # noqa: E501 ) await callback.answer("Error processing action: message context lost.", show_alert=True) return diff --git a/bot/handlers/admin/logs_admin.py b/bot/handlers/admin/logs_admin.py index bb11f2c..769a90c 100644 --- a/bot/handlers/admin/logs_admin.py +++ b/bot/handlers/admin/logs_admin.py @@ -148,7 +148,7 @@ async def _display_formatted_logs( ) except Exception as e: logging.warning( - f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)." + f"Failed to edit message for logs display (len: {len(text)}): {e}. Sending new message(s)." # noqa: E501 ) max_chunk_size = 4000 diff --git a/bot/handlers/admin/payments.py b/bot/handlers/admin/payments.py index 1930dd5..32b483d 100644 --- a/bot/handlers/admin/payments.py +++ b/bot/handlers/admin/payments.py @@ -138,7 +138,7 @@ async def view_payments_handler( for i, payment in enumerate(payments, 1): text_parts.append( - f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang, settings)}" + f"{page * page_size + i}. {format_payment_text(payment, i18n, current_lang, settings)}" # noqa: E501 ) text_parts.append("") # Empty line between payments diff --git a/bot/handlers/admin/promo/bulk.py b/bot/handlers/admin/promo/bulk.py index d71f47d..b399a7e 100644 --- a/bot/handlers/admin/promo/bulk.py +++ b/bot/handlers/admin/promo/bulk.py @@ -486,7 +486,7 @@ async def create_bulk_promo_codes_final( # Send CSV file if created if csv_file: - csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый" + csv_caption = f"📄 Промокоды для массового создания\n💫 Всего: {len(created_codes)} промокодов\n🎁 Бонус: {data['bonus_days']} дней каждый" # noqa: E501 await message_obj.answer_document(csv_file, caption=csv_caption) await state.clear() diff --git a/bot/handlers/admin/promo/manage.py b/bot/handlers/admin/promo/manage.py index f1c70d9..2bed707 100644 --- a/bot/handlers/admin/promo/manage.py +++ b/bot/handlers/admin/promo/manage.py @@ -113,7 +113,7 @@ async def view_promo_codes_handler( else "\n".join( [_("admin_active_promos_list_header"), ""] + [ - f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} {p.code} | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" + f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} {p.code} | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" # noqa: E501 for p in promo_models ] ) @@ -204,7 +204,7 @@ async def promo_management_handler( # Формируем заголовок с информацией о страницах title = _("admin_promo_management_title") if total_pages > 1: - title += f"\n{_('admin_promo_list_page_info', current=page + 1, total=total_pages, count=total_count)}" + title += f"\n{_('admin_promo_list_page_info', current=page + 1, total=total_pages, count=total_count)}" # noqa: E501 await callback.message.edit_text(title, reply_markup=builder.as_markup(), parse_mode="HTML") await callback.answer() diff --git a/bot/handlers/admin/statistics.py b/bot/handlers/admin/statistics.py index f56ca2f..b8e0446 100644 --- a/bot/handlers/admin/statistics.py +++ b/bot/handlers/admin/statistics.py @@ -97,7 +97,7 @@ async def show_statistics_handler( nodes_stats = await panel_service.get_nodes_statistics() logging.info( - f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}" + f"Panel stats response: system={system_stats}, bandwidth={bandwidth_stats}, nodes={nodes_stats}" # noqa: E501 ) if system_stats: @@ -173,7 +173,7 @@ async def show_statistics_handler( total_nodes_count = len(unique_nodes) # Assume all nodes are active since we don't have status info stats_text_parts.append( - f"🔗 {_('admin_panel_nodes_label')}: {total_nodes_count}/{total_nodes_count}" + f"🔗 {_('admin_panel_nodes_label')}: {total_nodes_count}/{total_nodes_count}" # noqa: E501 ) else: # Use nodes total from system stats as fallback @@ -191,7 +191,7 @@ async def show_statistics_handler( stats_text_parts.append(f"\n💰 {_('admin_financial_stats_header')}") stats_text_parts.append( - f"📅 {_('admin_financial_today_label')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" + f"📅 {_('admin_financial_today_label')}: {financial_stats['today_revenue']:.2f} RUB ({financial_stats['today_payments_count']} {_('admin_financial_payments_label')})" # noqa: E501 ) stats_text_parts.append( f"📅 {_('admin_financial_week_label')}: {financial_stats['week_revenue']:.2f} RUB" @@ -200,7 +200,7 @@ async def show_statistics_handler( f"📅 {_('admin_financial_month_label')}: {financial_stats['month_revenue']:.2f} RUB" ) stats_text_parts.append( - f"🏆 {_('admin_financial_all_time_label')}: {financial_stats['all_time_revenue']:.2f} RUB" + f"🏆 {_('admin_financial_all_time_label')}: {financial_stats['all_time_revenue']:.2f} RUB" # noqa: E501 ) last_payments_models: List[Payment] = await payment_dal.get_recent_payment_logs_with_user( @@ -264,7 +264,7 @@ async def show_statistics_handler( stats_text_parts.append(f" {_('admin_stats_sync_time')}: {sync_time_str}") stats_text_parts.append(f" {_('admin_stats_sync_status')}: {sync_status_model.status}") stats_text_parts.append( - f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" + f" {_('admin_stats_sync_users_processed')}: {sync_status_model.users_processed_from_panel}" # noqa: E501 ) stats_text_parts.append( f" {_('admin_stats_sync_subs_synced')}: {sync_status_model.subscriptions_synced}" diff --git a/bot/handlers/admin/sync_admin.py b/bot/handlers/admin/sync_admin.py index 6f009b2..19eacfd 100644 --- a/bot/handlers/admin/sync_admin.py +++ b/bot/handlers/admin/sync_admin.py @@ -79,7 +79,7 @@ async def _bind_panel_email_to_user( if not merged_user.email_verified_at: merged_user.email_verified_at = datetime.now(timezone.utc) logging.info( - "Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.", + "Merged email-only user %s into user %s while binding panel email %s for panel UUID %s.", # noqa: E501 user_with_email.user_id, merged_user.user_id, email_from_panel, @@ -169,9 +169,7 @@ async def perform_sync( try: panel_records_checked += 1 panel_uuid = panel_user_dict.get("uuid") - panel_subscription_uuid = panel_user_dict.get( - "subscriptionUuid" - ) or panel_user_dict.get("shortUuid") + panel_user_dict.get("subscriptionUuid") or panel_user_dict.get("shortUuid") telegram_id_from_panel = panel_user_dict.get("telegramId") email_from_panel = _normalize_panel_email(panel_user_dict.get("email")) @@ -204,8 +202,8 @@ async def perform_sync( if not existing_user: existing_user = await user_dal.get_user_by_panel_uuid(session, panel_uuid) if existing_user: - logging.info( - f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" + logging.debug( + f"Found user by panel UUID {panel_uuid}, telegramId: {existing_user.user_id}" # noqa: E501 ) # Update telegram ID if it was missing in panel data but we have local user if ( @@ -213,7 +211,7 @@ async def perform_sync( and existing_user.user_id != telegram_id_from_panel ): logging.warning( - f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}" + f"TelegramId mismatch: panel={telegram_id_from_panel}, local={existing_user.user_id}" # noqa: E501 ) # Finally, fall back to email. This mainly catches panel users that @@ -235,7 +233,7 @@ async def perform_sync( "email_verified_at": ( datetime.now(timezone.utc) if email_from_panel else None ), - "username": None, # Username will be updated when user interacts with bot + "username": None, # Username will be updated when user interacts with bot # noqa: E501 "first_name": None, # Panel doesn't provide this info "last_name": None, # Panel doesn't provide this info "language_code": "ru", # Default language @@ -248,7 +246,7 @@ async def perform_sync( if was_created: users_created += 1 logging.info( - f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}" + f"Created new user {telegram_id_from_panel} from panel sync with UUID {panel_uuid}" # noqa: E501 ) existing_user = new_user @@ -272,12 +270,12 @@ async def perform_sync( if was_created: users_created += 1 logging.info( - f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" + f"Created new email user {new_user.user_id} from panel sync with UUID {panel_uuid}" # noqa: E501 ) existing_user = new_user except Exception as e_create_email: sync_errors.append( - f"Error creating email user {email_from_panel}: {str(e_create_email)}" + f"Error creating email user {email_from_panel}: {str(e_create_email)}" # noqa: E501 ) logging.error( f"Error creating email user {email_from_panel}: {e_create_email}" @@ -285,7 +283,7 @@ async def perform_sync( continue else: logging.debug( - f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" + f"Panel user with UUID {panel_uuid} (no telegramId) not found in local DB - skipping" # noqa: E501 ) continue @@ -359,7 +357,7 @@ async def perform_sync( ) except Exception as e_desc: logging.warning( - f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" + f"Sync: Failed to update description for panel user {panel_uuid} (tg {actual_user_id}): {e_desc}" # noqa: E501 ) # Sync subscription data @@ -378,7 +376,7 @@ async def perform_sync( ) or panel_user_dict.get("shortUuid") if subscription_uuid_from_panel: - # Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные + # Если панель говорит, что подписка ACTIVE — сначала деактивируем все другие активные # noqa: E501 if panel_status == "ACTIVE": await session.execute( update(Subscription) @@ -397,7 +395,7 @@ async def perform_sync( ) ) - # Try to find subscription by its panel_subscription_uuid first (idempotent) + # Try to find subscription by its panel_subscription_uuid first (idempotent) # noqa: E501 existing_sub_by_uuid = ( await subscription_dal.get_subscription_by_panel_subscription_uuid( session, subscription_uuid_from_panel @@ -420,12 +418,12 @@ async def perform_sync( subscriptions_synced_count += 1 subscriptions_updated += 1 user_was_updated = True - logging.info( - f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " - f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" + logging.debug( + f"Synced existing subscription {existing_sub_by_uuid.subscription_id} " # noqa: E501 + f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501 ) else: - # Create a new subscription only when we have a concrete subscription UUID + # Create a new subscription only when we have a concrete subscription UUID # noqa: E501 sub_payload = { "user_id": actual_user_id, "panel_user_uuid": panel_uuid, @@ -445,12 +443,12 @@ async def perform_sync( subscriptions_synced_count += 1 subscriptions_created += 1 user_was_updated = True - logging.info( + logging.debug( f"Created subscription {created_sub.subscription_id} " - f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}" + f"for user {actual_user_id} by panel_sub_uuid {subscription_uuid_from_panel}" # noqa: E501 ) else: - # No subscription UUID from panel: only update an already active subscription for this user/panel UUID + # No subscription UUID from panel: only update an already active subscription for this user/panel UUID # noqa: E501 active_sub = await subscription_dal.get_active_subscription_by_user_id( session, actual_user_id, panel_uuid ) @@ -467,14 +465,14 @@ async def perform_sync( subscriptions_synced_count += 1 subscriptions_updated += 1 user_was_updated = True - logging.info( + logging.debug( f"Updated active subscription {active_sub.subscription_id} " - f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" + f"for user {actual_user_id}: expires {panel_expire_at}, status {panel_status}" # noqa: E501 ) else: - # Without a concrete subscription UUID we avoid creating new records to keep sync idempotent + # Without a concrete subscription UUID we avoid creating new records to keep sync idempotent # noqa: E501 logging.debug( - f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}" + f"No subscriptionUuid for panel user {panel_uuid}; skipped creation for user {actual_user_id}" # noqa: E501 ) except Exception as e: @@ -488,7 +486,7 @@ async def perform_sync( except Exception as e_user: sync_errors.append( - f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}" + f"Error processing panel user {panel_user_dict.get('uuid', 'unknown')}: {str(e_user)}" # noqa: E501 ) logging.error(f"Error syncing user: {e_user}") @@ -685,7 +683,7 @@ async def sync_status_command_handler( f"{_('admin_stats_last_sync_header')}\n" f" {_('admin_stats_sync_time')}: {last_time_str}\n" f" {_('admin_stats_sync_status')}: {status_record_model.status}\n" - f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n" + f" {_('admin_stats_sync_users_processed')}: {status_record_model.users_processed_from_panel}\n" # noqa: E501 f" {_('admin_stats_sync_subs_synced')}: {status_record_model.subscriptions_synced}\n" f" {_('admin_stats_sync_details_label')}: {details_str}" ) diff --git a/bot/handlers/admin/user_management.py b/bot/handlers/admin/user_management.py index f05fcb2..4d1e0bb 100644 --- a/bot/handlers/admin/user_management.py +++ b/bot/handlers/admin/user_management.py @@ -319,7 +319,7 @@ async def format_user_card( # Panel info if user.panel_user_uuid: card_parts.append( - f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}" + f"{_('admin_user_panel_uuid_label')} {hcode(user.panel_user_uuid[:8] + '...' if len(user.panel_user_uuid) > 8 else user.panel_user_uuid)}" # noqa: E501 ) card_parts.append("") # Empty line @@ -371,12 +371,12 @@ async def format_user_card( premium_bonus_bytes = int(subscription_details.get("premium_bonus_bytes") or 0) if premium_unlimited: card_parts.append( - f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_unlimited'))}" + f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_unlimited'))}" # noqa: E501 ) elif premium_bonus_bytes > 0: bonus_gb = premium_bonus_bytes / (1024**3) card_parts.append( - f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_bonus_value', gb=f'{bonus_gb:.2f}'))}" + f"{_('admin_user_premium_override_label')} {hcode(_('admin_user_premium_override_bonus_value', gb=f'{bonus_gb:.2f}'))}" # noqa: E501 ) else: card_parts.append( @@ -929,7 +929,6 @@ async def handle_toggle_ban( # Update on panel if user has panel UUID if user.panel_user_uuid: - panel_status = "DISABLED" if new_ban_status else "ACTIVE" await panel_service.update_user_status_on_panel( user.panel_user_uuid, not new_ban_status ) @@ -1431,7 +1430,7 @@ async def process_direct_message_handler( await message.answer(_("admin_direct_empty_message")) return - caption_with_signature = (content.text + admin_signature) if content.text else None + (content.text + admin_signature) if content.text else None # Send to target user using our fancy match/case function try: diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 290bc73..b00eaa7 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -71,7 +71,7 @@ async def process_successful_payment( or (not payment_db_id_str and not auto_renew_subscription_id_str) ): logging.error( - f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" + f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501 ) return @@ -103,7 +103,7 @@ async def process_successful_payment( try: if not yk_payment_id_from_hook: logging.error( - "Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." + "Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501 ) return from db.dal import payment_dal as _payment_dal @@ -126,7 +126,7 @@ async def process_successful_payment( payment_db_id = payment_record.payment_id except Exception as e_ensure: logging.error( - f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", + f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501 exc_info=True, ) return @@ -140,14 +140,14 @@ async def process_successful_payment( if payment_record and payment_record.status == "succeeded": logging.info( - f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." + f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501 ) return db_user = await user_dal.get_user_by_id(session, user_id) if not db_user: logging.error( - f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." + f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501 ) await payment_dal.update_payment_status_by_db_id( @@ -260,7 +260,7 @@ async def process_successful_payment( if not activation_details or not activation_details.get("end_date"): logging.error( - f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" + f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501 ) raise Exception(f"Subscription Error: Failed to activate for user {user_id}") @@ -272,7 +272,7 @@ async def process_successful_payment( ) if not updated_payment_record: logging.error( - f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" + f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501 ) raise Exception(f"DB Error: Could not update payment record {payment_db_id}") @@ -403,7 +403,7 @@ async def process_successful_payment( ) else: logging.error( - f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." + f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501 ) details_message = _("payment_successful_error_details") @@ -455,7 +455,7 @@ async def process_successful_payment( except Exception as e_process: logging.error( - f"Error during process_successful_payment main try block for user {user_id}: {e_process}", + f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501 exc_info=True, ) @@ -496,11 +496,11 @@ async def process_cancelled_payment( if updated_payment: logging.info( - f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." + f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501 ) else: logging.warning( - f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." + f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501 ) db_user = await user_dal.get_user_by_id(session, user_id) @@ -513,7 +513,7 @@ async def process_cancelled_payment( except Exception as e_process_cancel: logging.error( - f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", + f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501 exc_info=True, ) raise @@ -547,7 +547,7 @@ async def yookassa_webhook_route(request: web.Request): logging.info( f"YooKassa Webhook Parsed: Event='{notification_object.event}', " - f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" + f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501 ) if ( @@ -556,7 +556,7 @@ async def yookassa_webhook_route(request: web.Request): or payment_data_from_notification.metadata is None ): logging.error( - f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." + f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501 ) return web.Response(status=200, text="ok_error_no_metadata") @@ -633,8 +633,8 @@ async def yookassa_webhook_route(request: web.Request): await session.commit() else: logging.warning( - f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " - f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " + f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501 + f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501 f"paid='{payment_dict_for_processing.get('paid')}'" ) elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: @@ -682,7 +682,7 @@ async def yookassa_webhook_route(request: web.Request): "yoo-money", "wallet", }: - # Normalize wallet display name to avoid leaking full account from title + # Normalize wallet display name to avoid leaking full account from title # noqa: E501 display_network = "YooMoney" if ( isinstance(account_number, str) @@ -767,7 +767,7 @@ async def yookassa_webhook_route(request: web.Request): except Exception: await session.rollback() logging.exception( - "Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", + "Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501 notification_object.event, payment_dict_for_processing.get("id"), ) diff --git a/bot/handlers/user/promo_user.py b/bot/handlers/user/promo_user.py index e64ac31..2996bee 100644 --- a/bot/handlers/user/promo_user.py +++ b/bot/handlers/user/promo_user.py @@ -95,7 +95,7 @@ async def process_promo_code_input( session: AsyncSession, ): logging.info( - f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" + f"Processing promo code input from user {message.from_user.id} in state {await state.get_state()}: '{message.text}'" # noqa: E501 ) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) @@ -122,7 +122,7 @@ async def process_promo_code_input( ): is_suspicious = True logging.warning( - f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" + f"Suspicious input for promo code by user {user.id} (len: {len(code_input)}): '{code_input}'" # noqa: E501 ) response_to_user_text = "" @@ -182,7 +182,7 @@ async def process_promo_code_input( ) await state.clear() logging.info( - f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." + f"Promo code input '{code_input}' processing finished for user {message.from_user.id}. State cleared." # noqa: E501 ) @@ -203,7 +203,7 @@ async def cancel_promo_input_via_button( return logging.info( - f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." + f"User {callback.from_user.id} cancelled promo code input via button from state {await state.get_state()}. Clearing state." # noqa: E501 ) await state.clear() diff --git a/bot/handlers/user/referral.py b/bot/handlers/user/referral.py index ff94698..3059e4e 100644 --- a/bot/handlers/user/referral.py +++ b/bot/handlers/user/referral.py @@ -29,7 +29,7 @@ async def referral_command_handler( target_message_obj = event.message if isinstance(event, types.CallbackQuery) else event if not target_message_obj: logging.error( - "Target message is None in referral_command_handler (possibly from callback without message)." + "Target message is None in referral_command_handler (possibly from callback without message)." # noqa: E501 ) if isinstance(event, types.CallbackQuery): await event.answer("Error displaying referral info.", show_alert=True) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index c03372a..7918e9d 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -115,7 +115,7 @@ async def send_main_menu( await safe_answer_callback(target_event) except Exception as e_send_edit: logging.warning( - f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." + f"Failed to send/edit main menu (user: {user_id}, is_edit: {is_edit}): {type(e_send_edit).__name__} - {e_send_edit}." # noqa: E501 ) if is_edit and target_message_obj: try: @@ -530,7 +530,7 @@ async def start_command_handler( return logging.info( - f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." + f"New user {user_id} added to session. Referred by: {referred_by_user_id or 'N/A'}." # noqa: E501 ) # Auto-grant referral welcome bonus to newly registered referred users. @@ -550,7 +550,7 @@ async def start_command_handler( if referral_bonus_end_date: await session.commit() logging.info( - "Referral welcome bonus applied: user %s got %s days, new end date %s.", + "Referral welcome bonus applied: user %s got %s days, new end date %s.", # noqa: E501 user_id, referral_welcome_days, referral_bonus_end_date.isoformat(), @@ -566,7 +566,7 @@ async def start_command_handler( else: await session.rollback() logging.warning( - "Referral welcome bonus was not applied for user %s (referred by %s).", + "Referral welcome bonus was not applied for user %s (referred by %s).", # noqa: E501 user_id, referred_by_user_id, ) @@ -708,7 +708,7 @@ async def start_command_handler( else: await session.commit() logging.warning( - f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}" + f"Failed to auto-apply promo code '{promo_code_to_apply}' for user {user_id}: {result}" # noqa: E501 ) await message.answer(str(result), parse_mode="HTML") # Continue to show main menu if promo failed @@ -916,7 +916,6 @@ async def main_action_callback_handler( session: AsyncSession, ): action = callback.data.split(":")[1] - user_id = callback.from_user.id if action in {"back_to_main", "back_to_main_keep", "bot_interface"}: await state.clear() diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index e027dd4..cb53d4a 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -329,7 +329,7 @@ async def tariff_topup_list_callback( carryover_lines = [] if rub_packages or premium_packages: carryover_lines.append( - "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." + "Докупленный трафик не сгорает: сначала расходуется месячный лимит, затем докупленный остаток." # noqa: E501 ) if int(active.get("premium_limit_bytes") or 0) > 0: premium_left = max( @@ -345,7 +345,7 @@ async def tariff_topup_list_callback( if len(labels) > len(visible): premium_lines.append(f"• ... еще {len(labels) - len(visible)}") premium_lines.append( - f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." + f"Premium использовано: {active.get('premium_used')} из {active.get('premium_limit')}. Осталось: {premium_left / 2**30:.2f} GB." # noqa: E501 ) text = get_text("choose_payment_method_traffic") if carryover_lines: @@ -647,7 +647,7 @@ async def tariff_change_confirm_apply_callback( ], ] await callback.message.edit_text( - f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}", + f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nИзменение: {action_text}", # noqa: E501 reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), ) await callback.answer() @@ -680,7 +680,7 @@ async def tariff_change_confirm_pay_callback( ], ] await callback.message.edit_text( - f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", + f"Подтвердите смену тарифа\n\nНовый тариф: {target.name(current_lang)}\nБудет создана оплата на {amount_raw} RUB.", # noqa: E501 reply_markup=InlineKeyboardMarkup(inline_keyboard=rows), ) await callback.answer() @@ -905,7 +905,7 @@ async def my_subscription_command_handler( f"Докупленный остаток: {premium_balance / 2**30:.2f} GB\n" "Отдельный лимит действует на:\n" f"{label_block}\n\n" - "Premium-докупка не сгорает: сначала расходуется месячный лимит premium-серверов, затем докупленный premium-трафик." + "Premium-докупка не сгорает: сначала расходуется месячный лимит premium-серверов, затем докупленный premium-трафик." # noqa: E501 ) base_markup = get_back_to_main_menu_markup( @@ -1028,7 +1028,7 @@ async def my_subscription_command_handler( [ InlineKeyboardButton( text=toggle_text, - callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}", + callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}", # noqa: E501 ) ] ) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 8470cf7..62c2e31 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -310,7 +310,7 @@ async def payment_method_view( last_tx = lp.created_at.strftime("%Y-%m-%d") except Exception: pass - details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501 await callback.message.edit_text( details, reply_markup=get_payment_method_details_keyboard( @@ -366,7 +366,7 @@ async def payment_method_view( return _("payment_method_generic_title", network=network_name) title = _format_pm_title(billing.card_network, billing.card_last4) - details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" + details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501 await callback.message.edit_text( details, reply_markup=get_payment_method_details_keyboard( diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 3ca8a73..ac68b05 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -145,7 +145,7 @@ async def pay_fk_callback_handler( except Exception as e_status: await session.rollback() logging.error( - f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", + f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501 exc_info=True, ) @@ -209,7 +209,7 @@ async def pay_fk_callback_handler( return logging.error( - "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", + "FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501 payment_record.payment_id, response_data, ) @@ -230,7 +230,7 @@ async def pay_fk_callback_handler( except Exception as e_status: await session.rollback() logging.error( - f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", + f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501 exc_info=True, ) diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 1639292..3de1392 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -179,7 +179,7 @@ async def pay_platega_callback_handler( except Exception as e_status: await session.rollback() logging.error( - f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", + f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501 exc_info=True, ) @@ -232,7 +232,7 @@ async def pay_platega_callback_handler( return logging.error( - "Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", + "Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501 payment_record.payment_id, response_data, ) @@ -247,7 +247,7 @@ async def pay_platega_callback_handler( except Exception as e_status: await session.rollback() logging.error( - f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", + f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501 exc_info=True, ) diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index c63218e..0326b99 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -138,7 +138,7 @@ async def pay_severpay_callback_handler( except Exception as e_status: await session.rollback() logging.error( - f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", + f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501 exc_info=True, ) @@ -207,7 +207,7 @@ async def pay_severpay_callback_handler( except Exception as e_status: await session.rollback() logging.error( - f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", + f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501 exc_info=True, ) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index 2b6826d..1e331a7 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -64,7 +64,7 @@ async def select_subscription_period_callback_handler( ) if currency_methods_enabled: logging.error( - "Currency price missing for traffic option %s while fiat providers are enabled.", + "Currency price missing for traffic option %s while fiat providers are enabled.", # noqa: E501 months, ) try: @@ -76,7 +76,7 @@ async def select_subscription_period_callback_handler( currency_symbol_val = "⭐" else: logging.error( - f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." + f"Price not found for option {months} using {'traffic_packages' if traffic_mode else 'subscription_options'}." # noqa: E501 ) try: await callback.answer(get_text("error_try_again"), show_alert=True) diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 0179628..d03659e 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -115,7 +115,7 @@ async def _initiate_yk_payment( db_payment_record = await payment_dal.create_payment_record(session, payment_record_data) await session.commit() logging.info( - f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." + f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501 ) except Exception as e_db_payment: await session.rollback() @@ -227,7 +227,7 @@ async def _initiate_yk_payment( except Exception as e_db_update_ykid: await session.rollback() logging.error( - f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", + f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501 exc_info=True, ) try: @@ -300,7 +300,7 @@ async def _initiate_yk_payment( except Exception as e_db_update_saved: await session.rollback() logging.error( - f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", + f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501 exc_info=True, ) try: @@ -334,11 +334,11 @@ async def _initiate_yk_payment( except Exception as e_db_fail_create: await session.rollback() logging.error( - f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", + f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501 exc_info=True, ) logging.error( - f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" + f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501 ) try: await callback.message.edit_text(get_text("error_payment_gateway")) diff --git a/bot/handlers/user/trial_handler.py b/bot/handlers/user/trial_handler.py index ac02ce9..078080f 100644 --- a/bot/handlers/user/trial_handler.py +++ b/bot/handlers/user/trial_handler.py @@ -40,10 +40,9 @@ async def request_trial_confirmation_handler( pass return - show_trial_btn_in_menu_if_fail = False if settings.TRIAL_ENABLED: if not await subscription_service.has_had_any_subscription(session, user_id): - show_trial_btn_in_menu_if_fail = True + pass if not settings.TRIAL_ENABLED: await callback.message.edit_text( diff --git a/bot/keyboards/inline/user_keyboards.py b/bot/keyboards/inline/user_keyboards.py index bf9d34e..1396a39 100644 --- a/bot/keyboards/inline/user_keyboards.py +++ b/bot/keyboards/inline/user_keyboards.py @@ -427,7 +427,7 @@ def get_yk_autopay_choice_keyboard( has_saved_cards: bool = True, sale_mode: str = "subscription", ) -> InlineKeyboardMarkup: - """Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" + """Keyboard for choosing between saved card charge or new card payment when auto-renew is enabled.""" # noqa: E501 _ = lambda key, **kwargs: i18n_instance.gettext(lang, key, **kwargs) builder = InlineKeyboardBuilder() price_str = str(price) diff --git a/bot/main_bot.py b/bot/main_bot.py index 19793ba..9c802ca 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -54,7 +54,7 @@ async def on_startup_configured(dispatcher: Dispatcher): try: current_webhook_info = await bot.get_webhook_info() logging.info( - f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" + f"STARTUP: Current Telegram webhook info BEFORE setting: {current_webhook_info.model_dump_json(exclude_none=True, indent=2)}" # noqa: E501 ) set_success = await bot.set_webhook( @@ -76,11 +76,11 @@ async def on_startup_configured(dispatcher: Dispatcher): new_webhook_info = await bot.get_webhook_info() logging.info( - f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}" + f"STARTUP: Telegram Webhook info AFTER setting: {new_webhook_info.model_dump_json(exclude_none=True, indent=2)}" # noqa: E501 ) if not new_webhook_info.url: logging.error( - "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." + "STARTUP: CRITICAL - Telegram Webhook URL is EMPTY after set attempt. Check bot token and URL validity." # noqa: E501 ) except Exception: @@ -144,11 +144,11 @@ async def on_startup_configured(dispatcher: Dispatcher): if sync_result.get("status") == "completed": logging.info( - f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}" + f"STARTUP: Automatic sync completed successfully. Details: {sync_result.get('details', 'N/A')}" # noqa: E501 ) else: logging.warning( - f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}" + f"STARTUP: Automatic sync completed with issues. Status: {sync_result.get('status', 'unknown')}" # noqa: E501 ) except Exception: @@ -234,7 +234,7 @@ async def run_bot(settings_param: Settings): logging.warning("Bot username is empty; Telegram Login Widget will be unavailable.") except Exception as e: logging.error( - f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" + f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}" # noqa: E501 ) services = build_core_services( diff --git a/bot/middlewares/action_logger_middleware.py b/bot/middlewares/action_logger_middleware.py index d9f4624..a104944 100644 --- a/bot/middlewares/action_logger_middleware.py +++ b/bot/middlewares/action_logger_middleware.py @@ -76,7 +76,7 @@ class ActionLoggerMiddleware(BaseMiddleware): user_exists = await user_dal.get_user_by_id(session, user_id) if not user_exists: logging.warning( - f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL." + f"ActionLoggerMiddleware: User {user_id} not found in DB. Logging action with user_id=NULL." # noqa: E501 ) log_user_id_for_db = None @@ -95,7 +95,7 @@ class ActionLoggerMiddleware(BaseMiddleware): await message_log_dal.create_message_log_no_commit(session, log_payload) except Exception as e_log: logging.error( - f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}", + f"ActionLoggerMiddleware: Failed to add log to session for user {user_id}, type {current_event_type}: {e_log}", # noqa: E501 exc_info=True, ) diff --git a/bot/middlewares/ban_check_middleware.py b/bot/middlewares/ban_check_middleware.py index 67c1cd3..af4ea19 100644 --- a/bot/middlewares/ban_check_middleware.py +++ b/bot/middlewares/ban_check_middleware.py @@ -49,7 +49,7 @@ class BanCheckMiddleware(BaseMiddleware): if db_user_model and db_user_model.is_banned: logging.info( - f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access." + f"User {event_user.id} ({event_user.username or 'NoUsername'}) is banned. Blocking access." # noqa: E501 ) i18n_data_from_event = data.get("i18n_data", {}) @@ -113,7 +113,7 @@ class BanCheckMiddleware(BaseMiddleware): logging.warning(f"BanCheck: Bot is blocked by user {event_user.id}.") except Exception as e_send: logging.error( - f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}", + f"BanCheck: Failed to notify banned user {event_user.id}: {type(e_send).__name__} - {e_send}", # noqa: E501 exc_info=True, ) diff --git a/bot/middlewares/channel_subscription.py b/bot/middlewares/channel_subscription.py index d403008..d6d52cc 100644 --- a/bot/middlewares/channel_subscription.py +++ b/bot/middlewares/channel_subscription.py @@ -19,7 +19,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): """ Blocks access to handlers for users who have not yet passed the required channel subscription check. The /start command is allowed through so that the handler can re-run the verification. - """ + """ # noqa: E501 def __init__(self, settings: Settings, i18n_instance: JsonI18n): super().__init__() @@ -126,7 +126,7 @@ class ChannelSubscriptionMiddleware(BaseMiddleware): await callback.message.answer(prompt_text, reply_markup=keyboard) except Exception as send_error: logging.error( - "ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", + "ChannelSubscriptionMiddleware: failed to send prompt for callback in chat %s: %s", # noqa: E501 callback.message.chat.id, send_error, exc_info=True, diff --git a/bot/middlewares/i18n.py b/bot/middlewares/i18n.py index 66ea589..a73f73d 100644 --- a/bot/middlewares/i18n.py +++ b/bot/middlewares/i18n.py @@ -19,7 +19,7 @@ class JsonI18n: self.locales_data: Dict[str, Dict[str, str]] = {} self._load_locales() logging.info( - f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" + f"JsonI18n initialized. Loaded languages: {list(self.locales_data.keys())}. Default: {self.default_lang}" # noqa: E501 ) def _load_locales(self): @@ -35,7 +35,7 @@ class JsonI18n: self.locales_data[lang_code] = json.load(f) except json.JSONDecodeError as e_json_load: logging.error( - f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" + f"Error loading locale {lang_code} from {file_path} (JSON Decode Error): {e_json_load}" # noqa: E501 ) except Exception as e_load: logging.error( @@ -66,7 +66,7 @@ class JsonI18n: except Exception: return text logging.warning( - f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." + f"No language data for '{effective_lang_code}' (default '{self.default_lang}' also missing). Key '{key}' will be returned as is." # noqa: E501 ) return key.format(**kwargs) if kwargs else key @@ -78,19 +78,19 @@ class JsonI18n: if text is None: logging.warning( - f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." + f"Translation key '{key}' not found for lang '{effective_lang_code}' or default '{self.default_lang}'. Returning key." # noqa: E501 ) return key.format(**kwargs) if kwargs else key try: return text.format(**kwargs) if kwargs else text except KeyError as e_format: logging.warning( - f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" + f"Missing format key '{e_format}' for i18n key '{key}' (lang: {effective_lang_code}). Original text: '{text}'" # noqa: E501 ) return text except Exception as e_general_format: logging.error( - f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", + f"General error formatting i18n key '{key}' (lang: {effective_lang_code}): {e_general_format}. Original text: '{text}'", # noqa: E501 exc_info=True, ) return text @@ -147,7 +147,7 @@ class I18nMiddleware(BaseMiddleware): current_language = event_user.language_code.lower() except Exception as e_db_lang: logging.error( - f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", + f"I18nMiddleware: Error fetching user lang from DB for {event_user.id}: {e_db_lang}. Falling back.", # noqa: E501 exc_info=True, ) if event_user.language_code: diff --git a/bot/middlewares/profile_sync.py b/bot/middlewares/profile_sync.py index 9627426..cb6dedf 100644 --- a/bot/middlewares/profile_sync.py +++ b/bot/middlewares/profile_sync.py @@ -43,7 +43,7 @@ class ProfileSyncMiddleware(BaseMiddleware): if update_payload: await user_dal.update_user(session, db_user.user_id, update_payload) logging.info( - f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" + f"ProfileSyncMiddleware: Updated user {tg_user.id} profile fields: {list(update_payload.keys())}" # noqa: E501 ) # Also update description on panel if linked @@ -72,11 +72,11 @@ class ProfileSyncMiddleware(BaseMiddleware): ) except Exception as e_upd_desc: logging.warning( - f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" + f"ProfileSyncMiddleware: Failed to update panel description for user {tg_user.id}: {e_upd_desc}" # noqa: E501 ) except Exception as e: logging.error( - f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", + f"ProfileSyncMiddleware: Failed to sync profile for user {getattr(tg_user, 'id', 'N/A')}: {e}", # noqa: E501 exc_info=True, ) diff --git a/bot/services/email_auth_service.py b/bot/services/email_auth_service.py index f6b4759..2e2d389 100644 --- a/bot/services/email_auth_service.py +++ b/bot/services/email_auth_service.py @@ -562,7 +562,7 @@ class EmailAuthService: log_level = logging.WARNING if attempt_number < len(attempts) else logging.ERROR logger.log( log_level, - "SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s", + "SMTP send attempt %s/%s failed for custom email via %s:%s (ssl=%s, starttls=%s): %s", # noqa: E501 attempt_number, len(attempts), smtp_host, diff --git a/bot/services/email_templates.py b/bot/services/email_templates.py index 000b18d..bca9a36 100644 --- a/bot/services/email_templates.py +++ b/bot/services/email_templates.py @@ -140,7 +140,7 @@ def _layout( -""" +""" # noqa: E501 def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: @@ -152,13 +152,13 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: border = "" if index == last else f"border-bottom:1px solid {_BORDER};" cells.append( f"" - f'{html.escape(label)}' - f"{html.escape(value)}" + f'{html.escape(label)}' # noqa: E501 + f"{html.escape(value)}" # noqa: E501 f"" ) return ( f'' + f'style="margin:0 0 16px 0;background:{_BG};border:1px solid {_BORDER};border-radius:14px;padding:6px 16px;">' # noqa: E501 + "".join(cells) + "
" ) @@ -167,14 +167,14 @@ def _info_rows_html(rows: Sequence[Tuple[str, str]]) -> str: def _cta_button_html(*, label: str, url: str, accent: str) -> str: safe_label = html.escape(label) safe_url = html.escape(url, quote=True) - # Accent green is light, so contrast text is dark; works for the default and similar light accents. + # Accent green is light, so contrast text is dark; works for the default and similar light accents. # noqa: E501 return ( f'' - f'
' + f'
' # noqa: E501 f'{safe_label}' f"
" ) @@ -228,9 +228,9 @@ def render_login_code( text_lines.append(_t_text(i18n, lang, "email_login_code_text_magic", url=safe_magic_link)) code_block = ( - f'' - f'
' - f"
" + f'' # noqa: E501 + f'
' # noqa: E501 + f"
" # noqa: E501 f"{html.escape(code)}" f"
" ) @@ -242,24 +242,24 @@ def render_login_code( magic_intro = _t_text(i18n, lang, "email_login_code_magic_intro") magic_hint = _t_text(i18n, lang, "email_login_code_magic_hint") divider_html = ( - f'' + f'
' # noqa: E501 f"" - f'' - f'' - f'' + f'' # noqa: E501 + f'' # noqa: E501 + f'' # noqa: E501 f"
 {html.escape(divider_label)}  {html.escape(divider_label)} 
" ) magic_block = ( divider_html - + f'

{html.escape(magic_intro)}

' + + f'

{html.escape(magic_intro)}

' # noqa: E501 + _cta_button_html(label=cta_label, url=safe_magic_link, accent=accent) - + f'

{html.escape(magic_hint)}

' + + f'

{html.escape(magic_hint)}

' # noqa: E501 ) body_html = ( code_block - + f'

{expiry_html}

' - + f'

{html.escape(security)}

' + + f'

{expiry_html}

' # noqa: E501 + + f'

{html.escape(security)}

' # noqa: E501 + magic_block ) @@ -312,7 +312,7 @@ def render_account_merged( ] body_html = ( _info_rows_html(rows) - + f'

{html.escape(note)}

' + + f'

{html.escape(note)}

' # noqa: E501 ) rendered = _layout( @@ -413,7 +413,7 @@ def render_payment_success( if safe_dashboard_url: body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) body_parts.append( - f'

{html.escape(footer_note)}

' + f'

{html.escape(footer_note)}

' # noqa: E501 ) rendered = _layout( @@ -476,7 +476,7 @@ def render_subscription_expiring( if safe_dashboard_url: body_parts.append(_cta_button_html(label=cta_label, url=safe_dashboard_url, accent=accent)) body_parts.append( - f'

{html.escape(note)}

' + f'

{html.escape(note)}

' # noqa: E501 ) rendered = _layout( diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index e015e86..4439326 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -63,7 +63,7 @@ class FreeKassaService: ) if settings.FREEKASSA_ENABLED and not self.server_ip: logging.warning( - "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." + "FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501 ) @staticmethod diff --git a/bot/services/panel_api_service.py b/bot/services/panel_api_service.py index 93287f1..21ba81e 100644 --- a/bot/services/panel_api_service.py +++ b/bot/services/panel_api_service.py @@ -106,15 +106,15 @@ class PanelApiService: parsed_json_for_log, indent=2, ensure_ascii=False ) logging.info( - f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}" + f"{log_prefix} {log_suffix} | Full Response Body:\n{pretty_response_text}" # noqa: E501 ) except json.JSONDecodeError: logging.info( - f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}" + f"{log_prefix} {log_suffix} | Full Response Text (not JSON):\n{response_text[:2000]}{'...' if len(response_text) > 2000 else ''}" # noqa: E501 ) else: logging.debug( - f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}" + f"{log_prefix} {log_suffix} | OK. Response Body Preview: {response_text[:200]}{'...' if len(response_text) > 200 else ''}" # noqa: E501 ) if 200 <= response_status < 300: @@ -130,7 +130,7 @@ class PanelApiService: } except json.JSONDecodeError as e_json_ok: logging.error( - f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above." + f"{log_prefix} {log_suffix} | OK but JSON Parse Error. Error: {e_json_ok}. Body was logged above." # noqa: E501 ) return { "status": "success_parse_error", @@ -179,7 +179,7 @@ class PanelApiService: if not response_data or response_data.get("error"): logging.error( - f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}" + f"Failed to fetch panel users batch (start: {start_offset}). Response: {response_data}" # noqa: E501 ) return None users_batch = response_data.get("response", {}).get("users", []) @@ -289,7 +289,7 @@ class PanelApiService: return [] logging.error( - f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}" + f"Failed to fetch panel users with filter ({filter_used_log}). Last API response: {response_data if not log_response else '(logged above)'}" # noqa: E501 ) return None @@ -345,7 +345,7 @@ class PanelApiService: payload["hwidDeviceLimit"] = hwid_limit_int except (TypeError, ValueError): logging.warning( - f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." + f"Ignoring invalid HWID device limit '{hwid_limit_value}' while creating panel user '{username_on_panel}'." # noqa: E501 ) if specific_squad_uuids: payload["activeInternalSquads"] = specific_squad_uuids @@ -365,17 +365,17 @@ class PanelApiService: ) if response and not response.get("error") and "response" in response: logging.info( - f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})." + f"Panel user '{username_on_panel}' created successfully (UUID: {response.get('response', {}).get('uuid')})." # noqa: E501 ) return response logging.error( - f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" + f"Failed to create panel user '{username_on_panel}'. Payload: {payload}, Response: {response if not log_response else '(full response logged above)'}" # noqa: E501 ) return response async def update_user_details_on_panel( - self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = True + self, user_uuid: str, update_payload: Dict[str, Any], log_response: bool = False ) -> Optional[Dict[str, Any]]: if "uuid" not in update_payload: update_payload["uuid"] = user_uuid @@ -384,11 +384,11 @@ class PanelApiService: "PATCH", "/users", json=update_payload, log_full_response=log_response ) if full_response and not full_response.get("error") and "response" in full_response: - logging.info(f"User {user_uuid} details updated on panel.") + logging.debug("User %s details updated on panel.", user_uuid) return full_response.get("response") logging.error( - f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" + f"Failed to update user {user_uuid} details on panel. Payload: {update_payload}, Response: {full_response if not log_response else '(logged above)'}" # noqa: E501 ) return None @@ -404,17 +404,17 @@ class PanelApiService: expected_status = "ACTIVE" if enable else "DISABLED" if actual_status == expected_status: logging.info( - f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})." + f"User {user_uuid} status on panel successfully set to {action} (Actual: {actual_status})." # noqa: E501 ) return True else: logging.warning( - f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'." + f"User {user_uuid} status on panel action '{action}' called, but final status is '{actual_status}'." # noqa: E501 ) return False logging.error( - f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}" + f"Failed to {action} user {user_uuid} on panel. Response: {response_data if not log_response else '(logged above)'}" # noqa: E501 ) return False @@ -434,7 +434,7 @@ class PanelApiService: error_code = details.get("errorCode") or response_data.get("errorCode") if error_code in {"A062", "A040"}: logging.info( - f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." + f"Panel user {user_uuid} already absent (errorCode {error_code}). Treating as deleted." # noqa: E501 ) return True logging.error(f"Failed to delete user {user_uuid} on panel. Response: {response_data}") @@ -469,7 +469,7 @@ class PanelApiService: if response_data and not response_data.get("error") and "response" in response_data: return True logging.error( - f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" + f"Failed to disconnect device {hwid} for user {user_uuid}. Payload: {payload}, Response: {response_data}" # noqa: E501 ) return False diff --git a/bot/services/panel_webhook_service.py b/bot/services/panel_webhook_service.py index c4de04c..30c7be3 100644 --- a/bot/services/panel_webhook_service.py +++ b/bot/services/panel_webhook_service.py @@ -106,7 +106,7 @@ class PanelWebhookService: ok = await subscription_service.charge_subscription_renewal( session, sub ) - # If initiation succeeded, suppress the 24h reminder by returning early + # If initiation succeeded, suppress the 24h reminder by returning early # noqa: E501 if ok: await session.commit() return diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 6275401..f8e23c2 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -61,7 +61,7 @@ class PlategaService: ) else: logging.info( - "PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", + "PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501 "ON" if settings.PLATEGA_SBP_ENABLED else "OFF", self.sbp_method, "ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF", @@ -205,7 +205,7 @@ class PlategaService: ) if incoming_amount != expected_amount: logging.warning( - "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", + "Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501 payment.payment_id, expected_amount, incoming_amount, diff --git a/bot/services/promo_code_service.py b/bot/services/promo_code_service.py index b67ccd1..3f5c3a2 100644 --- a/bot/services/promo_code_service.py +++ b/bot/services/promo_code_service.py @@ -119,7 +119,7 @@ class PromoCodeService: return True, new_end_date else: logging.error( - f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}" + f"Failed to record activation or increment usage for promo {promo_data.code} by user {user_id}" # noqa: E501 ) return False, _("error_applying_promo_bonus") else: diff --git a/bot/services/referral_service.py b/bot/services/referral_service.py index a382f4e..6138fd3 100644 --- a/bot/services/referral_service.py +++ b/bot/services/referral_service.py @@ -43,7 +43,7 @@ class ReferralService: referee_user_model = await user_dal.get_user_by_id(session, referee_user_id) if not referee_user_model or referee_user_model.referred_by_id is None: logging.debug( - f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." + f"User {referee_user_id} not referred or inviter ID missing. No referral bonuses." # noqa: E501 ) return {"referee_bonus_applied_days": None, "referee_new_end_date": None} @@ -58,7 +58,7 @@ class ReferralService: ) if succeeded_count and succeeded_count > 0: logging.info( - f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments." + f"Referral bonuses skipped for user {referee_user_id}: already has {succeeded_count} succeeded payments." # noqa: E501 ) return {"referee_bonus_applied_days": None, "referee_new_end_date": None} except Exception as e_cnt: @@ -74,7 +74,7 @@ class ReferralService: session, referee_user_id ): logging.info( - f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription." + f"Referral bonuses skipped for user {referee_user_id}: user currently has an active subscription." # noqa: E501 ) return {"referee_bonus_applied_days": None, "referee_new_end_date": None} except Exception as e_sub: @@ -104,7 +104,7 @@ class ReferralService: if inviter_bonus_days and inviter_bonus_days > 0: if not inviter_user_model: logging.warning( - f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus." + f"Inviter user {inviter_user_id} not found in local DB. Cannot apply inviter bonus." # noqa: E501 ) else: ( @@ -118,7 +118,7 @@ class ReferralService: if not inviter_panel_uuid: logging.warning( - f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel." + f"Failed to get/create panel link for inviter {inviter_user_id}. Cannot apply inviter bonus directly to panel." # noqa: E501 ) else: @@ -134,7 +134,7 @@ class ReferralService: if new_end_date_inviter: inviter_bonus_successfully_applied = True logging.info( - f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}." + f"Bonus of {inviter_bonus_days} days successfully applied/extended for inviter {inviter_user_id}." # noqa: E501 ) try: @@ -153,11 +153,11 @@ class ReferralService: ) except Exception as e_notify_inviter: logging.error( - f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" + f"Failed to send bonus notification to inviter {inviter_user_id}: {e_notify_inviter}" # noqa: E501 ) else: logging.info( - f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days." + f"Inviter {inviter_user_id} has no active sub to extend. Creating new bonus subscription for {inviter_bonus_days} days." # noqa: E501 ) bonus_start_date = datetime.now(timezone.utc) @@ -165,7 +165,7 @@ class ReferralService: if not inviter_panel_sub_link_id: logging.error( - f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch." + f"Cannot create bonus subscription for inviter {inviter_user_id}: panel_sub_link_id is missing even after link detail fetch." # noqa: E501 ) else: bonus_sub_payload = { @@ -188,7 +188,7 @@ class ReferralService: session, bonus_sub_payload ) - panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel( + panel_update_success = await self.subscription_service.panel_service.update_user_details_on_panel( # noqa: E501 inviter_panel_uuid, { "expireAt": bonus_end_date.isoformat( @@ -200,7 +200,7 @@ class ReferralService: if panel_update_success: inviter_bonus_successfully_applied = True logging.info( - f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}." + f"New bonus subscription for {inviter_bonus_days} days created for inviter {inviter_user_id}." # noqa: E501 ) inviter_lang = ( @@ -221,12 +221,12 @@ class ReferralService: ) else: logging.warning( - f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel." + f"Failed to update panel for new bonus subscription for inviter {inviter_user_id}. Local bonus sub created (ID: {bonus_sub.subscription_id}) but may not be active on panel." # noqa: E501 ) except Exception as e_create_bonus_sub: logging.error( - f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}", + f"Failed to create new bonus subscription for inviter {inviter_user_id}: {e_create_bonus_sub}", # noqa: E501 exc_info=True, ) @@ -243,11 +243,11 @@ class ReferralService: referee_final_end_date = new_end_date_referee referee_bonus_applied_days = referee_bonus_days logging.info( - f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}." + f"Bonus of {referee_bonus_days} days successfully applied to referee {referee_user_id}." # noqa: E501 ) else: logging.warning( - f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)." + f"Failed to apply referee bonus for {referee_user_id} (could not extend their new subscription)." # noqa: E501 ) return { diff --git a/bot/services/stars_service.py b/bot/services/stars_service.py index 11a9edf..715a548 100644 --- a/bot/services/stars_service.py +++ b/bot/services/stars_service.py @@ -74,7 +74,7 @@ class StarsService: title=description, description=description, payload=payload, - provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. + provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. # noqa: E501 currency="XTR", prices=prices, ) diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 9dfb295..7d32551 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -1,2684 +1,12 @@ -import logging -import math -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple +"""Compatibility facade for the subscription service.""" -from aiogram import Bot -from sqlalchemy.ext.asyncio import AsyncSession +from bot.services.subscription_service_impl import _runtime as _runtime +from bot.services.subscription_service_impl.core import SubscriptionService -from bot.middlewares.i18n import JsonI18n -from bot.utils.config_link import prepare_config_links -from bot.utils.date_utils import add_months, month_start -from config.settings import Settings -from config.tariffs_config import Tariff -from db.dal import ( - payment_dal, - promo_code_dal, - subscription_dal, - tariff_dal, - user_billing_dal, - user_dal, -) -from db.models import Subscription, User +for _name, _value in vars(_runtime).items(): + if not _name.startswith("__") and _name != "annotations": + globals()[_name] = _value -from .email_auth_service import EmailAuthService -from .email_templates import render_payment_success -from .panel_api_service import PanelApiService +SubscriptionService.__module__ = __name__ - -class SubscriptionService: - def __init__( - self, - settings: Settings, - panel_service: PanelApiService, - bot: Optional[Bot] = None, - i18n: Optional[JsonI18n] = None, - ): - self.settings = settings - self.panel_service = panel_service - self.bot = bot - self.i18n = i18n - self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {} - - @staticmethod - def gb_to_bytes(gb: float) -> int: - return int(float(gb) * (1024**3)) - - @staticmethod - def _far_future() -> datetime: - return datetime(2099, 1, 1, tzinfo=timezone.utc) - - def _parse_sale_mode_context( - self, - sale_mode: str, - explicit_tariff_key: Optional[str] = None, - ) -> Tuple[str, Optional[str]]: - mode = (sale_mode or "subscription").strip() - tariff_key = explicit_tariff_key - for separator in ("@", "|"): - if separator in mode: - base, suffix = mode.split(separator, 1) - mode = base or mode - tariff_key = tariff_key or suffix or None - break - return mode, tariff_key - - def _tariffs_config(self): - return getattr(self.settings, "tariffs_config", None) - - def _default_tariff(self) -> Optional[Tariff]: - config = self._tariffs_config() - return config.default if config else None - - def _resolve_tariff( - self, tariff_key: Optional[str], billing_model: Optional[str] = None - ) -> Optional[Tariff]: - config = self._tariffs_config() - if not config: - return None - tariff = config.require(tariff_key or config.default_tariff) - if billing_model and tariff.billing_model != billing_model: - raise ValueError( - f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}" - ) - return tariff - - def _panel_squads_for_tariff( - self, - tariff: Optional[Tariff], - *, - include_premium: bool = True, - ) -> Optional[List[str]]: - if tariff: - squads = list(tariff.squad_uuids or []) - if include_premium: - squads.extend(tariff.premium_squad_uuids or []) - return list(dict.fromkeys(squads)) - return self.settings.parsed_user_squad_uuids - - def _traffic_limit_for_period_tariff( - self, - tariff: Optional[Tariff], - topup_balance_bytes: int = 0, - regular_bonus_bytes: int = 0, - regular_unlimited_override: bool = False, - traffic_used_bytes: int = 0, - ) -> int: - if tariff: - baseline = int(tariff.monthly_bytes or 0) - else: - baseline = int(self.settings.user_traffic_limit_bytes) - return self._compute_main_traffic_limit_bytes( - tier_baseline_bytes=baseline, - topup_balance_bytes=topup_balance_bytes, - regular_bonus_bytes=regular_bonus_bytes, - regular_unlimited_override=regular_unlimited_override, - traffic_used_bytes=traffic_used_bytes, - ) - - def _premium_limit_for_tariff( - self, tariff: Optional[Tariff], topup_balance_bytes: int = 0 - ) -> int: - if not tariff: - return 0 - return int(tariff.premium_monthly_bytes + max(0, topup_balance_bytes)) - - @staticmethod - def _premium_effective_limit_bytes( - premium_baseline_bytes: int, - premium_topup_balance_bytes: int = 0, - premium_topup_used_bytes: int = 0, - premium_bonus_bytes: int = 0, - ) -> int: - return ( - int(premium_baseline_bytes or 0) - + max(0, int(premium_topup_balance_bytes or 0)) - + max(0, int(premium_topup_used_bytes or 0)) - + max(0, int(premium_bonus_bytes or 0)) - ) - - def _compute_main_traffic_limit_bytes( - self, - *, - tier_baseline_bytes: int, - topup_balance_bytes: int, - regular_bonus_bytes: int, - regular_unlimited_override: bool, - traffic_used_bytes: int, - ) -> int: - """Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" - floor = ( - int(tier_baseline_bytes or 0) - + max(0, int(topup_balance_bytes or 0)) - + max(0, int(regular_bonus_bytes or 0)) - ) - if regular_unlimited_override: - used = max(0, int(traffic_used_bytes or 0)) - return max(floor, used + 512 * (1024**3), 1024**5) - return floor - - async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]: - if not tariff or not tariff.premium_squad_uuids: - return {"squad_uuids": [], "squad_labels": [], "node_labels": []} - - cache_key = tuple(sorted(str(uuid) for uuid in tariff.premium_squad_uuids)) - now_ts = datetime.now(timezone.utc).timestamp() - cached = self._premium_access_cache.get(cache_key) - if cached and now_ts - float(cached.get("ts", 0)) < 600: - return { - "squad_uuids": list(cached.get("squad_uuids") or []), - "squad_labels": list(cached.get("squad_labels") or []), - "node_labels": list(cached.get("node_labels") or []), - } - - def _extract_inbound_uuids(squad_obj: Dict[str, Any]) -> List[str]: - collected: List[str] = [] - for field in ("inbounds", "internalInbounds", "configProfileInbounds"): - value = squad_obj.get(field) - if not isinstance(value, list): - continue - for inbound in value: - if isinstance(inbound, dict): - ib_uuid = str( - inbound.get("uuid") - or inbound.get("inboundUuid") - or inbound.get("id") - or "" - ) - else: - ib_uuid = str(inbound or "") - if ib_uuid: - collected.append(ib_uuid) - return collected - - squad_name_map: Dict[str, str] = {} - squad_inbound_map: Dict[str, List[str]] = {} - try: - squads = await self.panel_service.get_internal_squads() or [] - for squad in squads: - if not isinstance(squad, dict): - continue - squad_uuid = str(squad.get("uuid") or squad.get("id") or "") - if not squad_uuid: - continue - squad_name_map[squad_uuid] = str( - squad.get("name") or squad.get("title") or squad_uuid - ) - squad_inbound_map[squad_uuid] = _extract_inbound_uuids(squad) - except Exception: - logging.debug("Failed to load internal squad names for premium display", exc_info=True) - - for squad_uuid in tariff.premium_squad_uuids: - squad_uuid_str = str(squad_uuid) - if squad_inbound_map.get(squad_uuid_str): - continue - try: - detail = await self.panel_service.get_internal_squad(squad_uuid_str) - except Exception: - logging.debug( - "Failed to load internal squad detail for %s", squad_uuid_str, exc_info=True - ) - detail = None - if isinstance(detail, dict): - squad_inbound_map[squad_uuid_str] = _extract_inbound_uuids(detail) - if squad_uuid_str not in squad_name_map: - squad_name_map[squad_uuid_str] = str( - detail.get("name") or detail.get("title") or squad_uuid_str - ) - - hosts_by_inbound: Dict[str, List[Dict[str, Any]]] = {} - try: - hosts = await self.panel_service.get_hosts() or [] - for host in hosts: - if not isinstance(host, dict): - continue - inbound_field = host.get("inbound") if isinstance(host.get("inbound"), dict) else {} - inbound_uuid = ( - host.get("inboundUuid") - or host.get("inbound_uuid") - or host.get("configProfileInboundUuid") - or inbound_field.get("configProfileInboundUuid") - or inbound_field.get("inboundUuid") - or inbound_field.get("uuid") - or "" - ) - inbound_uuid = str(inbound_uuid) - if not inbound_uuid: - continue - hosts_by_inbound.setdefault(inbound_uuid, []).append(host) - logging.debug( - "Premium label resolution: %d hosts grouped across %d inbounds; squad inbound map: %s", - len(hosts), - len(hosts_by_inbound), - {k: len(v) for k, v in squad_inbound_map.items()}, - ) - except Exception: - logging.debug("Failed to load hosts for premium display", exc_info=True) - - def _host_remark(host: Dict[str, Any]) -> str: - for key in ("remark", "name", "label", "title"): - value = host.get(key) - if value is None: - continue - candidate = str(value).strip() - if candidate: - return candidate - return "" - - node_labels: List[str] = [] - for squad_uuid in tariff.premium_squad_uuids: - squad_uuid_str = str(squad_uuid) - inbound_uuids = squad_inbound_map.get(squad_uuid_str) or [] - host_labels_for_squad: List[str] = [] - for inbound_uuid in inbound_uuids: - for host in hosts_by_inbound.get(inbound_uuid, []): - remark = _host_remark(host) - if remark: - host_labels_for_squad.append(remark) - - if host_labels_for_squad: - node_labels.extend(host_labels_for_squad) - continue - - try: - nodes = ( - await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or [] - ) - except Exception: - logging.debug( - "Failed to load accessible nodes for premium squad %s", - squad_uuid, - exc_info=True, - ) - nodes = [] - for node in nodes: - if not isinstance(node, dict): - continue - node_uuid = str( - node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") or "" - ) - node_name = "" - for key in ( - "nodeName", - "name", - "nodeRemark", - "remark", - "label", - "title", - "address", - "host", - ): - value = node.get(key) - if value is None: - continue - candidate = str(value).strip() - if candidate: - node_name = candidate - break - if node_name: - label = node_name - elif node_uuid: - label = f"{node_uuid[:8]}..." - else: - continue - node_labels.append(label) - - squad_labels = [ - squad_name_map.get(str(uuid), f"{str(uuid)[:8]}...") - for uuid in tariff.premium_squad_uuids - ] - payload = { - "ts": now_ts, - "squad_uuids": list(tariff.premium_squad_uuids), - "squad_labels": list(dict.fromkeys(squad_labels)), - "node_labels": list(dict.fromkeys(node_labels)), - } - self._premium_access_cache[cache_key] = payload - return { - "squad_uuids": list(payload["squad_uuids"]), - "squad_labels": list(payload["squad_labels"]), - "node_labels": list(payload["node_labels"]), - } - - def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]: - if tariff and tariff.hwid_device_limit is not None: - return int(tariff.hwid_device_limit) - value = self.settings.USER_HWID_DEVICE_LIMIT - return int(value) if value is not None else None - - @staticmethod - def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: - if base_limit is None: - return None - base_int = max(0, int(base_limit)) - if base_int == 0: - return 0 - return base_int + max(0, int(extra_devices or 0)) - - async def _record_payment_context( - self, - session: AsyncSession, - payment_db_id: int, - *, - sale_mode: str, - tariff_key: Optional[str], - purchased_gb: Optional[float] = None, - purchased_hwid_devices: Optional[int] = None, - ) -> None: - payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) - if not payment: - return - payment.sale_mode = sale_mode - payment.tariff_key = tariff_key - payment.purchased_gb = purchased_gb - payment.purchased_hwid_devices = purchased_hwid_devices - await session.flush() - - async def get_user_language(self, session: AsyncSession, user_id: int) -> str: - user_record = await user_dal.get_user_by_id(session, user_id) - return ( - user_record.language_code - if user_record and user_record.language_code - else self.settings.DEFAULT_LANGUAGE - ) - - async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: - return await subscription_dal.has_any_subscription_for_user(session, user_id) - - async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: - """Return True if user currently has an active subscription (end_date in future).""" - try: - user_record = await user_dal.get_user_by_id(session, user_id) - if not user_record or not user_record.panel_user_uuid: - return False - active_sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, user_record.panel_user_uuid - ) - if not active_sub or not active_sub.end_date: - return False - from datetime import datetime, timezone - - return active_sub.is_active and active_sub.end_date > datetime.now(timezone.utc) - except Exception: - return False - - def _extract_panel_traffic_details( - self, panel_user_data: Dict[str, Any] - ) -> Tuple[Optional[int], Optional[int], Optional[str]]: - traffic_stats = panel_user_data.get("userTraffic") or {} - used = traffic_stats.get("usedTrafficBytes") - if used is None: - used = panel_user_data.get("usedTrafficBytes") - limit = panel_user_data.get("trafficLimitBytes") - strategy = panel_user_data.get("trafficLimitStrategy") - if strategy is None: - strategy = traffic_stats.get("trafficLimitStrategy") - return used, limit, strategy - - def _extract_lifetime_used_traffic(self, panel_user_data: Dict[str, Any]) -> Optional[int]: - traffic_stats = panel_user_data.get("userTraffic") or {} - lifetime = traffic_stats.get("lifetimeUsedTrafficBytes") - if lifetime is None: - lifetime = panel_user_data.get("lifetimeUsedTrafficBytes") - try: - if lifetime is None: - return None - return int(lifetime) - except (TypeError, ValueError): - return None - - async def _notify_admin_panel_user_creation_failed(self, user_id: int): - if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: - return - admin_lang = self.settings.DEFAULT_LANGUAGE - _adm = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) - msg = _adm("admin_panel_user_creation_failed", user_id=user_id) - for admin_id in self.settings.ADMIN_IDS: - try: - await self.bot.send_message(admin_id, msg) - except Exception as e: - logging.error( - f"Failed to notify admin {admin_id} about panel user creation failure: {e}" - ) - - def _telegram_id_for_panel(self, db_user: User) -> Optional[int]: - if db_user.telegram_id: - return int(db_user.telegram_id) - if db_user.user_id and int(db_user.user_id) > 0: - return int(db_user.user_id) - return None - - async def _panel_username_for_user(self, session: AsyncSession, db_user: User) -> str: - telegram_id = self._telegram_id_for_panel(db_user) - if telegram_id and int(db_user.user_id) == telegram_id: - return f"tg_{telegram_id}" - referral_code = await user_dal.ensure_referral_code(session, db_user) - return f"em_{referral_code}" - - def _panel_description_for_user(self, db_user: User) -> str: - lines = [ - db_user.email or "", - db_user.username or "", - db_user.first_name or "", - db_user.last_name or "", - ] - return "\n".join(line for line in lines if line).strip() - - def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "description": self._panel_description_for_user(db_user), - } - telegram_id = self._telegram_id_for_panel(db_user) - if telegram_id: - payload["telegramId"] = telegram_id - if db_user.email: - payload["email"] = db_user.email - return payload - - async def _get_or_create_panel_user_link_details( - self, session: AsyncSession, user_id: int, db_user: Optional[User] = None - ) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: - if not db_user: - db_user = await user_dal.get_user_by_id(session, user_id) - - if not db_user: - logging.error( - f"_get_or_create_panel_user_link_details: User {user_id} not found in local DB. Cannot proceed." - ) - return None, None, None, False - - current_local_panel_uuid = db_user.panel_user_uuid - panel_username_on_panel_standard = await self._panel_username_for_user(session, db_user) - telegram_id_for_panel = self._telegram_id_for_panel(db_user) - - panel_user_obj_from_api = None - panel_user_created_or_linked_now = False - - panel_users_by_tg_id_list = None - if telegram_id_for_panel: - panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter( - telegram_id=telegram_id_for_panel - ) - if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1: - panel_user_obj_from_api = panel_users_by_tg_id_list[0] - logging.info( - f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" - ) - elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1: - logging.error( - f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed." - ) - return None, None, None, False - - if not panel_user_obj_from_api and db_user.email: - panel_users_by_email_list = await self.panel_service.get_users_by_filter( - email=db_user.email - ) - if panel_users_by_email_list and len(panel_users_by_email_list) == 1: - panel_user_obj_from_api = panel_users_by_email_list[0] - logging.info( - f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" - ) - elif panel_users_by_email_list and len(panel_users_by_email_list) > 1: - logging.error( - f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed." - ) - return None, None, None, False - - if not panel_user_obj_from_api: - if current_local_panel_uuid: - logging.info( - f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid." - ) - panel_user_obj_from_api = await self.panel_service.get_user_by_uuid( - current_local_panel_uuid - ) - if not panel_user_obj_from_api: - logging.warning( - f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced." - ) - logging.info( - f"Creating new panel user '{panel_username_on_panel_standard}' for TG user {user_id}." - ) - creation_response = await self.panel_service.create_panel_user( - username_on_panel=panel_username_on_panel_standard, - telegram_id=telegram_id_for_panel, - email=db_user.email, - description=self._panel_description_for_user(db_user), - specific_squad_uuids=self.settings.parsed_user_squad_uuids, - external_squad_uuid=self.settings.parsed_user_external_squad_uuid, - default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, - default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY, - ) - if ( - creation_response - and not creation_response.get("error") - and creation_response.get("response") - ): - panel_user_obj_from_api = creation_response.get("response") - panel_user_created_or_linked_now = True - else: - await self._notify_admin_panel_user_creation_failed(user_id) - return None, None, None, False - - else: - logging.info( - f"No panel user by TG ID & no local panel_uuid for TG user {user_id}. Creating new panel user '{panel_username_on_panel_standard}'." - ) - creation_response = await self.panel_service.create_panel_user( - username_on_panel=panel_username_on_panel_standard, - telegram_id=telegram_id_for_panel, - email=db_user.email, - description=self._panel_description_for_user(db_user), - specific_squad_uuids=self.settings.parsed_user_squad_uuids, - external_squad_uuid=self.settings.parsed_user_external_squad_uuid, - default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, - default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY, - ) - if ( - creation_response - and not creation_response.get("error") - and creation_response.get("response") - ): - panel_user_obj_from_api = creation_response.get("response") - panel_user_created_or_linked_now = True - - elif creation_response and creation_response.get("errorCode") == "A019": - logging.warning( - f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username." - ) - fetched_by_username_list = await self.panel_service.get_users_by_filter( - username=panel_username_on_panel_standard - ) - if fetched_by_username_list and len(fetched_by_username_list) == 1: - panel_user_obj_from_api = fetched_by_username_list[0] - - if not panel_user_obj_from_api: - logging.error( - f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}" - ) - await self._notify_admin_panel_user_creation_failed(user_id) - return None, None, None, False - - if not panel_user_obj_from_api: - logging.error( - f"Could not obtain panel user object for TG user {user_id} after all checks." - ) - - return ( - current_local_panel_uuid if current_local_panel_uuid else None, - None, - None, - panel_user_created_or_linked_now, - ) - - actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid") - panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId") - - if not actual_panel_uuid_from_api: - logging.error( - f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}" - ) - return ( - current_local_panel_uuid, - None, - None, - panel_user_created_or_linked_now, - ) - - needs_local_panel_uuid_update = False - if current_local_panel_uuid is None and actual_panel_uuid_from_api: - needs_local_panel_uuid_update = True - elif ( - current_local_panel_uuid is not None - and current_local_panel_uuid != actual_panel_uuid_from_api - ): - logging.warning( - f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') " - f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. " - f"Will attempt to update local to panel's version." - ) - needs_local_panel_uuid_update = True - - if needs_local_panel_uuid_update: - conflicting_user_record = await user_dal.get_user_by_panel_uuid( - session, actual_panel_uuid_from_api - ) - if conflicting_user_record and conflicting_user_record.user_id != user_id: - logging.error( - f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) " - f"is ALREADY LINKED in local DB to a different TG User {conflicting_user_record.user_id}. " - f"Cannot update panel_user_uuid for user {user_id}. Manual data correction needed." - ) - - return None, None, None, False - else: - update_data_for_local_user = {"panel_user_uuid": actual_panel_uuid_from_api} - - # Do not overwrite Telegram username with panel username. - # Only update the local linkage to panel UUID here. - await user_dal.update_user(session, user_id, update_data_for_local_user) - db_user.panel_user_uuid = actual_panel_uuid_from_api - panel_user_created_or_linked_now = True - current_local_panel_uuid = actual_panel_uuid_from_api - else: - pass - - panel_telegram_id_int = None - if panel_telegram_id_from_api is not None: - try: - panel_telegram_id_int = int(panel_telegram_id_from_api) - except ValueError: - pass - - if ( - panel_user_obj_from_api - and current_local_panel_uuid - and telegram_id_for_panel - and panel_telegram_id_int != telegram_id_for_panel - ): - logging.info( - f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'." - ) - await self.panel_service.update_user_details_on_panel( - current_local_panel_uuid, - self._panel_identity_payload_for_user(db_user), - ) - - panel_sub_link_id = panel_user_obj_from_api.get( - "subscriptionUuid" - ) or panel_user_obj_from_api.get("shortUuid") - panel_short_uuid = panel_user_obj_from_api.get("shortUuid") - - if not panel_sub_link_id and current_local_panel_uuid: - logging.warning( - f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})." - ) - - return ( - current_local_panel_uuid, - panel_sub_link_id, - panel_short_uuid, - panel_user_created_or_linked_now, - ) - - async def activate_trial_subscription( - self, session: AsyncSession, user_id: int - ) -> Optional[Dict[str, Any]]: - if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0: - return { - "eligible": False, - "activated": False, - "message_key": "trial_feature_disabled", - } - - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user: - logging.error(f"User {user_id} not found in DB, cannot activate trial.") - return { - "eligible": False, - "activated": False, - "message_key": "user_not_found_for_trial", - } - - if await self.has_had_any_subscription(session, user_id): - return { - "eligible": False, - "activated": False, - "message_key": "trial_already_had_subscription_or_trial", - } - - ( - panel_user_uuid, - panel_sub_link_id, - panel_short_uuid, - panel_user_created_now, - ) = await self._get_or_create_panel_user_link_details(session, user_id, db_user) - - if not panel_user_uuid or not panel_sub_link_id: - logging.error(f"Failed to get panel link details for trial user {user_id}.") - return { - "eligible": True, - "activated": False, - "message_key": "trial_activation_failed_panel_link", - } - - start_date = datetime.now(timezone.utc) - end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS) - - await subscription_dal.deactivate_other_active_subscriptions( - session, panel_user_uuid, panel_sub_link_id - ) - - trial_sub_data = { - "user_id": user_id, - "panel_user_uuid": panel_user_uuid, - "panel_subscription_uuid": panel_sub_link_id, - "start_date": start_date, - "end_date": end_date, - "duration_months": 0, - "is_active": True, - "status_from_panel": "TRIAL", - "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, - "traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY, - "auto_renew_enabled": False, - } - try: - await subscription_dal.upsert_subscription(session, trial_sub_data) - except Exception as e_upsert: - logging.error( - f"Failed to upsert trial subscription for user {user_id}: {e_upsert}", - exc_info=True, - ) - await session.rollback() - return { - "eligible": True, - "activated": False, - "message_key": "trial_activation_failed_db", - } - - panel_update_payload = self._build_panel_update_payload( - panel_user_uuid=panel_user_uuid, - expire_at=end_date, - status="ACTIVE", - traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, - traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY, - ) - - panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) - - updated_panel_user = await self.panel_service.update_user_details_on_panel( - panel_user_uuid, panel_update_payload - ) - if not updated_panel_user or updated_panel_user.get("error"): - logging.warning( - f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}" - ) - await session.rollback() - return { - "eligible": True, - "activated": False, - "message_key": "trial_activation_failed_panel_update", - } - - await session.commit() - - final_subscription_url = updated_panel_user.get("subscriptionUrl") - final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) - - return { - "eligible": True, - "activated": True, - "end_date": end_date, - "days": self.settings.TRIAL_DURATION_DAYS, - "traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB, - "panel_user_uuid": panel_user_uuid, - "panel_short_uuid": final_panel_short_uuid, - "subscription_url": final_subscription_url, - } - - async def _activate_traffic_package( - self, - session: AsyncSession, - user_id: int, - traffic_gb: float, - payment_amount: float, - payment_db_id: int, - provider: str = "yookassa", - tariff_key: Optional[str] = None, - sale_mode: str = "traffic", - ) -> Optional[Dict[str, Any]]: - """Activate or extend a traffic-based package instead of a time-based subscription.""" - tariff = self._resolve_tariff(tariff_key, "traffic") if self._tariffs_config() else None - await self._record_payment_context( - session, - payment_db_id, - sale_mode=sale_mode, - tariff_key=tariff.key if tariff else tariff_key, - purchased_gb=float(traffic_gb), - ) - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user: - logging.error("User %s not found for traffic package activation", user_id) - return None - - ( - panel_user_uuid, - panel_sub_link_id, - panel_short_uuid, - _, - ) = await self._get_or_create_panel_user_link_details(session, user_id, db_user) - - if not panel_user_uuid or not panel_sub_link_id: - logging.error( - "Failed to ensure panel linkage for user %s during traffic activation", user_id - ) - return None - - panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} - current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data) - - active_sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, panel_user_uuid - ) - if current_limit is None and active_sub: - current_limit = active_sub.traffic_limit_bytes - if current_used is None and active_sub: - current_used = active_sub.traffic_used_bytes - - purchase_bytes = self.gb_to_bytes(traffic_gb) - extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0) - base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) - effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) - remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0)) - new_balance = remaining_bytes + purchase_bytes - new_limit = int(current_used or 0) + new_balance - - start_date = datetime.now(timezone.utc) - # Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further. - far_future = self._far_future() - final_end_date = far_future - if active_sub and active_sub.end_date and active_sub.end_date > final_end_date: - final_end_date = active_sub.end_date - - await subscription_dal.deactivate_other_active_subscriptions( - session, panel_user_uuid, panel_sub_link_id - ) - - sub_payload = { - "user_id": user_id, - "panel_user_uuid": panel_user_uuid, - "panel_subscription_uuid": panel_sub_link_id, - "start_date": start_date, - "end_date": final_end_date, - "duration_months": 0, - "is_active": True, - "status_from_panel": "ACTIVE", - "traffic_limit_bytes": new_limit, - "traffic_used_bytes": current_used, - "provider": provider, - "skip_notifications": True, - "auto_renew_enabled": False, - "tariff_key": tariff.key if tariff else None, - "tier_baseline_bytes": 0, - "topup_balance_bytes": new_balance, - "premium_baseline_bytes": self._premium_limit_for_tariff(tariff, 0), - "premium_topup_balance_bytes": 0, - "premium_topup_used_bytes": 0, - "premium_used_bytes": 0, - "premium_is_limited": False, - "premium_period_start_at": None, - "period_start_at": None, - "is_throttled": False, - "effective_monthly_price_rub": None, - "hwid_device_limit": base_hwid_limit, - "extra_hwid_devices": extra_hwid_devices, - } - - try: - new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload) - except Exception as exc: - logging.error( - "Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True - ) - return None - - panel_update_payload = self._build_panel_update_payload( - panel_user_uuid=panel_user_uuid, - expire_at=final_end_date, - status="ACTIVE", - traffic_limit_bytes=new_limit, - traffic_limit_strategy="NO_RESET", - hwid_device_limit=effective_hwid_limit, - ) - if tariff: - panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(tariff) - - panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) - - updated_panel_user = await self.panel_service.update_user_details_on_panel( - panel_user_uuid, panel_update_payload - ) - if not updated_panel_user or updated_panel_user.get("error"): - logging.warning( - "Panel user details update FAILED for traffic package user %s. Response: %s", - panel_user_uuid, - updated_panel_user, - ) - return None - - final_subscription_url = updated_panel_user.get("subscriptionUrl") - final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) - await tariff_dal.create_traffic_topup( - session, - subscription_id=new_or_updated_sub.subscription_id, - payment_id=payment_db_id, - purchased_bytes=purchase_bytes, - kind="traffic_package", - ) - - await self._send_payment_success_email( - db_user=db_user, - sale_mode="traffic", - months=0, - traffic_gb=float(traffic_gb), - payment_amount=payment_amount, - end_date=None, - provider=provider, - ) - - return { - "subscription_id": new_or_updated_sub.subscription_id, - "end_date": final_end_date, - "is_active": True, - "panel_user_uuid": panel_user_uuid, - "panel_short_uuid": final_panel_short_uuid, - "subscription_url": final_subscription_url, - "applied_promo_bonus_days": 0, - "traffic_limit_bytes": new_limit, - "tariff_key": tariff.key if tariff else None, - } - - async def activate_topup( - self, - session: AsyncSession, - user_id: int, - tariff_key: str, - traffic_gb: float, - payment_amount: float, - payment_db_id: int, - provider: str = "yookassa", - ) -> Optional[Dict[str, Any]]: - tariff = self._resolve_tariff(tariff_key) - if tariff.billing_model == "traffic": - return await self._activate_traffic_package( - session=session, - user_id=user_id, - traffic_gb=traffic_gb, - payment_amount=payment_amount, - payment_db_id=payment_db_id, - provider=provider, - tariff_key=tariff.key, - sale_mode="traffic_package", - ) - - await self._record_payment_context( - session, - payment_db_id, - sale_mode="topup", - tariff_key=tariff.key, - purchased_gb=float(traffic_gb), - ) - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return None - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return None - - purchase_bytes = self.gb_to_bytes(traffic_gb) - new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes - baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes) - rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) - runl = bool(getattr(sub, "regular_unlimited_override", False)) - used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0) - new_limit = self._compute_main_traffic_limit_bytes( - tier_baseline_bytes=baseline, - topup_balance_bytes=new_topup_balance, - regular_bonus_bytes=rb, - regular_unlimited_override=runl, - traffic_used_bytes=used_for_lim, - ) - base_hwid_limit = ( - int(sub.hwid_device_limit) - if sub.hwid_device_limit is not None - else self._base_hwid_limit_for_tariff(tariff) - ) - effective_hwid_limit = self._effective_hwid_limit( - base_hwid_limit, - int(sub.extra_hwid_devices or 0), - ) - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "topup_balance_bytes": new_topup_balance, - "traffic_limit_bytes": new_limit, - "is_throttled": False, - "tariff_key": tariff.key, - "hwid_device_limit": base_hwid_limit, - }, - ) - panel_payload = self._build_panel_update_payload( - panel_user_uuid=db_user.panel_user_uuid, - expire_at=updated_sub.end_date, - status="ACTIVE", - traffic_limit_bytes=new_limit, - hwid_device_limit=effective_hwid_limit, - ) - panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( - tariff, - include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), - ) - panel_payload.update(self._panel_identity_payload_for_user(db_user)) - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, panel_payload - ) - await tariff_dal.create_traffic_topup( - session, - subscription_id=sub.subscription_id, - payment_id=payment_db_id, - purchased_bytes=purchase_bytes, - kind="topup", - ) - return { - "subscription_id": sub.subscription_id, - "traffic_limit_bytes": new_limit, - "topup_balance_bytes": new_topup_balance, - "tariff_key": tariff.key, - } - - async def activate_premium_topup( - self, - session: AsyncSession, - user_id: int, - tariff_key: str, - traffic_gb: float, - payment_amount: float, - payment_db_id: int, - provider: str = "yookassa", - ) -> Optional[Dict[str, Any]]: - tariff = self._resolve_tariff(tariff_key) - if not tariff or not tariff.premium_squad_uuids: - logging.error( - "Premium top-up requires a tariff with premium squads for user %s", user_id - ) - return None - - await self._record_payment_context( - session, - payment_db_id, - sale_mode="premium_topup", - tariff_key=tariff.key, - purchased_gb=float(traffic_gb), - ) - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return None - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return None - - purchase_bytes = self.gb_to_bytes(traffic_gb) - now = datetime.now(timezone.utc) - premium_period_start = month_start(now) - current_period_start = getattr(sub, "premium_period_start_at", None) - same_period = bool(current_period_start and current_period_start == premium_period_start) - previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0 - premium_used = int(sub.premium_used_bytes or 0) if same_period else 0 - premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) - premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) - premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes - overflow_to_cover = max( - 0, premium_used - premium_baseline - previous_topup_used - premium_bonus - ) - consume_now = min(premium_topup_balance, overflow_to_cover) - premium_topup_balance -= consume_now - premium_topup_used = previous_topup_used + consume_now - premium_limit = self._premium_effective_limit_bytes( - premium_baseline, - premium_topup_balance, - premium_topup_used, - premium_bonus, - ) - premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) - premium_is_limited = ( - not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit - ) - - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "premium_baseline_bytes": premium_baseline, - "premium_topup_balance_bytes": premium_topup_balance, - "premium_topup_used_bytes": premium_topup_used, - "premium_used_bytes": premium_used, - "premium_is_limited": premium_is_limited, - "premium_period_start_at": premium_period_start, - "tariff_key": tariff.key, - }, - ) - - panel_payload = { - "uuid": db_user.panel_user_uuid, - "activeInternalSquads": self._panel_squads_for_tariff( - tariff, - include_premium=not premium_is_limited, - ), - } - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, panel_payload - ) - await tariff_dal.create_traffic_topup( - session, - subscription_id=sub.subscription_id, - payment_id=payment_db_id, - purchased_bytes=purchase_bytes, - kind="premium_topup", - ) - return { - "subscription_id": sub.subscription_id, - "premium_limit_bytes": premium_limit, - "premium_topup_balance_bytes": premium_topup_balance, - "premium_topup_used_bytes": premium_topup_used, - "premium_is_limited": premium_is_limited, - "tariff_key": tariff.key, - } - - async def sync_premium_squad_access_to_panel( - self, - session: AsyncSession, - user_id: int, - ) -> None: - """Recompute premium quota flags from DB and push internal squads to Remnawave. - - Used when admin overrides change without going through the traffic worker - (Telegram/Web admin premium bonus / unlimited). - """ - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return - tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None - if not tariff or not getattr(tariff, "premium_squad_uuids", None): - return - - premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) - premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) - premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) - premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) - premium_used = int(sub.premium_used_bytes or 0) - premium_limit = self._premium_effective_limit_bytes( - premium_baseline, - premium_topup_balance, - premium_topup_used, - premium_bonus, - ) - premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) - premium_is_limited = ( - not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit - ) - - if bool(getattr(sub, "premium_is_limited", False)) != premium_is_limited: - await subscription_dal.update_subscription( - session, - sub.subscription_id, - {"premium_is_limited": premium_is_limited}, - ) - - squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited) - try: - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, - {"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads}, - log_response=False, - ) - except Exception: - logging.exception( - "sync_premium_squad_access_to_panel: failed to push squads for user %s", user_id - ) - - async def admin_grant_topup( - self, - session: AsyncSession, - user_id: int, - traffic_gb: float, - ) -> Optional[Dict[str, Any]]: - """Credit regular traffic to a user as if they purchased a top-up. - - Mirrors :meth:`activate_topup` but skips payment context and tariff - resolution: the grant simply increases ``topup_balance_bytes`` and - recomputes ``traffic_limit_bytes`` from the subscription's current - tier baseline. The audit row in ``traffic_topups`` is stored with - ``kind="admin_topup"`` and ``payment_id=NULL`` so reports stay clean. - """ - try: - gb_value = float(traffic_gb) - except (TypeError, ValueError): - logging.error("admin_grant_topup: invalid traffic_gb=%r", traffic_gb) - return None - if gb_value <= 0: - return None - - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return None - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return None - - tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None - purchase_bytes = self.gb_to_bytes(gb_value) - baseline_bytes = int( - sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0 - ) - new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes - rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) - runl = bool(getattr(sub, "regular_unlimited_override", False)) - used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0) - new_limit = self._compute_main_traffic_limit_bytes( - tier_baseline_bytes=baseline_bytes, - topup_balance_bytes=new_topup_balance, - regular_bonus_bytes=rb, - regular_unlimited_override=runl, - traffic_used_bytes=used_for_lim, - ) - base_hwid_limit = ( - int(sub.hwid_device_limit) - if sub.hwid_device_limit is not None - else self._base_hwid_limit_for_tariff(tariff) - ) - effective_hwid_limit = self._effective_hwid_limit( - base_hwid_limit, - int(sub.extra_hwid_devices or 0), - ) - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "topup_balance_bytes": new_topup_balance, - "traffic_limit_bytes": new_limit, - "is_throttled": False, - "hwid_device_limit": base_hwid_limit, - }, - ) - panel_payload = self._build_panel_update_payload( - panel_user_uuid=db_user.panel_user_uuid, - expire_at=updated_sub.end_date, - status="ACTIVE", - traffic_limit_bytes=new_limit, - hwid_device_limit=effective_hwid_limit, - ) - if tariff is not None: - panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( - tariff, - include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), - ) - panel_payload.update(self._panel_identity_payload_for_user(db_user)) - try: - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, panel_payload - ) - except Exception: - logging.exception("admin_grant_topup: failed to push panel update for user %s", user_id) - await tariff_dal.create_traffic_topup( - session, - subscription_id=sub.subscription_id, - payment_id=None, - purchased_bytes=purchase_bytes, - kind="admin_topup", - ) - return { - "subscription_id": sub.subscription_id, - "traffic_limit_bytes": new_limit, - "topup_balance_bytes": new_topup_balance, - "granted_bytes": purchase_bytes, - } - - async def sync_main_traffic_limit_to_panel( - self, - session: AsyncSession, - user_id: int, - ) -> None: - """Recompute main traffic limit from tier + topups + regular_bonus_bytes and push to panel.""" - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return - tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None - baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0) - rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) - runl = bool(getattr(sub, "regular_unlimited_override", False)) - used_now = int(getattr(sub, "traffic_used_bytes", 0) or 0) - new_limit = self._compute_main_traffic_limit_bytes( - tier_baseline_bytes=baseline, - topup_balance_bytes=int(sub.topup_balance_bytes or 0), - regular_bonus_bytes=rb, - regular_unlimited_override=runl, - traffic_used_bytes=used_now, - ) - sub.traffic_limit_bytes = new_limit - if runl: - sub.is_throttled = False - base_hwid_limit = ( - int(sub.hwid_device_limit) - if sub.hwid_device_limit is not None - else self._base_hwid_limit_for_tariff(tariff) - ) - effective_hwid_limit = self._effective_hwid_limit( - base_hwid_limit, - int(sub.extra_hwid_devices or 0), - ) - panel_payload = self._build_panel_update_payload( - panel_user_uuid=db_user.panel_user_uuid, - expire_at=sub.end_date, - status="ACTIVE", - traffic_limit_bytes=new_limit, - hwid_device_limit=effective_hwid_limit, - ) - if tariff is not None: - panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( - tariff, - include_premium=not bool(getattr(sub, "premium_is_limited", False)), - ) - panel_payload.update(self._panel_identity_payload_for_user(db_user)) - try: - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, panel_payload - ) - except Exception: - logging.exception("sync_main_traffic_limit_to_panel failed for user %s", user_id) - - async def admin_grant_premium_topup( - self, - session: AsyncSession, - user_id: int, - traffic_gb: float, - ) -> Optional[Dict[str, Any]]: - """Credit premium-squad traffic to a user as if they purchased a premium top-up. - - Mirrors :meth:`activate_premium_topup` but skips payment context. - Requires the user's current tariff to expose premium squads. The - balance is absorbed into ``premium_topup_balance_bytes`` (backfilling - any current overuse first), ``premium_is_limited`` is recomputed and, - if access becomes available again, the premium squads are returned to - the user on the panel. The audit row in ``traffic_topups`` is stored - with ``kind="admin_premium_topup"`` and ``payment_id=NULL``. - """ - try: - gb_value = float(traffic_gb) - except (TypeError, ValueError): - logging.error("admin_grant_premium_topup: invalid traffic_gb=%r", traffic_gb) - return None - if gb_value <= 0: - return None - - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return None - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return None - tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None - if not tariff or not tariff.premium_squad_uuids: - logging.error( - "admin_grant_premium_topup: tariff %s has no premium squads (user %s)", - getattr(tariff, "key", None), - user_id, - ) - return None - - purchase_bytes = self.gb_to_bytes(gb_value) - now = datetime.now(timezone.utc) - premium_period_start = month_start(now) - current_period_start = getattr(sub, "premium_period_start_at", None) - same_period = bool(current_period_start and current_period_start == premium_period_start) - previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0 - premium_used = int(sub.premium_used_bytes or 0) if same_period else 0 - premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) - premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) - premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes - overflow_to_cover = max( - 0, premium_used - premium_baseline - previous_topup_used - premium_bonus - ) - consume_now = min(premium_topup_balance, overflow_to_cover) - premium_topup_balance -= consume_now - premium_topup_used = previous_topup_used + consume_now - premium_limit = self._premium_effective_limit_bytes( - premium_baseline, - premium_topup_balance, - premium_topup_used, - premium_bonus, - ) - premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) - premium_is_limited = ( - not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit - ) - - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "premium_baseline_bytes": premium_baseline, - "premium_topup_balance_bytes": premium_topup_balance, - "premium_topup_used_bytes": premium_topup_used, - "premium_used_bytes": premium_used, - "premium_is_limited": premium_is_limited, - "premium_period_start_at": premium_period_start, - }, - ) - panel_payload = { - "uuid": db_user.panel_user_uuid, - "activeInternalSquads": self._panel_squads_for_tariff( - tariff, - include_premium=not premium_is_limited, - ), - } - try: - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, panel_payload - ) - except Exception: - logging.exception( - "admin_grant_premium_topup: failed to push panel update for user %s", - user_id, - ) - await tariff_dal.create_traffic_topup( - session, - subscription_id=sub.subscription_id, - payment_id=None, - purchased_bytes=purchase_bytes, - kind="admin_premium_topup", - ) - return { - "subscription_id": sub.subscription_id, - "premium_limit_bytes": premium_limit, - "premium_topup_balance_bytes": premium_topup_balance, - "premium_topup_used_bytes": premium_topup_used, - "premium_is_limited": premium_is_limited, - "granted_bytes": purchase_bytes, - } - - async def activate_hwid_device_topup( - self, - session: AsyncSession, - user_id: int, - device_count: int, - payment_amount: float, - payment_db_id: int, - provider: str = "yookassa", - tariff_key: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - try: - purchased_devices = int(device_count) - except (TypeError, ValueError): - purchased_devices = 0 - if purchased_devices <= 0: - logging.error("HWID device top-up requires positive device count for user %s", user_id) - return None - - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return None - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return None - - tariff = None - if self._tariffs_config(): - tariff = self._resolve_tariff(tariff_key or sub.tariff_key) - packages = ( - [*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars] - if tariff.hwid_device_packages - else [] - ) - if packages and not any(pkg.count == purchased_devices for pkg in packages): - logging.error( - "HWID device package %s is not available for tariff %s", - purchased_devices, - tariff.key, - ) - return None - - base_hwid_limit = ( - int(sub.hwid_device_limit) - if sub.hwid_device_limit is not None - else self._base_hwid_limit_for_tariff(tariff) - ) - if base_hwid_limit == 0: - logging.info( - "Skipping HWID top-up for user %s because current limit is unlimited", user_id - ) - return { - "subscription_id": sub.subscription_id, - "hwid_device_limit": 0, - "extra_hwid_devices": int(sub.extra_hwid_devices or 0), - "purchased_hwid_devices": 0, - } - - new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices - effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices) - await self._record_payment_context( - session, - payment_db_id, - sale_mode="hwid_devices", - tariff_key=tariff.key if tariff else sub.tariff_key, - purchased_hwid_devices=purchased_devices, - ) - updated_sub = await subscription_dal.update_subscription( - session, - sub.subscription_id, - { - "hwid_device_limit": base_hwid_limit, - "extra_hwid_devices": new_extra_devices, - "tariff_key": tariff.key if tariff else sub.tariff_key, - }, - ) - if not updated_sub: - return None - - panel_payload = self._build_panel_update_payload( - panel_user_uuid=db_user.panel_user_uuid, - expire_at=updated_sub.end_date, - status="ACTIVE", - hwid_device_limit=effective_hwid_limit, - ) - panel_payload.update(self._panel_identity_payload_for_user(db_user)) - updated_panel = await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, - panel_payload, - ) - if not updated_panel or updated_panel.get("error"): - logging.warning( - "Panel user HWID limit update failed for user %s. Response: %s", - user_id, - updated_panel, - ) - return None - - await tariff_dal.create_hwid_device_purchase( - session, - subscription_id=updated_sub.subscription_id, - payment_id=payment_db_id, - purchased_devices=purchased_devices, - ) - return { - "subscription_id": updated_sub.subscription_id, - "hwid_device_limit": effective_hwid_limit, - "extra_hwid_devices": new_extra_devices, - "purchased_hwid_devices": purchased_devices, - "tariff_key": tariff.key if tariff else sub.tariff_key, - } - - def calculate_tariff_switch_options( - self, sub: Subscription, target_tariff: Tariff - ) -> Dict[str, Any]: - current_tariff = ( - self._resolve_tariff(sub.tariff_key) if sub.tariff_key else self._default_tariff() - ) - now = datetime.now(timezone.utc) - remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0 - effective = float(sub.effective_monthly_price_rub or 0) - current_model = current_tariff.billing_model if current_tariff else "period" - - if current_model == "period" and target_tariff.billing_model == "period": - target_monthly = ( - target_tariff.period_price(1, "rub") - or target_tariff.min_period_price_rub() - or effective - or 1 - ) - remaining_value = remaining_days * (effective / 30) if effective else 0 - days_after = ( - math.floor((remaining_value / float(target_monthly)) * 30) - if target_monthly - else remaining_days - ) - paid_diff = ( - max(0, math.ceil((float(target_monthly) - effective) * remaining_days / 30)) - if effective - else 0 - ) - return { - "mode": "period_to_period", - "remaining_days": remaining_days, - "recalc_days": max(0, days_after), - "paid_diff_rub": paid_diff, - "target_monthly_rub": float(target_monthly), - } - - if current_model == "period" and target_tariff.billing_model == "traffic": - rub_per_gb = target_tariff.rub_per_gb_for_conversion() - remaining_value = remaining_days * (effective / 30) if effective else 0 - converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0 - return { - "mode": "period_to_traffic", - "remaining_days": remaining_days, - "converted_gb": max(0, converted_gb), - "rub_per_gb": rub_per_gb, - } - - return {"mode": "traffic_to_period", "remaining_days": remaining_days} - - async def switch_tariff_without_payment( - self, - session: AsyncSession, - user_id: int, - target_tariff_key: str, - mode: str, - ) -> Optional[Dict[str, Any]]: - config = self._tariffs_config() - if not config: - return None - target = config.require(target_tariff_key) - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - return None - sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, db_user.panel_user_uuid - ) - if not sub: - return None - before_tariff_key = sub.tariff_key - options = self.calculate_tariff_switch_options(sub, target) - now = datetime.now(timezone.utc) - premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) - premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) - premium_baseline = target.premium_monthly_bytes - premium_limit = self._premium_effective_limit_bytes( - premium_baseline, - premium_topup_balance, - premium_topup_used, - ) - premium_used = int(sub.premium_used_bytes or 0) - update_data: Dict[str, Any] = { - "tariff_key": target.key, - "is_throttled": False, - "premium_baseline_bytes": premium_baseline, - "premium_topup_balance_bytes": premium_topup_balance, - "premium_topup_used_bytes": premium_topup_used, - "premium_is_limited": bool(premium_limit > 0 and premium_used >= premium_limit), - } - converted_bytes = None - base_hwid_limit = self._base_hwid_limit_for_tariff(target) - extra_hwid_devices = int(sub.extra_hwid_devices or 0) - update_data["hwid_device_limit"] = base_hwid_limit - - if target.billing_model == "period": - update_data["tier_baseline_bytes"] = target.monthly_bytes - rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) - runl = bool(getattr(sub, "regular_unlimited_override", False)) - used_sub = int(sub.traffic_used_bytes or 0) - update_data["traffic_limit_bytes"] = self._compute_main_traffic_limit_bytes( - tier_baseline_bytes=target.monthly_bytes, - topup_balance_bytes=int(sub.topup_balance_bytes or 0), - regular_bonus_bytes=rb, - regular_unlimited_override=runl, - traffic_used_bytes=used_sub, - ) - update_data["period_start_at"] = None - update_data["effective_monthly_price_rub"] = ( - target.period_price(1, "rub") or target.min_period_price_rub() - ) - if mode == "recalc_days" and options.get("recalc_days") is not None: - update_data["end_date"] = now + timedelta(days=int(options["recalc_days"])) - else: - converted_gb = float(options.get("converted_gb", 0)) - converted_bytes = self.gb_to_bytes(converted_gb) - old_topup = int(sub.topup_balance_bytes or 0) - new_balance = old_topup + converted_bytes - rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) - runl = bool(getattr(sub, "regular_unlimited_override", False)) - panel_user = ( - await self.panel_service.get_user_by_uuid( - db_user.panel_user_uuid, log_response=False - ) - or {} - ) - current_used, _, _ = self._extract_panel_traffic_details(panel_user) - cur_used_int = int(current_used or 0) - update_data.update( - { - "end_date": self._far_future(), - "period_start_at": None, - "tier_baseline_bytes": 0, - "topup_balance_bytes": new_balance, - "traffic_limit_bytes": self._compute_main_traffic_limit_bytes( - tier_baseline_bytes=0, - topup_balance_bytes=new_balance, - regular_bonus_bytes=rb, - regular_unlimited_override=runl, - traffic_used_bytes=cur_used_int, - ), - "traffic_used_bytes": current_used, - "effective_monthly_price_rub": None, - "auto_renew_enabled": False, - "skip_notifications": True, - } - ) - - updated = await subscription_dal.update_subscription( - session, sub.subscription_id, update_data - ) - if not updated: - return None - panel_payload = self._build_panel_update_payload( - panel_user_uuid=db_user.panel_user_uuid, - expire_at=updated.end_date, - status="ACTIVE", - traffic_limit_bytes=updated.traffic_limit_bytes, - traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH", - hwid_device_limit=self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices), - ) - panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( - target, - include_premium=not bool(updated.premium_is_limited), - ) - panel_payload.update(self._panel_identity_payload_for_user(db_user)) - await self.panel_service.update_user_details_on_panel( - db_user.panel_user_uuid, panel_payload - ) - if converted_bytes: - await tariff_dal.create_traffic_topup( - session, - subscription_id=updated.subscription_id, - payment_id=None, - purchased_bytes=converted_bytes, - kind="conversion", - ) - await tariff_dal.create_tariff_change( - session, - { - "subscription_id": updated.subscription_id, - "from_tariff_key": before_tariff_key, - "to_tariff_key": target.key, - "mode": mode, - "payment_id": None, - "days_before": options.get("remaining_days"), - "days_after": (updated.end_date - now).days - if updated.end_date and target.billing_model == "period" - else None, - "converted_bytes": converted_bytes, - "eff_price_before": sub.effective_monthly_price_rub, - "eff_price_after": updated.effective_monthly_price_rub, - }, - ) - return {"subscription_id": updated.subscription_id, "tariff_key": target.key} - - async def activate_subscription( - self, - session: AsyncSession, - user_id: int, - months: int, - payment_amount: float, - payment_db_id: int, - promo_code_id_from_payment: Optional[int] = None, - provider: str = "yookassa", - sale_mode: str = "subscription", - traffic_gb: Optional[float] = None, - tariff_key: Optional[str] = None, - ) -> Optional[Dict[str, Any]]: - - sale_mode_base, sale_mode_tariff_key = self._parse_sale_mode_context(sale_mode, tariff_key) - tariff_key = sale_mode_tariff_key - if sale_mode_base in {"traffic", "traffic_package"} or ( - getattr(self.settings, "traffic_sale_mode", False) and not self._tariffs_config() - ): - target_gb = traffic_gb if traffic_gb is not None else float(months) - return await self._activate_traffic_package( - session=session, - user_id=user_id, - traffic_gb=target_gb, - payment_amount=payment_amount, - payment_db_id=payment_db_id, - provider=provider, - tariff_key=tariff_key, - sale_mode="traffic_package" if self._tariffs_config() else "traffic", - ) - if sale_mode_base == "topup": - if not tariff_key: - active_user = await user_dal.get_user_by_id(session, user_id) - active_sub = ( - await subscription_dal.get_active_subscription_by_user_id( - session, user_id, active_user.panel_user_uuid - ) - if active_user and active_user.panel_user_uuid - else None - ) - tariff_key = active_sub.tariff_key if active_sub else None - if not tariff_key: - logging.error("Top-up activation requires tariff_key for user %s", user_id) - return None - return await self.activate_topup( - session=session, - user_id=user_id, - tariff_key=tariff_key, - traffic_gb=traffic_gb if traffic_gb is not None else float(months), - payment_amount=payment_amount, - payment_db_id=payment_db_id, - provider=provider, - ) - if sale_mode_base == "premium_topup": - if not tariff_key: - active_user = await user_dal.get_user_by_id(session, user_id) - active_sub = ( - await subscription_dal.get_active_subscription_by_user_id( - session, user_id, active_user.panel_user_uuid - ) - if active_user and active_user.panel_user_uuid - else None - ) - tariff_key = active_sub.tariff_key if active_sub else None - if not tariff_key: - logging.error("Premium top-up activation requires tariff_key for user %s", user_id) - return None - return await self.activate_premium_topup( - session=session, - user_id=user_id, - tariff_key=tariff_key, - traffic_gb=traffic_gb if traffic_gb is not None else float(months), - payment_amount=payment_amount, - payment_db_id=payment_db_id, - provider=provider, - ) - if sale_mode_base in {"hwid_device", "hwid_devices"}: - target_devices = int(traffic_gb if traffic_gb is not None else months) - return await self.activate_hwid_device_topup( - session=session, - user_id=user_id, - device_count=target_devices, - payment_amount=payment_amount, - payment_db_id=payment_db_id, - provider=provider, - tariff_key=tariff_key, - ) - if sale_mode_base == "tariff_upgrade": - if not tariff_key: - logging.error("Tariff upgrade activation requires tariff_key for user %s", user_id) - return None - await self._record_payment_context( - session, - payment_db_id, - sale_mode="tariff_upgrade", - tariff_key=tariff_key, - purchased_gb=None, - ) - result = await self.switch_tariff_without_payment( - session, - user_id, - tariff_key, - "paid_diff", - ) - if result: - sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) - if sub: - await tariff_dal.create_tariff_change( - session, - { - "subscription_id": sub.subscription_id, - "from_tariff_key": None, - "to_tariff_key": tariff_key, - "mode": "paid_diff", - "payment_id": payment_db_id, - "days_before": None, - "days_after": (sub.end_date - datetime.now(timezone.utc)).days - if sub.end_date - else None, - "converted_bytes": None, - "eff_price_before": None, - "eff_price_after": sub.effective_monthly_price_rub, - }, - ) - result["end_date"] = sub.end_date - result["is_active"] = sub.is_active - return result - - tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None - await self._record_payment_context( - session, - payment_db_id, - sale_mode=sale_mode_base, - tariff_key=tariff.key if tariff else tariff_key, - purchased_gb=None, - ) - - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user: - logging.error(f"User {user_id} not found in DB for paid subscription activation.") - return None - - ( - panel_user_uuid, - panel_sub_link_id, - panel_short_uuid, - panel_user_created_now, - ) = await self._get_or_create_panel_user_link_details(session, user_id, db_user) - - if not panel_user_uuid or not panel_sub_link_id: - logging.error(f"Failed to ensure panel user for TG {user_id} during paid subscription.") - return None - - try: - months_int = int(months) - except Exception: - months_int = 1 - - current_active_sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, panel_user_uuid - ) - start_date = datetime.now(timezone.utc) - starts_after_lapse = not ( - current_active_sub - and current_active_sub.end_date - and current_active_sub.end_date > start_date - ) - if ( - current_active_sub - and current_active_sub.end_date - and current_active_sub.end_date > start_date - ): - start_date = current_active_sub.end_date - - # base duration by months - end_after_months = add_months(start_date, months_int) - duration_days_total = (end_after_months - start_date).days - applied_promo_bonus_days = 0 - - if promo_code_id_from_payment: - promo_model = await promo_code_dal.get_promo_code_by_id( - session, promo_code_id_from_payment - ) - if ( - promo_model - and promo_model.is_active - and promo_model.current_activations < promo_model.max_activations - ): - applied_promo_bonus_days = promo_model.bonus_days - duration_days_total += applied_promo_bonus_days - - activation = await promo_code_dal.record_promo_activation( - session, - promo_code_id_from_payment, - user_id, - payment_id=payment_db_id, - ) - if activation: - await promo_code_dal.increment_promo_code_usage( - session, promo_code_id_from_payment - ) - else: - logging.warning( - f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}." - ) - else: - logging.warning( - f"Promo code ID {promo_code_id_from_payment} (from payment) not found or invalid." - ) - promo_code_id_from_payment = None - - final_end_date = start_date + timedelta(days=duration_days_total) - await subscription_dal.deactivate_other_active_subscriptions( - session, panel_user_uuid, panel_sub_link_id - ) - - auto_renew_should_enable = False - if provider == "yookassa" and self.settings.yookassa_autopayments_active: - auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method( - session, user_id - ) - - topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0) - extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0) - premium_topup_balance_bytes = int( - getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0 - ) - premium_topup_used_bytes = int( - getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0 - ) - premium_used_bytes = int(getattr(current_active_sub, "premium_used_bytes", 0) or 0) - premium_period_start_at = getattr(current_active_sub, "premium_period_start_at", None) - tier_baseline_bytes = ( - tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes - ) - premium_baseline_bytes = tariff.premium_monthly_bytes if tariff else 0 - premium_limit_bytes = self._premium_effective_limit_bytes( - premium_baseline_bytes, - premium_topup_balance_bytes, - premium_topup_used_bytes, - ) - effective_monthly_price = float(payment_amount) / max(1, months_int) - regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) - regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) - traffic_limit_bytes = self._traffic_limit_for_period_tariff( - tariff, - topup_balance_bytes, - regular_bonus_carry, - regular_unlimited_override=regular_unl_carry, - traffic_used_bytes=0, - ) - base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) - effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) - premium_is_limited = bool( - premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes - ) - sub_payload = { - "user_id": user_id, - "panel_user_uuid": panel_user_uuid, - "panel_subscription_uuid": panel_sub_link_id, - "start_date": start_date, - "end_date": final_end_date, - "duration_months": months_int, - "is_active": True, - "status_from_panel": "ACTIVE", - "traffic_limit_bytes": traffic_limit_bytes, - "provider": provider, - "skip_notifications": False, - "auto_renew_enabled": auto_renew_should_enable, - "tariff_key": tariff.key if tariff else None, - "tier_baseline_bytes": tier_baseline_bytes, - "topup_balance_bytes": topup_balance_bytes, - "regular_bonus_bytes": regular_bonus_carry, - "regular_unlimited_override": regular_unl_carry, - "premium_baseline_bytes": premium_baseline_bytes, - "premium_topup_balance_bytes": premium_topup_balance_bytes, - "premium_topup_used_bytes": premium_topup_used_bytes, - "premium_used_bytes": premium_used_bytes, - "premium_is_limited": premium_is_limited, - "premium_period_start_at": premium_period_start_at, - "period_start_at": None, - "is_throttled": False, - "effective_monthly_price_rub": effective_monthly_price, - "hwid_device_limit": base_hwid_limit, - "extra_hwid_devices": extra_hwid_devices, - } - try: - new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload) - except Exception as e_upsert_sub: - logging.error( - f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}", - exc_info=True, - ) - return None - - panel_update_payload = self._build_panel_update_payload( - panel_user_uuid=panel_user_uuid, - expire_at=final_end_date, - status="ACTIVE", - traffic_limit_bytes=traffic_limit_bytes, - traffic_limit_strategy="MONTH" if tariff else self.settings.USER_TRAFFIC_STRATEGY, - hwid_device_limit=effective_hwid_limit, - ) - if tariff: - panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff( - tariff, - include_premium=not premium_is_limited, - ) - - panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) - - updated_panel_user = await self.panel_service.update_user_details_on_panel( - panel_user_uuid, panel_update_payload - ) - if not updated_panel_user or updated_panel_user.get("error"): - logging.warning( - f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}" - ) - return None - - final_subscription_url = updated_panel_user.get("subscriptionUrl") - final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) - - await self._send_payment_success_email( - db_user=db_user, - sale_mode="subscription", - months=months_int, - traffic_gb=None, - payment_amount=payment_amount, - end_date=final_end_date, - provider=provider, - ) - - return { - "subscription_id": new_or_updated_sub.subscription_id, - "end_date": final_end_date, - "is_active": True, - "panel_user_uuid": panel_user_uuid, - "panel_short_uuid": final_panel_short_uuid, - "subscription_url": final_subscription_url, - "applied_promo_bonus_days": applied_promo_bonus_days, - "tariff_key": tariff.key if tariff else None, - } - - async def extend_active_subscription_days( - self, - session: AsyncSession, - user_id: int, - bonus_days: int, - reason: str = "bonus", - ) -> Optional[datetime]: - reason_lower = (reason or "").lower() - apply_main_traffic_limit = any( - keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus") - ) - - user = await user_dal.get_user_by_id(session, user_id) - if not user: - logging.warning(f"Cannot extend subscription for user {user_id}: user not found.") - return None - - panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details( - session, user_id, user - ) - if not panel_uuid or not panel_sub_uuid: - logging.error( - f"Failed to ensure panel user for subscription extension of user {user_id}." - ) - return None - - active_sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, panel_uuid - ) - if not active_sub or not active_sub.end_date: - logging.info( - f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." - ) - start_date = datetime.now(timezone.utc) - new_end_date_obj = start_date + timedelta(days=bonus_days) - - # Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise - traffic_limit = ( - self.settings.user_traffic_limit_bytes - if apply_main_traffic_limit - else self.settings.trial_traffic_limit_bytes - ) - - bonus_sub_payload = { - "user_id": user_id, - "panel_user_uuid": panel_uuid, - "panel_subscription_uuid": panel_sub_uuid, - "start_date": start_date, - "end_date": new_end_date_obj, - "duration_months": 0, - "is_active": True, - "status_from_panel": "ACTIVE_BONUS", - "traffic_limit_bytes": traffic_limit, - "auto_renew_enabled": False, - } - await subscription_dal.deactivate_other_active_subscriptions( - session, panel_uuid, panel_sub_uuid - ) - updated_sub_model = await subscription_dal.upsert_subscription( - session, bonus_sub_payload - ) - else: - current_end_date = active_sub.end_date - now_utc = datetime.now(timezone.utc) - start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc - new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days) - - updated_sub_model = await subscription_dal.update_subscription_end_date( - session, active_sub.subscription_id, new_end_date_obj - ) - - if ( - apply_main_traffic_limit - and updated_sub_model - and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes - ): - updated_sub_model = await subscription_dal.update_subscription( - session, - updated_sub_model.subscription_id, - {"traffic_limit_bytes": self.settings.user_traffic_limit_bytes}, - ) - - if updated_sub_model: - # Prepare panel update payload - panel_update_payload = self._build_panel_update_payload( - expire_at=new_end_date_obj, - traffic_limit_bytes=( - self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None - ), - include_uuid=False, - include_default_squads=False, - ) - - panel_update_success = await self.panel_service.update_user_details_on_panel( - panel_uuid, - panel_update_payload, - ) - if not panel_update_success: - logging.warning( - f"Panel expiry update failed for {panel_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}." - ) - - logging.info( - f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date_obj}." - ) - return new_end_date_obj - else: - logging.error(f"Failed to update subscription end date locally for user {user_id}.") - return None - - async def get_active_subscription_details( - self, session: AsyncSession, user_id: int - ) -> Optional[Dict[str, Any]]: - db_user = await user_dal.get_user_by_id(session, user_id) - if not db_user or not db_user.panel_user_uuid: - logging.info( - f"User {user_id} not found in DB or no panel_user_uuid for 'my_subscription'." - ) - return None - - panel_user_uuid = db_user.panel_user_uuid - local_active_sub = await subscription_dal.get_active_subscription_by_user_id( - session, user_id, panel_user_uuid - ) - panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) - - if not panel_user_data: - logging.warning( - f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." - ) - await subscription_dal.deactivate_all_user_subscriptions(session, user_id) - await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) - return None - - panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data) - if ( - panel_lifetime_used is not None - and db_user.lifetime_used_traffic_bytes != panel_lifetime_used - ): - await user_dal.update_user( - session, - user_id, - {"lifetime_used_traffic_bytes": panel_lifetime_used}, - ) - - if local_active_sub: - update_payload_local = {} - panel_status = panel_user_data.get("status", "UNKNOWN").upper() - panel_expire_at_str = panel_user_data.get("expireAt") - panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details( - panel_user_data - ) - panel_sub_uuid_from_panel = panel_user_data.get( - "subscriptionUuid" - ) or panel_user_data.get("shortUuid") - - if local_active_sub.status_from_panel != panel_status: - update_payload_local["status_from_panel"] = panel_status - if panel_expire_at_str: - panel_expire_dt = datetime.fromisoformat(panel_expire_at_str.replace("Z", "+00:00")) - if local_active_sub.end_date.replace(microsecond=0) != panel_expire_dt.replace( - microsecond=0 - ): - update_payload_local["end_date"] = panel_expire_dt - update_payload_local["last_notification_sent"] = None - if ( - panel_traffic_used is not None - and local_active_sub.traffic_used_bytes != panel_traffic_used - ): - update_payload_local["traffic_used_bytes"] = panel_traffic_used - if ( - panel_traffic_limit is not None - and local_active_sub.traffic_limit_bytes != panel_traffic_limit - ): - update_payload_local["traffic_limit_bytes"] = panel_traffic_limit - if ( - panel_sub_uuid_from_panel - and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel - ): - update_payload_local["panel_subscription_uuid"] = panel_sub_uuid_from_panel - - is_active_based_on_panel = panel_status == "ACTIVE" and ( - panel_expire_dt > datetime.now(timezone.utc) if panel_expire_dt else False - ) - if local_active_sub.is_active != is_active_based_on_panel: - update_payload_local["is_active"] = is_active_based_on_panel - - if update_payload_local: - await subscription_dal.update_subscription( - session, local_active_sub.subscription_id, update_payload_local - ) - - panel_end_date = ( - datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00")) - if panel_user_data.get("expireAt") - else None - ) - panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = ( - self._extract_panel_traffic_details(panel_user_data) - ) - config_link_raw = panel_user_data.get("subscriptionUrl") - display_link, connect_button_url = await prepare_config_links( - self.settings, config_link_raw - ) - hwid_limit = panel_user_data.get("hwidDeviceLimit") - if hwid_limit is None: - if local_active_sub and local_active_sub.hwid_device_limit is not None: - hwid_limit = self._effective_hwid_limit( - local_active_sub.hwid_device_limit, - int(local_active_sub.extra_hwid_devices or 0), - ) - else: - hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT - tariff = None - if local_active_sub and local_active_sub.tariff_key and self._tariffs_config(): - try: - tariff = self._resolve_tariff(local_active_sub.tariff_key) - except Exception: - tariff = None - billing_model_display = ( - tariff.billing_model - if tariff - else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period") - ) - traffic_limit_strategy = panel_traffic_strategy - premium_access = ( - await self.premium_access_for_tariff(tariff) - if tariff - else { - "squad_uuids": [], - "squad_labels": [], - "node_labels": [], - } - ) - premium_baseline = ( - int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0 - ) - premium_topup_balance = ( - int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0 - ) - premium_topup_used = ( - int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0) - if local_active_sub - else 0 - ) - premium_bonus_bytes = ( - int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0 - ) - premium_unlimited_override = ( - bool(getattr(local_active_sub, "premium_unlimited_override", False)) - if local_active_sub - else False - ) - regular_bonus_bytes = ( - int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0 - ) - regular_unlimited_override = ( - bool(getattr(local_active_sub, "regular_unlimited_override", False)) - if local_active_sub - else False - ) - - return { - "user_id": panel_user_data.get("uuid"), - "end_date": panel_end_date, - "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), - "config_link": display_link, - "connect_button_url": connect_button_url, - "traffic_limit_bytes": panel_traffic_limit, - "traffic_used_bytes": panel_traffic_used, - "traffic_limit_strategy": traffic_limit_strategy, - "tariff_key": local_active_sub.tariff_key if local_active_sub else None, - "tariff_name": tariff.name(db_user.language_code or self.settings.DEFAULT_LANGUAGE) - if tariff - else None, - "tariff_description": tariff.description( - db_user.language_code or self.settings.DEFAULT_LANGUAGE - ) - if tariff - else None, - "premium_title": tariff.premium_name( - db_user.language_code or self.settings.DEFAULT_LANGUAGE - ) - if tariff - else None, - "billing_model": billing_model_display, - "tier_baseline_bytes": local_active_sub.tier_baseline_bytes - if local_active_sub - else None, - "topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0, - "regular_bonus_bytes": regular_bonus_bytes, - "regular_unlimited_override": regular_unlimited_override, - "premium_baseline_bytes": premium_baseline, - "premium_topup_balance_bytes": premium_topup_balance, - "premium_topup_used_bytes": premium_topup_used, - "premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0, - "premium_bonus_bytes": premium_bonus_bytes, - "premium_unlimited_override": premium_unlimited_override, - "premium_limit_bytes": self._premium_effective_limit_bytes( - premium_baseline, - premium_topup_balance, - premium_topup_used, - premium_bonus_bytes, - ), - "premium_is_limited": bool(local_active_sub.premium_is_limited) - if local_active_sub - else False, - "premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None) - if local_active_sub - else None, - "premium_squad_labels": premium_access.get("squad_labels") or [], - "premium_node_labels": premium_access.get("node_labels") or [], - "period_start_at": local_active_sub.period_start_at if local_active_sub else None, - "is_throttled": bool(local_active_sub.is_throttled) if local_active_sub else False, - "base_hwid_device_limit": local_active_sub.hwid_device_limit - if local_active_sub - else None, - "extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0) - if local_active_sub - else 0, - "user_bot_username": db_user.username, - "is_panel_data": True, - "max_devices": hwid_limit, - } - - async def get_subscriptions_ending_soon( - self, session: AsyncSession, days_threshold: int - ) -> List[Dict[str, Any]]: - subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration( - session, days_threshold - ) - results = [] - for sub_model in subs_models_with_users: - if sub_model.user and sub_model.end_date and not sub_model.skip_notifications: - days_left = (sub_model.end_date - datetime.now(timezone.utc)).total_seconds() / ( - 24 * 3600 - ) - results.append( - { - "user_id": sub_model.user_id, - "first_name": sub_model.user.first_name or f"User {sub_model.user_id}", - "language_code": sub_model.user.language_code - or self.settings.DEFAULT_LANGUAGE, - "end_date_str": sub_model.end_date.strftime("%Y-%m-%d"), - "days_left": max(0, int(round(days_left))), - "subscription_end_date_iso_for_update": sub_model.end_date, - } - ) - return results - - async def charge_subscription_renewal( - self, - session: AsyncSession, - sub: Subscription, - ) -> bool: - """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" - if getattr(self.settings, "traffic_sale_mode", False): - logging.info("Auto-renew skipped: traffic sale mode enabled") - return True - if not sub.auto_renew_enabled: - return True - # If autopayments are disabled globally, skip charging attempts - if not self.settings.yookassa_autopayments_active: - return True - if sub.provider != "yookassa": - logging.info( - "Auto-renew skipped: provider %s does not support auto-renew", sub.provider - ) - return True - - from db.dal.user_billing_dal import get_user_default_payment_method - - default_pm = await get_user_default_payment_method(session, sub.user_id) - if not default_pm: - logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}") - return False - - try: - from .yookassa_service import YooKassaService # local import to avoid cycles - - yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined] - except Exception: - yk = None # type: ignore - if not yk or not getattr(yk, "configured", False): - logging.warning("YooKassa unavailable for auto-renew") - return False - - months = sub.duration_months or 1 - amount = self.settings.subscription_options.get(months) - if not amount: - logging.error(f"Auto-renew price missing for {months} months") - return False - - metadata = { - "user_id": str(sub.user_id), - "auto_renew_for_subscription_id": str(sub.subscription_id), - "subscription_months": str(months), - } - resp = await yk.create_payment( - amount=float(amount), - currency="RUB", - description=f"Auto-renewal for {months} months", - metadata=metadata, - payment_method_id=default_pm.provider_payment_method_id, - save_payment_method=False, - capture=True, - ) - if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}: - logging.error(f"Auto-renew create_payment failed: {resp}") - return False - logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}") - return True - - _PROVIDER_LABELS = { - "yookassa": "YooKassa", - "freekassa": "FreeKassa", - "platega": "Platega", - "severpay": "SeverPay", - "cryptopay": "Crypto Pay", - "crypto_pay": "Crypto Pay", - "stars": "Telegram Stars", - "tribute": "Tribute", - } - - async def _send_payment_success_email( - self, - *, - db_user: User, - sale_mode: str, - months: int, - traffic_gb: Optional[float], - payment_amount: float, - end_date: Optional[datetime], - provider: str, - ) -> None: - """Best-effort branded email confirming the payment. No-op if SMTP or - the user's email aren't set. Failures are logged and swallowed so the - payment flow is never blocked by mail delivery.""" - if not self.settings.email_auth_configured: - return - recipient = (db_user.email or "").strip() if db_user else "" - if not recipient: - return - - end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" - provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) - dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None - - try: - content = render_payment_success( - self.settings, - language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE, - sale_mode=sale_mode, - months=int(months or 0), - traffic_gb=traffic_gb, - amount=float(payment_amount or 0), - currency=self.settings.DEFAULT_CURRENCY_SYMBOL, - end_date_text=end_date_text, - dashboard_url=dashboard_url, - provider_label=provider_label, - ) - email_service = EmailAuthService(self.settings) - await email_service.send_rendered_email(email=recipient, content=content) - except Exception: - logging.exception("Failed to send payment success email to user %s", db_user.user_id) - - async def update_last_notification_sent( - self, session: AsyncSession, user_id: int, subscription_end_date: datetime - ): - sub_to_update = await subscription_dal.find_subscription_for_notification_update( - session, user_id, subscription_end_date - ) - if sub_to_update: - await subscription_dal.update_subscription_notification_time( - session, sub_to_update.subscription_id, datetime.now(timezone.utc) - ) - logging.info( - f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}" - ) - else: - logging.warning( - f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time." - ) - - # Helpers - def _build_panel_update_payload( - self, - *, - panel_user_uuid: Optional[str] = None, - expire_at: Optional[datetime] = None, - status: Optional[str] = None, - traffic_limit_bytes: Optional[int] = None, - include_uuid: bool = True, - traffic_limit_strategy: Optional[str] = None, - hwid_device_limit: Optional[int] = None, - include_default_squads: bool = True, - ) -> Dict[str, Any]: - payload: Dict[str, Any] = {} - if include_uuid and panel_user_uuid: - payload["uuid"] = panel_user_uuid - if expire_at is not None: - payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace( - "+00:00", "Z" - ) - if status is not None: - payload["status"] = status - if traffic_limit_bytes is not None: - payload["trafficLimitBytes"] = traffic_limit_bytes - payload["trafficLimitStrategy"] = ( - traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY - ) - if hwid_device_limit is not None: - try: - hwid_limit_int = int(hwid_device_limit) - if hwid_limit_int >= 0: - payload["hwidDeviceLimit"] = hwid_limit_int - except (TypeError, ValueError): - pass - if include_default_squads: - if self.settings.parsed_user_squad_uuids: - payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids - if self.settings.parsed_user_external_squad_uuid: - payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid - return payload +__all__ = ["SubscriptionService"] diff --git a/bot/services/subscription_service_impl/__init__.py b/bot/services/subscription_service_impl/__init__.py new file mode 100644 index 0000000..4011091 --- /dev/null +++ b/bot/services/subscription_service_impl/__init__.py @@ -0,0 +1 @@ +"""Domain mixins for SubscriptionService.""" diff --git a/bot/services/subscription_service_impl/_runtime.py b/bot/services/subscription_service_impl/_runtime.py new file mode 100644 index 0000000..33ebab9 --- /dev/null +++ b/bot/services/subscription_service_impl/_runtime.py @@ -0,0 +1,29 @@ +# ruff: noqa: F401,F403,F405,I001 +import logging +import math +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional, Tuple + +from aiogram import Bot +from sqlalchemy.ext.asyncio import AsyncSession + +from bot.middlewares.i18n import JsonI18n +from bot.utils.config_link import prepare_config_links +from bot.utils.date_utils import add_months, month_start +from config.settings import Settings +from config.tariffs_config import Tariff +from db.dal import ( + payment_dal, + promo_code_dal, + subscription_dal, + tariff_dal, + user_billing_dal, + user_dal, +) +from db.models import Subscription, User + +from bot.services.email_auth_service import EmailAuthService +from bot.services.email_templates import render_payment_success +from bot.services.panel_api_service import PanelApiService + +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/bot/services/subscription_service_impl/core.py b/bot/services/subscription_service_impl/core.py new file mode 100644 index 0000000..e1a4264 --- /dev/null +++ b/bot/services/subscription_service_impl/core.py @@ -0,0 +1,34 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 +from .devices import HwidDeviceMixin +from .lifecycle import SubscriptionLifecycleMixin +from .panel_identity import PanelIdentityMixin +from .payments import PaymentContextMixin +from .renewal import RenewalMixin +from .tariffs import TariffMixin +from .traffic import TrafficMixin +from .trial import TrialSubscriptionMixin + + +class SubscriptionService( + TrialSubscriptionMixin, + TrafficMixin, + HwidDeviceMixin, + SubscriptionLifecycleMixin, + RenewalMixin, + PaymentContextMixin, + PanelIdentityMixin, + TariffMixin, +): + def __init__( + self, + settings: Settings, + panel_service: PanelApiService, + bot: Optional[Bot] = None, + i18n: Optional[JsonI18n] = None, + ): + self.settings = settings + self.panel_service = panel_service + self.bot = bot + self.i18n = i18n + self._premium_access_cache: Dict[Tuple[str, ...], Dict[str, Any]] = {} diff --git a/bot/services/subscription_service_impl/devices.py b/bot/services/subscription_service_impl/devices.py new file mode 100644 index 0000000..3673685 --- /dev/null +++ b/bot/services/subscription_service_impl/devices.py @@ -0,0 +1,117 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class HwidDeviceMixin: + async def activate_hwid_device_topup( + self, + session: AsyncSession, + user_id: int, + device_count: int, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + tariff_key: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + try: + purchased_devices = int(device_count) + except (TypeError, ValueError): + purchased_devices = 0 + if purchased_devices <= 0: + logging.error("HWID device top-up requires positive device count for user %s", user_id) + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = None + if self._tariffs_config(): + tariff = self._resolve_tariff(tariff_key or sub.tariff_key) + packages = ( + [*tariff.hwid_device_packages.rub, *tariff.hwid_device_packages.stars] + if tariff.hwid_device_packages + else [] + ) + if packages and not any(pkg.count == purchased_devices for pkg in packages): + logging.error( + "HWID device package %s is not available for tariff %s", + purchased_devices, + tariff.key, + ) + return None + + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + if base_hwid_limit == 0: + logging.info( + "Skipping HWID top-up for user %s because current limit is unlimited", user_id + ) + return { + "subscription_id": sub.subscription_id, + "hwid_device_limit": 0, + "extra_hwid_devices": int(sub.extra_hwid_devices or 0), + "purchased_hwid_devices": 0, + } + + new_extra_devices = int(sub.extra_hwid_devices or 0) + purchased_devices + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, new_extra_devices) + await self._record_payment_context( + session, + payment_db_id, + sale_mode="hwid_devices", + tariff_key=tariff.key if tariff else sub.tariff_key, + purchased_hwid_devices=purchased_devices, + ) + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": new_extra_devices, + "tariff_key": tariff.key if tariff else sub.tariff_key, + }, + ) + if not updated_sub: + return None + + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=updated_sub.end_date, + status="ACTIVE", + hwid_device_limit=effective_hwid_limit, + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + updated_panel = await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, + panel_payload, + ) + if not updated_panel or updated_panel.get("error"): + logging.warning( + "Panel user HWID limit update failed for user %s. Response: %s", + user_id, + updated_panel, + ) + return None + + await tariff_dal.create_hwid_device_purchase( + session, + subscription_id=updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_devices=purchased_devices, + ) + return { + "subscription_id": updated_sub.subscription_id, + "hwid_device_limit": effective_hwid_limit, + "extra_hwid_devices": new_extra_devices, + "purchased_hwid_devices": purchased_devices, + "tariff_key": tariff.key if tariff else sub.tariff_key, + } diff --git a/bot/services/subscription_service_impl/lifecycle.py b/bot/services/subscription_service_impl/lifecycle.py new file mode 100644 index 0000000..3f8102e --- /dev/null +++ b/bot/services/subscription_service_impl/lifecycle.py @@ -0,0 +1,838 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class SubscriptionLifecycleMixin: + async def switch_tariff_without_payment( + self, + session: AsyncSession, + user_id: int, + target_tariff_key: str, + mode: str, + ) -> Optional[Dict[str, Any]]: + config = self._tariffs_config() + if not config: + return None + target = config.require(target_tariff_key) + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + before_tariff_key = sub.tariff_key + options = self.calculate_tariff_switch_options(sub, target) + now = datetime.now(timezone.utc) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + premium_baseline = target.premium_monthly_bytes + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + ) + premium_used = int(sub.premium_used_bytes or 0) + update_data: Dict[str, Any] = { + "tariff_key": target.key, + "is_throttled": False, + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_is_limited": bool(premium_limit > 0 and premium_used >= premium_limit), + } + converted_bytes = None + base_hwid_limit = self._base_hwid_limit_for_tariff(target) + extra_hwid_devices = int(sub.extra_hwid_devices or 0) + update_data["hwid_device_limit"] = base_hwid_limit + + if target.billing_model == "period": + update_data["tier_baseline_bytes"] = target.monthly_bytes + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_sub = int(sub.traffic_used_bytes or 0) + update_data["traffic_limit_bytes"] = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=target.monthly_bytes, + topup_balance_bytes=int(sub.topup_balance_bytes or 0), + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_sub, + ) + update_data["period_start_at"] = None + update_data["effective_monthly_price_rub"] = ( + target.period_price(1, "rub") or target.min_period_price_rub() + ) + if mode == "recalc_days" and options.get("recalc_days") is not None: + update_data["end_date"] = now + timedelta(days=int(options["recalc_days"])) + else: + converted_gb = float(options.get("converted_gb", 0)) + converted_bytes = self.gb_to_bytes(converted_gb) + old_topup = int(sub.topup_balance_bytes or 0) + new_balance = old_topup + converted_bytes + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + panel_user = ( + await self.panel_service.get_user_by_uuid( + db_user.panel_user_uuid, log_response=False + ) + or {} + ) + current_used, _, _ = self._extract_panel_traffic_details(panel_user) + cur_used_int = int(current_used or 0) + update_data.update( + { + "end_date": self._far_future(), + "period_start_at": None, + "tier_baseline_bytes": 0, + "topup_balance_bytes": new_balance, + "traffic_limit_bytes": self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=0, + topup_balance_bytes=new_balance, + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=cur_used_int, + ), + "traffic_used_bytes": current_used, + "effective_monthly_price_rub": None, + "auto_renew_enabled": False, + "skip_notifications": True, + } + ) + + updated = await subscription_dal.update_subscription( + session, sub.subscription_id, update_data + ) + if not updated: + return None + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=updated.end_date, + status="ACTIVE", + traffic_limit_bytes=updated.traffic_limit_bytes, + traffic_limit_strategy="NO_RESET" if target.billing_model == "traffic" else "MONTH", + hwid_device_limit=self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices), + ) + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + target, + include_premium=not bool(updated.premium_is_limited), + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + if converted_bytes: + await tariff_dal.create_traffic_topup( + session, + subscription_id=updated.subscription_id, + payment_id=None, + purchased_bytes=converted_bytes, + kind="conversion", + ) + await tariff_dal.create_tariff_change( + session, + { + "subscription_id": updated.subscription_id, + "from_tariff_key": before_tariff_key, + "to_tariff_key": target.key, + "mode": mode, + "payment_id": None, + "days_before": options.get("remaining_days"), + "days_after": (updated.end_date - now).days + if updated.end_date and target.billing_model == "period" + else None, + "converted_bytes": converted_bytes, + "eff_price_before": sub.effective_monthly_price_rub, + "eff_price_after": updated.effective_monthly_price_rub, + }, + ) + return {"subscription_id": updated.subscription_id, "tariff_key": target.key} + + async def activate_subscription( + self, + session: AsyncSession, + user_id: int, + months: int, + payment_amount: float, + payment_db_id: int, + promo_code_id_from_payment: Optional[int] = None, + provider: str = "yookassa", + sale_mode: str = "subscription", + traffic_gb: Optional[float] = None, + tariff_key: Optional[str] = None, + ) -> Optional[Dict[str, Any]]: + + sale_mode_base, sale_mode_tariff_key = self._parse_sale_mode_context(sale_mode, tariff_key) + tariff_key = sale_mode_tariff_key + if sale_mode_base in {"traffic", "traffic_package"} or ( + getattr(self.settings, "traffic_sale_mode", False) and not self._tariffs_config() + ): + target_gb = traffic_gb if traffic_gb is not None else float(months) + return await self._activate_traffic_package( + session=session, + user_id=user_id, + traffic_gb=target_gb, + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + tariff_key=tariff_key, + sale_mode="traffic_package" if self._tariffs_config() else "traffic", + ) + if sale_mode_base == "topup": + if not tariff_key: + active_user = await user_dal.get_user_by_id(session, user_id) + active_sub = ( + await subscription_dal.get_active_subscription_by_user_id( + session, user_id, active_user.panel_user_uuid + ) + if active_user and active_user.panel_user_uuid + else None + ) + tariff_key = active_sub.tariff_key if active_sub else None + if not tariff_key: + logging.error("Top-up activation requires tariff_key for user %s", user_id) + return None + return await self.activate_topup( + session=session, + user_id=user_id, + tariff_key=tariff_key, + traffic_gb=traffic_gb if traffic_gb is not None else float(months), + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + ) + if sale_mode_base == "premium_topup": + if not tariff_key: + active_user = await user_dal.get_user_by_id(session, user_id) + active_sub = ( + await subscription_dal.get_active_subscription_by_user_id( + session, user_id, active_user.panel_user_uuid + ) + if active_user and active_user.panel_user_uuid + else None + ) + tariff_key = active_sub.tariff_key if active_sub else None + if not tariff_key: + logging.error("Premium top-up activation requires tariff_key for user %s", user_id) + return None + return await self.activate_premium_topup( + session=session, + user_id=user_id, + tariff_key=tariff_key, + traffic_gb=traffic_gb if traffic_gb is not None else float(months), + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + ) + if sale_mode_base in {"hwid_device", "hwid_devices"}: + target_devices = int(traffic_gb if traffic_gb is not None else months) + return await self.activate_hwid_device_topup( + session=session, + user_id=user_id, + device_count=target_devices, + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + tariff_key=tariff_key, + ) + if sale_mode_base == "tariff_upgrade": + if not tariff_key: + logging.error("Tariff upgrade activation requires tariff_key for user %s", user_id) + return None + await self._record_payment_context( + session, + payment_db_id, + sale_mode="tariff_upgrade", + tariff_key=tariff_key, + purchased_gb=None, + ) + result = await self.switch_tariff_without_payment( + session, + user_id, + tariff_key, + "paid_diff", + ) + if result: + sub = await subscription_dal.get_active_subscription_by_user_id(session, user_id) + if sub: + await tariff_dal.create_tariff_change( + session, + { + "subscription_id": sub.subscription_id, + "from_tariff_key": None, + "to_tariff_key": tariff_key, + "mode": "paid_diff", + "payment_id": payment_db_id, + "days_before": None, + "days_after": (sub.end_date - datetime.now(timezone.utc)).days + if sub.end_date + else None, + "converted_bytes": None, + "eff_price_before": None, + "eff_price_after": sub.effective_monthly_price_rub, + }, + ) + result["end_date"] = sub.end_date + result["is_active"] = sub.is_active + return result + + tariff = self._resolve_tariff(tariff_key, "period") if self._tariffs_config() else None + await self._record_payment_context( + session, + payment_db_id, + sale_mode=sale_mode_base, + tariff_key=tariff.key if tariff else tariff_key, + purchased_gb=None, + ) + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error(f"User {user_id} not found in DB for paid subscription activation.") + return None + + ( + panel_user_uuid, + panel_sub_link_id, + panel_short_uuid, + panel_user_created_now, + ) = await self._get_or_create_panel_user_link_details(session, user_id, db_user) + + if not panel_user_uuid or not panel_sub_link_id: + logging.error(f"Failed to ensure panel user for TG {user_id} during paid subscription.") + return None + + try: + months_int = int(months) + except Exception: + months_int = 1 + + current_active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid + ) + start_date = datetime.now(timezone.utc) + if ( + current_active_sub + and current_active_sub.end_date + and current_active_sub.end_date > start_date + ): + start_date = current_active_sub.end_date + + # base duration by months + end_after_months = add_months(start_date, months_int) + duration_days_total = (end_after_months - start_date).days + applied_promo_bonus_days = 0 + + if promo_code_id_from_payment: + promo_model = await promo_code_dal.get_promo_code_by_id( + session, promo_code_id_from_payment + ) + if ( + promo_model + and promo_model.is_active + and promo_model.current_activations < promo_model.max_activations + ): + applied_promo_bonus_days = promo_model.bonus_days + duration_days_total += applied_promo_bonus_days + + activation = await promo_code_dal.record_promo_activation( + session, + promo_code_id_from_payment, + user_id, + payment_id=payment_db_id, + ) + if activation: + await promo_code_dal.increment_promo_code_usage( + session, promo_code_id_from_payment + ) + else: + logging.warning( + f"Promo code {promo_code_id_from_payment} was already activated by user {user_id}, but bonus applied via payment {payment_db_id}." # noqa: E501 + ) + else: + logging.warning( + f"Promo code ID {promo_code_id_from_payment} (from payment) not found or invalid." # noqa: E501 + ) + promo_code_id_from_payment = None + + final_end_date = start_date + timedelta(days=duration_days_total) + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id + ) + + auto_renew_should_enable = False + if provider == "yookassa" and self.settings.yookassa_autopayments_active: + auto_renew_should_enable = await user_billing_dal.user_has_saved_payment_method( + session, user_id + ) + + topup_balance_bytes = int(getattr(current_active_sub, "topup_balance_bytes", 0) or 0) + extra_hwid_devices = int(getattr(current_active_sub, "extra_hwid_devices", 0) or 0) + premium_topup_balance_bytes = int( + getattr(current_active_sub, "premium_topup_balance_bytes", 0) or 0 + ) + premium_topup_used_bytes = int( + getattr(current_active_sub, "premium_topup_used_bytes", 0) or 0 + ) + premium_used_bytes = int(getattr(current_active_sub, "premium_used_bytes", 0) or 0) + premium_period_start_at = getattr(current_active_sub, "premium_period_start_at", None) + tier_baseline_bytes = ( + tariff.monthly_bytes if tariff else self.settings.user_traffic_limit_bytes + ) + premium_baseline_bytes = tariff.premium_monthly_bytes if tariff else 0 + premium_limit_bytes = self._premium_effective_limit_bytes( + premium_baseline_bytes, + premium_topup_balance_bytes, + premium_topup_used_bytes, + ) + effective_monthly_price = float(payment_amount) / max(1, months_int) + regular_bonus_carry = int(getattr(current_active_sub, "regular_bonus_bytes", 0) or 0) + regular_unl_carry = bool(getattr(current_active_sub, "regular_unlimited_override", False)) + traffic_limit_bytes = self._traffic_limit_for_period_tariff( + tariff, + topup_balance_bytes, + regular_bonus_carry, + regular_unlimited_override=regular_unl_carry, + traffic_used_bytes=0, + ) + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) + premium_is_limited = bool( + premium_limit_bytes > 0 and premium_used_bytes >= premium_limit_bytes + ) + sub_payload = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": final_end_date, + "duration_months": months_int, + "is_active": True, + "status_from_panel": "ACTIVE", + "traffic_limit_bytes": traffic_limit_bytes, + "provider": provider, + "skip_notifications": False, + "auto_renew_enabled": auto_renew_should_enable, + "tariff_key": tariff.key if tariff else None, + "tier_baseline_bytes": tier_baseline_bytes, + "topup_balance_bytes": topup_balance_bytes, + "regular_bonus_bytes": regular_bonus_carry, + "regular_unlimited_override": regular_unl_carry, + "premium_baseline_bytes": premium_baseline_bytes, + "premium_topup_balance_bytes": premium_topup_balance_bytes, + "premium_topup_used_bytes": premium_topup_used_bytes, + "premium_used_bytes": premium_used_bytes, + "premium_is_limited": premium_is_limited, + "premium_period_start_at": premium_period_start_at, + "period_start_at": None, + "is_throttled": False, + "effective_monthly_price_rub": effective_monthly_price, + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": extra_hwid_devices, + } + try: + new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload) + except Exception as e_upsert_sub: + logging.error( + f"Failed to upsert paid subscription for user {user_id}: {e_upsert_sub}", + exc_info=True, + ) + return None + + panel_update_payload = self._build_panel_update_payload( + panel_user_uuid=panel_user_uuid, + expire_at=final_end_date, + status="ACTIVE", + traffic_limit_bytes=traffic_limit_bytes, + traffic_limit_strategy="MONTH" if tariff else self.settings.USER_TRAFFIC_STRATEGY, + hwid_device_limit=effective_hwid_limit, + ) + if tariff: + panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not premium_is_limited, + ) + + panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) + + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload + ) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + f"Panel user details update FAILED for paid sub user {panel_user_uuid}. Response: {updated_panel_user}" # noqa: E501 + ) + return None + + final_subscription_url = updated_panel_user.get("subscriptionUrl") + final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + + await self._send_payment_success_email( + db_user=db_user, + sale_mode="subscription", + months=months_int, + traffic_gb=None, + payment_amount=payment_amount, + end_date=final_end_date, + provider=provider, + ) + + return { + "subscription_id": new_or_updated_sub.subscription_id, + "end_date": final_end_date, + "is_active": True, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, + "applied_promo_bonus_days": applied_promo_bonus_days, + "tariff_key": tariff.key if tariff else None, + } + + async def extend_active_subscription_days( + self, + session: AsyncSession, + user_id: int, + bonus_days: int, + reason: str = "bonus", + ) -> Optional[datetime]: + reason_lower = (reason or "").lower() + apply_main_traffic_limit = any( + keyword in reason_lower for keyword in ("admin", "promo code", "referral", "bonus") + ) + + user = await user_dal.get_user_by_id(session, user_id) + if not user: + logging.warning(f"Cannot extend subscription for user {user_id}: user not found.") + return None + + panel_uuid, panel_sub_uuid, _, _ = await self._get_or_create_panel_user_link_details( + session, user_id, user + ) + if not panel_uuid or not panel_sub_uuid: + logging.error( + f"Failed to ensure panel user for subscription extension of user {user_id}." + ) + return None + + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_uuid + ) + if not active_sub or not active_sub.end_date: + logging.info( + f"No active subscription found for user {user_id}. Creating new one for {bonus_days} days." # noqa: E501 + ) + start_date = datetime.now(timezone.utc) + new_end_date_obj = start_date + timedelta(days=bonus_days) + + # Apply main traffic limit for admin/referral/promo bonuses, fallback to trial limit otherwise # noqa: E501 + traffic_limit = ( + self.settings.user_traffic_limit_bytes + if apply_main_traffic_limit + else self.settings.trial_traffic_limit_bytes + ) + + bonus_sub_payload = { + "user_id": user_id, + "panel_user_uuid": panel_uuid, + "panel_subscription_uuid": panel_sub_uuid, + "start_date": start_date, + "end_date": new_end_date_obj, + "duration_months": 0, + "is_active": True, + "status_from_panel": "ACTIVE_BONUS", + "traffic_limit_bytes": traffic_limit, + "auto_renew_enabled": False, + } + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_uuid, panel_sub_uuid + ) + updated_sub_model = await subscription_dal.upsert_subscription( + session, bonus_sub_payload + ) + else: + current_end_date = active_sub.end_date + now_utc = datetime.now(timezone.utc) + start_point_for_bonus = current_end_date if current_end_date > now_utc else now_utc + new_end_date_obj = start_point_for_bonus + timedelta(days=bonus_days) + + updated_sub_model = await subscription_dal.update_subscription_end_date( + session, active_sub.subscription_id, new_end_date_obj + ) + + if ( + apply_main_traffic_limit + and updated_sub_model + and updated_sub_model.traffic_limit_bytes != self.settings.user_traffic_limit_bytes + ): + updated_sub_model = await subscription_dal.update_subscription( + session, + updated_sub_model.subscription_id, + {"traffic_limit_bytes": self.settings.user_traffic_limit_bytes}, + ) + + if updated_sub_model: + # Prepare panel update payload + panel_update_payload = self._build_panel_update_payload( + expire_at=new_end_date_obj, + traffic_limit_bytes=( + self.settings.user_traffic_limit_bytes if apply_main_traffic_limit else None + ), + include_uuid=False, + include_default_squads=False, + ) + + panel_update_success = await self.panel_service.update_user_details_on_panel( + panel_uuid, + panel_update_payload, + ) + if not panel_update_success: + logging.warning( + f"Panel expiry update failed for {panel_uuid} after {reason} bonus. Local DB was updated to {new_end_date_obj}." # noqa: E501 + ) + + logging.info( + f"Subscription for user {user_id} extended by {bonus_days} days ({reason}). New end date: {new_end_date_obj}." # noqa: E501 + ) + return new_end_date_obj + else: + logging.error(f"Failed to update subscription end date locally for user {user_id}.") + return None + + async def get_active_subscription_details( + self, session: AsyncSession, user_id: int + ) -> Optional[Dict[str, Any]]: + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + logging.info( + f"User {user_id} not found in DB or no panel_user_uuid for 'my_subscription'." + ) + return None + + panel_user_uuid = db_user.panel_user_uuid + local_active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid + ) + panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) + + if not panel_user_data: + logging.warning( + f"Panel user {panel_user_uuid} not found on panel for user {user_id}. Clearing local linkage." # noqa: E501 + ) + await subscription_dal.deactivate_all_user_subscriptions(session, user_id) + await user_dal.update_user(session, user_id, {"panel_user_uuid": None}) + return None + + panel_lifetime_used = self._extract_lifetime_used_traffic(panel_user_data) + if ( + panel_lifetime_used is not None + and db_user.lifetime_used_traffic_bytes != panel_lifetime_used + ): + await user_dal.update_user( + session, + user_id, + {"lifetime_used_traffic_bytes": panel_lifetime_used}, + ) + + if local_active_sub: + update_payload_local = {} + panel_status = panel_user_data.get("status", "UNKNOWN").upper() + panel_expire_at_str = panel_user_data.get("expireAt") + panel_traffic_used, panel_traffic_limit, _ = self._extract_panel_traffic_details( + panel_user_data + ) + panel_sub_uuid_from_panel = panel_user_data.get( + "subscriptionUuid" + ) or panel_user_data.get("shortUuid") + + if local_active_sub.status_from_panel != panel_status: + update_payload_local["status_from_panel"] = panel_status + if panel_expire_at_str: + panel_expire_dt = datetime.fromisoformat(panel_expire_at_str.replace("Z", "+00:00")) + if local_active_sub.end_date.replace(microsecond=0) != panel_expire_dt.replace( + microsecond=0 + ): + update_payload_local["end_date"] = panel_expire_dt + update_payload_local["last_notification_sent"] = None + if ( + panel_traffic_used is not None + and local_active_sub.traffic_used_bytes != panel_traffic_used + ): + update_payload_local["traffic_used_bytes"] = panel_traffic_used + if ( + panel_traffic_limit is not None + and local_active_sub.traffic_limit_bytes != panel_traffic_limit + ): + update_payload_local["traffic_limit_bytes"] = panel_traffic_limit + if ( + panel_sub_uuid_from_panel + and local_active_sub.panel_subscription_uuid != panel_sub_uuid_from_panel + ): + update_payload_local["panel_subscription_uuid"] = panel_sub_uuid_from_panel + + is_active_based_on_panel = panel_status == "ACTIVE" and ( + panel_expire_dt > datetime.now(timezone.utc) if panel_expire_dt else False + ) + if local_active_sub.is_active != is_active_based_on_panel: + update_payload_local["is_active"] = is_active_based_on_panel + + if update_payload_local: + await subscription_dal.update_subscription( + session, local_active_sub.subscription_id, update_payload_local + ) + + panel_end_date = ( + datetime.fromisoformat(panel_user_data["expireAt"].replace("Z", "+00:00")) + if panel_user_data.get("expireAt") + else None + ) + panel_traffic_used, panel_traffic_limit, panel_traffic_strategy = ( + self._extract_panel_traffic_details(panel_user_data) + ) + config_link_raw = panel_user_data.get("subscriptionUrl") + display_link, connect_button_url = await prepare_config_links( + self.settings, config_link_raw + ) + hwid_limit = panel_user_data.get("hwidDeviceLimit") + if hwid_limit is None: + if local_active_sub and local_active_sub.hwid_device_limit is not None: + hwid_limit = self._effective_hwid_limit( + local_active_sub.hwid_device_limit, + int(local_active_sub.extra_hwid_devices or 0), + ) + else: + hwid_limit = self.settings.USER_HWID_DEVICE_LIMIT + tariff = None + if local_active_sub and local_active_sub.tariff_key and self._tariffs_config(): + try: + tariff = self._resolve_tariff(local_active_sub.tariff_key) + except Exception: + tariff = None + billing_model_display = ( + tariff.billing_model + if tariff + else ("traffic" if getattr(self.settings, "traffic_sale_mode", False) else "period") + ) + traffic_limit_strategy = panel_traffic_strategy + premium_access = ( + await self.premium_access_for_tariff(tariff) + if tariff + else { + "squad_uuids": [], + "squad_labels": [], + "node_labels": [], + } + ) + premium_baseline = ( + int(local_active_sub.premium_baseline_bytes or 0) if local_active_sub else 0 + ) + premium_topup_balance = ( + int(local_active_sub.premium_topup_balance_bytes or 0) if local_active_sub else 0 + ) + premium_topup_used = ( + int(getattr(local_active_sub, "premium_topup_used_bytes", 0) or 0) + if local_active_sub + else 0 + ) + premium_bonus_bytes = ( + int(getattr(local_active_sub, "premium_bonus_bytes", 0) or 0) if local_active_sub else 0 + ) + premium_unlimited_override = ( + bool(getattr(local_active_sub, "premium_unlimited_override", False)) + if local_active_sub + else False + ) + regular_bonus_bytes = ( + int(getattr(local_active_sub, "regular_bonus_bytes", 0) or 0) if local_active_sub else 0 + ) + regular_unlimited_override = ( + bool(getattr(local_active_sub, "regular_unlimited_override", False)) + if local_active_sub + else False + ) + + return { + "user_id": panel_user_data.get("uuid"), + "end_date": panel_end_date, + "status_from_panel": panel_user_data.get("status", "UNKNOWN").upper(), + "config_link": display_link, + "connect_button_url": connect_button_url, + "traffic_limit_bytes": panel_traffic_limit, + "traffic_used_bytes": panel_traffic_used, + "traffic_limit_strategy": traffic_limit_strategy, + "tariff_key": local_active_sub.tariff_key if local_active_sub else None, + "tariff_name": tariff.name(db_user.language_code or self.settings.DEFAULT_LANGUAGE) + if tariff + else None, + "tariff_description": tariff.description( + db_user.language_code or self.settings.DEFAULT_LANGUAGE + ) + if tariff + else None, + "premium_title": tariff.premium_name( + db_user.language_code or self.settings.DEFAULT_LANGUAGE + ) + if tariff + else None, + "billing_model": billing_model_display, + "tier_baseline_bytes": local_active_sub.tier_baseline_bytes + if local_active_sub + else None, + "topup_balance_bytes": local_active_sub.topup_balance_bytes if local_active_sub else 0, + "regular_bonus_bytes": regular_bonus_bytes, + "regular_unlimited_override": regular_unlimited_override, + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_used_bytes": local_active_sub.premium_used_bytes if local_active_sub else 0, + "premium_bonus_bytes": premium_bonus_bytes, + "premium_unlimited_override": premium_unlimited_override, + "premium_limit_bytes": self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + premium_bonus_bytes, + ), + "premium_is_limited": bool(local_active_sub.premium_is_limited) + if local_active_sub + else False, + "premium_period_start_at": getattr(local_active_sub, "premium_period_start_at", None) + if local_active_sub + else None, + "premium_squad_labels": premium_access.get("squad_labels") or [], + "premium_node_labels": premium_access.get("node_labels") or [], + "period_start_at": local_active_sub.period_start_at if local_active_sub else None, + "is_throttled": bool(local_active_sub.is_throttled) if local_active_sub else False, + "base_hwid_device_limit": local_active_sub.hwid_device_limit + if local_active_sub + else None, + "extra_hwid_devices": int(local_active_sub.extra_hwid_devices or 0) + if local_active_sub + else 0, + "user_bot_username": db_user.username, + "is_panel_data": True, + "max_devices": hwid_limit, + } + + async def get_subscriptions_ending_soon( + self, session: AsyncSession, days_threshold: int + ) -> List[Dict[str, Any]]: + subs_models_with_users = await subscription_dal.get_subscriptions_near_expiration( + session, days_threshold + ) + results = [] + for sub_model in subs_models_with_users: + if sub_model.user and sub_model.end_date and not sub_model.skip_notifications: + days_left = (sub_model.end_date - datetime.now(timezone.utc)).total_seconds() / ( + 24 * 3600 + ) + results.append( + { + "user_id": sub_model.user_id, + "first_name": sub_model.user.first_name or f"User {sub_model.user_id}", + "language_code": sub_model.user.language_code + or self.settings.DEFAULT_LANGUAGE, + "end_date_str": sub_model.end_date.strftime("%Y-%m-%d"), + "days_left": max(0, int(round(days_left))), + "subscription_end_date_iso_for_update": sub_model.end_date, + } + ) + return results diff --git a/bot/services/subscription_service_impl/panel_identity.py b/bot/services/subscription_service_impl/panel_identity.py new file mode 100644 index 0000000..c71a7c2 --- /dev/null +++ b/bot/services/subscription_service_impl/panel_identity.py @@ -0,0 +1,344 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class PanelIdentityMixin: + def _extract_panel_traffic_details( + self, panel_user_data: Dict[str, Any] + ) -> Tuple[Optional[int], Optional[int], Optional[str]]: + traffic_stats = panel_user_data.get("userTraffic") or {} + used = traffic_stats.get("usedTrafficBytes") + if used is None: + used = panel_user_data.get("usedTrafficBytes") + limit = panel_user_data.get("trafficLimitBytes") + strategy = panel_user_data.get("trafficLimitStrategy") + if strategy is None: + strategy = traffic_stats.get("trafficLimitStrategy") + return used, limit, strategy + + def _extract_lifetime_used_traffic(self, panel_user_data: Dict[str, Any]) -> Optional[int]: + traffic_stats = panel_user_data.get("userTraffic") or {} + lifetime = traffic_stats.get("lifetimeUsedTrafficBytes") + if lifetime is None: + lifetime = panel_user_data.get("lifetimeUsedTrafficBytes") + try: + if lifetime is None: + return None + return int(lifetime) + except (TypeError, ValueError): + return None + + async def _notify_admin_panel_user_creation_failed(self, user_id: int): + if not self.bot or not self.i18n or not self.settings.ADMIN_IDS: + return + admin_lang = self.settings.DEFAULT_LANGUAGE + _adm = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) + msg = _adm("admin_panel_user_creation_failed", user_id=user_id) + for admin_id in self.settings.ADMIN_IDS: + try: + await self.bot.send_message(admin_id, msg) + except Exception as e: + logging.error( + f"Failed to notify admin {admin_id} about panel user creation failure: {e}" + ) + + def _telegram_id_for_panel(self, db_user: User) -> Optional[int]: + if db_user.telegram_id: + return int(db_user.telegram_id) + if db_user.user_id and int(db_user.user_id) > 0: + return int(db_user.user_id) + return None + + async def _panel_username_for_user(self, session: AsyncSession, db_user: User) -> str: + telegram_id = self._telegram_id_for_panel(db_user) + if telegram_id and int(db_user.user_id) == telegram_id: + return f"tg_{telegram_id}" + referral_code = await user_dal.ensure_referral_code(session, db_user) + return f"em_{referral_code}" + + def _panel_description_for_user(self, db_user: User) -> str: + lines = [ + db_user.email or "", + db_user.username or "", + db_user.first_name or "", + db_user.last_name or "", + ] + return "\n".join(line for line in lines if line).strip() + + def _panel_identity_payload_for_user(self, db_user: User) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "description": self._panel_description_for_user(db_user), + } + telegram_id = self._telegram_id_for_panel(db_user) + if telegram_id: + payload["telegramId"] = telegram_id + if db_user.email: + payload["email"] = db_user.email + return payload + + async def _get_or_create_panel_user_link_details( + self, session: AsyncSession, user_id: int, db_user: Optional[User] = None + ) -> Tuple[Optional[str], Optional[str], Optional[str], bool]: + if not db_user: + db_user = await user_dal.get_user_by_id(session, user_id) + + if not db_user: + logging.error( + f"_get_or_create_panel_user_link_details: User {user_id} not found in local DB. Cannot proceed." # noqa: E501 + ) + return None, None, None, False + + current_local_panel_uuid = db_user.panel_user_uuid + panel_username_on_panel_standard = await self._panel_username_for_user(session, db_user) + telegram_id_for_panel = self._telegram_id_for_panel(db_user) + + panel_user_obj_from_api = None + panel_user_created_or_linked_now = False + + panel_users_by_tg_id_list = None + if telegram_id_for_panel: + panel_users_by_tg_id_list = await self.panel_service.get_users_by_filter( + telegram_id=telegram_id_for_panel + ) + if panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) == 1: + panel_user_obj_from_api = panel_users_by_tg_id_list[0] + logging.info( + f"Found panel user by telegramId {telegram_id_for_panel}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" # noqa: E501 + ) + elif panel_users_by_tg_id_list and len(panel_users_by_tg_id_list) > 1: + logging.error( + f"CRITICAL: Multiple panel users found for telegramId {telegram_id_for_panel}. Manual intervention needed." # noqa: E501 + ) + return None, None, None, False + + if not panel_user_obj_from_api and db_user.email: + panel_users_by_email_list = await self.panel_service.get_users_by_filter( + email=db_user.email + ) + if panel_users_by_email_list and len(panel_users_by_email_list) == 1: + panel_user_obj_from_api = panel_users_by_email_list[0] + logging.info( + f"Found panel user by email {db_user.email}: UUID {panel_user_obj_from_api.get('uuid')}, Username: {panel_user_obj_from_api.get('username')}" # noqa: E501 + ) + elif panel_users_by_email_list and len(panel_users_by_email_list) > 1: + logging.error( + f"CRITICAL: Multiple panel users found for email {db_user.email}. Manual intervention needed." # noqa: E501 + ) + return None, None, None, False + + if not panel_user_obj_from_api: + if current_local_panel_uuid: + logging.info( + f"User {user_id} (local panel_uuid: {current_local_panel_uuid}) not found on panel by TG ID. Fetching by panel_uuid." # noqa: E501 + ) + panel_user_obj_from_api = await self.panel_service.get_user_by_uuid( + current_local_panel_uuid + ) + if not panel_user_obj_from_api: + logging.warning( + f"Local panel_uuid {current_local_panel_uuid} for TG user {user_id} also not found on panel. User might be deleted from panel or UUID desynced." # noqa: E501 + ) + logging.info( + f"Creating new panel user '{panel_username_on_panel_standard}' for TG user {user_id}." # noqa: E501 + ) + creation_response = await self.panel_service.create_panel_user( + username_on_panel=panel_username_on_panel_standard, + telegram_id=telegram_id_for_panel, + email=db_user.email, + description=self._panel_description_for_user(db_user), + specific_squad_uuids=self.settings.parsed_user_squad_uuids, + external_squad_uuid=self.settings.parsed_user_external_squad_uuid, + default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, + default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY, + ) + if ( + creation_response + and not creation_response.get("error") + and creation_response.get("response") + ): + panel_user_obj_from_api = creation_response.get("response") + panel_user_created_or_linked_now = True + else: + await self._notify_admin_panel_user_creation_failed(user_id) + return None, None, None, False + + else: + logging.info( + f"No panel user by TG ID & no local panel_uuid for TG user {user_id}. Creating new panel user '{panel_username_on_panel_standard}'." # noqa: E501 + ) + creation_response = await self.panel_service.create_panel_user( + username_on_panel=panel_username_on_panel_standard, + telegram_id=telegram_id_for_panel, + email=db_user.email, + description=self._panel_description_for_user(db_user), + specific_squad_uuids=self.settings.parsed_user_squad_uuids, + external_squad_uuid=self.settings.parsed_user_external_squad_uuid, + default_traffic_limit_bytes=self.settings.user_traffic_limit_bytes, + default_traffic_limit_strategy=self.settings.USER_TRAFFIC_STRATEGY, + ) + if ( + creation_response + and not creation_response.get("error") + and creation_response.get("response") + ): + panel_user_obj_from_api = creation_response.get("response") + panel_user_created_or_linked_now = True + + elif creation_response and creation_response.get("errorCode") == "A019": + logging.warning( + f"Panel user '{panel_username_on_panel_standard}' already exists (errorCode A019). Fetching by username." # noqa: E501 + ) + fetched_by_username_list = await self.panel_service.get_users_by_filter( + username=panel_username_on_panel_standard + ) + if fetched_by_username_list and len(fetched_by_username_list) == 1: + panel_user_obj_from_api = fetched_by_username_list[0] + + if not panel_user_obj_from_api: + logging.error( + f"Failed to create or link panel user for TG_ID {user_id} with panel username '{panel_username_on_panel_standard}'. Response: {creation_response if 'creation_response' in locals() else 'N/A'}" # noqa: E501 + ) + await self._notify_admin_panel_user_creation_failed(user_id) + return None, None, None, False + + if not panel_user_obj_from_api: + logging.error( + f"Could not obtain panel user object for TG user {user_id} after all checks." + ) + + return ( + current_local_panel_uuid if current_local_panel_uuid else None, + None, + None, + panel_user_created_or_linked_now, + ) + + actual_panel_uuid_from_api = panel_user_obj_from_api.get("uuid") + panel_telegram_id_from_api = panel_user_obj_from_api.get("telegramId") + + if not actual_panel_uuid_from_api: + logging.error( + f"Panel user object for TG user {user_id} does not contain 'uuid'. Data: {panel_user_obj_from_api}" # noqa: E501 + ) + return ( + current_local_panel_uuid, + None, + None, + panel_user_created_or_linked_now, + ) + + needs_local_panel_uuid_update = False + if current_local_panel_uuid is None and actual_panel_uuid_from_api: + needs_local_panel_uuid_update = True + elif ( + current_local_panel_uuid is not None + and current_local_panel_uuid != actual_panel_uuid_from_api + ): + logging.warning( + f"Local panel_uuid for user {user_id} ('{current_local_panel_uuid}') " + f"differs from panel's UUID ('{actual_panel_uuid_from_api}') for their telegramId. " + f"Will attempt to update local to panel's version." + ) + needs_local_panel_uuid_update = True + + if needs_local_panel_uuid_update: + conflicting_user_record = await user_dal.get_user_by_panel_uuid( + session, actual_panel_uuid_from_api + ) + if conflicting_user_record and conflicting_user_record.user_id != user_id: + logging.error( + f"CRITICAL CONFLICT: Panel UUID {actual_panel_uuid_from_api} (from panel for TG ID {user_id}) " # noqa: E501 + f"is ALREADY LINKED in local DB to a different TG User {conflicting_user_record.user_id}. " # noqa: E501 + f"Cannot update panel_user_uuid for user {user_id}. Manual data correction needed." # noqa: E501 + ) + + return None, None, None, False + else: + update_data_for_local_user = {"panel_user_uuid": actual_panel_uuid_from_api} + + # Do not overwrite Telegram username with panel username. + # Only update the local linkage to panel UUID here. + await user_dal.update_user(session, user_id, update_data_for_local_user) + db_user.panel_user_uuid = actual_panel_uuid_from_api + panel_user_created_or_linked_now = True + current_local_panel_uuid = actual_panel_uuid_from_api + else: + pass + + panel_telegram_id_int = None + if panel_telegram_id_from_api is not None: + try: + panel_telegram_id_int = int(panel_telegram_id_from_api) + except ValueError: + pass + + if ( + panel_user_obj_from_api + and current_local_panel_uuid + and telegram_id_for_panel + and panel_telegram_id_int != telegram_id_for_panel + ): + logging.info( + f"Panel user {current_local_panel_uuid} has telegramId '{panel_telegram_id_from_api}'. Updating on panel to '{telegram_id_for_panel}'." # noqa: E501 + ) + await self.panel_service.update_user_details_on_panel( + current_local_panel_uuid, + self._panel_identity_payload_for_user(db_user), + ) + + panel_sub_link_id = panel_user_obj_from_api.get( + "subscriptionUuid" + ) or panel_user_obj_from_api.get("shortUuid") + panel_short_uuid = panel_user_obj_from_api.get("shortUuid") + + if not panel_sub_link_id and current_local_panel_uuid: + logging.warning( + f"No subscriptionUuid or shortUuid found on panel for panel_user_uuid {current_local_panel_uuid} (TG ID: {user_id})." # noqa: E501 + ) + + return ( + current_local_panel_uuid, + panel_sub_link_id, + panel_short_uuid, + panel_user_created_or_linked_now, + ) + + def _build_panel_update_payload( + self, + *, + panel_user_uuid: Optional[str] = None, + expire_at: Optional[datetime] = None, + status: Optional[str] = None, + traffic_limit_bytes: Optional[int] = None, + include_uuid: bool = True, + traffic_limit_strategy: Optional[str] = None, + hwid_device_limit: Optional[int] = None, + include_default_squads: bool = True, + ) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + if include_uuid and panel_user_uuid: + payload["uuid"] = panel_user_uuid + if expire_at is not None: + payload["expireAt"] = expire_at.isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ) + if status is not None: + payload["status"] = status + if traffic_limit_bytes is not None: + payload["trafficLimitBytes"] = traffic_limit_bytes + payload["trafficLimitStrategy"] = ( + traffic_limit_strategy or self.settings.USER_TRAFFIC_STRATEGY + ) + if hwid_device_limit is not None: + try: + hwid_limit_int = int(hwid_device_limit) + if hwid_limit_int >= 0: + payload["hwidDeviceLimit"] = hwid_limit_int + except (TypeError, ValueError): + pass + if include_default_squads: + if self.settings.parsed_user_squad_uuids: + payload["activeInternalSquads"] = self.settings.parsed_user_squad_uuids + if self.settings.parsed_user_external_squad_uuid: + payload["externalSquadUuid"] = self.settings.parsed_user_external_squad_uuid + return payload diff --git a/bot/services/subscription_service_impl/payments.py b/bot/services/subscription_service_impl/payments.py new file mode 100644 index 0000000..fc817c2 --- /dev/null +++ b/bot/services/subscription_service_impl/payments.py @@ -0,0 +1,111 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class PaymentContextMixin: + async def _record_payment_context( + self, + session: AsyncSession, + payment_db_id: int, + *, + sale_mode: str, + tariff_key: Optional[str], + purchased_gb: Optional[float] = None, + purchased_hwid_devices: Optional[int] = None, + ) -> None: + payment = await payment_dal.get_payment_by_db_id(session, payment_db_id) + if not payment: + return + payment.sale_mode = sale_mode + payment.tariff_key = tariff_key + payment.purchased_gb = purchased_gb + payment.purchased_hwid_devices = purchased_hwid_devices + await session.flush() + + async def get_user_language(self, session: AsyncSession, user_id: int) -> str: + user_record = await user_dal.get_user_by_id(session, user_id) + return ( + user_record.language_code + if user_record and user_record.language_code + else self.settings.DEFAULT_LANGUAGE + ) + + async def has_had_any_subscription(self, session: AsyncSession, user_id: int) -> bool: + return await subscription_dal.has_any_subscription_for_user(session, user_id) + + async def has_active_subscription(self, session: AsyncSession, user_id: int) -> bool: + """Return True if user currently has an active subscription (end_date in future).""" + try: + user_record = await user_dal.get_user_by_id(session, user_id) + if not user_record or not user_record.panel_user_uuid: + return False + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, user_record.panel_user_uuid + ) + if not active_sub or not active_sub.end_date: + return False + from datetime import datetime, timezone + + return active_sub.is_active and active_sub.end_date > datetime.now(timezone.utc) + except Exception: + return False + + async def _send_payment_success_email( + self, + *, + db_user: User, + sale_mode: str, + months: int, + traffic_gb: Optional[float], + payment_amount: float, + end_date: Optional[datetime], + provider: str, + ) -> None: + """Best-effort branded email confirming the payment. No-op if SMTP or + the user's email aren't set. Failures are logged and swallowed so the + payment flow is never blocked by mail delivery.""" + if not self.settings.email_auth_configured: + return + recipient = (db_user.email or "").strip() if db_user else "" + if not recipient: + return + + end_date_text = end_date.strftime("%Y-%m-%d") if end_date else "" + provider_label = self._PROVIDER_LABELS.get((provider or "").lower()) + dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None + + try: + content = render_payment_success( + self.settings, + language_code=db_user.language_code or self.settings.DEFAULT_LANGUAGE, + sale_mode=sale_mode, + months=int(months or 0), + traffic_gb=traffic_gb, + amount=float(payment_amount or 0), + currency=self.settings.DEFAULT_CURRENCY_SYMBOL, + end_date_text=end_date_text, + dashboard_url=dashboard_url, + provider_label=provider_label, + ) + email_service = EmailAuthService(self.settings) + await email_service.send_rendered_email(email=recipient, content=content) + except Exception: + logging.exception("Failed to send payment success email to user %s", db_user.user_id) + + async def update_last_notification_sent( + self, session: AsyncSession, user_id: int, subscription_end_date: datetime + ): + sub_to_update = await subscription_dal.find_subscription_for_notification_update( + session, user_id, subscription_end_date + ) + if sub_to_update: + await subscription_dal.update_subscription_notification_time( + session, sub_to_update.subscription_id, datetime.now(timezone.utc) + ) + logging.info( + f"Updated last_notification_sent for user {user_id}, sub_id {sub_to_update.subscription_id}" # noqa: E501 + ) + else: + logging.warning( + f"Could not find subscription for user {user_id} ending at {subscription_end_date.isoformat()} to update notification time." # noqa: E501 + ) diff --git a/bot/services/subscription_service_impl/renewal.py b/bot/services/subscription_service_impl/renewal.py new file mode 100644 index 0000000..d8aed1e --- /dev/null +++ b/bot/services/subscription_service_impl/renewal.py @@ -0,0 +1,67 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class RenewalMixin: + async def charge_subscription_renewal( + self, + session: AsyncSession, + sub: Subscription, + ) -> bool: + """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" # noqa: E501 + if getattr(self.settings, "traffic_sale_mode", False): + logging.info("Auto-renew skipped: traffic sale mode enabled") + return True + if not sub.auto_renew_enabled: + return True + # If autopayments are disabled globally, skip charging attempts + if not self.settings.yookassa_autopayments_active: + return True + if sub.provider != "yookassa": + logging.info( + "Auto-renew skipped: provider %s does not support auto-renew", sub.provider + ) + return True + + from db.dal.user_billing_dal import get_user_default_payment_method + + default_pm = await get_user_default_payment_method(session, sub.user_id) + if not default_pm: + logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}") + return False + + try: + from .yookassa_service import YooKassaService # local import to avoid cycles + + yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined] + except Exception: + yk = None # type: ignore + if not yk or not getattr(yk, "configured", False): + logging.warning("YooKassa unavailable for auto-renew") + return False + + months = sub.duration_months or 1 + amount = self.settings.subscription_options.get(months) + if not amount: + logging.error(f"Auto-renew price missing for {months} months") + return False + + metadata = { + "user_id": str(sub.user_id), + "auto_renew_for_subscription_id": str(sub.subscription_id), + "subscription_months": str(months), + } + resp = await yk.create_payment( + amount=float(amount), + currency="RUB", + description=f"Auto-renewal for {months} months", + metadata=metadata, + payment_method_id=default_pm.provider_payment_method_id, + save_payment_method=False, + capture=True, + ) + if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}: + logging.error(f"Auto-renew create_payment failed: {resp}") + return False + logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}") + return True diff --git a/bot/services/subscription_service_impl/tariffs.py b/bot/services/subscription_service_impl/tariffs.py new file mode 100644 index 0000000..bf985e5 --- /dev/null +++ b/bot/services/subscription_service_impl/tariffs.py @@ -0,0 +1,369 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class TariffMixin: + @staticmethod + def gb_to_bytes(gb: float) -> int: + return int(float(gb) * (1024**3)) + + @staticmethod + def _far_future() -> datetime: + return datetime(2099, 1, 1, tzinfo=timezone.utc) + + def _parse_sale_mode_context( + self, + sale_mode: str, + explicit_tariff_key: Optional[str] = None, + ) -> Tuple[str, Optional[str]]: + mode = (sale_mode or "subscription").strip() + tariff_key = explicit_tariff_key + for separator in ("@", "|"): + if separator in mode: + base, suffix = mode.split(separator, 1) + mode = base or mode + tariff_key = tariff_key or suffix or None + break + return mode, tariff_key + + def _tariffs_config(self): + return getattr(self.settings, "tariffs_config", None) + + def _default_tariff(self) -> Optional[Tariff]: + config = self._tariffs_config() + return config.default if config else None + + def _resolve_tariff( + self, tariff_key: Optional[str], billing_model: Optional[str] = None + ) -> Optional[Tariff]: + config = self._tariffs_config() + if not config: + return None + tariff = config.require(tariff_key or config.default_tariff) + if billing_model and tariff.billing_model != billing_model: + raise ValueError( + f"Tariff {tariff.key} is {tariff.billing_model}, expected {billing_model}" + ) + return tariff + + def _panel_squads_for_tariff( + self, + tariff: Optional[Tariff], + *, + include_premium: bool = True, + ) -> Optional[List[str]]: + if tariff: + squads = list(tariff.squad_uuids or []) + if include_premium: + squads.extend(tariff.premium_squad_uuids or []) + return list(dict.fromkeys(squads)) + return self.settings.parsed_user_squad_uuids + + def _traffic_limit_for_period_tariff( + self, + tariff: Optional[Tariff], + topup_balance_bytes: int = 0, + regular_bonus_bytes: int = 0, + regular_unlimited_override: bool = False, + traffic_used_bytes: int = 0, + ) -> int: + if tariff: + baseline = int(tariff.monthly_bytes or 0) + else: + baseline = int(self.settings.user_traffic_limit_bytes) + return self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=topup_balance_bytes, + regular_bonus_bytes=regular_bonus_bytes, + regular_unlimited_override=regular_unlimited_override, + traffic_used_bytes=traffic_used_bytes, + ) + + def _premium_limit_for_tariff( + self, tariff: Optional[Tariff], topup_balance_bytes: int = 0 + ) -> int: + if not tariff: + return 0 + return int(tariff.premium_monthly_bytes + max(0, topup_balance_bytes)) + + @staticmethod + def _premium_effective_limit_bytes( + premium_baseline_bytes: int, + premium_topup_balance_bytes: int = 0, + premium_topup_used_bytes: int = 0, + premium_bonus_bytes: int = 0, + ) -> int: + return ( + int(premium_baseline_bytes or 0) + + max(0, int(premium_topup_balance_bytes or 0)) + + max(0, int(premium_topup_used_bytes or 0)) + + max(0, int(premium_bonus_bytes or 0)) + ) + + def _compute_main_traffic_limit_bytes( + self, + *, + tier_baseline_bytes: int, + topup_balance_bytes: int, + regular_bonus_bytes: int, + regular_unlimited_override: bool, + traffic_used_bytes: int, + ) -> int: + """Numeric cap sent to the panel; ``regular_unlimited_override`` uses a large practical ceiling.""" # noqa: E501 + floor = ( + int(tier_baseline_bytes or 0) + + max(0, int(topup_balance_bytes or 0)) + + max(0, int(regular_bonus_bytes or 0)) + ) + if regular_unlimited_override: + used = max(0, int(traffic_used_bytes or 0)) + return max(floor, used + 512 * (1024**3), 1024**5) + return floor + + async def premium_access_for_tariff(self, tariff: Optional[Tariff]) -> Dict[str, Any]: + if not tariff or not tariff.premium_squad_uuids: + return {"squad_uuids": [], "squad_labels": [], "node_labels": []} + + cache_key = tuple(sorted(str(uuid) for uuid in tariff.premium_squad_uuids)) + now_ts = datetime.now(timezone.utc).timestamp() + cached = self._premium_access_cache.get(cache_key) + if cached and now_ts - float(cached.get("ts", 0)) < 600: + return { + "squad_uuids": list(cached.get("squad_uuids") or []), + "squad_labels": list(cached.get("squad_labels") or []), + "node_labels": list(cached.get("node_labels") or []), + } + + def _extract_inbound_uuids(squad_obj: Dict[str, Any]) -> List[str]: + collected: List[str] = [] + for field in ("inbounds", "internalInbounds", "configProfileInbounds"): + value = squad_obj.get(field) + if not isinstance(value, list): + continue + for inbound in value: + if isinstance(inbound, dict): + ib_uuid = str( + inbound.get("uuid") + or inbound.get("inboundUuid") + or inbound.get("id") + or "" + ) + else: + ib_uuid = str(inbound or "") + if ib_uuid: + collected.append(ib_uuid) + return collected + + squad_name_map: Dict[str, str] = {} + squad_inbound_map: Dict[str, List[str]] = {} + try: + squads = await self.panel_service.get_internal_squads() or [] + for squad in squads: + if not isinstance(squad, dict): + continue + squad_uuid = str(squad.get("uuid") or squad.get("id") or "") + if not squad_uuid: + continue + squad_name_map[squad_uuid] = str( + squad.get("name") or squad.get("title") or squad_uuid + ) + squad_inbound_map[squad_uuid] = _extract_inbound_uuids(squad) + except Exception: + logging.debug("Failed to load internal squad names for premium display", exc_info=True) + + for squad_uuid in tariff.premium_squad_uuids: + squad_uuid_str = str(squad_uuid) + if squad_inbound_map.get(squad_uuid_str): + continue + try: + detail = await self.panel_service.get_internal_squad(squad_uuid_str) + except Exception: + logging.debug( + "Failed to load internal squad detail for %s", squad_uuid_str, exc_info=True + ) + detail = None + if isinstance(detail, dict): + squad_inbound_map[squad_uuid_str] = _extract_inbound_uuids(detail) + if squad_uuid_str not in squad_name_map: + squad_name_map[squad_uuid_str] = str( + detail.get("name") or detail.get("title") or squad_uuid_str + ) + + hosts_by_inbound: Dict[str, List[Dict[str, Any]]] = {} + try: + hosts = await self.panel_service.get_hosts() or [] + for host in hosts: + if not isinstance(host, dict): + continue + inbound_field = host.get("inbound") if isinstance(host.get("inbound"), dict) else {} + inbound_uuid = ( + host.get("inboundUuid") + or host.get("inbound_uuid") + or host.get("configProfileInboundUuid") + or inbound_field.get("configProfileInboundUuid") + or inbound_field.get("inboundUuid") + or inbound_field.get("uuid") + or "" + ) + inbound_uuid = str(inbound_uuid) + if not inbound_uuid: + continue + hosts_by_inbound.setdefault(inbound_uuid, []).append(host) + logging.debug( + "Premium label resolution: %d hosts grouped across %d inbounds; squad inbound map: %s", # noqa: E501 + len(hosts), + len(hosts_by_inbound), + {k: len(v) for k, v in squad_inbound_map.items()}, + ) + except Exception: + logging.debug("Failed to load hosts for premium display", exc_info=True) + + def _host_remark(host: Dict[str, Any]) -> str: + for key in ("remark", "name", "label", "title"): + value = host.get(key) + if value is None: + continue + candidate = str(value).strip() + if candidate: + return candidate + return "" + + node_labels: List[str] = [] + for squad_uuid in tariff.premium_squad_uuids: + squad_uuid_str = str(squad_uuid) + inbound_uuids = squad_inbound_map.get(squad_uuid_str) or [] + host_labels_for_squad: List[str] = [] + for inbound_uuid in inbound_uuids: + for host in hosts_by_inbound.get(inbound_uuid, []): + remark = _host_remark(host) + if remark: + host_labels_for_squad.append(remark) + + if host_labels_for_squad: + node_labels.extend(host_labels_for_squad) + continue + + try: + nodes = ( + await self.panel_service.get_internal_squad_accessible_nodes(squad_uuid) or [] + ) + except Exception: + logging.debug( + "Failed to load accessible nodes for premium squad %s", + squad_uuid, + exc_info=True, + ) + nodes = [] + for node in nodes: + if not isinstance(node, dict): + continue + node_uuid = str( + node.get("uuid") or node.get("nodeUuid") or node.get("node_uuid") or "" + ) + node_name = "" + for key in ( + "nodeName", + "name", + "nodeRemark", + "remark", + "label", + "title", + "address", + "host", + ): + value = node.get(key) + if value is None: + continue + candidate = str(value).strip() + if candidate: + node_name = candidate + break + if node_name: + label = node_name + elif node_uuid: + label = f"{node_uuid[:8]}..." + else: + continue + node_labels.append(label) + + squad_labels = [ + squad_name_map.get(str(uuid), f"{str(uuid)[:8]}...") + for uuid in tariff.premium_squad_uuids + ] + payload = { + "ts": now_ts, + "squad_uuids": list(tariff.premium_squad_uuids), + "squad_labels": list(dict.fromkeys(squad_labels)), + "node_labels": list(dict.fromkeys(node_labels)), + } + self._premium_access_cache[cache_key] = payload + return { + "squad_uuids": list(payload["squad_uuids"]), + "squad_labels": list(payload["squad_labels"]), + "node_labels": list(payload["node_labels"]), + } + + def _base_hwid_limit_for_tariff(self, tariff: Optional[Tariff]) -> Optional[int]: + if tariff and tariff.hwid_device_limit is not None: + return int(tariff.hwid_device_limit) + value = self.settings.USER_HWID_DEVICE_LIMIT + return int(value) if value is not None else None + + @staticmethod + def _effective_hwid_limit(base_limit: Optional[int], extra_devices: int = 0) -> Optional[int]: + if base_limit is None: + return None + base_int = max(0, int(base_limit)) + if base_int == 0: + return 0 + return base_int + max(0, int(extra_devices or 0)) + + def calculate_tariff_switch_options( + self, sub: Subscription, target_tariff: Tariff + ) -> Dict[str, Any]: + current_tariff = ( + self._resolve_tariff(sub.tariff_key) if sub.tariff_key else self._default_tariff() + ) + now = datetime.now(timezone.utc) + remaining_days = max(0, (sub.end_date - now).days) if sub.end_date else 0 + effective = float(sub.effective_monthly_price_rub or 0) + current_model = current_tariff.billing_model if current_tariff else "period" + + if current_model == "period" and target_tariff.billing_model == "period": + target_monthly = ( + target_tariff.period_price(1, "rub") + or target_tariff.min_period_price_rub() + or effective + or 1 + ) + remaining_value = remaining_days * (effective / 30) if effective else 0 + days_after = ( + math.floor((remaining_value / float(target_monthly)) * 30) + if target_monthly + else remaining_days + ) + paid_diff = ( + max(0, math.ceil((float(target_monthly) - effective) * remaining_days / 30)) + if effective + else 0 + ) + return { + "mode": "period_to_period", + "remaining_days": remaining_days, + "recalc_days": max(0, days_after), + "paid_diff_rub": paid_diff, + "target_monthly_rub": float(target_monthly), + } + + if current_model == "period" and target_tariff.billing_model == "traffic": + rub_per_gb = target_tariff.rub_per_gb_for_conversion() + remaining_value = remaining_days * (effective / 30) if effective else 0 + converted_gb = math.floor(remaining_value / rub_per_gb) if rub_per_gb else 0 + return { + "mode": "period_to_traffic", + "remaining_days": remaining_days, + "converted_gb": max(0, converted_gb), + "rub_per_gb": rub_per_gb, + } + + return {"mode": "traffic_to_period", "remaining_days": remaining_days} diff --git a/bot/services/subscription_service_impl/traffic.py b/bot/services/subscription_service_impl/traffic.py new file mode 100644 index 0000000..13cd3d1 --- /dev/null +++ b/bot/services/subscription_service_impl/traffic.py @@ -0,0 +1,693 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class TrafficMixin: + async def _activate_traffic_package( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + tariff_key: Optional[str] = None, + sale_mode: str = "traffic", + ) -> Optional[Dict[str, Any]]: + """Activate or extend a traffic-based package instead of a time-based subscription.""" + tariff = self._resolve_tariff(tariff_key, "traffic") if self._tariffs_config() else None + await self._record_payment_context( + session, + payment_db_id, + sale_mode=sale_mode, + tariff_key=tariff.key if tariff else tariff_key, + purchased_gb=float(traffic_gb), + ) + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error("User %s not found for traffic package activation", user_id) + return None + + ( + panel_user_uuid, + panel_sub_link_id, + panel_short_uuid, + _, + ) = await self._get_or_create_panel_user_link_details(session, user_id, db_user) + + if not panel_user_uuid or not panel_sub_link_id: + logging.error( + "Failed to ensure panel linkage for user %s during traffic activation", user_id + ) + return None + + panel_user_data = await self.panel_service.get_user_by_uuid(panel_user_uuid) or {} + current_used, current_limit, _ = self._extract_panel_traffic_details(panel_user_data) + + active_sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, panel_user_uuid + ) + if current_limit is None and active_sub: + current_limit = active_sub.traffic_limit_bytes + if current_used is None and active_sub: + current_used = active_sub.traffic_used_bytes + + purchase_bytes = self.gb_to_bytes(traffic_gb) + extra_hwid_devices = int(getattr(active_sub, "extra_hwid_devices", 0) or 0) + base_hwid_limit = self._base_hwid_limit_for_tariff(tariff) + effective_hwid_limit = self._effective_hwid_limit(base_hwid_limit, extra_hwid_devices) + remaining_bytes = max(0, int(current_limit or 0) - int(current_used or 0)) + new_balance = remaining_bytes + purchase_bytes + new_limit = int(current_used or 0) + new_balance + + start_date = datetime.now(timezone.utc) + # Set a far-future expiry to satisfy panel requirements; keep the latest known expiry if it's further. # noqa: E501 + far_future = self._far_future() + final_end_date = far_future + if active_sub and active_sub.end_date and active_sub.end_date > final_end_date: + final_end_date = active_sub.end_date + + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id + ) + + sub_payload = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": final_end_date, + "duration_months": 0, + "is_active": True, + "status_from_panel": "ACTIVE", + "traffic_limit_bytes": new_limit, + "traffic_used_bytes": current_used, + "provider": provider, + "skip_notifications": True, + "auto_renew_enabled": False, + "tariff_key": tariff.key if tariff else None, + "tier_baseline_bytes": 0, + "topup_balance_bytes": new_balance, + "premium_baseline_bytes": self._premium_limit_for_tariff(tariff, 0), + "premium_topup_balance_bytes": 0, + "premium_topup_used_bytes": 0, + "premium_used_bytes": 0, + "premium_is_limited": False, + "premium_period_start_at": None, + "period_start_at": None, + "is_throttled": False, + "effective_monthly_price_rub": None, + "hwid_device_limit": base_hwid_limit, + "extra_hwid_devices": extra_hwid_devices, + } + + try: + new_or_updated_sub = await subscription_dal.upsert_subscription(session, sub_payload) + except Exception as exc: + logging.error( + "Failed to upsert traffic subscription for user %s: %s", user_id, exc, exc_info=True + ) + return None + + panel_update_payload = self._build_panel_update_payload( + panel_user_uuid=panel_user_uuid, + expire_at=final_end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + traffic_limit_strategy="NO_RESET", + hwid_device_limit=effective_hwid_limit, + ) + if tariff: + panel_update_payload["activeInternalSquads"] = self._panel_squads_for_tariff(tariff) + + panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) + + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload + ) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + "Panel user details update FAILED for traffic package user %s. Response: %s", + panel_user_uuid, + updated_panel_user, + ) + return None + + final_subscription_url = updated_panel_user.get("subscriptionUrl") + final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + await tariff_dal.create_traffic_topup( + session, + subscription_id=new_or_updated_sub.subscription_id, + payment_id=payment_db_id, + purchased_bytes=purchase_bytes, + kind="traffic_package", + ) + + await self._send_payment_success_email( + db_user=db_user, + sale_mode="traffic", + months=0, + traffic_gb=float(traffic_gb), + payment_amount=payment_amount, + end_date=None, + provider=provider, + ) + + return { + "subscription_id": new_or_updated_sub.subscription_id, + "end_date": final_end_date, + "is_active": True, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, + "applied_promo_bonus_days": 0, + "traffic_limit_bytes": new_limit, + "tariff_key": tariff.key if tariff else None, + } + + async def activate_topup( + self, + session: AsyncSession, + user_id: int, + tariff_key: str, + traffic_gb: float, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + ) -> Optional[Dict[str, Any]]: + tariff = self._resolve_tariff(tariff_key) + if tariff.billing_model == "traffic": + return await self._activate_traffic_package( + session=session, + user_id=user_id, + traffic_gb=traffic_gb, + payment_amount=payment_amount, + payment_db_id=payment_db_id, + provider=provider, + tariff_key=tariff.key, + sale_mode="traffic_package", + ) + + await self._record_payment_context( + session, + payment_db_id, + sale_mode="topup", + tariff_key=tariff.key, + purchased_gb=float(traffic_gb), + ) + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + purchase_bytes = self.gb_to_bytes(traffic_gb) + new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes + baseline = int(sub.tier_baseline_bytes or tariff.monthly_bytes) + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0) + new_limit = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=new_topup_balance, + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_for_lim, + ) + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + effective_hwid_limit = self._effective_hwid_limit( + base_hwid_limit, + int(sub.extra_hwid_devices or 0), + ) + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "topup_balance_bytes": new_topup_balance, + "traffic_limit_bytes": new_limit, + "is_throttled": False, + "tariff_key": tariff.key, + "hwid_device_limit": base_hwid_limit, + }, + ) + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=updated_sub.end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + hwid_device_limit=effective_hwid_limit, + ) + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=payment_db_id, + purchased_bytes=purchase_bytes, + kind="topup", + ) + return { + "subscription_id": sub.subscription_id, + "traffic_limit_bytes": new_limit, + "topup_balance_bytes": new_topup_balance, + "tariff_key": tariff.key, + } + + async def activate_premium_topup( + self, + session: AsyncSession, + user_id: int, + tariff_key: str, + traffic_gb: float, + payment_amount: float, + payment_db_id: int, + provider: str = "yookassa", + ) -> Optional[Dict[str, Any]]: + tariff = self._resolve_tariff(tariff_key) + if not tariff or not tariff.premium_squad_uuids: + logging.error( + "Premium top-up requires a tariff with premium squads for user %s", user_id + ) + return None + + await self._record_payment_context( + session, + payment_db_id, + sale_mode="premium_topup", + tariff_key=tariff.key, + purchased_gb=float(traffic_gb), + ) + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + purchase_bytes = self.gb_to_bytes(traffic_gb) + now = datetime.now(timezone.utc) + premium_period_start = month_start(now) + current_period_start = getattr(sub, "premium_period_start_at", None) + same_period = bool(current_period_start and current_period_start == premium_period_start) + previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0 + premium_used = int(sub.premium_used_bytes or 0) if same_period else 0 + premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) + premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes + overflow_to_cover = max( + 0, premium_used - premium_baseline - previous_topup_used - premium_bonus + ) + consume_now = min(premium_topup_balance, overflow_to_cover) + premium_topup_balance -= consume_now + premium_topup_used = previous_topup_used + consume_now + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + premium_bonus, + ) + premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) + premium_is_limited = ( + not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit + ) + + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_used_bytes": premium_used, + "premium_is_limited": premium_is_limited, + "premium_period_start_at": premium_period_start, + "tariff_key": tariff.key, + }, + ) + + panel_payload = { + "uuid": db_user.panel_user_uuid, + "activeInternalSquads": self._panel_squads_for_tariff( + tariff, + include_premium=not premium_is_limited, + ), + } + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=payment_db_id, + purchased_bytes=purchase_bytes, + kind="premium_topup", + ) + return { + "subscription_id": sub.subscription_id, + "premium_limit_bytes": premium_limit, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_is_limited": premium_is_limited, + "tariff_key": tariff.key, + } + + async def sync_premium_squad_access_to_panel( + self, + session: AsyncSession, + user_id: int, + ) -> None: + """Recompute premium quota flags from DB and push internal squads to Remnawave. + + Used when admin overrides change without going through the traffic worker + (Telegram/Web admin premium bonus / unlimited). + """ + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + if not tariff or not getattr(tariff, "premium_squad_uuids", None): + return + + premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) + premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + premium_topup_used = int(getattr(sub, "premium_topup_used_bytes", 0) or 0) + premium_used = int(sub.premium_used_bytes or 0) + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + premium_bonus, + ) + premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) + premium_is_limited = ( + not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit + ) + + if bool(getattr(sub, "premium_is_limited", False)) != premium_is_limited: + await subscription_dal.update_subscription( + session, + sub.subscription_id, + {"premium_is_limited": premium_is_limited}, + ) + + squads = self._panel_squads_for_tariff(tariff, include_premium=not premium_is_limited) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, + {"uuid": db_user.panel_user_uuid, "activeInternalSquads": squads}, + log_response=False, + ) + except Exception: + logging.exception( + "sync_premium_squad_access_to_panel: failed to push squads for user %s", user_id + ) + + async def admin_grant_topup( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + ) -> Optional[Dict[str, Any]]: + """Credit regular traffic to a user as if they purchased a top-up. + + Mirrors :meth:`activate_topup` but skips payment context and tariff + resolution: the grant simply increases ``topup_balance_bytes`` and + recomputes ``traffic_limit_bytes`` from the subscription's current + tier baseline. The audit row in ``traffic_topups`` is stored with + ``kind="admin_topup"`` and ``payment_id=NULL`` so reports stay clean. + """ + try: + gb_value = float(traffic_gb) + except (TypeError, ValueError): + logging.error("admin_grant_topup: invalid traffic_gb=%r", traffic_gb) + return None + if gb_value <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + purchase_bytes = self.gb_to_bytes(gb_value) + baseline_bytes = int( + sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0 + ) + new_topup_balance = int(sub.topup_balance_bytes or 0) + purchase_bytes + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_for_lim = int(getattr(sub, "traffic_used_bytes", 0) or 0) + new_limit = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline_bytes, + topup_balance_bytes=new_topup_balance, + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_for_lim, + ) + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + effective_hwid_limit = self._effective_hwid_limit( + base_hwid_limit, + int(sub.extra_hwid_devices or 0), + ) + updated_sub = await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "topup_balance_bytes": new_topup_balance, + "traffic_limit_bytes": new_limit, + "is_throttled": False, + "hwid_device_limit": base_hwid_limit, + }, + ) + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=updated_sub.end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + hwid_device_limit=effective_hwid_limit, + ) + if tariff is not None: + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(updated_sub, "premium_is_limited", False)), + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception("admin_grant_topup: failed to push panel update for user %s", user_id) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=None, + purchased_bytes=purchase_bytes, + kind="admin_topup", + ) + return { + "subscription_id": sub.subscription_id, + "traffic_limit_bytes": new_limit, + "topup_balance_bytes": new_topup_balance, + "granted_bytes": purchase_bytes, + } + + async def sync_main_traffic_limit_to_panel( + self, + session: AsyncSession, + user_id: int, + ) -> None: + """Recompute main traffic limit from tier + topups + regular_bonus_bytes and push to panel.""" # noqa: E501 + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + baseline = int(sub.tier_baseline_bytes or (tariff.monthly_bytes if tariff else 0) or 0) + rb = int(getattr(sub, "regular_bonus_bytes", 0) or 0) + runl = bool(getattr(sub, "regular_unlimited_override", False)) + used_now = int(getattr(sub, "traffic_used_bytes", 0) or 0) + new_limit = self._compute_main_traffic_limit_bytes( + tier_baseline_bytes=baseline, + topup_balance_bytes=int(sub.topup_balance_bytes or 0), + regular_bonus_bytes=rb, + regular_unlimited_override=runl, + traffic_used_bytes=used_now, + ) + sub.traffic_limit_bytes = new_limit + if runl: + sub.is_throttled = False + base_hwid_limit = ( + int(sub.hwid_device_limit) + if sub.hwid_device_limit is not None + else self._base_hwid_limit_for_tariff(tariff) + ) + effective_hwid_limit = self._effective_hwid_limit( + base_hwid_limit, + int(sub.extra_hwid_devices or 0), + ) + panel_payload = self._build_panel_update_payload( + panel_user_uuid=db_user.panel_user_uuid, + expire_at=sub.end_date, + status="ACTIVE", + traffic_limit_bytes=new_limit, + hwid_device_limit=effective_hwid_limit, + ) + if tariff is not None: + panel_payload["activeInternalSquads"] = self._panel_squads_for_tariff( + tariff, + include_premium=not bool(getattr(sub, "premium_is_limited", False)), + ) + panel_payload.update(self._panel_identity_payload_for_user(db_user)) + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception("sync_main_traffic_limit_to_panel failed for user %s", user_id) + + async def admin_grant_premium_topup( + self, + session: AsyncSession, + user_id: int, + traffic_gb: float, + ) -> Optional[Dict[str, Any]]: + """Credit premium-squad traffic to a user as if they purchased a premium top-up. + + Mirrors :meth:`activate_premium_topup` but skips payment context. + Requires the user's current tariff to expose premium squads. The + balance is absorbed into ``premium_topup_balance_bytes`` (backfilling + any current overuse first), ``premium_is_limited`` is recomputed and, + if access becomes available again, the premium squads are returned to + the user on the panel. The audit row in ``traffic_topups`` is stored + with ``kind="admin_premium_topup"`` and ``payment_id=NULL``. + """ + try: + gb_value = float(traffic_gb) + except (TypeError, ValueError): + logging.error("admin_grant_premium_topup: invalid traffic_gb=%r", traffic_gb) + return None + if gb_value <= 0: + return None + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user or not db_user.panel_user_uuid: + return None + sub = await subscription_dal.get_active_subscription_by_user_id( + session, user_id, db_user.panel_user_uuid + ) + if not sub: + return None + tariff = self._resolve_tariff(sub.tariff_key) if sub.tariff_key else None + if not tariff or not tariff.premium_squad_uuids: + logging.error( + "admin_grant_premium_topup: tariff %s has no premium squads (user %s)", + getattr(tariff, "key", None), + user_id, + ) + return None + + purchase_bytes = self.gb_to_bytes(gb_value) + now = datetime.now(timezone.utc) + premium_period_start = month_start(now) + current_period_start = getattr(sub, "premium_period_start_at", None) + same_period = bool(current_period_start and current_period_start == premium_period_start) + previous_topup_used = int(sub.premium_topup_used_bytes or 0) if same_period else 0 + premium_used = int(sub.premium_used_bytes or 0) if same_period else 0 + premium_baseline = int(tariff.premium_monthly_bytes or sub.premium_baseline_bytes or 0) + premium_bonus = max(0, int(getattr(sub, "premium_bonus_bytes", 0) or 0)) + premium_topup_balance = int(sub.premium_topup_balance_bytes or 0) + purchase_bytes + overflow_to_cover = max( + 0, premium_used - premium_baseline - previous_topup_used - premium_bonus + ) + consume_now = min(premium_topup_balance, overflow_to_cover) + premium_topup_balance -= consume_now + premium_topup_used = previous_topup_used + consume_now + premium_limit = self._premium_effective_limit_bytes( + premium_baseline, + premium_topup_balance, + premium_topup_used, + premium_bonus, + ) + premium_unlimited = bool(getattr(sub, "premium_unlimited_override", False)) + premium_is_limited = ( + not premium_unlimited and premium_limit > 0 and premium_used >= premium_limit + ) + + await subscription_dal.update_subscription( + session, + sub.subscription_id, + { + "premium_baseline_bytes": premium_baseline, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_used_bytes": premium_used, + "premium_is_limited": premium_is_limited, + "premium_period_start_at": premium_period_start, + }, + ) + panel_payload = { + "uuid": db_user.panel_user_uuid, + "activeInternalSquads": self._panel_squads_for_tariff( + tariff, + include_premium=not premium_is_limited, + ), + } + try: + await self.panel_service.update_user_details_on_panel( + db_user.panel_user_uuid, panel_payload + ) + except Exception: + logging.exception( + "admin_grant_premium_topup: failed to push panel update for user %s", + user_id, + ) + await tariff_dal.create_traffic_topup( + session, + subscription_id=sub.subscription_id, + payment_id=None, + purchased_bytes=purchase_bytes, + kind="admin_premium_topup", + ) + return { + "subscription_id": sub.subscription_id, + "premium_limit_bytes": premium_limit, + "premium_topup_balance_bytes": premium_topup_balance, + "premium_topup_used_bytes": premium_topup_used, + "premium_is_limited": premium_is_limited, + "granted_bytes": purchase_bytes, + } diff --git a/bot/services/subscription_service_impl/trial.py b/bot/services/subscription_service_impl/trial.py new file mode 100644 index 0000000..23dcfcb --- /dev/null +++ b/bot/services/subscription_service_impl/trial.py @@ -0,0 +1,119 @@ +# ruff: noqa: F401,F403,F405,I001 +from ._runtime import * # noqa: F403,F405 + + +class TrialSubscriptionMixin: + async def activate_trial_subscription( + self, session: AsyncSession, user_id: int + ) -> Optional[Dict[str, Any]]: + if not self.settings.TRIAL_ENABLED or self.settings.TRIAL_DURATION_DAYS <= 0: + return { + "eligible": False, + "activated": False, + "message_key": "trial_feature_disabled", + } + + db_user = await user_dal.get_user_by_id(session, user_id) + if not db_user: + logging.error(f"User {user_id} not found in DB, cannot activate trial.") + return { + "eligible": False, + "activated": False, + "message_key": "user_not_found_for_trial", + } + + if await self.has_had_any_subscription(session, user_id): + return { + "eligible": False, + "activated": False, + "message_key": "trial_already_had_subscription_or_trial", + } + + ( + panel_user_uuid, + panel_sub_link_id, + panel_short_uuid, + panel_user_created_now, + ) = await self._get_or_create_panel_user_link_details(session, user_id, db_user) + + if not panel_user_uuid or not panel_sub_link_id: + logging.error(f"Failed to get panel link details for trial user {user_id}.") + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed_panel_link", + } + + start_date = datetime.now(timezone.utc) + end_date = start_date + timedelta(days=self.settings.TRIAL_DURATION_DAYS) + + await subscription_dal.deactivate_other_active_subscriptions( + session, panel_user_uuid, panel_sub_link_id + ) + + trial_sub_data = { + "user_id": user_id, + "panel_user_uuid": panel_user_uuid, + "panel_subscription_uuid": panel_sub_link_id, + "start_date": start_date, + "end_date": end_date, + "duration_months": 0, + "is_active": True, + "status_from_panel": "TRIAL", + "traffic_limit_bytes": self.settings.trial_traffic_limit_bytes, + "traffic_limit_strategy": self.settings.TRIAL_TRAFFIC_STRATEGY, + "auto_renew_enabled": False, + } + try: + await subscription_dal.upsert_subscription(session, trial_sub_data) + except Exception as e_upsert: + logging.error( + f"Failed to upsert trial subscription for user {user_id}: {e_upsert}", + exc_info=True, + ) + await session.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed_db", + } + + panel_update_payload = self._build_panel_update_payload( + panel_user_uuid=panel_user_uuid, + expire_at=end_date, + status="ACTIVE", + traffic_limit_bytes=self.settings.trial_traffic_limit_bytes, + traffic_limit_strategy=self.settings.TRIAL_TRAFFIC_STRATEGY, + ) + + panel_update_payload.update(self._panel_identity_payload_for_user(db_user)) + + updated_panel_user = await self.panel_service.update_user_details_on_panel( + panel_user_uuid, panel_update_payload + ) + if not updated_panel_user or updated_panel_user.get("error"): + logging.warning( + f"Panel user details update FAILED for trial user {panel_user_uuid}. Response: {updated_panel_user}" # noqa: E501 + ) + await session.rollback() + return { + "eligible": True, + "activated": False, + "message_key": "trial_activation_failed_panel_update", + } + + await session.commit() + + final_subscription_url = updated_panel_user.get("subscriptionUrl") + final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) + + return { + "eligible": True, + "activated": True, + "end_date": end_date, + "days": self.settings.TRIAL_DURATION_DAYS, + "traffic_gb": self.settings.TRIAL_TRAFFIC_LIMIT_GB, + "panel_user_uuid": panel_user_uuid, + "panel_short_uuid": final_panel_short_uuid, + "subscription_url": final_subscription_url, + } diff --git a/bot/services/tariff_worker.py b/bot/services/tariff_worker.py index 3ddf2e6..7132252 100644 --- a/bot/services/tariff_worker.py +++ b/bot/services/tariff_worker.py @@ -445,7 +445,7 @@ class TariffTrafficWorker: ratio = used_val / limit_val levels = list(getattr(self.settings, "tariff_traffic_warning_levels", [85, 90, 95])) - # Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%). + # Fully exhausted or over quota — one message per period (same idea as regular traffic at 100%). # noqa: E501 if ratio >= 1.0: depleted_existing = await tariff_dal.get_warning( session, diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index d398e28..be1e52a 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -25,7 +25,7 @@ class YooKassaService: if self.settings and not self.settings.YOOKASSA_ENABLED: logging.warning( - "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." + "YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501 ) self.configured = False elif not shop_id or not secret_key: @@ -48,7 +48,7 @@ class YooKassaService: elif bot_username_for_default_return: self.return_url = f"https://t.me/{bot_username_for_default_return}" logging.info( - f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" + f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501 ) else: self.return_url = "https://example.com/payment_error_no_return_url_configured" @@ -77,7 +77,7 @@ class YooKassaService: if not self.settings: logging.error( - "YooKassaService: Settings object not available. Cannot create payment with receipt details." + "YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501 ) return { "error": True, @@ -93,11 +93,11 @@ class YooKassaService: customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL else: logging.error( - "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." + "CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501 ) return { "error": True, - "internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", + "internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501 } try: @@ -163,7 +163,7 @@ class YooKassaService: ) logging.info( - f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" + f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501 ) return { @@ -203,7 +203,7 @@ class YooKassaService: if payment_info_yk: logging.info( - f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" + f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501 ) pm = getattr(payment_info_yk, "payment_method", None) pm_payload: Dict[str, Any] = {} diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 91fb85a..0dec494 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -312,7 +312,7 @@ async def send_direct_message( Отправляет прямое сообщение с дополнительной обработкой для sticker и video_note. Для этих типов медиа отправляется отдельное текстовое сообщение, т.к. они не поддерживают caption. Автоматически фильтрует неподдерживаемые параметры. - """ + """ # noqa: E501 match content.content_type: case "sticker": # Отправляем стикер с отфильтрованными параметрами diff --git a/bot/utils/mini_app_url.py b/bot/utils/mini_app_url.py index 4a7ed83..3dbea92 100644 --- a/bot/utils/mini_app_url.py +++ b/bot/utils/mini_app_url.py @@ -25,7 +25,7 @@ def append_query_params(base_url: str, params: dict[str, str]) -> str: def subscription_mini_app_topup_url(settings: Settings, kind: str) -> Optional[str]: - """Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``).""" + """Return Mini App URL that opens the traffic top-up flow for ``kind`` (``regular`` or ``premium``).""" # noqa: E501 base = str(getattr(settings, "SUBSCRIPTION_MINI_APP_URL", None) or "").strip() if not base: return None diff --git a/config/settings.py b/config/settings.py index 5779b50..31d8302 100644 --- a/config/settings.py +++ b/config/settings.py @@ -138,11 +138,11 @@ class Settings(BaseSettings): # Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment") YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") - # Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) + # Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) # noqa: E501 YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False) YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field( default=True, - description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox.", + description="When true, new YooKassa payments in autopay mode force card binding without a user checkbox.", # noqa: E501 ) LKNPD_INN: Optional[str] = Field( @@ -163,18 +163,18 @@ class Settings(BaseSettings): LKNPD_RECEIPT_NAME_SUBSCRIPTION: str = Field( default="subscription {months} months", alias="NALOGO_RECEIPT_NAME_SUBSCRIPTION", - description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration.", + description="Receipt item name for time-based subscriptions. Use {months} placeholder for duration.", # noqa: E501 ) LKNPD_RECEIPT_NAME_TRAFFIC: str = Field( default="traffic package {gb} GB", alias="NALOGO_RECEIPT_NAME_TRAFFIC", - description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount.", + description="Receipt item name for traffic packages. Use {gb} placeholder for traffic amount.", # noqa: E501 ) WEBHOOK_BASE_URL: Optional[str] = None TRUSTED_PROXIES: Optional[str] = Field( default="127.0.0.1,::1", - description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", + description="Comma-separated list of reverse proxy IPs or CIDRs trusted to forward X-Forwarded-For.", # noqa: E501 ) CRYPTOPAY_TOKEN: Optional[str] = None @@ -188,7 +188,7 @@ class Settings(BaseSettings): PLATEGA_SECRET: Optional[str] = None PLATEGA_PAYMENT_METHOD: int = Field( default=2, - description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.", + description="Legacy Platega payment method ID. Used as fallback for PLATEGA_SBP_METHOD when the new field is unset.", # noqa: E501 ) PLATEGA_SBP_ENABLED: bool = Field( default=False, @@ -236,7 +236,7 @@ class Settings(BaseSettings): STARS_ENABLED: bool = Field(default=True) PAYMENT_METHODS_ORDER: Optional[str] = Field( default=None, - description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", + description="Comma-separated list of payment methods to show (e.g., severpay,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501 ) MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED") @@ -257,16 +257,16 @@ class Settings(BaseSettings): TRAFFIC_PACKAGES: Optional[str] = Field( default=None, - description="Comma-separated list of traffic packages in the format ':', e.g. '10:199,50:799'", + description="Comma-separated list of traffic packages in the format ':', e.g. '10:199,50:799'", # noqa: E501 ) STARS_TRAFFIC_PACKAGES: Optional[str] = Field( default=None, - description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", + description="Comma-separated list of traffic packages priced in Stars, e.g. '5:500,20:1500'", # noqa: E501 ) TARIFFS_CONFIG_PATH: str = Field(default="data/tariffs.json") TARIFF_TRAFFIC_WARNING_LEVELS: str = Field( default="85,90,95", - description="Comma-separated traffic usage warning levels for tariff traffic limits, e.g. '85,90,95'", + description="Comma-separated traffic usage warning levels for tariff traffic limits, e.g. '85,90,95'", # noqa: E501 ) SUBSCRIPTION_NOTIFICATIONS_ENABLED: bool = Field(default=True) @@ -303,15 +303,15 @@ class Settings(BaseSettings): # Referral program configuration REFERRAL_ONE_BONUS_PER_REFEREE: bool = Field( default=True, - description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment.", + description="When true, referral bonuses (for inviter and referee) are applied only once per invited user - on their first successful payment.", # noqa: E501 ) REFERRAL_WELCOME_BONUS_DAYS: int = Field( default=3, - description="Welcome bonus days granted to a newly registered user who joined via referral link.", + description="Welcome bonus days granted to a newly registered user who joined via referral link.", # noqa: E501 ) LEGACY_REFS: bool = Field( default=True, - description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset.", + description="Allow legacy referral links like ref_ to continue working. Defaults to True when unset.", # noqa: E501 ) PANEL_API_URL: Optional[str] = None @@ -367,15 +367,15 @@ class Settings(BaseSettings): WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60) TELEGRAM_OAUTH_CLIENT_ID: Optional[int] = Field( default=None, - description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.", + description="Telegram Web Login Client ID from BotFather. Defaults to the numeric bot ID from BOT_TOKEN.", # noqa: E501 ) TELEGRAM_OAUTH_CLIENT_SECRET: Optional[str] = Field( default=None, - description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.", + description="Telegram Web Login Client Secret from BotFather. Reserved for full OIDC authorization code integrations.", # noqa: E501 ) TELEGRAM_OAUTH_REQUEST_ACCESS: Optional[str] = Field( default="write", - description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.", + description="Comma-separated Telegram Login permissions to request: write,phone. Leave empty to request only OpenID profile.", # noqa: E501 ) SMTP_HOST: str = Field(default="smtp-relay.brevo.com") @@ -393,7 +393,7 @@ class Settings(BaseSettings): EMAIL_CODE_MAX_ATTEMPTS: int = Field(default=5) BRUTE_FORCE_MAX_FAILURES: int = Field( default=5, - description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.", + description="Maximum failed code attempts allowed within the throttle window before a temporary lockout is applied.", # noqa: E501 ) BRUTE_FORCE_WINDOW_SECONDS: int = Field( default=15 * 60, @@ -553,7 +553,7 @@ class Settings(BaseSettings): ] except ValueError: logging.error( - f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers." + f"Invalid ADMIN_IDS_STR format: '{self.ADMIN_IDS_STR}'. Expected comma-separated integers." # noqa: E501 ) return [] return [] @@ -879,7 +879,7 @@ class Settings(BaseSettings): @computed_field @property def platega_sbp_method_resolved(self) -> int: - """SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default.""" + """SBP method ID, falling back to legacy PLATEGA_PAYMENT_METHOD when SBP-specific value is the default.""" # noqa: E501 if self.PLATEGA_SBP_METHOD != 2: return self.PLATEGA_SBP_METHOD return self.PLATEGA_PAYMENT_METHOD or 2 @@ -1040,18 +1040,18 @@ def get_settings() -> Settings: ) if not os.getenv("WEBAPP_SESSION_SECRET"): logging.warning( - "WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." + "WEBAPP_SESSION_SECRET is not set. A generated secret will be used for this process only." # noqa: E501 ) if not os.getenv("WEBHOOK_SECRET_TOKEN"): logging.warning( - "WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only." + "WEBHOOK_SECRET_TOKEN is not set. A generated secret will be used for this process only." # noqa: E501 ) if ( not _settings_instance.YOOKASSA_SHOP_ID or not _settings_instance.YOOKASSA_SECRET_KEY ): logging.warning( - "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." + "CRITICAL: YooKassa credentials (SHOP_ID or SECRET_KEY) are not set. Payments will not work." # noqa: E501 ) if (_settings_instance.LKNPD_INN or _settings_instance.LKNPD_PASSWORD) and not ( _settings_instance.LKNPD_INN and _settings_instance.LKNPD_PASSWORD @@ -1065,15 +1065,15 @@ def get_settings() -> Settings: or not _settings_instance.FREEKASSA_API_KEY ): logging.warning( - "CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." + "CRITICAL: FreeKassa is enabled but SHOP_ID or API key is missing. FreeKassa payments will not work." # noqa: E501 ) if not _settings_instance.FREEKASSA_SECOND_SECRET: logging.warning( - "WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." + "WARNING: FreeKassa second secret is not set. Incoming payment notifications cannot be verified." # noqa: E501 ) if not _settings_instance.subscription_options: logging.warning( - "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." + "CRITICAL: FreeKassa is enabled but no subscription prices are configured (RUB_PRICE_*). Users will not see payment buttons." # noqa: E501 ) if _settings_instance.PLATEGA_ENABLED: @@ -1082,12 +1082,12 @@ def get_settings() -> Settings: or not _settings_instance.PLATEGA_SECRET ): logging.warning( - "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." + "CRITICAL: Platega is enabled but merchant credentials (PLATEGA_MERCHANT_ID/PLATEGA_SECRET) are missing. Platega payments will not work." # noqa: E501 ) if _settings_instance.SEVERPAY_ENABLED: if not _settings_instance.SEVERPAY_MID or not _settings_instance.SEVERPAY_TOKEN: logging.warning( - "CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." + "CRITICAL: SeverPay is enabled but MID or TOKEN is missing. SeverPay payments will not work." # noqa: E501 ) except ValidationError as e: diff --git a/config/tariffs_config.py b/config/tariffs_config.py index ab723b0..358e05c 100644 --- a/config/tariffs_config.py +++ b/config/tariffs_config.py @@ -112,7 +112,7 @@ class Tariff(BaseModel): stars_price = self.prices_stars.get(str(months), 0) or 0 if rub_price <= 0 and stars_price <= 0: raise ValueError( - f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" + f"period tariff {self.key}: period {months} needs a non-zero rub or stars price" # noqa: E501 ) return self @@ -122,7 +122,7 @@ class Tariff(BaseModel): raise ValueError(f"traffic tariff {self.key}: conversion_rate_rub_per_gb must be > 0") if not self.traffic_packages.rub and self.conversion_rate_rub_per_gb is None: raise ValueError( - f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" + f"traffic tariff {self.key}: conversion_rate_rub_per_gb is required without RUB packages" # noqa: E501 ) return self diff --git a/db/dal/ad_dal.py b/db/dal/ad_dal.py index 10a541e..3f6edef 100644 --- a/db/dal/ad_dal.py +++ b/db/dal/ad_dal.py @@ -20,7 +20,7 @@ async def create_campaign( await session.flush() await session.refresh(campaign) logging.info( - f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}" + f"AdCampaign created id={campaign.ad_campaign_id}, source={source}, start={start_param}, cost={cost}" # noqa: E501 ) return campaign diff --git a/db/dal/message_log_dal.py b/db/dal/message_log_dal.py index b501803..e475224 100644 --- a/db/dal/message_log_dal.py +++ b/db/dal/message_log_dal.py @@ -75,7 +75,7 @@ async def create_message_log_no_commit(session: AsyncSession, log_data: dict) -> target_user = await get_user_by_id(session, log_data["target_user_id"]) if not target_user: logging.warning( - f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL." + f"Target user {log_data['target_user_id']} not found for message log. Setting to NULL." # noqa: E501 ) log_data["target_user_id"] = None @@ -83,6 +83,6 @@ async def create_message_log_no_commit(session: AsyncSession, log_data: dict) -> session.add(new_log) logging.debug( - f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}" + f"Message log added to session: user {log_data.get('user_id')}, event {log_data.get('event_type')}" # noqa: E501 ) return new_log diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index dd00791..f1bb997 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -165,7 +165,7 @@ async def update_provider_payment_and_status( await session.flush() await session.refresh(payment) logging.info( - f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." + f"Payment record {payment.payment_id} updated with provider id {provider_payment_id} and status {new_status}." # noqa: E501 ) else: logging.warning(f"Payment record with DB ID {payment_db_id} not found for provider update.") diff --git a/db/dal/promo_code_dal.py b/db/dal/promo_code_dal.py index 934216b..8047792 100644 --- a/db/dal/promo_code_dal.py +++ b/db/dal/promo_code_dal.py @@ -170,7 +170,7 @@ async def record_promo_activation( existing_activation = await get_user_activation_for_promo(session, promo_code_id, user_id) if existing_activation: logging.info( - f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}" + f"User {user_id} has already activated promo code {promo_code_id}. Activation ID: {existing_activation.activation_id}" # noqa: E501 ) return existing_activation @@ -203,6 +203,6 @@ async def record_promo_activation( await session.flush() await session.refresh(new_activation) logging.info( - f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" + f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}" # noqa: E501 ) return new_activation diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 04568f6..b02c1c3 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -99,7 +99,7 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any] if existing_sub: logging.info( - f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" + f"Updating existing subscription {existing_sub.subscription_id} by panel_sub_uuid {panel_sub_uuid}" # noqa: E501 ) for key, value in sub_payload.items(): if hasattr(existing_sub, key): @@ -120,7 +120,7 @@ async def upsert_subscription(session: AsyncSession, sub_payload: Dict[str, Any] user = await get_user_by_id(session, sub_payload["user_id"]) if not user: raise ValueError( - f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." + f"User {sub_payload['user_id']} not found for new subscription with panel_uuid {panel_sub_uuid}." # noqa: E501 ) new_sub = Subscription(**sub_payload) @@ -147,7 +147,7 @@ async def deactivate_other_active_subscriptions( result = await session.execute(stmt) if result.rowcount > 0: logging.info( - f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}." + f"Deactivated {result.rowcount} other active subscriptions for panel_user_uuid {panel_user_uuid}." # noqa: E501 ) @@ -160,7 +160,7 @@ async def deactivate_all_user_subscriptions(session: AsyncSession, user_id: int) result = await session.execute(stmt) if result.rowcount > 0: logging.info( - f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user." + f"Deactivated {result.rowcount} subscriptions for user {user_id} due to missing panel user." # noqa: E501 ) return result.rowcount diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py index 52b4003..f7d76a8 100644 --- a/db/dal/user_billing_dal.py +++ b/db/dal/user_billing_dal.py @@ -169,7 +169,7 @@ async def delete_user_payment_method_by_provider_id( """Delete a saved payment method by its provider payment_method.id for a specific user. Useful when callbacks pass the provider id (e.g., YooKassa pm_...) instead of our internal method_id. - """ + """ # noqa: E501 stmt = select(UserPaymentMethod).where( UserPaymentMethod.user_id == user_id, UserPaymentMethod.provider_payment_method_id == provider_payment_method_id, diff --git a/db/database_setup.py b/db/database_setup.py index d8a401e..5763187 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -65,7 +65,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): logging.warning("init_db: async_engine was None, re-initializing via init_db_connection.") raise RuntimeError( - "async_engine is not initialized. Call init_db_connection and get session_factory first." + "async_engine is not initialized. Call init_db_connection and get session_factory first." # noqa: E501 ) async with async_engine.begin() as conn: @@ -136,7 +136,7 @@ async def init_db(settings: Settings, session_factory: sessionmaker): ) WHERE s.is_active = TRUE AND s.tariff_key IS NULL - """ + """ # noqa: E501 ), { "tariff_key": default_tariff.key, diff --git a/db/migrator.py b/db/migrator.py index 0ed34bd..f721d60 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -244,7 +244,7 @@ def _migration_0008_add_email_verification_code_status(connection: Connection) - if "status" not in columns: connection.execute( text( - "ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'" + "ALTER TABLE email_verification_codes ADD COLUMN status VARCHAR NOT NULL DEFAULT 'active'" # noqa: E501 ) ) else: @@ -331,11 +331,11 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None: ) if "premium_topup_balance_bytes" not in sub_columns: sub_statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_used_bytes" not in sub_columns: sub_statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_used_bytes" not in sub_columns: sub_statements.append( @@ -407,7 +407,7 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None: sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uq_traffic_warning_period_level UNIQUE (subscription_id, period_start_at, level) ) - """ + """ # noqa: E501 ) ) connection.execute( @@ -446,16 +446,16 @@ def _migration_0012_add_tariffs_schema(connection: Connection) -> None: for stmt in [ "CREATE INDEX IF NOT EXISTS ix_subscriptions_tariff_key ON subscriptions (tariff_key)", "CREATE INDEX IF NOT EXISTS ix_subscriptions_is_throttled ON subscriptions (is_throttled)", - "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", + "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_payments_sale_mode ON payments (sale_mode)", "CREATE INDEX IF NOT EXISTS ix_payments_tariff_key ON payments (tariff_key)", - "CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", + "CREATE INDEX IF NOT EXISTS ix_traffic_topups_subscription_id ON traffic_topups (subscription_id)", # noqa: E501 "CREATE INDEX IF NOT EXISTS ix_traffic_topups_payment_id ON traffic_topups (payment_id)", "CREATE INDEX IF NOT EXISTS ix_traffic_topups_kind ON traffic_topups (kind)", - "CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", - "CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", - "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", - "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", + "CREATE INDEX IF NOT EXISTS ix_traffic_warnings_subscription_id ON traffic_warnings (subscription_id)", # noqa: E501 + "CREATE INDEX IF NOT EXISTS ix_tariff_changes_subscription_id ON tariff_changes (subscription_id)", # noqa: E501 + "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_subscription_id ON hwid_device_purchases (subscription_id)", # noqa: E501 + "CREATE INDEX IF NOT EXISTS ix_hwid_device_purchases_payment_id ON hwid_device_purchases (payment_id)", # noqa: E501 ]: connection.execute(text(stmt)) @@ -497,11 +497,11 @@ def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) -> ) if "premium_topup_balance_bytes" not in sub_columns: statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_used_bytes" not in sub_columns: statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_used_bytes" not in sub_columns: statements.append( @@ -519,7 +519,7 @@ def _migration_0014_add_premium_squad_traffic_fields(connection: Connection) -> connection.execute(text(stmt)) connection.execute( text( - "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" + "CREATE INDEX IF NOT EXISTS ix_subscriptions_premium_is_limited ON subscriptions (premium_is_limited)" # noqa: E501 ) ) @@ -530,7 +530,7 @@ def _migration_0015_add_premium_topup_carryover_fields(connection: Connection) - statements: List[str] = [] if "premium_topup_used_bytes" not in sub_columns: statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_period_start_at" not in sub_columns: statements.append( @@ -559,7 +559,7 @@ def _migration_0016_add_message_logs_admin_fields(connection: Connection) -> Non connection.execute( text( - "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" + "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501 ) ) @@ -571,7 +571,7 @@ def _migration_0018_add_premium_admin_overrides(connection: Connection) -> None: statements: List[str] = [] if "premium_unlimited_override" not in sub_columns: statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" + "ALTER TABLE subscriptions ADD COLUMN premium_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501 ) if "premium_bonus_bytes" not in sub_columns: statements.append( @@ -593,7 +593,7 @@ def _migration_0021_add_regular_unlimited_override(connection: Connection) -> No if "regular_unlimited_override" not in sub_columns: connection.execute( text( - "ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" + "ALTER TABLE subscriptions ADD COLUMN regular_unlimited_override BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501 ) ) connection.execute( @@ -670,15 +670,15 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> ) if "premium_baseline_bytes" not in sub_columns: sub_statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_baseline_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_balance_bytes" not in sub_columns: sub_statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_balance_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_topup_used_bytes" not in sub_columns: sub_statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" + "ALTER TABLE subscriptions ADD COLUMN premium_topup_used_bytes BIGINT NOT NULL DEFAULT 0" # noqa: E501 ) if "premium_used_bytes" not in sub_columns: sub_statements.append( @@ -686,7 +686,7 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> ) if "premium_is_limited" not in sub_columns: sub_statements.append( - "ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" + "ALTER TABLE subscriptions ADD COLUMN premium_is_limited BOOLEAN NOT NULL DEFAULT FALSE" # noqa: E501 ) if "is_throttled" not in sub_columns: sub_statements.append( @@ -718,13 +718,13 @@ def _migration_0017_reconcile_legacy_admin_api_schema(connection: Connection) -> ) if "target_user_id" not in msg_columns: msg_statements.append( - "ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" + "ALTER TABLE message_logs ADD COLUMN target_user_id BIGINT REFERENCES users(user_id)" # noqa: E501 ) for stmt in msg_statements: connection.execute(text(stmt)) connection.execute( text( - "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" + "CREATE INDEX IF NOT EXISTS ix_message_logs_target_user_id ON message_logs (target_user_id)" # noqa: E501 ) ) @@ -828,12 +828,12 @@ MIGRATIONS: List[Migration] = [ ), Migration( id="0018_add_premium_admin_overrides", - description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", + description="Per-subscription admin overrides for premium traffic (unlimited toggle + bonus bytes)", # noqa: E501 upgrade=_migration_0018_add_premium_admin_overrides, ), Migration( id="0019_clear_subscription_months_for_non_subscription_payments", - description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", + description="Backfill: null out subscription_duration_months for legacy traffic/topup/hwid payments", # noqa: E501 upgrade=_migration_0019_clear_subscription_months_for_non_subscription_payments, ), Migration( diff --git a/db/models.py b/db/models.py index 2a450f8..3ce5338 100644 --- a/db/models.py +++ b/db/models.py @@ -136,7 +136,7 @@ class Subscription(Base): user = relationship("User", back_populates="subscriptions") def __repr__(self): - return f"" + return f"" # noqa: E501 class EmailVerificationCode(Base): @@ -402,7 +402,7 @@ class AdCampaign(Base): ) def __repr__(self): - return f"" + return f"" # noqa: E501 class AdAttribution(Base): diff --git a/pyproject.toml b/pyproject.toml index 298c300..d15daf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,12 +16,9 @@ select = [ "I", # isort ] ignore = [ - "E501", # line length (legacy lines; tighten later if desired) "E731", # lambda assignment (common in handlers) - "E701", # multiple statements on one line (compact early returns) "E712", # `== True` / `== False` in SQLAlchemy filters "E711", # `== None` on ORM columns (use `.is_(None)` in refactors) - "F841", # unused locals (clean up incrementally) ] [tool.ruff.lint.isort] diff --git a/scripts/send_premium_traffic_warning_test.py b/scripts/send_premium_traffic_warning_test.py index ad04b0e..33e1ff8 100644 --- a/scripts/send_premium_traffic_warning_test.py +++ b/scripts/send_premium_traffic_warning_test.py @@ -6,7 +6,7 @@ Target chat (first match): 3) First entry in ``ADMIN_IDS`` from app settings No default Telegram ID is embedded in this script. -""" +""" # noqa: E501 import asyncio import os diff --git a/tests/test_panel_api_service_logging.py b/tests/test_panel_api_service_logging.py new file mode 100644 index 0000000..c76b5a8 --- /dev/null +++ b/tests/test_panel_api_service_logging.py @@ -0,0 +1,51 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from bot.services.panel_api_service import PanelApiService + + +class PanelApiServiceLoggingTests(unittest.IsolatedAsyncioTestCase): + def _make_service(self) -> PanelApiService: + return PanelApiService( + SimpleNamespace( + PANEL_API_URL="https://panel.example.test/api", + PANEL_API_KEY="panel-key", + USER_HWID_DEVICE_LIMIT=None, + ) + ) + + async def test_update_user_details_does_not_log_full_response_by_default(self): + service = self._make_service() + service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) + + with patch("bot.services.panel_api_service.logging.info") as info_log: + result = await service.update_user_details_on_panel( + "user-uuid", + {"description": "profile"}, + ) + + self.assertEqual(result, {"uuid": "user-uuid"}) + service._request.assert_awaited_once_with( + "PATCH", + "/users", + json={"description": "profile", "uuid": "user-uuid"}, + log_full_response=False, + ) + info_log.assert_not_called() + + async def test_update_user_details_can_still_request_full_response_logging(self): + service = self._make_service() + service._request = AsyncMock(return_value={"response": {"uuid": "user-uuid"}}) + + await service.update_user_details_on_panel( + "user-uuid", + {"description": "profile"}, + log_response=True, + ) + + self.assertTrue(service._request.await_args.kwargs["log_full_response"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_security.py b/tests/test_security.py index 611fc6b..7242b94 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -9,6 +9,7 @@ from urllib.parse import parse_qs, urlsplit from aiohttp import web from bot.app.web import admin_api, subscription_webapp +from bot.app.web.admin_api_impl import settings as admin_settings_routes from bot.app.web.webapp_auth import ( create_telegram_oauth_nonce, create_webapp_session_token, @@ -405,7 +406,7 @@ class AdminSettingsSecurityTests(unittest.IsolatedAsyncioTestCase): request.get = lambda key, default=None: getattr(request, key, default) with ( - patch.object(admin_api, "_require_admin_user_id", return_value=1), + patch.object(admin_settings_routes, "_require_admin_user_id", return_value=1), patch.object( admin_api.app_settings_dal, "get_overrides_with_meta", diff --git a/tests/test_subscription_service_behavior.py b/tests/test_subscription_service_behavior.py new file mode 100644 index 0000000..c331aac --- /dev/null +++ b/tests/test_subscription_service_behavior.py @@ -0,0 +1,292 @@ +import json +import tempfile +import unittest +from datetime import datetime, timezone +from pathlib import Path +from unittest.mock import AsyncMock + +from bot.services.panel_api_service import PanelApiService +from bot.services.subscription_service import SubscriptionService +from config.settings import Settings + +GIB = 1024**3 + + +def _tariffs_config_payload() -> dict: + return { + "default_tariff": "standard", + "tariffs": [ + { + "key": "standard", + "names": {"en": "Standard"}, + "descriptions": {"en": "Base period plan"}, + "squad_uuids": ["main-squad", "shared-squad"], + "premium_squad_uuids": ["premium-squad", "shared-squad"], + "premium_monthly_gb": 25, + "billing_model": "period", + "monthly_gb": 100, + "prices_rub": {"1": 150}, + "prices_stars": {"1": 0}, + "enabled_periods": [1], + "hwid_device_limit": 3, + "enabled": True, + }, + { + "key": "traffic", + "names": {"en": "Traffic"}, + "descriptions": {"en": "Traffic package"}, + "squad_uuids": ["traffic-squad"], + "billing_model": "traffic", + "monthly_gb": 0, + "traffic_packages": {"rub": [{"gb": 50, "price": 400}], "stars": []}, + "enabled": True, + }, + ], + } + + +def _make_settings(payload: dict, tmpdir: str, **overrides) -> Settings: + config_path = Path(tmpdir) / "tariffs.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + values = { + "_env_file": None, + "BOT_TOKEN": "token", + "POSTGRES_USER": "app_user", + "POSTGRES_PASSWORD": "app_password", + "TARIFFS_CONFIG_PATH": str(config_path), + } + values.update(overrides) + return Settings(**values) + + +def _make_service(settings: Settings) -> SubscriptionService: + panel_service = AsyncMock(spec=PanelApiService) + return SubscriptionService(settings, panel_service) + + +class SubscriptionServiceCalculationTests(unittest.TestCase): + def test_panel_squads_for_tariff_deduplicates_and_can_hide_premium(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + tariff = settings.tariffs_config.require("standard") + + self.assertEqual( + service._panel_squads_for_tariff(tariff), + ["main-squad", "shared-squad", "premium-squad"], + ) + self.assertEqual( + service._panel_squads_for_tariff(tariff, include_premium=False), + ["main-squad", "shared-squad"], + ) + + def test_panel_squads_falls_back_to_default_settings_without_tariff(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + USER_SQUAD_UUIDS="fallback-a, fallback-b", + ) + service = _make_service(settings) + + self.assertEqual( + service._panel_squads_for_tariff(None), + ["fallback-a", "fallback-b"], + ) + + def test_main_traffic_limit_includes_topup_bonus_and_unlimited_floor(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + + regular_limit = service._compute_main_traffic_limit_bytes( + tier_baseline_bytes=100 * GIB, + topup_balance_bytes=10 * GIB, + regular_bonus_bytes=5 * GIB, + regular_unlimited_override=False, + traffic_used_bytes=500 * GIB, + ) + self.assertEqual(regular_limit, 115 * GIB) + + unlimited_limit = service._compute_main_traffic_limit_bytes( + tier_baseline_bytes=100 * GIB, + topup_balance_bytes=0, + regular_bonus_bytes=0, + regular_unlimited_override=True, + traffic_used_bytes=2 * (1024**5), + ) + self.assertEqual(unlimited_limit, 2 * (1024**5) + 512 * GIB) + + def test_premium_effective_limit_ignores_negative_balances(self): + self.assertEqual( + SubscriptionService._premium_effective_limit_bytes( + premium_baseline_bytes=25 * GIB, + premium_topup_balance_bytes=-5 * GIB, + premium_topup_used_bytes=3 * GIB, + premium_bonus_bytes=-1 * GIB, + ), + 28 * GIB, + ) + + def test_build_panel_update_payload_preserves_panel_contract_fields(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings( + _tariffs_config_payload(), + tmpdir, + USER_SQUAD_UUIDS="squad-a,squad-b", + USER_EXTERNAL_SQUAD_UUID="external-squad", + USER_TRAFFIC_STRATEGY="MONTH", + ) + service = _make_service(settings) + expire_at = datetime(2026, 5, 13, 12, 34, 56, 789000, tzinfo=timezone.utc) + + payload = service._build_panel_update_payload( + panel_user_uuid="panel-uuid", + expire_at=expire_at, + status="ACTIVE", + traffic_limit_bytes=12345, + hwid_device_limit="4", + ) + + self.assertEqual(payload["uuid"], "panel-uuid") + self.assertEqual(payload["expireAt"], "2026-05-13T12:34:56.789Z") + self.assertEqual(payload["status"], "ACTIVE") + self.assertEqual(payload["trafficLimitBytes"], 12345) + self.assertEqual(payload["trafficLimitStrategy"], "MONTH") + self.assertEqual(payload["hwidDeviceLimit"], 4) + self.assertEqual(payload["activeInternalSquads"], ["squad-a", "squad-b"]) + self.assertEqual(payload["externalSquadUuid"], "external-squad") + + def test_extract_panel_traffic_details_accepts_nested_and_top_level_shapes(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + + self.assertEqual( + service._extract_panel_traffic_details( + { + "userTraffic": { + "usedTrafficBytes": 15, + "trafficLimitStrategy": "MONTH", + }, + "trafficLimitBytes": 100, + } + ), + (15, 100, "MONTH"), + ) + self.assertEqual( + service._extract_panel_traffic_details( + { + "usedTrafficBytes": 20, + "trafficLimitBytes": 200, + "trafficLimitStrategy": "NO_RESET", + } + ), + (20, 200, "NO_RESET"), + ) + + +class SubscriptionServiceActivationDispatchTests(unittest.IsolatedAsyncioTestCase): + async def test_activate_subscription_dispatches_traffic_sale_mode(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + service._activate_traffic_package = AsyncMock(return_value={"kind": "traffic"}) + + result = await service.activate_subscription( + session=AsyncMock(), + user_id=42, + months=3, + payment_amount=500, + payment_db_id=9, + provider="stars", + sale_mode="traffic@traffic", + ) + + self.assertEqual(result, {"kind": "traffic"}) + service._activate_traffic_package.assert_awaited_once() + kwargs = service._activate_traffic_package.await_args.kwargs + self.assertEqual(kwargs["user_id"], 42) + self.assertEqual(kwargs["traffic_gb"], 3.0) + self.assertEqual(kwargs["payment_db_id"], 9) + self.assertEqual(kwargs["provider"], "stars") + self.assertEqual(kwargs["tariff_key"], "traffic") + self.assertEqual(kwargs["sale_mode"], "traffic_package") + + async def test_activate_subscription_dispatches_regular_topup_sale_mode(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + service.activate_topup = AsyncMock(return_value={"kind": "topup"}) + + result = await service.activate_subscription( + session=AsyncMock(), + user_id=42, + months=1, + payment_amount=250, + payment_db_id=10, + provider="yookassa", + sale_mode="topup@standard", + traffic_gb=12.5, + ) + + self.assertEqual(result, {"kind": "topup"}) + service.activate_topup.assert_awaited_once() + kwargs = service.activate_topup.await_args.kwargs + self.assertEqual(kwargs["user_id"], 42) + self.assertEqual(kwargs["tariff_key"], "standard") + self.assertEqual(kwargs["traffic_gb"], 12.5) + self.assertEqual(kwargs["payment_amount"], 250) + self.assertEqual(kwargs["payment_db_id"], 10) + + async def test_activate_subscription_dispatches_premium_topup_sale_mode(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + service.activate_premium_topup = AsyncMock(return_value={"kind": "premium"}) + + result = await service.activate_subscription( + session=AsyncMock(), + user_id=77, + months=1, + payment_amount=350, + payment_db_id=11, + provider="cryptopay", + sale_mode="premium_topup|standard", + traffic_gb=20, + ) + + self.assertEqual(result, {"kind": "premium"}) + service.activate_premium_topup.assert_awaited_once() + kwargs = service.activate_premium_topup.await_args.kwargs + self.assertEqual(kwargs["user_id"], 77) + self.assertEqual(kwargs["tariff_key"], "standard") + self.assertEqual(kwargs["traffic_gb"], 20) + self.assertEqual(kwargs["provider"], "cryptopay") + + async def test_activate_subscription_dispatches_hwid_device_sale_mode(self): + with tempfile.TemporaryDirectory() as tmpdir: + settings = _make_settings(_tariffs_config_payload(), tmpdir) + service = _make_service(settings) + service.activate_hwid_device_topup = AsyncMock(return_value={"kind": "hwid"}) + + result = await service.activate_subscription( + session=AsyncMock(), + user_id=88, + months=2, + payment_amount=150, + payment_db_id=12, + sale_mode="hwid_devices@standard", + ) + + self.assertEqual(result, {"kind": "hwid"}) + service.activate_hwid_device_topup.assert_awaited_once() + kwargs = service.activate_hwid_device_topup.await_args.kwargs + self.assertEqual(kwargs["user_id"], 88) + self.assertEqual(kwargs["device_count"], 2) + self.assertEqual(kwargs["tariff_key"], "standard") + self.assertEqual(kwargs["payment_db_id"], 12) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_webapp_assets.py b/tests/test_webapp_assets.py index a39f7c5..4d3be96 100644 --- a/tests/test_webapp_assets.py +++ b/tests/test_webapp_assets.py @@ -9,6 +9,7 @@ from types import SimpleNamespace from unittest.mock import patch from bot.app.web import subscription_webapp +from bot.app.web.webapp import assets as webapp_assets from config.settings import Settings @@ -196,7 +197,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): os.utime(old_asset, (1, 1)) os.utime(new_asset, (2, 2)) - with patch.object(subscription_webapp, "ASSET_DIR", asset_dir): + with patch.object(webapp_assets, "ASSET_DIR", asset_dir): self.assertEqual( subscription_webapp._resolve_webapp_js_asset_name(), "subscription_webapp.min.22222222.js", @@ -213,7 +214,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): match_info={"asset_hash": "abcdef12"}, ) - with patch.object(subscription_webapp, "ASSET_DIR", asset_dir): + with patch.object(webapp_assets, "ASSET_DIR", asset_dir): response = await subscription_webapp.js_asset_route(request) self.assertEqual( @@ -226,7 +227,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): logo_url = "https://cdn.example.com/logo.png" logo = (b"png-bytes", "image/png") - with patch.object(subscription_webapp, "WEBAPP_LOGO_CACHE_DIR", Path(tmpdir)): + with patch.object(webapp_assets, "WEBAPP_LOGO_CACHE_DIR", Path(tmpdir)): subscription_webapp._write_webapp_logo_to_disk(logo_url, logo) self.assertEqual(subscription_webapp._read_webapp_logo_from_disk(logo_url), logo) @@ -242,13 +243,13 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): } with ( - patch.object(subscription_webapp, "WEBAPP_LOGO_CACHE_DIR", Path(tmpdir)), + patch.object(webapp_assets, "WEBAPP_LOGO_CACHE_DIR", Path(tmpdir)), patch.object( - subscription_webapp, + webapp_assets, "_hostname_resolves_to_public_address", return_value=True, ), - patch.object(subscription_webapp, "_fetch_webapp_logo") as fetch_logo, + patch.object(webapp_assets, "_fetch_webapp_logo") as fetch_logo, ): subscription_webapp._write_webapp_logo_to_disk(logo_url, logo) @@ -259,7 +260,7 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): def test_webapp_animated_emoji_disk_cache_roundtrip(self): with tempfile.TemporaryDirectory() as tmpdir: - with patch.object(subscription_webapp, "WEBAPP_EMOJI_CACHE_DIR", Path(tmpdir)): + with patch.object(webapp_assets, "WEBAPP_EMOJI_CACHE_DIR", Path(tmpdir)): subscription_webapp._write_webapp_animated_emoji_to_disk( "1f929", "gif", @@ -283,8 +284,8 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase): } with ( - patch.object(subscription_webapp, "WEBAPP_EMOJI_CACHE_DIR", Path(tmpdir)), - patch.object(subscription_webapp, "_fetch_webapp_animated_emoji") as fetch_emoji, + patch.object(webapp_assets, "WEBAPP_EMOJI_CACHE_DIR", Path(tmpdir)), + patch.object(webapp_assets, "_fetch_webapp_animated_emoji") as fetch_emoji, ): subscription_webapp._write_webapp_animated_emoji_to_disk( "1f929", diff --git a/tests/test_webapp_route_contract.py b/tests/test_webapp_route_contract.py new file mode 100644 index 0000000..444b07e --- /dev/null +++ b/tests/test_webapp_route_contract.py @@ -0,0 +1,194 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from aiohttp import web + +from bot.app.web import admin_api, subscription_webapp +from bot.app.web.admin_api_impl import auth as admin_auth_routes +from bot.app.web.webapp_auth import create_webapp_session_token + + +class _Request(dict): + def __init__(self, *, path="/", app=None, headers=None, cookies=None): + super().__init__() + self.path = path + self.app = app or {} + self.headers = headers or {} + self.cookies = cookies or {} + + +class _AsyncSessionFactory: + def __call__(self): + return self + + async def __aenter__(self): + return object() + + async def __aexit__(self, exc_type, exc, tb): + return False + + +def _route_map(app: web.Application) -> dict[tuple[str, str], str]: + return { + (route.method, route.resource.canonical): route.handler.__name__ + for route in app.router.routes() + if route.method != "HEAD" + } + + +class WebAppRouteContractTests(unittest.TestCase): + def test_subscription_webapp_registers_public_and_api_routes(self): + app = web.Application() + + subscription_webapp.setup_subscription_webapp_routes(app) + + routes = _route_map(app) + expected = { + ("GET", "/"): "index_route", + ("GET", "/home"): "index_route", + ("GET", "/invite"): "index_route", + ("GET", "/devices"): "index_route", + ("GET", "/settings"): "index_route", + ("GET", "/admin"): "index_route", + ("GET", "/admin/{section}"): "index_route", + ("GET", "/admin/users/{user_id}"): "index_route", + ("GET", "/auth/telegram/start"): "telegram_oauth_start_route", + ("GET", "/auth/telegram/callback"): "telegram_oauth_callback_route", + ("GET", "/health"): "health_route", + ("GET", "/webapp-logo"): "webapp_logo_route", + ("GET", "/webapp-emoji/{codepoints}/512.{ext}"): "webapp_animated_emoji_route", + ("GET", "/subscription_webapp.css"): "css_asset_route", + ("GET", "/subscription_webapp.min.{asset_hash}.js"): "js_asset_route", + ("GET", "/subscription_webapp.js"): "js_asset_route", + ("POST", "/api/auth/telegram/nonce"): "telegram_oauth_nonce_route", + ("POST", "/api/auth/token"): "auth_token_route", + ("POST", "/api/auth/email/request"): "email_auth_request_route", + ("POST", "/api/auth/email/verify"): "email_auth_verify_route", + ("POST", "/api/auth/email/magic"): "email_auth_magic_route", + ("POST", "/api/auth/logout"): "logout_route", + ("GET", "/api/me"): "me_route", + ("GET", "/api/account/avatar"): "account_avatar_route", + ("POST", "/api/account/language"): "account_language_route", + ("POST", "/api/account/email/request"): "account_email_request_route", + ("POST", "/api/account/email/verify"): "account_email_verify_route", + ("POST", "/api/account/telegram/link"): "account_telegram_link_route", + ("POST", "/api/promo/apply"): "apply_promo_route", + ("POST", "/api/trial/activate"): "activate_trial_route", + ("GET", "/api/devices"): "devices_route", + ("POST", "/api/devices/disconnect"): "disconnect_device_route", + ("GET", "/api/devices/topup-options"): "device_topup_options_route", + ("GET", "/api/tariffs/topup-options"): "tariff_topup_options_route", + ("GET", "/api/tariffs/change-options"): "tariff_change_options_route", + ("POST", "/api/tariffs/change"): "tariff_change_route", + ("POST", "/api/tariffs/change-payment"): "tariff_change_payment_route", + ("POST", "/api/payments"): "create_payment_route", + ("GET", "/api/payments/{payment_id}"): "payment_status_route", + } + + for key, handler_name in expected.items(): + self.assertEqual(routes.get(key), handler_name, key) + + def test_admin_api_registers_expected_routes(self): + app = web.Application() + + admin_api.setup_admin_routes(app) + + routes = _route_map(app) + expected = { + ("GET", "/api/admin/me"): "admin_me_route", + ("GET", "/api/admin/stats"): "admin_stats_route", + ("GET", "/api/admin/users"): "admin_users_list_route", + ("GET", "/api/admin/users/{user_id}"): "admin_user_detail_route", + ("GET", "/api/admin/users/{user_id}/avatar"): "admin_user_avatar_route", + ("POST", "/api/admin/users/{user_id}/ban"): "admin_user_ban_route", + ("POST", "/api/admin/users/{user_id}/message"): "admin_user_message_route", + ( + "POST", + "/api/admin/users/{user_id}/message/preview", + ): "admin_user_message_preview_route", + ("POST", "/api/admin/users/{user_id}/reset-trial"): "admin_user_reset_trial_route", + ("POST", "/api/admin/users/{user_id}/extend"): "admin_user_extend_route", + ( + "POST", + "/api/admin/users/{user_id}/premium-override", + ): "admin_user_premium_override_route", + ( + "POST", + "/api/admin/users/{user_id}/regular-traffic-override", + ): "admin_user_regular_traffic_override_route", + ("POST", "/api/admin/users/{user_id}/traffic-grant"): "admin_user_traffic_grant_route", + ("DELETE", "/api/admin/users/{user_id}"): "admin_user_delete_route", + ("GET", "/api/admin/payments"): "admin_payments_list_route", + ("GET", "/api/admin/payments/export.csv"): "admin_payments_export_route", + ("GET", "/api/admin/promos"): "admin_promos_list_route", + ("POST", "/api/admin/promos"): "admin_promo_create_route", + ("PATCH", "/api/admin/promos/{promo_id}"): "admin_promo_update_route", + ("DELETE", "/api/admin/promos/{promo_id}"): "admin_promo_delete_route", + ("GET", "/api/admin/logs"): "admin_logs_route", + ("POST", "/api/admin/broadcast"): "admin_broadcast_route", + ("POST", "/api/admin/sync"): "admin_sync_route", + ("GET", "/api/admin/ads"): "admin_ads_list_route", + ("POST", "/api/admin/ads"): "admin_ad_create_route", + ("POST", "/api/admin/ads/{campaign_id}/toggle"): "admin_ad_toggle_route", + ("DELETE", "/api/admin/ads/{campaign_id}"): "admin_ad_delete_route", + ("GET", "/api/admin/settings"): "admin_settings_get_route", + ("PATCH", "/api/admin/settings"): "admin_settings_patch_route", + ("GET", "/api/admin/tariffs"): "admin_tariffs_get_route", + ("PUT", "/api/admin/tariffs"): "admin_tariffs_save_route", + ("GET", "/api/admin/panel/internal-squads"): "admin_panel_internal_squads_route", + } + + for key, handler_name in expected.items(): + self.assertEqual(routes.get(key), handler_name, key) + + +class AdminApiAuthContractTests(unittest.IsolatedAsyncioTestCase): + def _settings(self): + return SimpleNamespace( + ADMIN_IDS=[999], + WEBAPP_SESSION_SECRET="session-secret", + WEBAPP_SESSION_TTL_SECONDS=3600, + ) + + async def test_admin_auth_middleware_resolves_telegram_id_from_webapp_session(self): + settings = self._settings() + token = create_webapp_session_token(settings, 42) + request = _Request( + path="/api/admin/me", + app={ + "settings": settings, + "async_session_factory": _AsyncSessionFactory(), + }, + headers={}, + cookies={"rw_webapp_session": token}, + ) + handler = AsyncMock(return_value=web.json_response({"ok": True})) + db_user = SimpleNamespace(user_id=42, telegram_id=999) + + with patch.object( + admin_auth_routes.user_dal, + "get_user_by_id", + AsyncMock(return_value=db_user), + ): + response = await admin_api.admin_auth_middleware(request, handler) + + self.assertEqual(response.status, 200) + self.assertEqual(request["admin_telegram_id"], 999) + handler.assert_awaited_once_with(request) + + async def test_admin_route_rejects_authenticated_non_admin(self): + settings = self._settings() + token = create_webapp_session_token(settings, 42) + request = _Request( + path="/api/admin/me", + app={"settings": settings}, + headers={}, + cookies={"rw_webapp_session": token}, + ) + request["admin_telegram_id"] = 123 + + with self.assertRaises(web.HTTPForbidden) as raised: + await admin_api.admin_me_route(request) + + self.assertEqual(raised.exception.status, 403)