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(