diff --git a/.env.example b/.env.example index 93e8a4ca..77f05953 100644 --- a/.env.example +++ b/.env.example @@ -290,6 +290,10 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_SERVICE_NAME_EN=Bedolaga VPN +MINIAPP_SERVICE_NAME_RU=Bedolaga VPN +MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection +MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/README.md b/README.md index 189ad150..c1fdb9f1 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,10 @@ docker compose logs Подробное пошаговое руководство по запуску административного веб-API и подключению внешней панели находится в [docs/web-admin-integration.md](docs/web-admin-integration.md). +### 📱 Telegram Mini App с подпиской + +Инструкция по развёртыванию мини-приложения, публикации статической страницы и настройке reverse-proxy доступна в [docs/miniapp-setup.md](docs/miniapp-setup.md). + ### 📊 Статус серверов в главном меню | Переменная | Описание | Пример | @@ -531,6 +535,10 @@ CONNECT_BUTTON_MODE=guide # URL для режима miniapp_custom (обязателен при CONNECT_BUTTON_MODE=miniapp_custom) MINIAPP_CUSTOM_URL= +MINIAPP_SERVICE_NAME_EN=Bedolaga VPN +MINIAPP_SERVICE_NAME_RU=Bedolaga VPN +MINIAPP_SERVICE_DESCRIPTION_EN=Secure & Fast Connection +MINIAPP_SERVICE_DESCRIPTION_RU=Безопасное и быстрое подключение # Параметры режима happ_cryptolink CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED=false diff --git a/app/config.py b/app/config.py index a2cbaf80..991efca3 100644 --- a/app/config.py +++ b/app/config.py @@ -213,6 +213,11 @@ class Settings(BaseSettings): CONNECT_BUTTON_MODE: str = "guide" MINIAPP_CUSTOM_URL: str = "" + MINIAPP_PURCHASE_URL: str = "" + MINIAPP_SERVICE_NAME_EN: str = "Bedolaga VPN" + MINIAPP_SERVICE_NAME_RU: str = "Bedolaga VPN" + MINIAPP_SERVICE_DESCRIPTION_EN: str = "Secure & Fast Connection" + MINIAPP_SERVICE_DESCRIPTION_RU: str = "Безопасное и быстрое подключение" CONNECT_BUTTON_HAPP_DOWNLOAD_ENABLED: bool = False HAPP_CRYPTOLINK_REDIRECT_TEMPLATE: Optional[str] = None HAPP_DOWNLOAD_LINK_IOS: Optional[str] = None @@ -518,6 +523,34 @@ class Settings(BaseSettings): def is_deep_links_enabled(self) -> bool: return self.ENABLE_DEEP_LINKS + + def get_miniapp_branding(self) -> Dict[str, Dict[str, Optional[str]]]: + def _clean(value: Optional[str]) -> Optional[str]: + if value is None: + return None + value_str = str(value).strip() + return value_str or None + + name_en = _clean(self.MINIAPP_SERVICE_NAME_EN) + name_ru = _clean(self.MINIAPP_SERVICE_NAME_RU) + desc_en = _clean(self.MINIAPP_SERVICE_DESCRIPTION_EN) + desc_ru = _clean(self.MINIAPP_SERVICE_DESCRIPTION_RU) + + default_name = name_en or name_ru or "RemnaWave VPN" + default_description = desc_en or desc_ru or "Secure & Fast Connection" + + return { + "service_name": { + "default": default_name, + "en": name_en, + "ru": name_ru, + }, + "service_description": { + "default": default_description, + "en": desc_en, + "ru": desc_ru, + }, + } def get_app_config_cache_ttl(self) -> int: return self.APP_CONFIG_CACHE_TTL diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index 0df8e252..1ce9432a 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -89,6 +89,7 @@ class BotConfigurationService: "HAPP": "🅷 Happ настройки", "SKIP": "⚡ Быстрый старт", "ADDITIONAL": "📱 Приложения и DeepLinks", + "MINIAPP": "📱 Mini App", "DATABASE": "🗄️ Режим БД", "POSTGRES": "🐘 PostgreSQL", "SQLITE": "💾 SQLite", @@ -189,6 +190,7 @@ class BotConfigurationService: "CONNECT_BUTTON_HAPP": "HAPP", "HAPP_": "HAPP", "SKIP_": "SKIP", + "MINIAPP_": "MINIAPP", "MONITORING_": "MONITORING", "NOTIFICATION_": "NOTIFICATIONS", "SERVER_STATUS": "SERVER", diff --git a/app/utils/telegram_webapp.py b/app/utils/telegram_webapp.py new file mode 100644 index 00000000..43d74d5c --- /dev/null +++ b/app/utils/telegram_webapp.py @@ -0,0 +1,91 @@ +"""Utilities for validating Telegram WebApp initialization data.""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import time +from typing import Any, Dict +from urllib.parse import parse_qsl + + +class TelegramWebAppAuthError(Exception): + """Raised when Telegram WebApp init data fails validation.""" + + +def parse_webapp_init_data( + init_data: str, + bot_token: str, + *, + max_age_seconds: int = 86400, +) -> Dict[str, Any]: + """Validate and parse Telegram WebApp init data. + + Args: + init_data: Raw init data string provided by Telegram WebApp. + bot_token: Bot token used to verify the signature. + max_age_seconds: Maximum allowed age for the payload. Defaults to 24 hours. + + Returns: + Parsed init data as a dictionary. + + Raises: + TelegramWebAppAuthError: If validation fails. + """ + + if not init_data: + raise TelegramWebAppAuthError("Missing init data") + + if not bot_token: + raise TelegramWebAppAuthError("Bot token is not configured") + + parsed_pairs = parse_qsl(init_data, strict_parsing=True, keep_blank_values=True) + data: Dict[str, Any] = {key: value for key, value in parsed_pairs} + + received_hash = data.pop("hash", None) + if not received_hash: + raise TelegramWebAppAuthError("Missing init data signature") + + data_check_string = "\n".join( + f"{key}={value}" for key, value in sorted(data.items()) + ) + + secret_key = hmac.new( + key=b"WebAppData", + msg=bot_token.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + + computed_hash = hmac.new( + key=secret_key, + msg=data_check_string.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + + if not hmac.compare_digest(computed_hash, received_hash): + raise TelegramWebAppAuthError("Invalid init data signature") + + auth_date_raw = data.get("auth_date") + if auth_date_raw is not None: + try: + auth_date = int(auth_date_raw) + except (TypeError, ValueError): + raise TelegramWebAppAuthError("Invalid auth_date value") from None + + if max_age_seconds and auth_date: + current_ts = int(time.time()) + if current_ts - auth_date > max_age_seconds: + raise TelegramWebAppAuthError("Init data is too old") + + data["auth_date"] = auth_date + + user_payload = data.get("user") + if user_payload is not None: + try: + data["user"] = json.loads(user_payload) + except json.JSONDecodeError as error: + raise TelegramWebAppAuthError("Invalid user payload") from error + + return data + diff --git a/app/webapi/app.py b/app/webapi/app.py index 0761ecad..06a10569 100644 --- a/app/webapi/app.py +++ b/app/webapi/app.py @@ -13,6 +13,7 @@ from .routes import ( config, health, promocodes, + miniapp, promo_groups, remnawave, stats, @@ -68,6 +69,10 @@ OPENAPI_TAGS = [ "данных между ботом и панелью." ), }, + { + "name": "miniapp", + "description": "Endpoint для Telegram Mini App с информацией о подписке пользователя.", + }, ] @@ -110,5 +115,6 @@ def create_web_api_app() -> FastAPI: app.include_router(campaigns.router, prefix="/campaigns", tags=["campaigns"]) app.include_router(tokens.router, prefix="/tokens", tags=["auth"]) app.include_router(remnawave.router, prefix="/remnawave", tags=["remnawave"]) + app.include_router(miniapp.router, prefix="/miniapp", tags=["miniapp"]) return app diff --git a/app/webapi/routes/__init__.py b/app/webapi/routes/__init__.py index 31aef685..22ed34ae 100644 --- a/app/webapi/routes/__init__.py +++ b/app/webapi/routes/__init__.py @@ -1,6 +1,7 @@ from . import ( config, health, + miniapp, promo_groups, remnawave, stats, @@ -14,6 +15,7 @@ from . import ( __all__ = [ "config", "health", + "miniapp", "promo_groups", "remnawave", "stats", diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py new file mode 100644 index 00000000..1a446032 --- /dev/null +++ b/app/webapi/routes/miniapp.py @@ -0,0 +1,396 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional, Tuple, Union + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.database.crud.server_squad import get_server_squad_by_uuid +from app.database.crud.user import get_user_by_telegram_id +from app.database.models import Subscription, Transaction, User +from app.services.remnawave_service import ( + RemnaWaveConfigurationError, + RemnaWaveService, +) +from app.services.subscription_service import SubscriptionService +from app.utils.subscription_utils import get_happ_cryptolink_redirect_link +from app.utils.telegram_webapp import ( + TelegramWebAppAuthError, + parse_webapp_init_data, +) + +from ..dependencies import get_db_session +from ..schemas.miniapp import ( + MiniAppConnectedServer, + MiniAppDevice, + MiniAppPromoGroup, + MiniAppSubscriptionRequest, + MiniAppSubscriptionResponse, + MiniAppSubscriptionUser, + MiniAppTransaction, +) + + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +def _format_gb(value: Optional[float]) -> float: + if value is None: + return 0.0 + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _format_gb_label(value: float) -> str: + absolute = abs(value) + if absolute >= 100: + return f"{value:.0f} GB" + if absolute >= 10: + return f"{value:.1f} GB" + return f"{value:.2f} GB" + + +def _format_limit_label(limit: Optional[int]) -> str: + if not limit: + return "Unlimited" + return f"{limit} GB" + + +def _bytes_to_gb(bytes_value: Optional[int]) -> float: + if not bytes_value: + return 0.0 + return round(bytes_value / (1024 ** 3), 2) + + +def _status_label(status: str) -> str: + mapping = { + "active": "Active", + "trial": "Trial", + "expired": "Expired", + "disabled": "Disabled", + } + return mapping.get(status, status.title()) + + +def _parse_datetime_string(value: Optional[str]) -> Optional[str]: + if not value: + return None + + try: + cleaned = value.strip() + if cleaned.endswith("Z"): + cleaned = f"{cleaned[:-1]}+00:00" + # Normalize duplicated timezone suffixes like +00:00+00:00 + if "+00:00+00:00" in cleaned: + cleaned = cleaned.replace("+00:00+00:00", "+00:00") + + datetime.fromisoformat(cleaned) + return cleaned + except Exception: # pragma: no cover - defensive + return value + + +async def _resolve_connected_servers( + db: AsyncSession, + squad_uuids: List[str], +) -> List[MiniAppConnectedServer]: + if not squad_uuids: + return [] + + resolved: Dict[str, str] = {} + missing: List[str] = [] + + for squad_uuid in squad_uuids: + if squad_uuid in resolved: + continue + server = await get_server_squad_by_uuid(db, squad_uuid) + if server and server.display_name: + resolved[squad_uuid] = server.display_name + else: + missing.append(squad_uuid) + + if missing: + try: + service = RemnaWaveService() + if service.is_configured: + squads = await service.get_all_squads() + for squad in squads: + uuid = squad.get("uuid") + name = squad.get("name") + if uuid in missing and name: + resolved[uuid] = name + except RemnaWaveConfigurationError: + logger.debug("RemnaWave is not configured; skipping server name enrichment") + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to resolve server names from RemnaWave: %s", error) + + connected_servers: List[MiniAppConnectedServer] = [] + for squad_uuid in squad_uuids: + name = resolved.get(squad_uuid, squad_uuid) + connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name)) + + return connected_servers + + +async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]: + remnawave_uuid = getattr(user, "remnawave_uuid", None) + if not remnawave_uuid: + return 0, [] + + try: + service = RemnaWaveService() + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to initialise RemnaWave service: %s", error) + return 0, [] + + if not service.is_configured: + return 0, [] + + try: + async with service.get_api_client() as api: + response = await api.get_user_devices(remnawave_uuid) + except RemnaWaveConfigurationError: + logger.debug("RemnaWave configuration missing while loading devices") + return 0, [] + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load devices from RemnaWave: %s", error) + return 0, [] + + total_devices = int(response.get("total") or 0) + devices_payload = response.get("devices") or [] + + devices: List[MiniAppDevice] = [] + for device in devices_payload: + platform = device.get("platform") or device.get("platformType") + model = device.get("deviceModel") or device.get("model") or device.get("name") + app_version = device.get("appVersion") or device.get("version") + last_seen_raw = ( + device.get("updatedAt") + or device.get("lastSeen") + or device.get("lastActiveAt") + or device.get("createdAt") + ) + last_ip = device.get("ip") or device.get("ipAddress") + + devices.append( + MiniAppDevice( + platform=platform, + device_model=model, + app_version=app_version, + last_seen=_parse_datetime_string(last_seen_raw), + last_ip=last_ip, + ) + ) + + if total_devices == 0: + total_devices = len(devices) + + return total_devices, devices + + +def _resolve_display_name(user_data: Dict[str, Any]) -> str: + username = user_data.get("username") + if username: + return username + + first = user_data.get("first_name") + last = user_data.get("last_name") + parts = [part for part in [first, last] if part] + if parts: + return " ".join(parts) + + telegram_id = user_data.get("telegram_id") + return f"User {telegram_id}" if telegram_id else "User" + + +def _is_remnawave_configured() -> bool: + params = settings.get_remnawave_auth_params() + return bool(params.get("base_url") and params.get("api_key")) + + +def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction: + return MiniAppTransaction( + id=transaction.id, + type=transaction.type, + amount_kopeks=transaction.amount_kopeks, + amount_rubles=round(transaction.amount_kopeks / 100, 2), + description=transaction.description, + payment_method=transaction.payment_method, + external_id=transaction.external_id, + is_completed=transaction.is_completed, + created_at=transaction.created_at, + completed_at=transaction.completed_at, + ) + + +async def _load_subscription_links( + subscription: Subscription, +) -> Dict[str, Any]: + if not subscription.remnawave_short_uuid or not _is_remnawave_configured(): + return {} + + try: + service = SubscriptionService() + info = await service.get_subscription_info(subscription.remnawave_short_uuid) + except Exception as error: # pragma: no cover - defensive logging + logger.warning("Failed to load subscription info from RemnaWave: %s", error) + return {} + + if not info: + return {} + + payload: Dict[str, Any] = { + "links": list(info.links or []), + "ss_conf_links": dict(info.ss_conf_links or {}), + "subscription_url": info.subscription_url, + "happ": info.happ, + "happ_link": getattr(info, "happ_link", None), + "happ_crypto_link": getattr(info, "happ_crypto_link", None), + } + + return payload + + +@router.post("/subscription", response_model=MiniAppSubscriptionResponse) +async def get_subscription_details( + payload: MiniAppSubscriptionRequest, + db: AsyncSession = Depends(get_db_session), +) -> MiniAppSubscriptionResponse: + try: + webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN) + except TelegramWebAppAuthError as error: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(error), + ) from error + + telegram_user = webapp_data.get("user") + if not isinstance(telegram_user, dict) or "id" not in telegram_user: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user payload", + ) + + try: + telegram_id = int(telegram_user["id"]) + except (TypeError, ValueError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid Telegram user identifier", + ) from None + + user = await get_user_by_telegram_id(db, telegram_id) + purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip() + if not user or not user.subscription: + detail: Union[str, Dict[str, str]] = "Subscription not found" + if purchase_url: + detail = { + "message": "Subscription not found", + "purchase_url": purchase_url, + } + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=detail, + ) + + subscription = user.subscription + traffic_used = _format_gb(subscription.traffic_used_gb) + traffic_limit = subscription.traffic_limit_gb or 0 + lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0)) + + status_actual = subscription.actual_status + links_payload = await _load_subscription_links(subscription) + + subscription_url = links_payload.get("subscription_url") or subscription.subscription_url + subscription_crypto_link = ( + links_payload.get("happ_crypto_link") + or subscription.subscription_crypto_link + ) + + happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link) + + connected_squads: List[str] = list(subscription.connected_squads or []) + connected_servers = await _resolve_connected_servers(db, connected_squads) + devices_count, devices = await _load_devices_info(user) + links: List[str] = links_payload.get("links") or connected_squads + ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {} + + transactions_query = ( + select(Transaction) + .where(Transaction.user_id == user.id) + .order_by(Transaction.created_at.desc()) + .limit(10) + ) + transactions_result = await db.execute(transactions_query) + transactions = list(transactions_result.scalars().all()) + + balance_currency = getattr(user, "balance_currency", None) + if isinstance(balance_currency, str): + balance_currency = balance_currency.upper() + + promo_group = getattr(user, "promo_group", None) + + response_user = MiniAppSubscriptionUser( + telegram_id=user.telegram_id, + username=user.username, + first_name=user.first_name, + last_name=user.last_name, + display_name=_resolve_display_name( + { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "telegram_id": user.telegram_id, + } + ), + language=user.language, + status=user.status, + subscription_status=subscription.status, + subscription_actual_status=status_actual, + status_label=_status_label(status_actual), + expires_at=subscription.end_date, + device_limit=subscription.device_limit, + traffic_used_gb=round(traffic_used, 2), + traffic_used_label=_format_gb_label(traffic_used), + traffic_limit_gb=traffic_limit, + traffic_limit_label=_format_limit_label(traffic_limit), + lifetime_used_traffic_gb=lifetime_used, + has_active_subscription=status_actual in {"active", "trial"}, + ) + + return MiniAppSubscriptionResponse( + subscription_id=subscription.id, + remnawave_short_uuid=subscription.remnawave_short_uuid, + user=response_user, + subscription_url=subscription_url, + subscription_crypto_link=subscription_crypto_link, + subscription_purchase_url=purchase_url or None, + links=links, + ss_conf_links=ss_conf_links, + connected_squads=connected_squads, + connected_servers=connected_servers, + connected_devices_count=devices_count, + connected_devices=devices, + happ=links_payload.get("happ"), + happ_link=links_payload.get("happ_link"), + happ_crypto_link=links_payload.get("happ_crypto_link"), + happ_cryptolink_redirect_link=happ_redirect_link, + balance_kopeks=user.balance_kopeks, + balance_rubles=round(user.balance_rubles, 2), + balance_currency=balance_currency, + transactions=[_serialize_transaction(tx) for tx in transactions], + promo_group=MiniAppPromoGroup(id=promo_group.id, name=promo_group.name) + if promo_group + else None, + subscription_type="trial" if subscription.is_trial else "paid", + autopay_enabled=bool(subscription.autopay_enabled), + branding=settings.get_miniapp_branding(), + ) + diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py new file mode 100644 index 00000000..3461e170 --- /dev/null +++ b/app/webapi/schemas/miniapp.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class MiniAppBranding(BaseModel): + service_name: Dict[str, Optional[str]] = Field(default_factory=dict) + service_description: Dict[str, Optional[str]] = Field(default_factory=dict) + + +class MiniAppSubscriptionRequest(BaseModel): + init_data: str = Field(..., alias="initData") + + +class MiniAppSubscriptionUser(BaseModel): + telegram_id: int + username: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + display_name: str + language: Optional[str] = None + status: str + subscription_status: str + subscription_actual_status: str + status_label: str + expires_at: Optional[datetime] = None + device_limit: Optional[int] = None + traffic_used_gb: float = 0.0 + traffic_used_label: str + traffic_limit_gb: Optional[int] = None + traffic_limit_label: str + lifetime_used_traffic_gb: float = 0.0 + has_active_subscription: bool = False + + +class MiniAppPromoGroup(BaseModel): + id: int + name: str + + +class MiniAppConnectedServer(BaseModel): + uuid: str + name: str + + +class MiniAppDevice(BaseModel): + platform: Optional[str] = None + device_model: Optional[str] = None + app_version: Optional[str] = None + last_seen: Optional[str] = None + last_ip: Optional[str] = None + + +class MiniAppTransaction(BaseModel): + id: int + type: str + amount_kopeks: int + amount_rubles: float + description: Optional[str] = None + payment_method: Optional[str] = None + external_id: Optional[str] = None + is_completed: bool + created_at: datetime + completed_at: Optional[datetime] = None + + +class MiniAppSubscriptionResponse(BaseModel): + success: bool = True + subscription_id: int + remnawave_short_uuid: Optional[str] = None + user: MiniAppSubscriptionUser + subscription_url: Optional[str] = None + subscription_crypto_link: Optional[str] = None + subscription_purchase_url: Optional[str] = None + links: List[str] = Field(default_factory=list) + ss_conf_links: Dict[str, str] = Field(default_factory=dict) + connected_squads: List[str] = Field(default_factory=list) + connected_servers: List[MiniAppConnectedServer] = Field(default_factory=list) + connected_devices_count: int = 0 + connected_devices: List[MiniAppDevice] = Field(default_factory=list) + happ: Optional[Dict[str, Any]] = None + happ_link: Optional[str] = None + happ_crypto_link: Optional[str] = None + happ_cryptolink_redirect_link: Optional[str] = None + balance_kopeks: int = 0 + balance_rubles: float = 0.0 + balance_currency: Optional[str] = None + transactions: List[MiniAppTransaction] = Field(default_factory=list) + promo_group: Optional[MiniAppPromoGroup] = None + subscription_type: str + autopay_enabled: bool = False + branding: Optional[MiniAppBranding] = None + diff --git a/docs/miniapp-setup.md b/docs/miniapp-setup.md new file mode 100644 index 00000000..0a133da1 --- /dev/null +++ b/docs/miniapp-setup.md @@ -0,0 +1,127 @@ +# Настройка мини-приложения Telegram подписки + +Эта инструкция описывает, как запустить статическую страницу из каталога `miniapp/index.html`, подключить её к административному API бота и опубликовать через reverse-proxy (nginx или Caddy). Страница отображает текущую подписку пользователя и использует Telegram WebApp init data для авторизации. + +## 1. Требования + +- Развёрнутый бот Bedolaga c актуальной базой данных. +- Включённое административное API (`WEB_API_ENABLED=true`). +- Доменное имя с действующим TLS-сертификатом (Telegram открывает веб-приложения только по HTTPS). +- Возможность разместить статические файлы (`miniapp/index.html` и `miniapp/app-config.json`) и проксировать запросы `/miniapp/*` к боту. + +## 2. Настройка окружения + +1. Скопируйте пример конфигурации и включите веб-API: +2. Задайте как минимум следующие переменные: + ```env + WEB_API_ENABLED=true # включает FastAPI + WEB_API_HOST=0.0.0.0 + WEB_API_PORT=8080 + WEB_API_ALLOWED_ORIGINS=https://miniapp.example.com + WEB_API_DEFAULT_TOKEN=super-secret-token + ``` + - `WEB_API_ALLOWED_ORIGINS` должен содержать домен, с которого будет открываться мини-приложение. + - `WEB_API_DEFAULT_TOKEN` создаёт bootstrap-токен для запросов от страницы. Его можно заменить на токен, созданный через `POST /tokens`. +3. Если используете RemnaWave, убедитесь, что заданы `REMNAWAVE_API_URL` и `REMNAWAVE_API_KEY`, чтобы в мини-приложении отображались дополнительные ссылки подписки. + +## 3. Запуск административного API + +API можно запускать вместе с ботом (`python main.py`) или отдельно для тестов: +```bash +uvicorn app.webapi.app:create_web_api_app --host 0.0.0.0 --port 8080 +``` +После старта проверьте доступность: +```bash +curl -H "X-API-Key: super-secret-token" https://miniapp.example.com/miniapp/health || \ +curl -H "X-API-Key: super-secret-token" http://127.0.0.1:8080/health +``` + +## 4. Подготовка статических файлов + +1. Скопируйте `miniapp/index.html` и `miniapp/app-config.json` на сервер, из которого nginx/Caddy будет отдавать статический контент. Например: + ```bash + sudo mkdir -p /var/www/remnawave-miniapp + sudo cp miniapp/index.html /var/www/remnawave-miniapp/ + sudo cp app-config.json /var/www/remnawave-miniapp/ + ``` +2. При необходимости отредактируйте `app-config.json`, чтобы настроить инструкции и ссылки на нужные клиенты. +3. Убедитесь, что файлы доступны для чтения пользователем веб-сервера. + +## 5. Настройка кнопки в Telegram + +1. В `.env` уже выставлен `SERVER_STATUS_MODE=external_link_miniapp` и `SERVER_STATUS_EXTERNAL_URL=https://miniapp.example.com`. +2. Перезапустите бота. В главном меню появится кнопка «Статус серверов», открывающая веб-приложение внутри Telegram. +3. При необходимости задайте кастомную кнопку через `@BotFather` (команда `/setmenu` -> Web App URL). + +## 6. Конфигурация nginx + +```nginx +server { + listen 80; + listen 443 ssl http2; + server_name miniapp.example.com; + + ssl_certificate /etc/letsencrypt/live/miniapp.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/miniapp.example.com/privkey.pem; + + # Статические файлы мини-приложения + root /var/www/remnawave-miniapp; + index index.html; + + location = /miniapp/app-config.json { + add_header Access-Control-Allow-Origin "*"; + try_files $uri =404; + } + + location / { + try_files $uri /index.html =404; + } + + # Проксирование запросов к административному API + location /miniapp/ { + proxy_pass http://127.0.0.1:8080/miniapp/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Если нужно проксировать другие эндпоинты API, добавьте аналогичные location-блоки. +} +``` + +## 7. Конфигурация Caddy + +```caddy +miniapp.example.com { + encode gzip zstd + root * /var/www/remnawave-miniapp + file_server + + @config path /app-config.json + header @config Access-Control-Allow-Origin "*" + + reverse_proxy /miniapp/* 127.0.0.1:8080 { + header_up Host {host} + header_up X-Real-IP {remote_host} + } +} +``` +Caddy автоматически выпустит сертификаты через ACME. Убедитесь, что порт 443 проброшен и домен указывает на сервер. + +## 8. Проверка работы + +1. Откройте мини-приложение прямо в Telegram или через браузер: `https://miniapp.example.com`. +2. В консоли разработчика убедитесь, что запрос к `https://miniapp.example.com/miniapp/subscription` возвращает JSON с данными подписки. +3. Проверьте, что ссылки из блока «Подключить подписку» открываются и копируются без ошибок. + +## 9. Диагностика + +| Симптом | Возможная причина | Проверка | +|---------|------------------|----------| +| Белый экран и ошибка 401 | Неверный `X-API-Key` или `WEB_API_ALLOWED_ORIGINS`. | Проверьте токен и заголовки запроса, перегенерируйте токен через `/tokens`. | +| Ошибка 404 на `/miniapp/subscription` | Прокси не пробрасывает запросы или API не запущено. | Проверьте лог nginx/Caddy и убедитесь, что бот запущен с `WEB_API_ENABLED=true`. | +| Mini App не открывается в Telegram | URL не соответствует HTTPS или отсутствует сертификат. | Обновите сертификаты и убедитесь, что домен доступен по HTTPS. | +| Нет ссылок подписки | Не настроена интеграция с RemnaWave или у пользователя нет активной подписки. | Проверьте `REMNAWAVE_API_URL/KEY` и статус подписки пользователя. | + +После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую. diff --git a/miniapp/index.html b/miniapp/index.html new file mode 100644 index 00000000..cfea1fef --- /dev/null +++ b/miniapp/index.html @@ -0,0 +1,2715 @@ + + +
+ + + +