Merge pull request #617 from Fr1ngg/bedolaga/integrate-miniapp-page-with-bot-api

Document miniapp deployment and reverse proxy setup
This commit is contained in:
Egor
2025-10-01 02:32:57 +03:00
committed by GitHub
8 changed files with 818 additions and 95 deletions

View File

@@ -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).
### 📊 Статус серверов в главном меню
| Переменная | Описание | Пример |

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -0,0 +1,213 @@
from __future__ import annotations
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.user import get_user_by_telegram_id
from app.database.models import Subscription
from app.services.subscription_service import SubscriptionService
from app.utils.telegram_webapp import (
TelegramWebAppAuthError,
parse_webapp_init_data,
)
from ..dependencies import get_db_session
from ..schemas.miniapp import (
MiniAppSubscriptionRequest,
MiniAppSubscriptionResponse,
MiniAppSubscriptionUser,
)
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 _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"))
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)
if not user or not user.subscription:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Subscription not found",
)
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
)
connected_squads: List[str] = list(subscription.connected_squads or [])
links: List[str] = links_payload.get("links") or connected_squads
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
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,
links=links,
ss_conf_links=ss_conf_links,
connected_squads=connected_squads,
happ=links_payload.get("happ"),
happ_link=links_payload.get("happ_link"),
happ_crypto_link=links_payload.get("happ_crypto_link"),
)

View File

@@ -0,0 +1,47 @@
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
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 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
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)
happ: Optional[Dict[str, Any]] = None
happ_link: Optional[str] = None
happ_crypto_link: Optional[str] = None

136
docs/miniapp-setup.md Normal file
View File

