mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-03-03 20:34:10 +00:00
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
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",
|
||||
|
||||
396
app/webapi/routes/miniapp.py
Normal file
396
app/webapi/routes/miniapp.py
Normal file
@@ -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(),
|
||||
)
|
||||
|
||||
96
app/webapi/schemas/miniapp.py
Normal file
96
app/webapi/schemas/miniapp.py
Normal file
@@ -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
|
||||
|
||||
127
docs/miniapp-setup.md
Normal file
127
docs/miniapp-setup.md
Normal file
@@ -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` и статус подписки пользователя. |
|
||||
|
||||
После настройки вы сможете использовать мини-приложение в бот-меню и отправлять ссылку пользователям напрямую.
|
||||
2715
miniapp/index.html
Normal file
2715
miniapp/index.html
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user