mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-22 04:12:09 +00:00
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:
@@ -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).
|
||||
|
||||
### 📊 Статус серверов в главном меню
|
||||
|
||||
| Переменная | Описание | Пример |
|
||||
|
||||
91
app/utils/telegram_webapp.py
Normal file
91
app/utils/telegram_webapp.py
Normal 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
213
app/webapi/routes/miniapp.py
Normal file
213
app/webapi/routes/miniapp.py
Normal 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"),
|
||||
)
|
||||
|
||||
47
app/webapi/schemas/miniapp.py
Normal file
47
app/webapi/schemas/miniapp.py
Normal 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
136
docs/miniapp-setup.md
Normal 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` и статус подписки пользователя. |
|
||||
|
||||
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user