feat: web app

This commit is contained in:
3252a8
2026-04-22 16:00:55 +03:00
parent 1aa529ab23
commit 4f1b7d0832
13 changed files with 2045 additions and 25 deletions
+12 -1
View File
@@ -19,7 +19,7 @@ SERVER_STATUS_URL=https://status.yourdomain.tld/status/your_service #
TERMS_OF_SERVICE_URL=https://example.com/tos # Link to the terms of service
PRIVACY_POLICY_URL=https://example.com/privacy # Link to the privacy policy
USER_AGREEMENT_URL=https://example.com/user-agreement # Link to the user agreement
SUBSCRIPTION_MINI_APP_URL= # URL of the subscription mini-app
SUBSCRIPTION_MINI_APP_URL= # Public URL of the subscription Mini App, e.g. https://app.yourdomain.tld/
START_COMMAND_DESCRIPTION= # Description of the /start command
DISABLE_WELCOME_MESSAGE= # Disable the welcome message
MY_DEVICES_SECTION_ENABLED=False # Enable the My Devices section in the subscription menu
@@ -32,6 +32,17 @@ REQUIRED_CHANNEL_LINK=https://t.me/your_channel #
# Webhook Base URL (used for Telegram and payment providers)
WEBHOOK_BASE_URL=https://webhooks.yourdomain.tld
# Subscription Mini App (same container, separate port)
WEBAPP_ENABLED=True # Run Mini App HTTP server
WEBAPP_SERVER_HOST=0.0.0.0 # Internal listen host
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="Моя подписка" # Mini App title
WEBAPP_PRIMARY_COLOR="#10b981" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL
WEBAPP_SESSION_TTL_SECONDS=2592000 # Web App session lifetime
WEBAPP_AUTH_MAX_AGE_SECONDS=86400 # Max Telegram initData age
WEBAPP_LOGIN_TOKEN_TTL_SECONDS=600 # External browser login link lifetime
# Payment Method Toggles
YOOKASSA_ENABLED=True # Turn on YOOKASSA
FREEKASSA_ENABLED=True # Turn on FreeKassa
+80 -7
View File
@@ -7,6 +7,7 @@
### Для пользователей:
- **Регистрация и выбор языка:** Поддержка русского и английского языков.
- **Просмотр подписки:** Пользователи могут видеть статус своей подписки, дату окончания и ссылку на конфигурацию.
- **Web App (Mini App):** отдельный веб-интерфейс для просмотра ссылки подключения, остатка времени и оплаты подписки.
- **Мои устройства:** Опциональный раздел для просмотра и отключения подключенных устройств (активируется через переменную `MY_DEVICES_SECTION_ENABLED`).
- **Пробная подписка:** Система пробных подписок для новых пользователей (активируется вручную по кнопке).
- **Промокоды:** Возможность применять промокоды для получения скидок или бонусных дней.
@@ -65,9 +66,14 @@
| `BOT_TOKEN` | **Обязательно.** Токен вашего Telegram-бота. | `1234567890:ABC-DEF1234ghIkl-zyx57W2v1u123ew11` |
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) URL Mini App для показа подписки. | `https://t.me/your_bot/app` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `SUBSCRIPTION_MINI_APP_URL` | (Опционально) Публичный URL Mini App для показа подписки. Если задан, кнопка «Моя подписка» откроет Web App. | `https://app.domain.com/` |
| `WEBAPP_ENABLED` | Включить Web App в том же контейнере, но на отдельном порту. | `true` |
| `WEBAPP_SERVER_PORT` | Внутренний порт Web App. | `8081` |
| `WEBAPP_TITLE` | Заголовок Web App. | `Моя подписка` |
| `WEBAPP_PRIMARY_COLOR` | Основной цвет Web App. | `#10b981` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` |
| `MY_DEVICES_SECTION_ENABLED` | Включить раздел «Мои устройства» в меню подписки (`true`/`false`). | `false` |
| `REQUIRED_CHANNEL_ID` | (Опционально) ID канала, на который пользователь должен подписаться перед использованием. Оставьте пустым, если проверка не нужна. | `-1001234567890` |
| `REQUIRED_CHANNEL_LINK` | (Опционально) Публичная ссылка или invite на канал для кнопки «Проверить подписку». | `https://t.me/your_channel` |
</details>
@@ -77,10 +83,12 @@
| Переменная | Описание |
| --- | --- |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `WEBHOOK_BASE_URL`| **Обязательно.** Базовый URL для вебхуков, например `https://your.domain.com`. |
| `WEB_SERVER_HOST` | Хост для веб-сервера. | `0.0.0.0` |
| `WEB_SERVER_PORT` | Порт для веб-сервера. | `8080` |
| `WEBAPP_SERVER_HOST` | Хост отдельного веб-сервера Mini App. | `0.0.0.0` |
| `WEBAPP_SERVER_PORT` | Порт отдельного веб-сервера Mini App. | `8081` |
| `PAYMENT_METHODS_ORDER` | (Опционально) Порядок отображения кнопок оплаты через запятую. Поддерживаемые ключи: `severpay`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. Первый будет сверху. |
| `YOOKASSA_ENABLED` | Включить/выключить YooKassa (`true`/`false`). |
| `YOOKASSA_SHOP_ID` | ID вашего магазина в YooKassa. |
| `YOOKASSA_SECRET_KEY`| Секретный ключ магазина YooKassa. |
@@ -170,6 +178,11 @@
Где `remnawave-tg-shop` — это имя сервиса из `docker-compose.yml`, а `<WEB_SERVER_PORT>` — порт, указанный в `.env`.
**Отдельный порт Web App:**
- `https://<домен_web_app>/` → `http://remnawave-tg-shop:<WEBAPP_SERVER_PORT>/`
Web App не должен проксироваться на `WEB_SERVER_PORT`: этот порт оставьте для Telegram, платежных и Remnawave webhooks.
5. **Просмотр логов:**
```bash
docker compose logs -f remnawave-tg-shop
@@ -177,6 +190,66 @@
> 💡 Если включена проверка подписки на канал (`REQUIRED_CHANNEL_ID`), добавьте бота администратором в этот канал. Пользователь увидит кнопку «Проверить подписку», и, после первого успешного подтверждения, дальнейшие действия блокироваться не будут.
### Настройка Web App / Mini App
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется fallback через deep-link в бота.
1. Укажите в `.env` публичный URL Web App и порт:
```env
WEBAPP_ENABLED=True
WEBAPP_SERVER_HOST=0.0.0.0
WEBAPP_SERVER_PORT=8081
SUBSCRIPTION_MINI_APP_URL=https://app.domain.com/
WEBAPP_TITLE="Моя подписка"
WEBAPP_PRIMARY_COLOR="#10b981"
WEBAPP_LOGO_URL=
```
2. Убедитесь, что `docker-compose.yml` публикует порт Web App:
```yaml
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
```
3. Проксируйте отдельный домен или location на порт Web App:
```nginx
upstream remnawave-tg-shop-webapp {
server remnawave-tg-shop:8081;
}
server {
server_name app.domain.com;
listen 443 ssl;
http2 on;
ssl_certificate "/etc/nginx/ssl/app_fullchain.pem";
ssl_certificate_key "/etc/nginx/ssl/app_privkey.key";
location / {
proxy_pass http://remnawave-tg-shop-webapp;
proxy_http_version 1.1;
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;
}
}
```
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`.
5. Перезапустите контейнер:
```bash
docker compose up -d --build
```
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени, трафик, тарифы и создает платежные ссылки через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
+887
View File
@@ -0,0 +1,887 @@
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.app.web.webapp_auth import (
consume_authorized_webapp_auth_token,
create_pending_webapp_auth_token,
create_webapp_session_token,
validate_telegram_webapp_init_data,
verify_webapp_session_token,
)
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
from config.settings import Settings
from db.dal import payment_dal, subscription_dal, user_dal
from db.models import Payment, User
logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
def create_subscription_webapp_application(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
) -> web.Application:
app = web.Application()
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
for key in (
"subscription_service",
"yookassa_service",
"freekassa_service",
"cryptopay_service",
"platega_service",
"severpay_service",
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
def setup_subscription_webapp_routes(app: web.Application) -> None:
app.router.add_get("/", index_route)
app.router.add_get("/health", health_route)
app.router.add_post("/api/auth/token", auth_token_route)
app.router.add_get("/api/auth/request-token", auth_request_token_route)
app.router.add_get("/api/auth/check-token/{token}", auth_check_token_route)
app.router.add_get("/api/me", me_route)
app.router.add_post("/api/payments", create_payment_route)
app.router.add_get("/api/payments/{payment_id}", payment_status_route)
async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
html = TEMPLATE_PATH.read_text(encoding="utf-8")
config = {
"title": settings.WEBAPP_TITLE,
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"logoUrl": settings.WEBAPP_LOGO_URL or "",
"apiBase": "/api",
"supportUrl": settings.SUPPORT_LINK or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
}
html = html.replace(
"__WEBAPP_CONFIG__",
json.dumps(config, ensure_ascii=False, separators=(",", ":")),
)
return web.Response(text=html, content_type="text/html", charset="utf-8")
async def auth_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
payload = await _read_json(request)
init_data = str(payload.get("init_data") or "")
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
if not telegram_user:
return _json_error(401, "invalid_auth", "Invalid Telegram auth data")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
try:
db_user = await _ensure_user_from_telegram(session, telegram_user, settings)
if db_user.is_banned:
await session.rollback()
return _json_error(403, "banned", "Access denied")
await session.commit()
except Exception as exc:
await session.rollback()
logger.error("WebApp auth failed: %s", exc, exc_info=True)
return _json_error(500, "auth_failed", "Auth failed")
token = create_webapp_session_token(settings, int(telegram_user["id"]))
return web.json_response({"ok": True, "token": token})
async def auth_request_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
token = create_pending_webapp_auth_token(settings)
bot: Bot = request.app["bot"]
bot_info = await bot.get_me()
auth_url = f"https://t.me/{bot_info.username}?start=webapp_auth_{token}"
return web.json_response({"ok": True, "token": token, "auth_url": auth_url})
async def auth_check_token_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
token = request.match_info.get("token", "")
user_id = consume_authorized_webapp_auth_token(settings, token)
if not user_id:
return web.json_response({"ok": True, "authorized": False})
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return web.json_response(
{"ok": True, "authorized": False, "error": "Access denied"}
)
session_token = create_webapp_session_token(settings, user_id)
return web.json_response(
{
"ok": True,
"authorized": True,
"token": session_token,
}
)
async def me_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
data = await _build_user_payload(request, user_id)
return web.json_response({"ok": True, **data})
async def create_payment_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
payload = await _read_json(request)
method = str(payload.get("method") or "").strip().lower()
try:
months = int(float(payload.get("months")))
except (TypeError, ValueError):
return _json_error(400, "invalid_plan", "Invalid subscription period")
settings: Settings = request.app["settings"]
price = settings.subscription_options.get(months)
stars_price = settings.stars_subscription_options.get(months)
if price is None and method != "stars":
return _json_error(400, "invalid_plan", "Subscription period is not available")
if method == "stars" and (stars_price is None or int(stars_price) <= 0):
return _json_error(400, "invalid_plan", "Stars price is not configured")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
return _json_error(403, "access_denied", "Access denied")
lang = db_user.language_code or settings.DEFAULT_LANGUAGE
return await _create_subscription_payment(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=float(price or 0),
stars_price=stars_price,
lang=lang,
)
async def payment_status_route(request: web.Request) -> web.Response:
user_id = _require_user_id(request)
try:
payment_id = int(request.match_info["payment_id"])
except (TypeError, ValueError):
return _json_error(400, "invalid_payment", "Invalid payment id")
async_session_factory: sessionmaker = request.app["async_session_factory"]
async with async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment or payment.user_id != user_id:
return _json_error(404, "not_found", "Payment not found")
return web.json_response(
{
"ok": True,
"payment_id": payment.payment_id,
"status": payment.status,
"paid": payment.status == "succeeded",
}
)
async def _read_json(request: web.Request) -> Dict[str, Any]:
try:
data = await request.json()
return data if isinstance(data, dict) else {}
except Exception:
return {}
def _json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response(
{"ok": False, "error": code, "message": message},
status=status,
)
def _require_user_id(request: web.Request) -> int:
settings: Settings = request.app["settings"]
header = request.headers.get("Authorization", "")
prefix = "Bearer "
token = header[len(prefix):].strip() if header.startswith(prefix) else ""
user_id = verify_webapp_session_token(settings, token)
if not user_id:
raise web.HTTPUnauthorized(
text=json.dumps({"ok": False, "error": "unauthorized"}),
content_type="application/json",
)
return user_id
async def _ensure_user_from_telegram(
session: AsyncSession,
telegram_user: Dict[str, Any],
settings: Settings,
) -> User:
user_id = int(telegram_user["id"])
language_code = telegram_user.get("language_code") or settings.DEFAULT_LANGUAGE
if language_code not in {"ru", "en"}:
language_code = settings.DEFAULT_LANGUAGE
update_data = {
"username": sanitize_username(telegram_user.get("username")),
"first_name": sanitize_display_name(telegram_user.get("first_name")),
"last_name": sanitize_display_name(telegram_user.get("last_name")),
"language_code": language_code,
}
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
db_user, _ = await user_dal.create_user(
session,
{
"user_id": user_id,
**update_data,
"registration_date": datetime.now(timezone.utc),
},
)
return db_user
changed = {
key: value
for key, value in update_data.items()
if getattr(db_user, key) != value
}
if changed:
db_user = await user_dal.update_user(session, user_id, changed) or db_user
return db_user
async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, Any]:
settings: Settings = request.app["settings"]
async_session_factory: sessionmaker = request.app["async_session_factory"]
subscription_service: SubscriptionService = request.app["subscription_service"]
async with async_session_factory() as session:
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user or db_user.is_banned:
raise web.HTTPForbidden(
text=json.dumps({"ok": False, "error": "access_denied"}),
content_type="application/json",
)
active = await subscription_service.get_active_subscription_details(
session, user_id
)
local_sub = await subscription_dal.get_active_subscription_by_user_id(
session,
user_id,
db_user.panel_user_uuid,
) if db_user.panel_user_uuid else None
try:
await session.commit()
except Exception:
await session.rollback()
return {
"user": {
"id": user_id,
"username": db_user.username,
"first_name": db_user.first_name,
"language_code": db_user.language_code or settings.DEFAULT_LANGUAGE,
},
"subscription": _serialize_subscription(active, local_sub),
"plans": _serialize_plans(settings),
"payment_methods": _serialize_payment_methods(settings, request.app),
"settings": {
"support_url": settings.SUPPORT_LINK,
"traffic_mode": bool(settings.traffic_sale_mode),
},
}
def _serialize_subscription(
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
) -> Dict[str, Any]:
if not active:
return {
"active": False,
"status": "INACTIVE",
"remaining_text": "Нет активной подписки",
"days_left": 0,
"config_link": None,
"connect_url": None,
}
end_date = active.get("end_date")
if end_date and end_date.tzinfo is None:
end_date = end_date.replace(tzinfo=timezone.utc)
seconds_left = 0
if end_date:
seconds_left = max(
0,
int((end_date - datetime.now(timezone.utc)).total_seconds()),
)
return {
"active": seconds_left > 0,
"status": active.get("status_from_panel") or "UNKNOWN",
"end_date": end_date.isoformat() if end_date else None,
"end_date_text": end_date.strftime("%d.%m.%Y %H:%M") if end_date else "N/A",
"days_left": seconds_left // 86400,
"remaining_text": _format_remaining(seconds_left),
"config_link": active.get("config_link"),
"connect_url": active.get("connect_button_url") or active.get("config_link"),
"traffic_limit": _format_bytes(active.get("traffic_limit_bytes")),
"traffic_used": _format_bytes(active.get("traffic_used_bytes")),
"auto_renew_enabled": bool(getattr(local_sub, "auto_renew_enabled", False)),
"provider": getattr(local_sub, "provider", None),
}
def _serialize_plans(settings: Settings) -> List[Dict[str, Any]]:
plans: List[Dict[str, Any]] = []
for months, price in sorted(settings.subscription_options.items()):
plan = {
"months": int(months),
"price": float(price),
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"title": _format_months_title(int(months)),
}
stars_price = settings.stars_subscription_options.get(months)
if stars_price is not None and int(stars_price) > 0:
plan["stars_price"] = int(stars_price)
plans.append(plan)
return plans
def _serialize_payment_methods(
settings: Settings,
app: web.Application,
) -> List[Dict[str, Any]]:
labels = {
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega": "Platega",
"yookassa": "Банковская карта",
"stars": "Telegram Stars",
"cryptopay": "CryptoPay",
}
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if method == "severpay" and _service_configured(app, "severpay_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "freekassa" and _service_configured(app, "freekassa_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "platega" and _service_configured(app, "platega_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "yookassa" and _service_configured(app, "yookassa_service"):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif method == "cryptopay" and _service_configured(app, "cryptopay_service"):
methods.append({"id": method, "name": labels[method]})
return methods
def _service_configured(app: web.Application, key: str) -> bool:
service = app.get(key)
return bool(service and getattr(service, "configured", False))
async def _create_subscription_payment(
*,
request: web.Request,
session: AsyncSession,
user_id: int,
method: str,
months: int,
price: float,
stars_price: Optional[int],
lang: str,
) -> web.Response:
settings: Settings = request.app["settings"]
description = _payment_description(months, lang)
if method == "yookassa":
return await _create_yookassa_payment(
request, session, user_id, months, price, description
)
if method == "freekassa":
return await _create_freekassa_payment(
request, session, user_id, months, price, description
)
if method == "platega":
return await _create_platega_payment(
request, session, user_id, months, price, description
)
if method == "severpay":
return await _create_severpay_payment(
request, session, user_id, months, price, description
)
if method == "cryptopay":
service: CryptoPayService = request.app["cryptopay_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
url = await service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price,
description=description,
sale_mode="subscription",
)
if not url:
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{"ok": True, "action": "open_link", "payment_url": url, "payment_id": None}
)
if method == "stars":
if not settings.STARS_ENABLED or stars_price is None:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_stars_payment(
request, session, user_id, months, int(stars_price), description
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
async def _create_base_payment_record(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: int,
provider: str,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": months,
"provider": provider,
},
)
await session.commit()
return payment
async def _create_yookassa_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: YooKassaService = request.app["yookassa_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency="RUB",
status="pending_yookassa",
description=description,
months=months,
provider="yookassa",
)
response = await service.create_payment(
amount=price,
currency="RUB",
description=description,
metadata={
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment.payment_id),
"sale_mode": "subscription",
"source": "webapp",
},
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=bool(
settings.yookassa_autopayments_active
and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING
),
)
payment_url = response.get("confirmation_url") if response else None
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
await payment_dal.update_payment_status_by_db_id(
session,
payment.payment_id,
response.get("status", "pending"),
yk_payment_id=response.get("id"),
)
await session.commit()
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("YooKassa WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_freekassa_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: FreeKassaService = request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=service.default_currency,
status="pending_freekassa",
description=description,
months=months,
provider="freekassa",
)
success, response_data = await service.create_order(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=service.default_currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
)
payment_url = response_data.get("location") if success else None
provider_id = response_data.get("orderHash") or response_data.get("orderId")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session, payment.payment_id, str(provider_id), payment.status
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("FreeKassa WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_platega_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: PlategaService = request.app["platega_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
description=description,
months=months,
provider="platega",
)
payload = json.dumps(
{
"payment_db_id": payment.payment_id,
"user_id": user_id,
"months": months,
"sale_mode": "subscription",
"source": "webapp",
}
)
success, response_data = await service.create_transaction(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
payload=payload,
)
payment_url = (
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
) if success else None
provider_id = response_data.get("transactionId") or response_data.get("id")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_id),
str(response_data.get("status", payment.status)),
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("Platega WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_severpay_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
price: float,
description: str,
) -> web.Response:
settings: Settings = request.app["settings"]
service: SeverPayService = request.app["severpay_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_severpay",
description=description,
months=months,
provider="severpay",
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
)
payment_url = (
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
) if success else None
provider_id = response_data.get("id") or response_data.get("uid")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session, payment.payment_id, str(provider_id), payment.status
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("SeverPay WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_stars_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: int,
stars_price: int,
description: str,
) -> web.Response:
bot: Bot = request.app["bot"]
try:
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=float(stars_price),
currency="XTR",
status="pending_stars",
description=description,
months=months,
provider="telegram_stars",
)
payload = f"{payment.payment_id}:{months}:subscription"
prices = [LabeledPrice(label=description, amount=stars_price)]
create_invoice_link = getattr(bot, "create_invoice_link", None)
if callable(create_invoice_link):
invoice_url = await create_invoice_link(
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "open_invoice",
"payment_url": invoice_url,
"payment_id": payment.payment_id,
}
)
await bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "invoice_sent",
"payment_id": payment.payment_id,
}
)
except Exception as exc:
await session.rollback()
logger.error("Stars WebApp payment failed: %s", exc, exc_info=True)
return _json_error(502, "payment_failed", "Failed to create invoice")
def _format_remaining(seconds: int) -> str:
if seconds <= 0:
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
return f"{hours} ч. {minutes} мин."
return f"{max(1, minutes)} мин."
def _format_bytes(value: Optional[Any]) -> str:
if value is None:
return "N/A"
try:
size = float(value)
except (TypeError, ValueError):
return str(value)
if size <= 0:
return ""
units = ["B", "KB", "MB", "GB", "TB"]
index = 0
while size >= 1024 and index < len(units) - 1:
size /= 1024
index += 1
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int) -> str:
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
return f"{months} месяца"
return f"{months} месяцев"
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {months} month(s)"
return f"Подписка на {_format_months_title(months)}"
@@ -0,0 +1,791 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="robots" content="noindex, nofollow">
<meta name="theme-color" content="#111827">
<title>Моя подписка</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
:root {
color-scheme: light dark;
--accent: #10b981;
--bg: #f5f7fb;
--panel: #ffffff;
--panel-soft: #eef2f7;
--text: #111827;
--muted: #667085;
--line: #d8dee8;
--danger: #dc2626;
--success: #059669;
--shadow: 0 12px 28px rgba(15, 23, 42, 0.08);
}
.theme-dark {
--bg: #101318;
--panel: #171b22;
--panel-soft: #202631;
--text: #f4f7fb;
--muted: #9aa4b2;
--line: #2e3542;
--shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
}
* {
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
html,
body {
min-height: 100%;
margin: 0;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
letter-spacing: 0;
}
body {
padding: max(env(safe-area-inset-top), 10px) 14px max(env(safe-area-inset-bottom), 18px);
}
button,
a {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
.app {
width: min(100%, 520px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 44px;
gap: 10px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.brand-mark,
.icon-btn {
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
flex: 0 0 auto;
}
.brand-mark {
background: var(--panel-soft);
color: var(--accent);
font-weight: 800;
overflow: hidden;
}
.brand-mark img {
width: 100%;
height: 100%;
object-fit: cover;
}
.brand-title {
min-width: 0;
font-size: 18px;
font-weight: 800;
line-height: 1.1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.top-actions {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.icon-btn {
background: var(--panel);
color: var(--muted);
box-shadow: inset 0 0 0 1px var(--line);
}
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: var(--shadow);
}
.status-panel {
padding: 16px;
display: grid;
gap: 14px;
}
.status-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.eyebrow {
color: var(--muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
.status-title {
margin-top: 3px;
font-size: 28px;
font-weight: 850;
line-height: 1.05;
}
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 9px;
border-radius: 8px;
background: color-mix(in srgb, var(--accent) 14%, transparent);
color: var(--success);
font-size: 12px;
font-weight: 800;
white-space: nowrap;
}
.badge.off {
background: color-mix(in srgb, var(--danger) 14%, transparent);
color: var(--danger);
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.metric {
background: var(--panel-soft);
border-radius: 8px;
padding: 10px;
min-width: 0;
}
.metric-label {
color: var(--muted);
font-size: 11px;
font-weight: 700;
margin-bottom: 5px;
}
.metric-value {
font-size: 14px;
font-weight: 800;
overflow-wrap: anywhere;
}
.actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.btn {
min-height: 44px;
border-radius: 8px;
padding: 10px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
background: var(--panel-soft);
color: var(--text);
font-weight: 800;
text-decoration: none;
}
.btn.primary {
background: var(--accent);
color: #06110d;
}
.btn.danger {
color: var(--danger);
}
.btn:disabled {
opacity: 0.55;
cursor: progress;
}
.section-title {
margin: 8px 2px 0;
font-size: 14px;
color: var(--muted);
font-weight: 800;
text-transform: uppercase;
}
.plans,
.methods {
display: grid;
gap: 8px;
}
.plan,
.method {
width: 100%;
min-height: 58px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--text);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
}
.plan.active,
.method:active {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.plan-name,
.method-name {
font-weight: 850;
font-size: 15px;
}
.plan-meta,
.method-meta {
color: var(--muted);
font-size: 12px;
font-weight: 700;
margin-top: 3px;
}
.empty,
.notice {
padding: 14px;
border-radius: 8px;
background: var(--panel);
border: 1px solid var(--line);
color: var(--muted);
font-size: 14px;
line-height: 1.4;
}
.payment-panel {
display: none;
gap: 10px;
padding: 12px;
}
.payment-panel.show {
display: grid;
}
.loader {
min-height: 70vh;
display: grid;
place-items: center;
color: var(--muted);
font-weight: 800;
}
.login {
min-height: 76vh;
display: none;
align-items: center;
justify-content: center;
}
.login.show {
display: flex;
}
.login-card {
width: min(100%, 360px);
padding: 18px;
display: grid;
gap: 12px;
text-align: center;
}
.login-title {
font-size: 22px;
font-weight: 850;
line-height: 1.15;
}
.login-text {
color: var(--muted);
font-size: 14px;
line-height: 1.45;
}
.hidden {
display: none !important;
}
@media (max-width: 360px) {
body {
padding-left: 10px;
padding-right: 10px;
}
.status-title {
font-size: 24px;
}
.actions,
.metric-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div id="loader" class="loader">Загрузка...</div>
<main id="app" class="app hidden">
<header class="topbar">
<div class="brand">
<div id="brand-mark" class="brand-mark">R</div>
<div id="brand-title" class="brand-title">Моя подписка</div>
</div>
<div class="top-actions">
<button class="icon-btn" type="button" title="Обновить" onclick="reloadData()"></button>
<button class="icon-btn" type="button" title="Выйти" onclick="logout()"></button>
</div>
</header>
<section class="panel status-panel">
<div class="status-row">
<div>
<div class="eyebrow">Осталось</div>
<div id="remaining" class="status-title">...</div>
</div>
<div id="status-badge" class="badge">...</div>
</div>
<div class="metric-grid">
<div class="metric">
<div class="metric-label">Окончание</div>
<div id="end-date" class="metric-value">...</div>
</div>
<div class="metric">
<div class="metric-label">Трафик</div>
<div id="traffic" class="metric-value">...</div>
</div>
</div>
<div id="connect-actions" class="actions">
<button id="connect-btn" class="btn primary" type="button" onclick="openConnectLink()">Подключиться</button>
<button id="copy-btn" class="btn" type="button" onclick="copyConfigLink()">Скопировать ссылку</button>
</div>
</section>
<div class="section-title">Оплата подписки</div>
<section id="plans" class="plans"></section>
<section id="methods-wrap" class="hidden">
<div class="section-title">Способ оплаты</div>
<div id="methods" class="methods"></div>
</section>
<section id="payment-panel" class="panel payment-panel">
<div id="payment-message" class="notice"></div>
<button id="payment-open-btn" class="btn primary" type="button" onclick="openPaymentUrl()">Открыть оплату</button>
<button id="payment-check-btn" class="btn" type="button" onclick="checkPayment()">Проверить оплату</button>
</section>
<a id="support-link" class="btn hidden" href="#" target="_blank" rel="noopener">Поддержка</a>
</main>
<section id="login" class="login">
<div class="panel login-card">
<div class="login-title">Вход через Telegram</div>
<div class="login-text">Откройте бота и подтвердите вход. После подтверждения эта страница обновится автоматически.</div>
<a id="auth-link" class="btn primary" href="#" target="_blank" rel="noopener">Открыть Telegram</a>
<button class="btn" type="button" onclick="startExternalAuth()">Обновить ссылку</button>
</div>
</section>
<script>
const CFG = __WEBAPP_CONFIG__;
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
const state = {
token: localStorage.getItem('rw_webapp_token') || '',
data: null,
selectedPlan: null,
payment: null,
authPoll: null
};
document.documentElement.classList.toggle('theme-dark', !tg || tg.colorScheme !== 'light');
document.documentElement.style.setProperty('--accent', CFG.primaryColor || '#10b981');
document.title = CFG.title || 'Моя подписка';
document.getElementById('brand-title').textContent = CFG.title || 'Моя подписка';
if (CFG.logoUrl) {
document.getElementById('brand-mark').innerHTML = '<img src="' + escapeAttr(CFG.logoUrl) + '" alt="">';
}
if (CFG.supportUrl) {
const support = document.getElementById('support-link');
support.href = CFG.supportUrl;
support.classList.remove('hidden');
}
if (tg) {
try {
tg.ready();
tg.expand();
tg.setHeaderColor(CFG.primaryColor || '#10b981');
} catch (e) { }
}
boot();
async function boot() {
showLoader();
if (tg && tg.initData) {
try {
const auth = await fetch(CFG.apiBase + '/auth/token', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({init_data: tg.initData})
});
const data = await auth.json();
if (data.ok && data.token) {
setToken(data.token);
await loadData();
return;
}
} catch (e) { }
}
if (state.token) {
try {
await loadData();
return;
} catch (e) {
clearToken();
}
}
await startExternalAuth();
}
async function startExternalAuth() {
showLogin();
try {
const response = await fetch(CFG.apiBase + '/auth/request-token');
const data = await response.json();
if (!data.ok) return;
document.getElementById('auth-link').href = data.auth_url;
if (state.authPoll) clearInterval(state.authPoll);
state.authPoll = setInterval(() => checkExternalAuth(data.token), 1800);
} catch (e) { }
}
async function checkExternalAuth(token) {
try {
const response = await fetch(CFG.apiBase + '/auth/check-token/' + encodeURIComponent(token));
const data = await response.json();
if (data.authorized && data.token) {
clearInterval(state.authPoll);
setToken(data.token);
await loadData();
}
} catch (e) { }
}
async function loadData() {
const data = await api('/me');
if (!data.ok) throw new Error(data.error || 'load failed');
state.data = data;
state.selectedPlan = data.plans && data.plans.length ? data.plans[0] : null;
render();
showApp();
}
async function reloadData() {
showToast('Обновляю данные');
await loadData();
}
function render() {
renderSubscription(state.data.subscription);
renderPlans(state.data.plans || []);
renderMethods();
}
function renderSubscription(sub) {
const badge = document.getElementById('status-badge');
badge.textContent = sub.active ? 'Активна' : 'Не активна';
badge.classList.toggle('off', !sub.active);
document.getElementById('remaining').textContent = sub.remaining_text || 'Нет активной подписки';
document.getElementById('end-date').textContent = sub.end_date_text || 'N/A';
document.getElementById('traffic').textContent = (sub.traffic_used || 'N/A') + ' / ' + (sub.traffic_limit || 'N/A');
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
}
function renderPlans(plans) {
const wrap = document.getElementById('plans');
if (!plans.length) {
wrap.innerHTML = '<div class="empty">Тарифы не настроены.</div>';
document.getElementById('methods-wrap').classList.add('hidden');
return;
}
wrap.innerHTML = plans.map(plan => {
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
const stars = plan.stars_price ? ' · ' + plan.stars_price + ' ⭐' : '';
return `
<button class="plan ${isActive ? 'active' : ''}" type="button" onclick="selectPlan(${plan.months})">
<span>
<span class="plan-name">${escapeHtml(plan.title)}</span>
<span class="plan-meta">Доступ на ${escapeHtml(plan.title)}</span>
</span>
<span class="plan-name">${formatMoney(plan.price, plan.currency)}${stars}</span>
</button>
`;
}).join('');
}
function renderMethods() {
const wrap = document.getElementById('methods-wrap');
const methods = document.getElementById('methods');
if (!state.selectedPlan) {
wrap.classList.add('hidden');
return;
}
const available = (state.data.payment_methods || []).filter(method => {
if (method.id === 'stars') return Number.isFinite(Number(state.selectedPlan.stars_price));
return true;
});
wrap.classList.remove('hidden');
if (!available.length) {
methods.innerHTML = '<div class="empty">Нет доступных способов оплаты.</div>';
return;
}
methods.innerHTML = available.map(method => {
const amount = method.id === 'stars'
? state.selectedPlan.stars_price + ' ⭐'
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
return `
<button class="method" type="button" onclick="createPayment('${escapeAttr(method.id)}')">
<span>
<span class="method-name">${escapeHtml(method.name)}</span>
<span class="method-meta">${escapeHtml(state.selectedPlan.title)}</span>
</span>
<span class="method-name">${amount}</span>
</button>
`;
}).join('');
}
function selectPlan(months) {
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months);
renderPlans(state.data.plans || []);
renderMethods();
document.getElementById('payment-panel').classList.remove('show');
}
async function createPayment(method) {
if (!state.selectedPlan) return;
setMethodsDisabled(true);
try {
const data = await api('/payments', {
method: 'POST',
body: JSON.stringify({months: state.selectedPlan.months, method})
});
if (!data.ok) throw new Error(data.message || 'Не удалось создать платеж');
state.payment = data;
showPaymentPanel(data);
openPaymentUrl();
} catch (e) {
showToast(e.message || 'Ошибка оплаты');
} finally {
setMethodsDisabled(false);
}
}
function showPaymentPanel(payment) {
const panel = document.getElementById('payment-panel');
const msg = document.getElementById('payment-message');
const openBtn = document.getElementById('payment-open-btn');
const checkBtn = document.getElementById('payment-check-btn');
panel.classList.add('show');
if (payment.action === 'invoice_sent') {
msg.textContent = 'Счет отправлен в чат с ботом.';
openBtn.classList.add('hidden');
} else {
msg.textContent = 'Платеж создан. После оплаты нажмите проверку или обновите страницу.';
openBtn.classList.remove('hidden');
}
checkBtn.classList.toggle('hidden', !payment.payment_id);
}
function openPaymentUrl() {
const payment = state.payment;
if (!payment || !payment.payment_url) return;
if (payment.action === 'open_invoice' && tg && tg.openInvoice) {
tg.openInvoice(payment.payment_url, function(status) {
if (status === 'paid') loadData();
});
return;
}
if (tg && tg.openLink) {
tg.openLink(payment.payment_url);
} else {
window.open(payment.payment_url, '_blank', 'noopener');
}
}
async function checkPayment() {
if (!state.payment || !state.payment.payment_id) return;
const data = await api('/payments/' + state.payment.payment_id);
if (data.paid) {
showToast('Оплата подтверждена');
await loadData();
} else {
showToast('Платеж пока не подтвержден');
}
}
function openConnectLink() {
const sub = state.data && state.data.subscription;
const url = sub && (sub.connect_url || sub.config_link);
if (!url) return;
if (tg && tg.openLink) tg.openLink(url);
else window.open(url, '_blank', 'noopener');
}
async function copyConfigLink() {
const sub = state.data && state.data.subscription;
const link = sub && sub.config_link;
if (!link) return;
try {
await navigator.clipboard.writeText(link);
showToast('Ссылка скопирована');
} catch (e) {
const area = document.createElement('textarea');
area.value = link;
document.body.appendChild(area);
area.select();
document.execCommand('copy');
area.remove();
showToast('Ссылка скопирована');
}
}
async function api(path, options = {}) {
const headers = Object.assign({'Authorization': 'Bearer ' + state.token}, options.headers || {});
if (options.body && !headers['Content-Type']) headers['Content-Type'] = 'application/json';
const response = await fetch(CFG.apiBase + path, Object.assign({}, options, {headers}));
if (response.status === 401) {
clearToken();
await startExternalAuth();
throw new Error('Unauthorized');
}
return response.json();
}
function setToken(token) {
state.token = token;
localStorage.setItem('rw_webapp_token', token);
}
function clearToken() {
state.token = '';
localStorage.removeItem('rw_webapp_token');
}
function logout() {
clearToken();
startExternalAuth();
}
function showLoader() {
document.getElementById('loader').classList.remove('hidden');
document.getElementById('app').classList.add('hidden');
document.getElementById('login').classList.remove('show');
}
function showApp() {
document.getElementById('loader').classList.add('hidden');
document.getElementById('login').classList.remove('show');
document.getElementById('app').classList.remove('hidden');
}
function showLogin() {
document.getElementById('loader').classList.add('hidden');
document.getElementById('app').classList.add('hidden');
document.getElementById('login').classList.add('show');
}
function setMethodsDisabled(disabled) {
document.querySelectorAll('.method').forEach(btn => btn.disabled = disabled);
}
function showToast(message) {
if (tg && tg.showAlert) {
tg.showAlert(message);
} else {
alert(message);
}
}
function formatMoney(value, currency) {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
return formatted + ' ' + (currency || 'RUB');
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function escapeAttr(value) {
return escapeHtml(value).replaceAll('`', '&#096;');
}
</script>
</body>
</html>
+51 -10
View File
@@ -8,18 +8,17 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
async def build_and_start_web_app(
def _inject_shared_instances(
app: web.Application,
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
):
app = web.Application()
) -> None:
app["bot"] = bot
app["dp"] = dp
app["settings"] = settings
app["async_session_factory"] = async_session_factory
# Inject shared instances used by webhook handlers
app["i18n"] = dp.get("i18n_instance")
for key in (
"yookassa_service",
@@ -34,10 +33,19 @@ async def build_and_start_web_app(
"platega_service",
"severpay_service",
):
# Access dispatcher workflow_data directly to avoid sequence protocol issues
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore
async def build_and_start_web_app(
dp: Dispatcher,
bot: Bot,
settings: Settings,
async_session_factory: sessionmaker,
):
app = web.Application()
_inject_shared_instances(app, dp, bot, settings, async_session_factory)
setup_application(app, dp, bot=bot)
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
@@ -87,10 +95,13 @@ async def build_and_start_web_app(
app.router.add_post(panel_path, panel_webhook_route)
logging.info(f"Panel webhook route configured at: [POST] {panel_path}")
web_app_runner = web.AppRunner(app)
await web_app_runner.setup()
runners = []
webhooks_runner = web.AppRunner(app)
await webhooks_runner.setup()
runners.append(webhooks_runner)
site = web.TCPSite(
web_app_runner,
webhooks_runner,
host=settings.WEB_SERVER_HOST,
port=settings.WEB_SERVER_PORT,
)
@@ -100,5 +111,35 @@ async def build_and_start_web_app(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
# Run until cancelled
await asyncio.Event().wait()
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
subscription_app = create_subscription_webapp_application(
dp,
bot,
settings,
async_session_factory,
)
subscription_runner = web.AppRunner(subscription_app)
await subscription_runner.setup()
runners.append(subscription_runner)
subscription_site = web.TCPSite(
subscription_runner,
host=settings.WEBAPP_SERVER_HOST,
port=settings.WEBAPP_SERVER_PORT,
)
await subscription_site.start()
logging.info(
"Subscription WebApp server started on http://%s:%s",
settings.WEBAPP_SERVER_HOST,
settings.WEBAPP_SERVER_PORT,
)
try:
await asyncio.Event().wait()
finally:
for runner in reversed(runners):
try:
await runner.cleanup()
except Exception as cleanup_error:
logging.warning("Failed to cleanup aiohttp runner: %s", cleanup_error)
+173
View File
@@ -0,0 +1,173 @@
import base64
import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass
from typing import Any, Dict, Optional
from urllib.parse import parse_qsl
from config.settings import Settings
logger = logging.getLogger(__name__)
@dataclass
class PendingWebAppAuth:
created_at: int
user_id: Optional[int] = None
_PENDING_AUTH_TOKENS: Dict[str, PendingWebAppAuth] = {}
def _urlsafe_b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _urlsafe_b64decode(raw: str) -> bytes:
padded = raw + ("=" * (-len(raw) % 4))
return base64.urlsafe_b64decode(padded.encode("ascii"))
def _session_secret(settings: Settings) -> bytes:
return hmac.new(
settings.BOT_TOKEN.encode("utf-8"),
b"remnawave-tg-shop-webapp-session",
hashlib.sha256,
).digest()
def create_webapp_session_token(settings: Settings, user_id: int) -> str:
now = int(time.time())
payload = {
"sub": int(user_id),
"iat": now,
"exp": now + max(60, int(settings.WEBAPP_SESSION_TTL_SECONDS)),
}
payload_part = _urlsafe_b64encode(
json.dumps(payload, separators=(",", ":")).encode("utf-8")
)
signature = hmac.new(
_session_secret(settings),
payload_part.encode("ascii"),
hashlib.sha256,
).digest()
return f"{payload_part}.{_urlsafe_b64encode(signature)}"
def verify_webapp_session_token(settings: Settings, token: str) -> Optional[int]:
if not token or "." not in token:
return None
try:
payload_part, signature_part = token.split(".", 1)
expected_signature = hmac.new(
_session_secret(settings),
payload_part.encode("ascii"),
hashlib.sha256,
).digest()
received_signature = _urlsafe_b64decode(signature_part)
if not hmac.compare_digest(expected_signature, received_signature):
return None
payload = json.loads(_urlsafe_b64decode(payload_part).decode("utf-8"))
if int(payload.get("exp", 0)) < int(time.time()):
return None
return int(payload["sub"])
except Exception as exc:
logger.debug("Failed to verify webapp session token: %s", exc)
return None
def validate_telegram_webapp_init_data(
init_data: str,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Mini App initData and return the trusted user payload."""
try:
parsed_data = dict(parse_qsl(init_data or "", keep_blank_values=True))
received_hash = parsed_data.pop("hash", None)
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hmac.new(
b"WebAppData",
bot_token.encode("utf-8"),
hashlib.sha256,
).digest()
calculated_hash = hmac.new(
secret_key,
data_check_string.encode("utf-8"),
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(calculated_hash, received_hash):
logger.warning("Telegram WebApp initData hash mismatch.")
return None
auth_date_raw = parsed_data.get("auth_date")
if auth_date_raw:
auth_date = int(auth_date_raw)
now = int(time.time())
max_age = max(60, int(max_age_seconds))
if auth_date > now + 300 or now - auth_date > max_age:
logger.warning("Telegram WebApp initData auth_date is stale.")
return None
user_json = parsed_data.get("user")
if not user_json:
return None
user_data = json.loads(user_json)
if not user_data.get("id"):
return None
return user_data
except Exception as exc:
logger.warning("Failed to validate Telegram WebApp initData: %s", exc)
return None
def _cleanup_pending_auth(settings: Settings) -> None:
now = int(time.time())
ttl = max(60, int(settings.WEBAPP_LOGIN_TOKEN_TTL_SECONDS))
expired = [
token
for token, value in _PENDING_AUTH_TOKENS.items()
if now - value.created_at > ttl
]
for token in expired:
_PENDING_AUTH_TOKENS.pop(token, None)
def create_pending_webapp_auth_token(settings: Settings) -> str:
_cleanup_pending_auth(settings)
token = secrets.token_urlsafe(24)
_PENDING_AUTH_TOKENS[token] = PendingWebAppAuth(created_at=int(time.time()))
return token
def authorize_pending_webapp_auth_token(token: str, user_id: int) -> bool:
pending = _PENDING_AUTH_TOKENS.get(token)
if not pending:
return False
pending.user_id = int(user_id)
return True
def consume_authorized_webapp_auth_token(
settings: Settings,
token: str,
) -> Optional[int]:
_cleanup_pending_auth(settings)
pending = _PENDING_AUTH_TOKENS.get(token)
if not pending or pending.user_id is None:
return None
_PENDING_AUTH_TOKENS.pop(token, None)
return int(pending.user_id)
+16 -2
View File
@@ -25,6 +25,7 @@ from bot.services.promo_code_service import PromoCodeService
from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
from bot.app.web.webapp_auth import authorize_pending_webapp_auth_token
router = Router(name="user_start_router")
@@ -307,7 +308,8 @@ async def ensure_required_channel_subscription(
@router.message(CommandStart(magic=F.args.regexp(r"^promo_(\w+)$").as_("promo_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^admin_user_(\d+)$").as_("admin_user_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^page_ref$").as_("page_ref_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^webapp_auth_([A-Za-z0-9_\-]{16,128})$").as_("webapp_auth_match")))
@router.message(CommandStart(magic=F.args.regexp(r"^(?!ref_|promo_|admin_user_|page_ref$|webapp_auth_)([A-Za-z0-9_\-]{2,64})$").as_("ad_param_match")))
async def start_command_handler(message: types.Message,
state: FSMContext,
settings: Settings,
@@ -319,7 +321,8 @@ async def start_command_handler(message: types.Message,
promo_match: Optional[re.Match] = None,
page_ref_match: Optional[re.Match] = None,
ad_param_match: Optional[re.Match] = None,
admin_user_match: Optional[re.Match] = None):
admin_user_match: Optional[re.Match] = None,
webapp_auth_match: Optional[re.Match] = None):
await state.clear()
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -385,6 +388,7 @@ async def start_command_handler(message: types.Message,
promo_code_to_apply: Optional[str] = None
should_open_referral_from_start = False
ad_start_param: Optional[str] = None
webapp_auth_token: Optional[str] = None
if ref_match:
raw_ref_value = ref_match.group(1)
@@ -410,6 +414,9 @@ async def start_command_handler(message: types.Message,
elif page_ref_match:
should_open_referral_from_start = True
logging.info(f"User {user_id} started with page_ref deep-link.")
elif webapp_auth_match:
webapp_auth_token = webapp_auth_match.group(1)
logging.info("User %s started WebApp auth flow.", user_id)
elif ad_param_match:
ad_start_param = ad_param_match.group(1)
logging.info(f"User {user_id} started with ad start param: {ad_start_param}")
@@ -565,6 +572,13 @@ async def start_command_handler(message: types.Message,
db_user):
return
if webapp_auth_token:
if authorize_pending_webapp_auth_token(webapp_auth_token, user_id):
await message.answer(_("webapp_auth_success"))
else:
await message.answer(_("webapp_auth_expired"))
return
open_referral_page_for_existing_user = (
should_open_referral_from_start and is_existing_user
)
+13 -5
View File
@@ -21,12 +21,20 @@ def get_main_menu_inline_keyboard(
builder.row(
InlineKeyboardButton(text=_(key="menu_subscribe_inline"),
callback_data="main_action:subscribe"))
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
if settings.SUBSCRIPTION_MINI_APP_URL:
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
web_app=WebAppInfo(url=settings.SUBSCRIPTION_MINI_APP_URL),
)
)
else:
builder.row(
InlineKeyboardButton(
text=_(key="menu_my_subscription_inline"),
callback_data="main_action:my_subscription",
)
)
)
referral_button = InlineKeyboardButton(
text=_(key="menu_referral_inline"),
+16
View File
@@ -201,6 +201,20 @@ class Settings(BaseSettings):
WEB_SERVER_HOST: str = Field(default="0.0.0.0")
WEB_SERVER_PORT: int = Field(default=8080)
WEBAPP_ENABLED: bool = Field(
default=True,
description="Run the subscription Mini App in the same container on a separate port.",
)
WEBAPP_SERVER_HOST: str = Field(default="0.0.0.0")
WEBAPP_SERVER_PORT: int = Field(default=8081)
WEBAPP_TITLE: str = Field(default="Моя подписка")
WEBAPP_PRIMARY_COLOR: str = Field(default="#10b981")
WEBAPP_LOGO_URL: Optional[str] = Field(default=None)
WEBAPP_SESSION_TTL_SECONDS: int = Field(default=30 * 24 * 60 * 60)
WEBAPP_AUTH_MAX_AGE_SECONDS: int = Field(default=24 * 60 * 60)
WEBAPP_LOGIN_TOKEN_TTL_SECONDS: int = Field(default=10 * 60)
LOGS_PAGE_SIZE: int = Field(default=10)
SUBSCRIPTION_MINI_APP_URL: Optional[str] = Field(default=None)
@@ -552,6 +566,8 @@ class Settings(BaseSettings):
'CRYPT4_REDIRECT_URL',
'PRIVACY_POLICY_URL',
'USER_AGREEMENT_URL',
'SUBSCRIPTION_MINI_APP_URL',
'WEBAPP_LOGO_URL',
mode='before',
)
@classmethod
+1
View File
@@ -7,6 +7,7 @@ services:
- .env
ports:
- '127.0.0.1:8080:8080'
- '127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}'
networks:
- remnawave-network
environment:
+1
View File
@@ -13,6 +13,7 @@ services:
condition: service_healthy
ports:
- 127.0.0.1:8080:8080
- 127.0.0.1:${WEBAPP_SERVER_PORT:-8081}:${WEBAPP_SERVER_PORT:-8081}
remnawave-tg-shop-db:
image: postgres:17
+2
View File
@@ -5,6 +5,8 @@
"channel_subscription_verify_button": "Check subscription",
"channel_subscription_check_failed": "Couldn't verify the subscription. Please try again later or contact support.",
"channel_subscription_verified_success": "✅ Subscription confirmed! You're good to go.",
"webapp_auth_success": "✅ Login confirmed. Return to the Web App.",
"webapp_auth_expired": "The login link has expired. Return to the Web App and try again.",
"main_menu_greeting": "Hi, {user_name}! 👋\nWhat would you like to do?\n\nTo set up the connection on your device, go to \"My subscription\" and click \"Connect\". If needed, add access days in the \"Buy\" section.",
"menu_activate_trial_button": "🆓 Free Trial",
"menu_subscribe_inline": "🚀 Purchase",
+2
View File
@@ -5,6 +5,8 @@
"channel_subscription_verify_button": "Проверить подписку",
"channel_subscription_check_failed": "Не удалось проверить подписку. Попробуйте позже или обратитесь в поддержку.",
"channel_subscription_verified_success": "✅ Подписка подтверждена! Можно продолжать.",
"webapp_auth_success": "✅ Вход подтвержден. Вернитесь в Web App.",
"webapp_auth_expired": "Ссылка авторизации устарела. Вернитесь в Web App и попробуйте еще раз.",
"main_menu_greeting": "Привет, {user_name}! 👋\nЧто бы вы хотели сделать?\n\nДля настройки подключения на устройстве перейдите в раздел \"Моя подписка\" и нажмите кнопку \"Подключиться\". При необходимости добавьте дни доступа в разделе \"Купить\".",
"menu_activate_trial_button": "🆓 Пробный период",
"menu_subscribe_inline": "🚀 Купить",