@@ -0,0 +1,136 @@
# Настройка мини-приложения 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` и `app-config.json`) и проксировать запросы `/miniapp/*` к боту.
## 2. Настройка окружения
1. Скопируйте пример конфигурации и включите веб-API:
```bash
cp .env.example .env
nano .env
```
2. Задайте как минимум следующие переменные:
```env
BOT_TOKEN=123456:ABCDEF # токен вашего бота
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
SERVER_STATUS_MODE=external_link_miniapp
SERVER_STATUS_EXTERNAL_URL=https://miniapp.example.com
```
- `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` и `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 = /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}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}
```
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` и статус подписки пользователя. |
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.

View File

@@ -158,6 +158,21 @@
color: #842029;
}
.status-trial {
background: #fff3cd;
color: #664d03;
}
.status-disabled {
background: #e2e3e5;
color: #41464b;
}
.status-unknown {
background: #e7eaf3;
color: #495057;
}
/* Stats Grid */
.stats-grid {
display: grid;
@@ -242,6 +257,12 @@
border: 1px solid var(--border-color);
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
pointer-events: none;
}
.btn-icon {
width: 20px;
height: 20px;
@@ -427,8 +448,8 @@
<!-- Error State -->
<div id="errorState" class="error hidden">
<div class="error-icon">⚠️</div>
<div class="error-title">Subscription Not Found</div>
<div class="error-text">Please contact support to activate your subscription</div>
<div class="error-title" id="errorTitle">Subscription Not Found</div>
<div class="error-text" id="errorText">Please contact support to activate your subscription</div>
</div>
<!-- Main Content -->
@@ -504,92 +525,160 @@
</div>
<script>
// Initialize Telegram WebApp
const tg = window.Telegram.WebApp;
tg.expand();
tg.ready();
const tg = window.Telegram?.WebApp || {
initData: '',
initDataUnsafe: {},
expand: () => {},
ready: () => {},
themeParams: {},
showPopup: null,
};
if (typeof tg.expand === 'function') {
tg.expand();
}
if (typeof tg.ready === 'function') {
tg.ready();
}
// Apply Telegram theme
if (tg.themeParams) {
Object.keys(tg.themeParams).forEach(key => {
document.documentElement.style.setProperty(`--tg-theme-${key.replace(/_/g, '-')}`, tg.themeParams[key]);
});
}
// State
let userData = null;
let appsConfig = null;
let appsConfig = {};
let currentPlatform = 'android';
let preferredLanguage = 'en';
function createError(title, message, status) {
const error = new Error(message || title);
if (title) {
error.title = title;
}
if (status) {
error.status = status;
}
return error;
}
// Initialize
async function init() {
try {
// Get user data from Telegram
const telegramUser = tg.initDataUnsafe?.user;
if (!telegramUser) {
throw new Error('No Telegram user data');
if (telegramUser?.language_code) {
preferredLanguage = telegramUser.language_code.split('-')[0];
}
// Load apps config
await loadAppsConfig();
// Fetch user subscription data from your API
// Replace this URL with your actual API endpoint
const response = await fetch(`/api/subscription/${telegramUser.id}`);
const initData = tg.initData || '';
if (!initData) {
throw createError('Authorization Error', 'Missing Telegram init data');
}
const response = await fetch('/miniapp/subscription', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ initData })
});
if (!response.ok) {
throw new Error('Subscription not found');
let detail = response.status === 401
? 'Authorization failed. Please open the mini app from Telegram.'
: 'Subscription not found';
let title = response.status === 401 ? 'Authorization Error' : 'Subscription Not Found';
try {
const errorPayload = await response.json();
if (errorPayload?.detail) {
detail = errorPayload.detail;
}
} catch (parseError) {
// ignore
}
throw createError(title, detail, response.status);
}
userData = await response.json();
// Render UI
userData.subscriptionUrl = userData.subscription_url || null;
userData.subscriptionCryptoLink = userData.subscription_crypto_link || null;
if (userData?.user?.language) {
preferredLanguage = userData.user.language;
}
renderUserData();
detectPlatform();
setActivePlatformButton();
renderApps();
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('mainContent').classList.remove('hidden');
} catch (error) {
console.error('Initialization error:', error);
document.getElementById('loadingState').classList.add('hidden');
document.getElementById('errorState').classList.remove('hidden');
showError(error);
}
}
async function loadAppsConfig() {
const response = await fetch('/assets/app-config.json');
appsConfig = await response.json();
try {
const response = await fetch('/app-config.json', { cache: 'no-cache' });
if (!response.ok) {
throw new Error('Failed to load app config');
}
const data = await response.json();
appsConfig = data?.platforms || {};
} catch (error) {
console.warn('Unable to load apps configuration:', error);
appsConfig = {};
}
}
function renderUserData() {
if (!userData?.response) return;
if (!userData?.user) {
return;
}
const { user } = userData.response;
// User info
const avatar = user.username?.[0]?.toUpperCase() || 'U';
document.getElementById('userAvatar').textContent = avatar;
document.getElementById('userName').textContent = user.username || 'User';
// Status
const user = userData.user;
const rawName = user.display_name || user.username || '';
const fallbackName = rawName || [user.first_name, user.last_name].filter(Boolean).join(' ') || `User ${user.telegram_id || ''}`.trim();
const avatarChar = (fallbackName.replace(/^@/, '')[0] || 'U').toUpperCase();
document.getElementById('userAvatar').textContent = avatarChar;
document.getElementById('userName').textContent = fallbackName;
const statusValueRaw = (user.subscription_actual_status || user.subscription_status || 'active').toLowerCase();
const knownStatuses = ['active', 'expired', 'trial', 'disabled'];
const statusClass = knownStatuses.includes(statusValueRaw) ? statusValueRaw : 'unknown';
const statusBadge = document.getElementById('statusBadge');
statusBadge.textContent = user.userStatus;
statusBadge.className = `status-badge status-${user.userStatus.toLowerCase()}`;
// Stats
const expiresDate = new Date(user.expiresAt);
const daysLeft = Math.ceil((expiresDate - new Date()) / (1000 * 60 * 60 * 24));
document.getElementById('daysLeft').textContent = daysLeft > 0 ? daysLeft : '0';
const serversCount = userData.response.links?.length || 0;
statusBadge.textContent = user.status_label || statusClass.charAt(0).toUpperCase() + statusClass.slice(1);
statusBadge.className = `status-badge status-${statusClass}`;
const expiresAt = user.expires_at ? new Date(user.expires_at) : null;
let daysLeft = '—';
if (expiresAt && !Number.isNaN(expiresAt.getTime())) {
const diffDays = Math.ceil((expiresAt - new Date()) / (1000 * 60 * 60 * 24));
daysLeft = diffDays > 0 ? diffDays : '0';
}
document.getElementById('daysLeft').textContent = daysLeft;
document.getElementById('expiresAt').textContent = expiresAt && !Number.isNaN(expiresAt.getTime())
? expiresAt.toLocaleDateString()
: '—';
const serversCount = userData.links?.length ?? userData.connected_squads?.length ?? 0;
document.getElementById('serversCount').textContent = serversCount;
// Details
document.getElementById('expiresAt').textContent = expiresDate.toLocaleDateString();
document.getElementById('trafficUsed').textContent = user.trafficUsed || '0 GB';
document.getElementById('trafficLimit').textContent = user.trafficLimit === '0' ? 'Unlimited' : user.trafficLimit;
document.getElementById('trafficUsed').textContent =
user.traffic_used_label || formatTraffic(user.traffic_used_gb);
document.getElementById('trafficLimit').textContent =
user.traffic_limit_label || formatTrafficLimit(user.traffic_limit_gb);
updateActionButtons();
}
function detectPlatform() {
@@ -601,28 +690,61 @@
} else {
currentPlatform = 'pc';
}
document.querySelector(`.platform-btn[data-platform="${currentPlatform}"]`)?.classList.add('active');
}
function setActivePlatformButton() {
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.classList.toggle('active', btn.dataset.platform === currentPlatform);
});
}
function getPlatformKey(platform) {
const mapping = {
ios: 'ios',
android: 'android',
pc: 'windows',
tv: 'androidTV',
mac: 'macos'
};
return mapping[platform] || platform;
}
function getAppsForCurrentPlatform() {
const platformKey = getPlatformKey(currentPlatform);
return appsConfig?.[platformKey] || [];
}
function renderApps() {
const apps = appsConfig?.[currentPlatform] || [];
const container = document.getElementById('appsContainer');
container.innerHTML = apps.map(app => `
<div class="app-card ${app.isFeatured ? 'featured' : ''}">
<div class="app-header">
<div class="app-icon">${app.name[0]}</div>
<div>
<div class="app-name">${app.name}</div>
${app.isFeatured ? '<span class="featured-badge">Recommended</span>' : ''}
if (!container) {
return;
}
const apps = getAppsForCurrentPlatform();
if (!apps.length) {
container.innerHTML = '<div class="step-description">No installation guide available for this platform yet.</div>';
return;
}
container.innerHTML = apps.map(app => {
const iconChar = (app.name?.[0] || 'A').toUpperCase();
const featuredBadge = app.isFeatured ? '<span class="featured-badge">Recommended</span>' : '';
return `
<div class="app-card ${app.isFeatured ? 'featured' : ''}">
<div class="app-header">
<div class="app-icon">${iconChar}</div>
<div>
<div class="app-name">${app.name || 'App'}</div>
${featuredBadge}
</div>
</div>
<div class="app-steps">
${renderAppSteps(app)}
</div>
</div>
<div class="app-steps">
${renderAppSteps(app)}
</div>
</div>
`).join('');
`;
}).join('');
}
function renderAppSteps(app) {
@@ -633,14 +755,14 @@
html += `
<div class="step">
<div class="step-title">
<span class="step-number">${stepNum}</span>
<span class="step-number">${stepNum++}</span>
Download & Install
</div>
${app.installationStep.description ? `<div class="step-description">${getLocalizedText(app.installationStep.description)}</div>` : ''}
${app.installationStep.buttons ? `
${Array.isArray(app.installationStep.buttons) && app.installationStep.buttons.length ? `
<div class="step-buttons">
${app.installationStep.buttons.map(btn => `
<a href="${btn.buttonLink}" class="step-btn" target="_blank">
<a href="${btn.buttonLink}" class="step-btn" target="_blank" rel="noopener">
${getLocalizedText(btn.buttonText)}
</a>
`).join('')}
@@ -648,27 +770,25 @@
` : ''}
</div>
`;
stepNum++;
}
if (app.addSubscriptionStep) {
html += `
<div class="step">
<div class="step-title">
<span class="step-number">${stepNum}</span>
<span class="step-number">${stepNum++}</span>
Add Subscription
</div>
<div class="step-description">${getLocalizedText(app.addSubscriptionStep.description)}</div>
</div>
`;
stepNum++;
}
if (app.connectAndUseStep) {
html += `
<div class="step">
<div class="step-title">
<span class="step-number">${stepNum}</span>
<span class="step-number">${stepNum++}</span>
Connect & Use
</div>
<div class="step-description">${getLocalizedText(app.connectAndUseStep.description)}</div>
@@ -680,44 +800,148 @@
}
function getLocalizedText(textObj) {
if (typeof textObj === 'string') return textObj;
return textObj.ru || textObj.en || '';
if (!textObj) {
return '';
}
if (typeof textObj === 'string') {
return textObj;
}
const telegramLang = tg.initDataUnsafe?.user?.language_code;
const preferenceOrder = [
preferredLanguage,
preferredLanguage?.split('-')[0],
userData?.user?.language,
telegramLang,
telegramLang?.split('-')[0],
'en',
'ru'
].filter(Boolean).map(lang => lang.toLowerCase());
const seen = new Set();
for (const lang of preferenceOrder) {
if (seen.has(lang)) {
continue;
}
seen.add(lang);
if (textObj[lang]) {
return textObj[lang];
}
}
const fallback = Object.values(textObj).find(value => typeof value === 'string' && value.trim().length);
return fallback || '';
}
function formatTraffic(value) {
const numeric = typeof value === 'number' ? value : Number.parseFloat(value ?? '0');
if (!Number.isFinite(numeric)) {
return '0 GB';
}
if (numeric >= 100) {
return `${numeric.toFixed(0)} GB`;
}
if (numeric >= 10) {
return `${numeric.toFixed(1)} GB`;
}
return `${numeric.toFixed(2)} GB`;
}
function formatTrafficLimit(limit) {
const numeric = typeof limit === 'number' ? limit : Number.parseFloat(limit ?? '0');
if (!Number.isFinite(numeric) || numeric <= 0) {
return 'Unlimited';
}
return `${numeric.toFixed(0)} GB`;
}
function getCurrentSubscriptionUrl() {
return userData?.subscription_url || userData?.subscriptionUrl || '';
}
function updateActionButtons() {
const connectBtn = document.getElementById('connectBtn');
const copyBtn = document.getElementById('copyBtn');
const hasUrl = Boolean(getCurrentSubscriptionUrl());
if (connectBtn) {
connectBtn.disabled = !hasUrl;
}
if (copyBtn) {
copyBtn.disabled = !hasUrl || !navigator.clipboard;
}
}
function showPopup(message, title = 'Info') {
if (typeof tg.showPopup === 'function') {
tg.showPopup({
title,
message,
buttons: [{ type: 'ok' }]
});
} else {
alert(message);
}
}
// Event Listeners
document.querySelectorAll('.platform-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.platform-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentPlatform = btn.dataset.platform;
setActivePlatformButton();
renderApps();
});
});
document.getElementById('connectBtn')?.addEventListener('click', () => {
if (userData?.response?.subscriptionUrl) {
const app = appsConfig?.[currentPlatform]?.find(a => a.isFeatured) || appsConfig?.[currentPlatform]?.[0];
if (app?.urlScheme) {
window.location.href = app.urlScheme + userData.response.subscriptionUrl;
} else {
window.location.href = userData.response.subscriptionUrl;
}
const subscriptionUrl = getCurrentSubscriptionUrl();
if (!subscriptionUrl) {
return;
}
const apps = getAppsForCurrentPlatform();
const featuredApp = apps.find(app => app.isFeatured) || apps[0];
if (featuredApp?.urlScheme) {
window.location.href = `${featuredApp.urlScheme}${subscriptionUrl}`;
} else if (userData?.happ_link && featuredApp?.id === 'happ') {
window.location.href = userData.happ_link;
} else {
window.location.href = subscriptionUrl;
}
});
document.getElementById('copyBtn')?.addEventListener('click', () => {
if (userData?.response?.subscriptionUrl) {
navigator.clipboard.writeText(userData.response.subscriptionUrl).then(() => {
tg.showPopup({
title: 'Copied!',
message: 'Subscription link copied to clipboard',
buttons: [{type: 'ok'}]
});
});
document.getElementById('copyBtn')?.addEventListener('click', async () => {
const subscriptionUrl = getCurrentSubscriptionUrl();
if (!subscriptionUrl || !navigator.clipboard) {
return;
}
try {
await navigator.clipboard.writeText(subscriptionUrl);
showPopup('Subscription link copied to clipboard', 'Copied');
} catch (error) {
console.warn('Clipboard copy failed:', error);
showPopup('Unable to copy the subscription link automatically. Please copy it manually.', 'Copy failed');
}
});
// Start
function showError(error) {
document.getElementById('loadingState').classList.add('hidden');
const titleElement = document.getElementById('errorTitle');
const textElement = document.getElementById('errorText');
if (titleElement) {
titleElement.textContent = error?.title || 'Subscription Not Found';
}
if (textElement) {
textElement.textContent = error?.message || 'Please contact support to activate your subscription';
}
document.getElementById('errorState').classList.remove('hidden');
updateActionButtons();
}
init();
</script>
</body>