feat: tune webapp visual, use telegram widget for login

This commit is contained in:
3252a8
2026-04-23 14:06:21 +03:00
parent eab803652b
commit b9cb1fec06
14 changed files with 6265 additions and 1212 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ WEBAPP_SERVER_HOST=0.0.0.0 #
WEBAPP_SERVER_PORT=8081 # Internal/published Mini App port
WEBAPP_TITLE="Моя подписка" # Mini App title
WEBAPP_PRIMARY_COLOR="#00fe7a" # Main UI color
WEBAPP_LOGO_URL= # Optional logo URL
WEBAPP_LOGO_URL= # Optional logo URL; shown in the header and login screen, leave empty to hide
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
+18 -3
View File
@@ -67,12 +67,14 @@
| `ADMIN_IDS` | **Обязательно.** ID администраторов в Telegram через запятую. | `12345678,98765432` |
| `DEFAULT_LANGUAGE` | Язык по умолчанию для новых пользователей. | `ru` |
| `SUPPORT_LINK` | (Опционально) Ссылка на поддержку. | `https://t.me/your_support` |
| `PRIVACY_POLICY_URL` | (Опционально) Ссылка на политику конфиденциальности, показывается внизу Web App. | `https://example.com/privacy` |
| `USER_AGREEMENT_URL` | (Опционально) Ссылка на пользовательское соглашение, показывается внизу Web App. | `https://example.com/agreement` |
| `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. | `#00fe7a` |
| `WEBAPP_LOGO_URL` | (Опционально) URL логотипа Web App. | `https://domain.com/logo.png` |
| `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` |
@@ -192,7 +194,7 @@
### Настройка Web App / Mini App
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, используется fallback через deep-link в бота.
Web App запускается в том же контейнере, что и бот, но слушает отдельный порт `WEBAPP_SERVER_PORT` (по умолчанию `8081`). Внутри Web App пользователь авторизуется через Telegram Mini Apps `initData`; если страницу открыть вне Telegram, показывается официальный Telegram Login Widget. После успешного входа страница обновляет данные сразу, без сообщений боту.
1. Укажите в `.env` публичный URL Web App и порт:
@@ -240,7 +242,7 @@ Web App запускается в том же контейнере, что и б
}
```
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`.
4. В BotFather настройте домен Mini App для бота (`/setdomain`) и укажите домен из `SUBSCRIPTION_MINI_APP_URL`. Этот же домен используется и Telegram Login Widget.
5. Перезапустите контейнер:
@@ -250,6 +252,19 @@ Web App запускается в том же контейнере, что и б
После этого кнопка «Моя подписка» в меню бота откроет Web App. В MVP Web App показывает текущую ссылку подключения, остаток времени и трафик. Оплата открывается отдельной кнопкой «Продлить подписку/Добавить дни»: пользователь пошагово выбирает срок, способ оплаты и создает платежную ссылку через уже настроенные платежные системы. Успешные оплаты обрабатываются существующими webhook-обработчиками.
Шапка и модальные окна Web App учитывают Telegram safe area через `--tg-content-safe-area-inset-top` и `--tg-safe-area-inset-top`, а сверху добавлен повышенный дополнительный буфер, чтобы интерфейс не уезжал под панель Telegram при открытии из списка чатов.
Для настройки внешнего вида без запуска бота можно открыть файл `bot/app/web/templates/subscription_webapp.html` напрямую в браузере. Рядом с ним лежат `telegram-web-app.js`, `telegram-widget.js`, `subscription_webapp.css` и `subscription_webapp.js`, поэтому локальный предпросмотр работает без сервера и без внешнего CDN. При отдаче страницы через Web App сервер dev-mock автоматически вырезается и не попадает пользователям.
Локальная копия Telegram Web App SDK хранится в `bot/app/web/templates/telegram-web-app.js`, а локальная копия Telegram Login Widget - в `bot/app/web/templates/telegram-widget.js`. В контейнере обе копии автоматически обновляются при старте Web App и затем раз в 24 часа; если источник временно недоступен, используется уже сохраненная версия. У локального `telegram-widget.js` есть минимальная нормализация, чтобы при загрузке с вашего домена виджет все равно открывал iframe на Telegram-origin, а не на `/embed/...` вашего сайта. Для ручного обновления можно запустить команды:
```bash
python scripts/update_telegram_web_app_js.py
python scripts/update_telegram_widget_js.py
```
При необходимости оба скрипта принимают `--source-url` и `--target`, если нужно скачать файл в другое место или проверить альтернативный источник.
## Подробная инструкция для развертывания на сервере с панелью Remnawave
### 1. Клонирование репозитория
+272 -57
View File
@@ -4,16 +4,15 @@ from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from aiohttp import web
from aiohttp import ClientSession, ClientTimeout, 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_login_widget_data,
validate_telegram_webapp_init_data,
verify_webapp_session_token,
)
@@ -31,6 +30,16 @@ from db.models import Payment, User
logger = logging.getLogger(__name__)
TEMPLATE_PATH = Path(__file__).resolve().parent / "templates" / "subscription_webapp.html"
ASSET_DIR = TEMPLATE_PATH.parent
TELEGRAM_WEB_APP_SDK_URL = "https://telegram.org/js/telegram-web-app.js"
TELEGRAM_WEB_APP_SDK_PATH = ASSET_DIR / "telegram-web-app.js"
TELEGRAM_WIDGET_SDK_URL = "https://telegram.org/js/telegram-widget.js?23"
TELEGRAM_WIDGET_SDK_PATH = ASSET_DIR / "telegram-widget.js"
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
WEBAPP_CONFIG_PLACEHOLDER = "<!-- WEBAPP_CONFIG_SCRIPT -->"
DEV_MOCK_START_MARKER = "<!-- WEBAPP_DEV_MOCK_START -->"
DEV_MOCK_END_MARKER = "<!-- WEBAPP_DEV_MOCK_END -->"
def create_subscription_webapp_application(
@@ -57,6 +66,9 @@ def create_subscription_webapp_application(
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
if hasattr(dp, "workflow_data") and "bot_username" in dp.workflow_data: # type: ignore[attr-defined]
app["bot_username"] = dp.workflow_data["bot_username"] # type: ignore[index]
setup_subscription_webapp_routes(app)
return app
@@ -64,9 +76,11 @@ def create_subscription_webapp_application(
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_get("/telegram-web-app.js", telegram_web_app_asset_route)
app.router.add_get("/telegram-widget.js", telegram_widget_asset_route)
app.router.add_get("/subscription_webapp.css", css_asset_route)
app.router.add_get("/subscription_webapp.js", js_asset_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)
@@ -76,6 +90,171 @@ async def health_route(request: web.Request) -> web.Response:
return web.json_response({"ok": True})
async def css_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(request, "subscription_webapp.css", "text/css")
async def telegram_web_app_asset_route(request: web.Request) -> web.Response:
if not TELEGRAM_WEB_APP_SDK_PATH.exists():
await refresh_telegram_web_app_sdk()
try:
response = await _serve_template_asset(
request,
"telegram-web-app.js",
"application/javascript",
)
except FileNotFoundError:
logger.exception(
"Telegram Web App SDK is unavailable at %s",
TELEGRAM_WEB_APP_SDK_PATH,
)
raise web.HTTPServiceUnavailable(text="telegram_web_app_sdk_unavailable")
response.headers["Cache-Control"] = "no-cache"
return response
async def telegram_widget_asset_route(request: web.Request) -> web.Response:
if not TELEGRAM_WIDGET_SDK_PATH.exists():
await refresh_telegram_login_widget_sdk()
try:
data = TELEGRAM_WIDGET_SDK_PATH.read_bytes()
except FileNotFoundError:
logger.exception(
"Telegram Login Widget SDK is unavailable at %s",
TELEGRAM_WIDGET_SDK_PATH,
)
raise web.HTTPServiceUnavailable(text="telegram_widget_sdk_unavailable")
data = _normalize_telegram_login_widget_sdk(data)
response = web.Response(body=data, content_type="application/javascript")
response.headers["Cache-Control"] = "no-cache"
return response
async def refresh_telegram_web_app_sdk() -> bool:
"""Best-effort refresh of the vendored Telegram Web App SDK."""
try:
timeout = ClientTimeout(total=30)
async with ClientSession(
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
) as session:
async with session.get(TELEGRAM_WEB_APP_SDK_URL) as response:
if response.status != 200:
logger.warning(
"Telegram Web App SDK refresh returned HTTP %s; keeping the bundled copy.",
response.status,
)
return False
data = await response.read()
except Exception as exc:
logger.warning("Failed to refresh Telegram Web App SDK: %s", exc)
return False
try:
TELEGRAM_WEB_APP_SDK_PATH.parent.mkdir(parents=True, exist_ok=True)
existing_data = (
TELEGRAM_WEB_APP_SDK_PATH.read_bytes()
if TELEGRAM_WEB_APP_SDK_PATH.exists()
else None
)
if existing_data == data:
logger.info("Telegram Web App SDK is already up to date.")
return True
temp_path = TELEGRAM_WEB_APP_SDK_PATH.with_name(
f"{TELEGRAM_WEB_APP_SDK_PATH.name}.tmp"
)
temp_path.write_bytes(data)
temp_path.replace(TELEGRAM_WEB_APP_SDK_PATH)
logger.info(
"Telegram Web App SDK updated at %s (%d bytes).",
TELEGRAM_WEB_APP_SDK_PATH,
len(data),
)
return True
except Exception as exc:
logger.warning("Failed to store Telegram Web App SDK locally: %s", exc)
return False
async def refresh_telegram_login_widget_sdk() -> bool:
"""Best-effort refresh of the vendored Telegram Login Widget SDK."""
try:
timeout = ClientTimeout(total=30)
async with ClientSession(
timeout=timeout,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
) as session:
async with session.get(TELEGRAM_WIDGET_SDK_URL) as response:
if response.status != 200:
logger.warning(
"Telegram Login Widget SDK refresh returned HTTP %s; keeping the bundled copy.",
response.status,
)
return False
data = await response.read()
except Exception as exc:
logger.warning("Failed to refresh Telegram Login Widget SDK: %s", exc)
return False
try:
data = _normalize_telegram_login_widget_sdk(data)
TELEGRAM_WIDGET_SDK_PATH.parent.mkdir(parents=True, exist_ok=True)
existing_data = (
TELEGRAM_WIDGET_SDK_PATH.read_bytes()
if TELEGRAM_WIDGET_SDK_PATH.exists()
else None
)
if existing_data == data:
logger.info("Telegram Login Widget SDK is already up to date.")
return True
temp_path = TELEGRAM_WIDGET_SDK_PATH.with_name(
f"{TELEGRAM_WIDGET_SDK_PATH.name}.tmp"
)
temp_path.write_bytes(data)
temp_path.replace(TELEGRAM_WIDGET_SDK_PATH)
logger.info(
"Telegram Login Widget SDK updated at %s (%d bytes).",
TELEGRAM_WIDGET_SDK_PATH,
len(data),
)
return True
except Exception as exc:
logger.warning("Failed to store Telegram Login Widget SDK locally: %s", exc)
return False
def _normalize_telegram_login_widget_sdk(data: bytes) -> bytes:
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
text = data.decode("utf-8")
normalized = text.replace(
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
_PATCHED_WIDGET_ORIGIN_SNIPPET,
1,
)
return normalized.encode("utf-8")
async def js_asset_route(request: web.Request) -> web.Response:
return await _serve_template_asset(
request,
"subscription_webapp.js",
"application/javascript",
strip_dev_mock=True,
)
async def index_route(request: web.Request) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
@@ -87,25 +266,76 @@ async def index_route(request: web.Request) -> web.Response:
"primaryColor": settings.WEBAPP_PRIMARY_COLOR,
"logoUrl": settings.WEBAPP_LOGO_URL or "",
"apiBase": "/api",
"telegramLoginBotUsername": request.app.get("bot_username") or "",
"supportUrl": settings.SUPPORT_LINK or "",
"privacyPolicyUrl": settings.PRIVACY_POLICY_URL or "",
"userAgreementUrl": settings.USER_AGREEMENT_URL or "",
"currency": settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
"language": _normalize_language(settings.DEFAULT_LANGUAGE),
}
html = _strip_marked_block(html, DEV_MOCK_START_MARKER, DEV_MOCK_END_MARKER)
html = html.replace(
"__WEBAPP_CONFIG__",
json.dumps(config, ensure_ascii=False, separators=(",", ":")),
WEBAPP_CONFIG_PLACEHOLDER,
(
"<script>window.__WEBAPP_CONFIG__="
+ json.dumps(config, ensure_ascii=False, separators=(",", ":"))
+ ";</script>"
),
)
return web.Response(text=html, content_type="text/html", charset="utf-8")
async def _serve_template_asset(
request: web.Request,
filename: str,
content_type: str,
*,
strip_dev_mock: bool = False,
) -> web.Response:
settings: Settings = request.app["settings"]
if not settings.WEBAPP_ENABLED:
raise web.HTTPNotFound(text="webapp_disabled")
path = ASSET_DIR / filename
text = path.read_text(encoding="utf-8")
if strip_dev_mock:
text = _strip_marked_block(
text,
"/* WEBAPP_DEV_MOCK_START */",
"/* WEBAPP_DEV_MOCK_END */",
)
return web.Response(text=text, content_type=content_type, charset="utf-8")
def _strip_marked_block(html: str, start_marker: str, end_marker: str) -> str:
start = html.find(start_marker)
if start == -1:
return html
end = html.find(end_marker, start)
if end == -1:
return html[:start]
return html[:start] + html[end + len(end_marker):]
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,
)
auth_data = payload.get("auth_data")
telegram_user = None
if init_data:
telegram_user = validate_telegram_webapp_init_data(
init_data,
settings.BOT_TOKEN,
max_age_seconds=settings.WEBAPP_AUTH_MAX_AGE_SECONDS,
)
elif auth_data is not None:
telegram_user = validate_telegram_login_widget_data(
auth_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")
@@ -126,40 +356,6 @@ async def auth_token_route(request: web.Request) -> web.Response:
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)
@@ -317,15 +513,16 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
except Exception:
await session.rollback()
lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE)
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,
"language_code": lang,
},
"subscription": _serialize_subscription(active, local_sub),
"plans": _serialize_plans(settings),
"subscription": _serialize_subscription(active, local_sub, lang),
"plans": _serialize_plans(settings, lang),
"payment_methods": _serialize_payment_methods(settings, request.app),
"settings": {
"support_url": settings.SUPPORT_LINK,
@@ -337,12 +534,13 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
def _serialize_subscription(
active: Optional[Dict[str, Any]],
local_sub: Optional[Any],
lang: str,
) -> Dict[str, Any]:
if not active:
return {
"active": False,
"status": "INACTIVE",
"remaining_text": "Нет активной подписки",
"remaining_text": _format_remaining(0, lang),
"days_left": 0,
"config_link": None,
"connect_url": None,
@@ -365,7 +563,7 @@ def _serialize_subscription(
"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),
"remaining_text": _format_remaining(seconds_left, lang),
"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")),
@@ -375,14 +573,14 @@ def _serialize_subscription(
}
def _serialize_plans(settings: Settings) -> List[Dict[str, Any]]:
def _serialize_plans(settings: Settings, lang: str) -> 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)),
"title": _format_months_title(int(months), lang),
}
stars_price = settings.stars_subscription_options.get(months)
if stars_price is not None and int(stars_price) > 0:
@@ -843,12 +1041,25 @@ async def _create_stars_payment(
return _json_error(502, "payment_failed", "Failed to create invoice")
def _format_remaining(seconds: int) -> str:
def _normalize_language(lang: Optional[str]) -> str:
value = (lang or "ru").split("-")[0].lower()
return value if value in {"ru", "en"} else "ru"
def _format_remaining(seconds: int, lang: str) -> str:
if seconds <= 0:
if lang == "en":
return "Subscription inactive"
return "Подписка не активна"
days, rem = divmod(seconds, 86400)
hours, rem = divmod(rem, 3600)
minutes = rem // 60
if lang == "en":
if days > 0:
return f"{days} d. {hours} h."
if hours > 0:
return f"{hours} h. {minutes} min."
return f"{max(1, minutes)} min."
if days > 0:
return f"{days} д. {hours} ч."
if hours > 0:
@@ -873,7 +1084,11 @@ def _format_bytes(value: Optional[Any]) -> str:
return f"{size:.2f} {units[index]}"
def _format_months_title(months: int) -> str:
def _format_months_title(months: int, lang: str) -> str:
if lang == "en":
if months == 1:
return "1 month"
return f"{months} months"
if months == 1:
return "1 месяц"
if 2 <= months <= 4:
@@ -883,5 +1098,5 @@ def _format_months_title(months: int) -> str:
def _payment_description(months: int, lang: str) -> str:
if lang == "en":
return f"Subscription for {months} month(s)"
return f"Подписка на {_format_months_title(months)}"
return f"Subscription for {_format_months_title(months, lang)}"
return f"Подписка на {_format_months_title(months, lang)}"
@@ -0,0 +1,836 @@
:root {
color-scheme: dark;
--bg-primary: #05070a;
--bg-secondary: #0a0e17;
--bg-card: #0f1521;
--bg-card-hover: #161e2e;
--surface: #1e2940;
--border: #1e2940;
--border-strong: #2a3752;
--text-primary: #e2e8f0;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--accent: #00fe7a;
--accent-hover: #5aff9f;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
--font-mono: "JetBrains Mono", "Fira Code", monospace;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 8px;
--transition: 0.2s cubic-bezier(0.4, 0, 0.2, 1);
--app-safe-top-extra: 10px;
--app-safe-top: max(var(--tg-content-safe-area-inset-top, 0px), var(--tg-safe-area-inset-top, 0px), env(safe-area-inset-top));
--app-safe-bottom: max(var(--tg-content-safe-area-inset-bottom, 0px), var(--tg-safe-area-inset-bottom, 0px), env(safe-area-inset-bottom));
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
margin: 0;
letter-spacing: 0;
}
body {
background: var(--bg-primary);
background:
linear-gradient(180deg, color-mix(in srgb, var(--accent) 7%, transparent), transparent 320px),
var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-sans);
line-height: 1.5;
overflow-x: hidden;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
body.modal-open {
overflow: hidden;
}
button,
a {
font: inherit;
}
button {
border: 0;
cursor: pointer;
}
button:disabled {
cursor: progress;
opacity: 0.58;
}
.app {
width: min(100%, 560px);
margin: 0 auto;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 18px);
display: flex;
flex-direction: column;
gap: 14px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
}
.brand {
display: flex;
align-items: center;
min-width: 0;
gap: 10px;
}
.brand-logo,
.icon-btn {
width: 38px;
height: 38px;
flex: 0 0 auto;
}
.brand-logo {
display: block;
object-fit: contain;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
}
.brand-logo--lg {
width: 84px;
height: 84px;
}
.brand-title {
max-width: 260px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 17px;
font-weight: 800;
line-height: 1.15;
letter-spacing: 0.04em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.icon-btn {
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
color: var(--text-secondary);
transition: var(--transition);
}
.icon-btn:active,
.btn:active,
.plan:active,
.method:active {
transform: translateY(1px);
}
.icon,
.icon-btn svg,
.btn svg {
width: 16px;
height: 16px;
display: block;
fill: currentColor;
flex: 0 0 auto;
}
.panel {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0.01)), var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: 0 22px 48px rgba(0, 0, 0, 0.38);
}
.subscription-panel {
padding: 16px;
display: grid;
gap: 16px;
}
.badge-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.mono-pill,
.badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 10px;
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.mono-pill {
border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border));
background: color-mix(in srgb, var(--accent) 9%, transparent);
color: var(--accent);
}
.badge {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
}
.badge.off {
background: rgba(239, 68, 68, 0.1);
color: var(--danger);
}
.status-title {
margin: 0;
font-size: 32px;
font-weight: 850;
line-height: 1.08;
overflow-wrap: anywhere;
}
.gradient-text {
color: var(--text-primary);
background: linear-gradient(135deg, var(--text-primary), color-mix(in srgb, var(--accent) 68%, #ffffff));
-webkit-background-clip: text;
background-clip: text;
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.gradient-text {
color: transparent;
}
}
@supports (-webkit-text-fill-color: transparent) {
.gradient-text {
-webkit-text-fill-color: transparent;
}
}
@supports not (color: color-mix(in srgb, #000 50%, #fff)) {
body {
background: var(--bg-primary);
}
.mono-pill,
.btn.primary,
.step.active,
.plan.active,
.method.active {
border-color: var(--accent);
}
.mono-pill,
.step.active,
.plan.active,
.method.active {
background: rgba(0, 254, 122, 0.08);
}
.btn.primary {
background: var(--accent);
}
.gradient-text {
background: none;
color: var(--text-primary);
-webkit-text-fill-color: currentColor;
}
}
@supports not ((-webkit-background-clip: text) or (background-clip: text)) {
.gradient-text {
color: var(--text-primary);
-webkit-text-fill-color: currentColor;
}
}
.status-subtitle {
margin: 4px 0 0;
color: var(--text-secondary);
font-size: 14px;
}
.metrics {
display: grid;
gap: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.metric {
display: grid;
grid-template-columns: minmax(0, 0.88fr) minmax(0, 1.12fr);
gap: 10px;
min-height: 48px;
align-items: center;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.02);
}
.metric + .metric {
border-top: 1px solid var(--border);
}
.metric-label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
}
.metric-value {
color: var(--text-primary);
font-size: 14px;
font-weight: 750;
text-align: right;
overflow-wrap: anywhere;
}
.actions {
display: grid;
grid-template-columns: minmax(0, 1fr) 48px;
gap: 8px;
}
.step-actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.btn {
min-height: 46px;
border-radius: var(--radius-md);
padding: 11px 13px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.03);
color: var(--text-primary);
font-weight: 800;
text-align: center;
text-decoration: none;
transition: transform var(--transition), border-color var(--transition), background var(--transition), color var(--transition), box-shadow var(--transition);
}
.btn.primary {
border-color: color-mix(in srgb, var(--accent) 80%, var(--border));
background:
linear-gradient(135deg, var(--accent), color-mix(in srgb, var(--accent) 76%, #00c95f));
color: #04110a;
box-shadow: 0 10px 26px color-mix(in srgb, var(--accent) 20%, transparent);
}
.btn.ghost {
color: var(--text-secondary);
}
.btn.full {
width: 100%;
}
.btn.icon-only {
width: 48px;
min-width: 48px;
padding: 0;
}
.logout-btn {
min-height: 38px;
padding: 8px 11px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1;
}
.extend-row {
margin-top: 2px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.extend-btn {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border));
background: color-mix(in srgb, var(--accent) 5%, transparent);
color: var(--accent);
box-shadow: none;
}
.support-link {
margin-top: 2px;
}
.legal-links {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 6px 16px;
margin-top: 4px;
}
#legal-links-login {
width: min(100%, 560px);
}
.legal-link {
flex: 0 1 auto;
display: inline;
padding: 0;
border: 0;
background: transparent;
color: var(--text-secondary);
font-size: 13px;
font-weight: 650;
text-decoration: underline;
text-decoration-color: color-mix(in srgb, var(--accent) 42%, currentColor);
text-underline-offset: 3px;
transition: color var(--transition), opacity var(--transition);
}
.legal-link:hover:not(:disabled) {
color: var(--accent);
opacity: 0.98;
}
.modal {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
align-items: center;
justify-items: center;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 14px);
opacity: 0;
pointer-events: none;
transition: opacity 0.18s ease;
}
.modal.show {
opacity: 1;
pointer-events: auto;
}
.modal-backdrop {
position: absolute;
inset: 0;
padding: 0;
border: 0;
background: rgba(0, 0, 0, 0.62);
backdrop-filter: blur(7px);
}
.modal-card {
position: relative;
width: min(100%, 560px);
max-height: min(86vh, 760px);
overflow: auto;
transform: translateY(18px) scale(0.98);
transition: transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.modal.show .modal-card {
transform: translateY(0) scale(1);
}
.payment-flow {
padding: 14px;
display: grid;
gap: 14px;
}
.flow-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.flow-title {
font-size: 18px;
font-weight: 850;
line-height: 1.2;
}
.flow-caption {
margin-top: 3px;
color: var(--text-secondary);
font-size: 13px;
}
.stepper {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.step {
min-height: 58px;
padding: 9px;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.02);
color: var(--text-muted);
}
.step-num {
display: block;
color: inherit;
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
line-height: 1.1;
}
.step-name {
display: block;
margin-top: 5px;
color: inherit;
font-size: 12px;
font-weight: 800;
line-height: 1.15;
}
.step.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 9%, transparent);
color: var(--accent);
}
.step.done {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
color: var(--text-secondary);
}
.step-pane {
display: grid;
gap: 10px;
}
.section-label {
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 800;
}
.plans,
.methods {
display: grid;
gap: 8px;
}
.plan,
.method {
width: 100%;
min-height: 64px;
padding: 13px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
text-align: left;
transition: transform var(--transition), border-color var(--transition), background var(--transition), box-shadow var(--transition);
}
.plan.active,
.method.active {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 58%, transparent);
}
.plan-name,
.method-name,
.plan-price {
display: block;
font-size: 15px;
font-weight: 850;
line-height: 1.2;
overflow-wrap: anywhere;
}
.plan-meta,
.method-meta {
display: block;
margin-top: 4px;
color: var(--text-muted);
font-family: var(--font-mono);
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.plan-price {
flex: 0 0 auto;
color: var(--accent);
font-family: var(--font-mono);
text-align: right;
}
.notice,
.empty {
padding: 13px;
border-radius: var(--radius-md);
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.02);
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.result-box {
display: grid;
gap: 10px;
}
.loader {
min-height: 78vh;
display: grid;
place-items: center;
color: var(--text-muted);
font-family: var(--font-mono);
font-weight: 800;
}
.login {
min-height: 100vh;
padding: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra)) 14px max(var(--app-safe-bottom), 18px);
display: none;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
}
.login.show {
display: flex;
}
.login-card {
width: min(100%, 560px);
padding: 18px;
display: grid;
gap: 13px;
text-align: center;
}
.login-brand {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
min-width: 0;
}
.login-brand-title {
max-width: 260px;
color: var(--accent);
font-family: var(--font-mono);
font-size: 24px;
font-weight: 800;
line-height: 1.1;
letter-spacing: 0.04em;
text-align: left;
overflow-wrap: anywhere;
}
.login-text {
margin: 0;
color: var(--text-secondary);
font-size: 14px;
line-height: 1.45;
}
.telegram-login-widget {
min-height: 56px;
display: flex;
align-items: center;
justify-content: center;
}
.login-status {
min-height: 20px;
margin: 0;
}
.login-status.error {
color: var(--danger);
}
.toast {
position: fixed;
left: 50%;
bottom: max(var(--app-safe-bottom), 18px);
z-index: 40;
width: max-content;
max-width: calc(100vw - 28px);
padding: 11px 13px;
border: 1px solid color-mix(in srgb, var(--accent) 42%, var(--border));
border-radius: var(--radius-md);
background: rgba(15, 21, 33, 0.96);
color: var(--text-primary);
box-shadow: 0 16px 38px rgba(0, 0, 0, 0.42);
font-size: 13px;
font-weight: 750;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 8px);
transition: opacity var(--transition), transform var(--transition);
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
.hidden {
display: none !important;
}
@media (hover: hover) and (pointer: fine) {
.btn:hover:not(:disabled),
.icon-btn:hover:not(:disabled),
.plan:hover:not(:disabled),
.method:hover:not(:disabled) {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent) 42%, var(--border));
background: rgba(255, 255, 255, 0.055);
}
.btn.primary:hover:not(:disabled) {
background:
linear-gradient(135deg, var(--accent-hover), color-mix(in srgb, var(--accent) 78%, #00c95f));
box-shadow: 0 14px 34px color-mix(in srgb, var(--accent) 24%, transparent);
}
.extend-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 10%, transparent);
box-shadow: 0 10px 24px color-mix(in srgb, var(--accent) 10%, transparent);
}
}
@media (max-width: 380px) {
.app {
padding-left: 10px;
padding-right: 10px;
}
.brand-title {
max-width: 164px;
}
.status-title {
font-size: 28px;
}
.metric {
grid-template-columns: 1fr;
}
.metric-value {
text-align: left;
}
.step-actions {
grid-template-columns: 1fr;
}
.stepper {
gap: 6px;
}
.step {
min-height: 54px;
padding: 8px 7px;
}
.logout-btn span {
display: none;
}
.brand-logo--lg {
width: 68px;
height: 68px;
}
.login-brand {
gap: 10px;
}
.login-brand-title {
max-width: 200px;
font-size: 21px;
}
}
@media (max-width: 560px) {
.modal {
align-items: stretch;
justify-items: stretch;
top: calc(max(var(--app-safe-top), 14px) + var(--app-safe-top-extra));
bottom: var(--app-safe-bottom);
padding: 0;
}
.modal-card {
width: 100vw;
max-height: none;
height: 100%;
border-radius: 0;
transform: translateY(12px);
}
.modal.show .modal-card {
transform: translateY(0);
}
.payment-flow {
min-height: 100%;
border-radius: 0;
}
.legal-link {
flex: 1 1 100%;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,888 @@
/* WEBAPP_DEV_MOCK_START */
window.__WEBAPP_DEV_MOCK__ = {
config: {
title: 'Моя подписка',
primaryColor: '#00fe7a',
logoUrl: '',
apiBase: '/api',
supportUrl: 'https://t.me/support',
privacyPolicyUrl: 'https://example.com/privacy',
userAgreementUrl: 'https://example.com/agreement',
currency: 'RUB',
language: 'ru'
},
data: {
ok: true,
user: {
id: 100200300,
username: 'preview',
first_name: 'Preview',
language_code: 'ru'
},
subscription: {
active: true,
status: 'ACTIVE',
remaining_text: '27 д. 5 ч.',
end_date_text: '19.05.2026 22:40',
days_left: 27,
config_link: 'https://sub.example.com/sub/preview-token',
connect_url: 'https://sub.example.com/connect/preview-token',
traffic_used: '18.42 GB',
traffic_limit: '500.00 GB',
auto_renew_enabled: false,
provider: null
},
plans: [
{months: 1, price: 190, currency: 'RUB', title: '1 месяц'},
{months: 3, price: 550, currency: 'RUB', title: '3 месяца'},
{months: 6, price: 1100, currency: 'RUB', title: '6 месяцев'},
{months: 12, price: 2000, currency: 'RUB', title: '12 месяцев'}
],
payment_methods: [
{id: 'cryptopay', name: 'CryptoPay'},
{id: 'platega', name: 'Platega'},
{id: 'freekassa', name: 'FreeKassa / СБП'}
],
settings: {
support_url: 'https://t.me/support',
traffic_mode: false
}
}
};
/* WEBAPP_DEV_MOCK_END */
const MOCK = (() => {
const mock = window.__WEBAPP_DEV_MOCK__;
const host = window.location.hostname;
const isLocal = window.location.protocol === 'file:' || host === 'localhost' || host === '127.0.0.1' || host === '';
return mock && isLocal ? mock : null;
})();
const CFG = window.__WEBAPP_CONFIG__ || (MOCK && MOCK.config) || {};
const tg = window.Telegram && window.Telegram.WebApp ? window.Telegram.WebApp : null;
const TELEGRAM_LOGIN_WIDGET_URL = './telegram-widget.js';
const state = {
token: MOCK ? 'local-preview' : (localStorage.getItem('rw_webapp_token') || ''),
data: null,
selectedPlan: null,
selectedMethod: null,
payment: null,
paymentFlowOpen: false,
paymentStep: 'plan',
creatingPayment: false,
authInProgress: false,
toastTimer: null
};
const I18N = {
ru: {
page_title: 'Моя подписка',
loading: 'Загрузка...',
refresh: 'Обновить',
logout: 'Выйти',
subscription: 'Подписка',
subscription_subtitle: 'Ваша ссылка подключения и срок доступа',
ends_at: 'Окончание',
traffic: 'Трафик',
connect: 'Подключиться',
copy_link: 'Скопировать ссылку',
extend_subscription: 'Продлить подписку/Добавить дни',
payment_title: 'Оплата подписки',
payment_caption: 'Выберите срок, способ оплаты и создайте платеж.',
close_payment: 'Закрыть оплату',
payment_steps: 'Шаги оплаты',
step_plan: 'Срок',
step_method: 'Метод',
step_result: 'Платеж',
select_period: 'Выберите срок',
select_method: 'Выберите способ оплаты',
payment_status: 'Статус платежа',
choose_payment_method: 'Выбрать способ оплаты',
back: 'Назад',
create_payment: 'Создать платеж',
open_payment: 'Открыть оплату',
check_payment: 'Проверить оплату',
choose_other_method: 'Выбрать другой способ',
support: 'Поддержка',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Проверяю вход...',
telegram_auth_failed: 'Не удалось подтвердить Telegram-вход. Попробуйте еще раз.',
telegram_auth_unavailable: 'Telegram Login Widget недоступен. Проверьте username бота и доступ к telegram.org.',
telegram_auth_access_denied: 'Доступ запрещен.',
active: 'Активна',
inactive: 'Не активна',
no_active_subscription: 'Нет активной подписки',
not_available: 'N/A',
no_plans: 'Тарифы не настроены.',
access_period: 'Срок доступа',
choose_plan_first: 'Сначала выберите срок подписки.',
no_methods: 'Нет доступных способов оплаты.',
payment_not_created: 'Платеж еще не создан.',
invoice_sent: 'Счет отправлен в чат с ботом. После оплаты вернитесь сюда и проверьте статус.',
payment_created: 'Платеж создан. Откройте оплату, завершите ее у провайдера и затем проверьте статус.',
updating: 'Обновляю данные',
choose_plan_toast: 'Выберите срок подписки',
choose_method_toast: 'Выберите способ оплаты',
create_payment_toast: 'Создайте платеж',
payment_confirmed: 'Оплата подтверждена',
payment_pending: 'Платеж пока не подтвержден',
link_copied: 'Ссылка скопирована',
no_link: 'Ссылка пока недоступна',
payment_error: 'Ошибка оплаты',
privacy_policy: 'Политика конфиденциальности',
user_agreement: 'Пользовательское соглашение'
},
en: {
page_title: 'My subscription',
loading: 'Loading...',
refresh: 'Refresh',
logout: 'Log out',
subscription: 'Subscription',
subscription_subtitle: 'Your connection link and access time',
ends_at: 'Ends at',
traffic: 'Traffic',
connect: 'Connect',
copy_link: 'Copy link',
extend_subscription: 'Renew subscription/Add days',
payment_title: 'Subscription payment',
payment_caption: 'Choose a period, payment method, and create a payment.',
close_payment: 'Close payment',
payment_steps: 'Payment steps',
step_plan: 'Period',
step_method: 'Method',
step_result: 'Payment',
select_period: 'Select period',
select_method: 'Select payment method',
payment_status: 'Payment status',
choose_payment_method: 'Choose payment method',
back: 'Back',
create_payment: 'Create payment',
open_payment: 'Open payment',
check_payment: 'Check payment',
choose_other_method: 'Choose another method',
support: 'Support',
telegram_auth: 'Telegram auth',
telegram_auth_verifying: 'Verifying login...',
telegram_auth_failed: 'Could not verify Telegram login. Try again.',
telegram_auth_unavailable: 'Telegram Login Widget is unavailable. Check the bot username and access to telegram.org.',
telegram_auth_access_denied: 'Access denied.',
active: 'Active',
inactive: 'Inactive',
no_active_subscription: 'No active subscription',
not_available: 'N/A',
no_plans: 'Plans are not configured.',
access_period: 'Access period',
choose_plan_first: 'Choose a subscription period first.',
no_methods: 'No payment methods available.',
payment_not_created: 'Payment has not been created yet.',
invoice_sent: 'The invoice was sent to the bot chat. Pay it, then return here and check the status.',
payment_created: 'Payment was created. Open it, complete payment with the provider, then check the status.',
updating: 'Refreshing data',
choose_plan_toast: 'Choose a subscription period',
choose_method_toast: 'Choose a payment method',
create_payment_toast: 'Create a payment',
payment_confirmed: 'Payment confirmed',
payment_pending: 'Payment is not confirmed yet',
link_copied: 'Link copied',
no_link: 'Link is not available yet',
payment_error: 'Payment error',
privacy_policy: 'Privacy policy',
user_agreement: 'User agreement'
}
};
const accent = CFG.primaryColor || '#00fe7a';
document.documentElement.style.setProperty('--accent', accent);
setBrand();
applyI18n();
applyLegalLinks();
if (CFG.supportUrl) {
const support = document.getElementById('support-link');
support.href = CFG.supportUrl;
support.classList.remove('hidden');
}
if (tg) {
try {
tg.ready();
tg.expand();
const canSetColors = typeof tg.isVersionAtLeast === 'function' && tg.isVersionAtLeast('6.1');
if (canSetColors) {
tg.setHeaderColor('#05070a');
tg.setBackgroundColor('#05070a');
}
} catch (e) { }
}
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && state.paymentFlowOpen) {
closePaymentFlow();
}
});
boot();
async function boot() {
showLoader();
if (MOCK) {
await loadData();
return;
}
const widgetAuthData = readTelegramLoginWidgetAuthData();
if (widgetAuthData) {
const authenticated = await finalizeTelegramAuth(widgetAuthData);
if (authenticated) {
return;
}
clearToken();
startExternalAuth({resetStatus: false});
return;
}
if (tg && tg.initData) {
try {
const authenticated = await finalizeTelegramAuth(tg.initData, 'init_data');
if (authenticated) {
return;
}
} catch (e) { }
}
if (state.token) {
try {
await loadData();
return;
} catch (e) {
clearToken();
}
}
await startExternalAuth();
}
function readTelegramLoginWidgetAuthData() {
const query = new URLSearchParams(window.location.search);
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
const authData = {};
let hasAuthValue = false;
keys.forEach(key => {
if (!query.has(key)) return;
authData[key] = query.get(key) || '';
hasAuthValue = true;
});
if (!hasAuthValue || !authData.id || !authData.auth_date || !authData.hash) {
return null;
}
return authData;
}
function clearTelegramLoginWidgetQuery() {
const url = new URL(window.location.href);
const keys = ['id', 'first_name', 'last_name', 'username', 'photo_url', 'auth_date', 'hash'];
keys.forEach(key => url.searchParams.delete(key));
if (window.history && window.history.replaceState) {
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
}
async function finalizeTelegramAuth(authData, source = 'auth_data') {
if (state.authInProgress) return false;
state.authInProgress = true;
setAuthStatus(t('telegram_auth_verifying'));
try {
const payload = source === 'init_data'
? {init_data: authData}
: {auth_data: authData};
const response = await fetch(CFG.apiBase + '/auth/token', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (response.ok && data.ok && data.token) {
setToken(data.token);
clearTelegramLoginWidgetQuery();
try {
setAuthStatus('');
await loadData();
return true;
} catch (e) {
clearToken();
setAuthStatus(t('telegram_auth_failed'), true);
return false;
}
}
const errorKey = data.error === 'banned' || data.error === 'access_denied'
? 'telegram_auth_access_denied'
: 'telegram_auth_failed';
setAuthStatus(t(errorKey), true);
} catch (e) {
setAuthStatus(t('telegram_auth_failed'), true);
} finally {
state.authInProgress = false;
}
clearTelegramLoginWidgetQuery();
return false;
}
async function handleTelegramLoginWidgetAuth(user) {
if (!user) {
setAuthStatus(t('telegram_auth_failed'), true);
return;
}
await finalizeTelegramAuth(user, 'auth_data');
}
function renderTelegramLoginWidget() {
const container = document.getElementById('telegram-login-widget');
if (!container) return;
container.innerHTML = '';
const botUsername = String(CFG.telegramLoginBotUsername || '').trim();
if (!botUsername) {
setAuthStatus(t('telegram_auth_unavailable'), true);
return;
}
if (typeof window.onTelegramAuth !== 'function') {
window.onTelegramAuth = async function(user) {
await handleTelegramLoginWidgetAuth(user);
};
}
const script = document.createElement('script');
script.async = true;
script.src = TELEGRAM_LOGIN_WIDGET_URL;
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-userpic', 'false');
script.setAttribute('data-request-access', 'write');
script.setAttribute('data-onauth', 'onTelegramAuth(user)');
script.onerror = () => setAuthStatus(t('telegram_auth_unavailable'), true);
container.appendChild(script);
}
function startExternalAuth(options = {}) {
const resetStatus = options.resetStatus !== false;
showLogin();
if (resetStatus) {
setAuthStatus('');
}
renderTelegramLoginWidget();
}
async function loadData() {
const previousMonths = state.selectedPlan && state.selectedPlan.months;
const data = await api('/me');
if (!data.ok) throw new Error(data.error || 'load failed');
state.data = data;
setBrand();
applyI18n();
applyLegalLinks();
const plans = data.plans || [];
state.selectedPlan = plans.find(plan => plan.months === previousMonths) || plans[0] || null;
state.selectedMethod = null;
state.payment = null;
state.paymentStep = 'plan';
render();
showApp();
}
async function reloadData() {
showToast(t('updating'));
await loadData();
}
function render() {
renderSubscription(state.data.subscription);
renderPaymentFlow();
}
function renderSubscription(sub) {
const badge = document.getElementById('status-badge');
badge.textContent = sub.active ? t('active') : t('inactive');
badge.classList.toggle('off', !sub.active);
document.getElementById('remaining').textContent = sub.remaining_text || t('no_active_subscription');
document.getElementById('end-date').textContent = sub.end_date_text || t('not_available');
document.getElementById('traffic').textContent = (sub.traffic_used || t('not_available')) + ' / ' + (sub.traffic_limit || t('not_available'));
document.getElementById('connect-actions').classList.toggle('hidden', !sub.connect_url && !sub.config_link);
}
function togglePaymentFlow() {
if (state.paymentFlowOpen) {
closePaymentFlow();
return;
}
state.paymentFlowOpen = true;
state.paymentStep = 'plan';
state.payment = null;
state.selectedMethod = null;
renderPaymentFlow();
}
function closePaymentFlow() {
state.paymentFlowOpen = false;
state.paymentStep = 'plan';
state.payment = null;
state.selectedMethod = null;
renderPaymentFlow();
}
function renderPaymentFlow() {
const modal = document.getElementById('payment-modal');
if (!state.paymentFlowOpen) {
modal.classList.remove('show');
document.body.classList.remove('modal-open');
window.setTimeout(() => {
if (!state.paymentFlowOpen) modal.classList.add('hidden');
}, 180);
return;
}
modal.classList.remove('hidden');
document.body.classList.add('modal-open');
window.requestAnimationFrame(() => modal.classList.add('show'));
applyI18n(modal);
renderStepState();
renderPlans(state.data.plans || []);
renderMethods();
renderPaymentResult();
}
function renderStepState() {
const order = ['plan', 'method', 'result'];
order.forEach((step, index) => {
const node = document.getElementById('step-' + step);
const currentIndex = order.indexOf(state.paymentStep);
node.classList.toggle('active', state.paymentStep === step);
node.classList.toggle('done', currentIndex > index);
});
document.getElementById('plan-step').classList.toggle('hidden', state.paymentStep !== 'plan');
document.getElementById('method-step').classList.toggle('hidden', state.paymentStep !== 'method');
document.getElementById('result-step').classList.toggle('hidden', state.paymentStep !== 'result');
}
function renderPlans(plans) {
const wrap = document.getElementById('plans');
const nextBtn = document.getElementById('to-methods-btn');
if (!plans.length) {
wrap.innerHTML = '<div class="empty">' + escapeHtml(t('no_plans')) + '</div>';
nextBtn.disabled = true;
return;
}
nextBtn.disabled = !state.selectedPlan;
wrap.innerHTML = plans.map(plan => {
const isActive = state.selectedPlan && state.selectedPlan.months === plan.months;
const stars = plan.stars_price ? ' / ' + plan.stars_price + ' Stars' : '';
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(t('access_period'))}</span>
</span>
<span class="plan-price">${escapeHtml(formatMoney(plan.price, plan.currency) + stars)}</span>
</button>
`;
}).join('');
}
function renderMethods() {
const methods = document.getElementById('methods');
const createBtn = document.getElementById('create-payment-btn');
if (!state.selectedPlan) {
methods.innerHTML = '<div class="empty">' + escapeHtml(t('choose_plan_first')) + '</div>';
createBtn.disabled = true;
return;
}
const available = getAvailableMethods();
if (state.selectedMethod && !available.some(method => method.id === state.selectedMethod)) {
state.selectedMethod = null;
}
createBtn.disabled = !state.selectedMethod || state.creatingPayment;
if (!available.length) {
methods.innerHTML = '<div class="empty">' + escapeHtml(t('no_methods')) + '</div>';
createBtn.disabled = true;
return;
}
methods.innerHTML = available.map(method => {
const isActive = state.selectedMethod === method.id;
const amount = method.id === 'stars'
? state.selectedPlan.stars_price + ' Stars'
: formatMoney(state.selectedPlan.price, state.selectedPlan.currency);
return `
<button class="method ${isActive ? 'active' : ''}" type="button" onclick="selectMethod('${escapeAttr(method.id)}')">
<span>
<span class="method-name">${escapeHtml(method.name)}</span>
<span class="method-meta">${escapeHtml(state.selectedPlan.title)}</span>
</span>
<span class="plan-price">${escapeHtml(amount)}</span>
</button>
`;
}).join('');
}
function renderPaymentResult() {
const payment = state.payment;
const msg = document.getElementById('payment-message');
const openBtn = document.getElementById('payment-open-btn');
const checkBtn = document.getElementById('payment-check-btn');
if (!payment) {
msg.textContent = t('payment_not_created');
openBtn.classList.add('hidden');
checkBtn.classList.add('hidden');
return;
}
if (payment.action === 'invoice_sent') {
msg.textContent = t('invoice_sent');
openBtn.classList.add('hidden');
} else {
msg.textContent = t('payment_created');
openBtn.classList.toggle('hidden', !payment.payment_url);
}
checkBtn.classList.toggle('hidden', !payment.payment_id);
}
function selectPlan(months) {
state.selectedPlan = (state.data.plans || []).find(plan => plan.months === months) || null;
state.selectedMethod = null;
state.payment = null;
renderPaymentFlow();
}
function selectMethod(method) {
state.selectedMethod = method;
state.payment = null;
renderMethods();
renderPaymentResult();
}
function goToPaymentStep(step) {
if (step === 'method' && !state.selectedPlan) {
showToast(t('choose_plan_toast'));
return;
}
if (step === 'result' && !state.payment) {
showToast(t('create_payment_toast'));
return;
}
state.paymentStep = step;
renderPaymentFlow();
}
async function createPaymentFromSelection() {
if (!state.selectedPlan) {
showToast(t('choose_plan_toast'));
return;
}
if (!state.selectedMethod) {
showToast(t('choose_method_toast'));
return;
}
state.creatingPayment = true;
renderMethods();
try {
const data = await api('/payments', {
method: 'POST',
body: JSON.stringify({months: state.selectedPlan.months, method: state.selectedMethod})
});
if (!data.ok) throw new Error(data.message || t('payment_error'));
state.payment = data;
state.paymentStep = 'result';
renderPaymentFlow();
} catch (e) {
showToast(e.message || t('payment_error'));
} finally {
state.creatingPayment = false;
renderPaymentFlow();
}
}
function getAvailableMethods() {
if (!state.data || !state.selectedPlan) return [];
return (state.data.payment_methods || []).filter(method => {
if (method.id === 'stars') return Number(state.selectedPlan.stars_price || 0) > 0;
return true;
});
}
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(t('payment_confirmed'));
state.paymentFlowOpen = false;
await loadData();
} else {
showToast(t('payment_pending'));
}
}
function openConnectLink() {
const sub = state.data && state.data.subscription;
const url = sub && (sub.connect_url || sub.config_link);
if (!url) {
showToast(t('no_link'));
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) {
showToast(t('no_link'));
return;
}
try {
await navigator.clipboard.writeText(link);
showToast(t('link_copied'));
} catch (e) {
const area = document.createElement('textarea');
area.value = link;
document.body.appendChild(area);
area.select();
document.execCommand('copy');
area.remove();
showToast(t('link_copied'));
}
}
async function api(path, options = {}) {
if (MOCK) {
return mockApi(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();
}
async function mockApi(path, options = {}) {
await new Promise(resolve => window.setTimeout(resolve, 120));
if (path === '/me') {
return JSON.parse(JSON.stringify(MOCK.data));
}
if (path === '/payments' && String(options.method || '').toUpperCase() === 'POST') {
return {
ok: true,
action: 'open_link',
payment_url: 'https://example.com/payment-preview',
payment_id: 10001
};
}
if (path.startsWith('/payments/')) {
return {
ok: true,
payment_id: 10001,
status: 'pending',
paid: false
};
}
return {ok: false, error: 'not_found'};
}
function setToken(token) {
state.token = token;
localStorage.setItem('rw_webapp_token', token);
}
function clearToken() {
state.token = '';
localStorage.removeItem('rw_webapp_token');
}
function logout() {
clearToken();
closePaymentFlow();
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 setAuthStatus(message, isError = false) {
const status = document.getElementById('auth-status');
if (!status) return;
if (!message) {
status.textContent = '';
status.classList.add('hidden');
status.classList.remove('error');
return;
}
status.textContent = message;
status.classList.remove('hidden');
status.classList.toggle('error', Boolean(isError));
}
function showToast(message) {
if (tg && tg.showAlert) {
tg.showAlert(message);
return;
}
const toast = document.getElementById('toast');
toast.textContent = message;
toast.classList.remove('hidden');
window.requestAnimationFrame(() => toast.classList.add('show'));
if (state.toastTimer) window.clearTimeout(state.toastTimer);
state.toastTimer = window.setTimeout(() => {
toast.classList.remove('show');
window.setTimeout(() => toast.classList.add('hidden'), 180);
}, 1800);
}
function setBrand() {
const title = CFG.title || t('page_title');
const logoUrl = CFG.logoUrl || '';
document.title = title;
document.querySelectorAll('[data-brand-title]').forEach(node => {
node.textContent = title;
});
document.querySelectorAll('[data-brand-logo]').forEach(logo => {
if (!logoUrl) {
logo.removeAttribute('src');
logo.classList.add('hidden');
return;
}
logo.onload = () => {
logo.classList.remove('hidden');
};
logo.onerror = () => {
logo.removeAttribute('src');
logo.classList.add('hidden');
};
logo.src = logoUrl;
if (logo.complete && logo.naturalWidth > 0) {
logo.classList.remove('hidden');
} else {
logo.classList.add('hidden');
}
});
}
function applyI18n(root = document) {
document.documentElement.lang = getLanguage();
root.querySelectorAll('[data-i18n]').forEach(node => {
node.textContent = t(node.dataset.i18n);
});
root.querySelectorAll('[data-title-i18n]').forEach(node => {
const value = t(node.dataset.titleI18n);
node.setAttribute('title', value);
node.setAttribute('aria-label', value);
});
root.querySelectorAll('[data-aria-i18n]').forEach(node => {
node.setAttribute('aria-label', t(node.dataset.ariaI18n));
});
}
function applyLegalLinks(root = document) {
const urls = {
privacyPolicyUrl: CFG.privacyPolicyUrl || '',
userAgreementUrl: CFG.userAgreementUrl || ''
};
root.querySelectorAll('[data-legal-key]').forEach(node => {
const key = node.dataset.legalKey;
const url = urls[key] || '';
node.href = url || '#';
node.classList.toggle('hidden', !url);
node.setAttribute('aria-hidden', url ? 'false' : 'true');
});
root.querySelectorAll('.legal-links').forEach(container => {
const visible = Array.from(container.querySelectorAll('[data-legal-key]'))
.some(node => !node.classList.contains('hidden'));
container.classList.toggle('hidden', !visible);
});
}
function getLanguage() {
const raw = (
state.data && state.data.user && state.data.user.language_code
? state.data.user.language_code
: (CFG.language || document.documentElement.lang || 'ru')
).toLowerCase();
const short = raw.split('-')[0];
return I18N[short] ? short : 'ru';
}
function t(key, params = {}) {
const table = I18N[getLanguage()] || I18N.ru;
const fallback = I18N.ru[key] || key;
const template = table[key] || fallback;
return template.replace(/\{(\w+)\}/g, (_, name) => (
Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : ''
));
}
function formatMoney(value, currency) {
const numeric = Number(value || 0);
const formatted = Number.isInteger(numeric) ? String(numeric) : numeric.toFixed(2);
return formatted + ' ' + (currency || CFG.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;');
}
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
(function(window) {
(function(window){
window.__parseFunction = function(__func, __attrs) {
__attrs = __attrs || [];
__func = '(function(' + __attrs.join(',') + '){' + __func + '})';
return window.execScript ? window.execScript(__func) : eval(__func);
}
}(window));
(function(window){
function addEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.addEventListener) {
el.addEventListener(events[i], handler);
} else {
el.attachEvent('on' + events[i], handler);
}
}
}
function removeEvent(el, event, handler) {
var events = event.split(/\s+/);
for (var i = 0; i < events.length; i++) {
if (el.removeEventListener) {
el.removeEventListener(events[i], handler);
} else {
el.detachEvent('on' + events[i], handler);
}
}
}
function getCssProperty(el, prop) {
if (window.getComputedStyle) {
return window.getComputedStyle(el, '').getPropertyValue(prop) || null;
} else if (el.currentStyle) {
return el.currentStyle[prop] || null;
}
return null;
}
function geById(el_or_id) {
if (typeof el_or_id == 'string' || el_or_id instanceof String) {
return document.getElementById(el_or_id);
} else if (el_or_id instanceof HTMLElement) {
return el_or_id;
}
return null;
}
var getWidgetsOrigin = function(default_origin, dev_origin) {
var link = document.createElement('A'), origin;
link.href = document.currentScript && document.currentScript.src || default_origin;
origin = link.origin || link.protocol + '//' + link.hostname;
if (origin == 'https://telegram.org') {
origin = default_origin;
} else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {
origin = dev_origin;
} else {
origin = default_origin;
}
return origin;
};
var getPageCanonical = function() {
var a = document.createElement('A'), link, href;
if (document.querySelector) {
link = document.querySelector('link[rel="canonical"]');
if (link && (href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
} else {
var links = document.getElementsByTagName('LINK');
for (var i = 0; i < links.length; i++) {
if ((link = links[i]) &&
(link.getAttribute('rel') == 'canonical') &&
(href = link.getAttribute('href'))) {
a.href = href;
return a.href;
}
}
}
return false;
};
function haveTgAuthResult() {
var locationHash = '', re = /[#\?\&]tgAuthResult=([A-Za-z0-9\-_=]*)$/, match;
try {
locationHash = location.hash.toString();
if (match = locationHash.match(re)) {
location.hash = locationHash.replace(re, '');
var data = match[1] || '';
data = data.replace(/-/g, '+').replace(/_/g, '/');
var pad = data.length % 4;
if (pad > 1) {
data += new Array(5 - pad).join('=');
}
return JSON.parse(window.atob(data));
}
} catch (e) {}
return false;
}
function getXHR() {
if (navigator.appName == "Microsoft Internet Explorer"){
return new ActiveXObject("Microsoft.XMLHTTP");
} else {
return new XMLHttpRequest();
}
}
if (!window.Telegram) {
window.Telegram = {};
}
if (!window.Telegram.__WidgetUuid) {
window.Telegram.__WidgetUuid = 0;
}
if (!window.Telegram.__WidgetLastId) {
window.Telegram.__WidgetLastId = 0;
}
if (!window.Telegram.__WidgetCallbacks) {
window.Telegram.__WidgetCallbacks = {};
}
function postMessageToIframe(iframe, event, data, callback) {
if (!iframe._ready) {
if (!iframe._readyQueue) iframe._readyQueue = [];
iframe._readyQueue.push([event, data, callback]);
return;
}
try {
data = data || {};
data.event = event;
if (callback) {
data._cb = ++window.Telegram.__WidgetLastId;
window.Telegram.__WidgetCallbacks[data._cb] = {
iframe: iframe,
callback: callback
};
}
iframe.contentWindow.postMessage(JSON.stringify(data), '*');
} catch(e) {}
}
function initWidget(widgetEl) {
var widgetId, widgetElId, widgetsOrigin, existsEl,
src, styles = {}, allowedAttrs = [],
defWidth, defHeight, scrollable = false, onInitAuthUser, onAuthUser, onUnauth;
if (!widgetEl.tagName ||
!(widgetEl.tagName.toUpperCase() == 'SCRIPT' ||
widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE' &&
widgetEl.classList.contains('telegram-post'))) {
return null;
}
if (widgetEl._iframe) {
return widgetEl._iframe;
}
if (widgetId = widgetEl.getAttribute('data-telegram-post')) {
var comment = widgetEl.getAttribute('data-comment') || '';
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-post-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + (comment ? '-comment' + comment : '');
src = widgetsOrigin + '/' + widgetId + '?embed=1';
allowedAttrs = ['comment', 'userpic', 'mode', 'single?', 'color', 'dark', 'dark_color'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = '';
styles.minWidth = '320px';
}
else if (widgetId = widgetEl.getAttribute('data-telegram-discussion')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-discussion-' + widgetId.replace(/[^a-z0-9_]/ig, '-') + '-' + (++window.Telegram.__WidgetUuid);
var websitePageUrl = widgetEl.getAttribute('data-page-url');
if (!websitePageUrl) {
websitePageUrl = getPageCanonical();
}
src = widgetsOrigin + '/' + widgetId + '?embed=1&discussion=1' + (websitePageUrl ? '&page_url=' + encodeURIComponent(websitePageUrl) : '');
allowedAttrs = ['comments_limit', 'color', 'colorful', 'dark', 'dark_color', 'width', 'height'];
defWidth = widgetEl.getAttribute('data-width') || '100%';
defHeight = widgetEl.getAttribute('data-height') || 0;
styles.minWidth = '320px';
if (defHeight > 0) {
scrollable = true;
}
}
else if (widgetEl.hasAttribute('data-telegram-login')) {
widgetId = widgetEl.getAttribute('data-telegram-login');
widgetsOrigin = getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev');
widgetElId = 'telegram-login-' + widgetId.replace(/[^a-z0-9_]/ig, '-');
src = widgetsOrigin + '/embed/' + widgetId + '?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + '&return_to=' + encodeURIComponent(location.href);
allowedAttrs = ['size', 'userpic', 'init_auth', 'request_access', 'radius', 'min_width', 'max_width', 'lang'];
defWidth = 186;
defHeight = 28;
if (widgetEl.hasAttribute('data-size')) {
var size = widgetEl.getAttribute('data-size');
if (size == 'small') defWidth = 148, defHeight = 20;
else if (size == 'large') defWidth = 238, defHeight = 40;
}
if (widgetEl.hasAttribute('data-onauth')) {
onInitAuthUser = onAuthUser = __parseFunction(widgetEl.getAttribute('data-onauth'), ['user']);
}
else if (widgetEl.hasAttribute('data-auth-url')) {
var a = document.createElement('A');
a.href = widgetEl.getAttribute('data-auth-url');
onAuthUser = function(user) {
var authUrl = a.href;
authUrl += (authUrl.indexOf('?') >= 0) ? '&' : '?';
var params = [];
for (var key in user) {
params.push(key + '=' + encodeURIComponent(user[key]));
}
authUrl += params.join('&');
location.href = authUrl;
};
}
if (widgetEl.hasAttribute('data-onunauth')) {
onUnauth = __parseFunction(widgetEl.getAttribute('data-onunauth'));
}
var auth_result = haveTgAuthResult();
if (auth_result && onAuthUser) {
onAuthUser(auth_result);
}
}
else if (widgetId = widgetEl.getAttribute('data-telegram-share-url')) {
widgetsOrigin = getWidgetsOrigin('https://t.me', 'https://post.tg.dev');
widgetElId = 'telegram-share-' + window.btoa(widgetId);
src = widgetsOrigin + '/share/embed?origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname);
allowedAttrs = ['telegram-share-url', 'comment', 'size', 'text'];
defWidth = 60;
defHeight = 20;
if (widgetEl.getAttribute('data-size') == 'large') {
defWidth = 76;
defHeight = 28;
}
}
else {
return null;
}
existsEl = document.getElementById(widgetElId);
if (existsEl) {
return existsEl;
}
for (var i = 0; i < allowedAttrs.length; i++) {
var attr = allowedAttrs[i];
var novalue = attr.substr(-1) == '?';
if (novalue) {
attr = attr.slice(0, -1);
}
var data_attr = 'data-' + attr.replace(/_/g, '-');
if (widgetEl.hasAttribute(data_attr)) {
var attr_value = novalue ? '1' : encodeURIComponent(widgetEl.getAttribute(data_attr));
src += '&' + attr + '=' + attr_value;
}
}
function getCurCoords(iframe) {
var docEl = document.documentElement;
var frect = iframe.getBoundingClientRect();
return {
frameTop: frect.top,
frameBottom: frect.bottom,
frameLeft: frect.left,
frameRight: frect.right,
frameWidth: frect.width,
frameHeight: frect.height,
scrollTop: window.pageYOffset,
scrollLeft: window.pageXOffset,
clientWidth: docEl.clientWidth,
clientHeight: docEl.clientHeight
};
}
function visibilityHandler() {
if (isVisible(iframe, 50)) {
postMessageToIframe(iframe, 'visible', {frame: widgetElId});
}
}
function focusHandler() {
postMessageToIframe(iframe, 'focus', {has_focus: document.hasFocus()});
}
function postMessageHandler(event) {
if (event.source !== iframe.contentWindow ||
event.origin != widgetsOrigin) {
return;
}
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (data.event == 'resize') {
if (data.height) {
iframe.style.height = data.height + 'px';
}
if (data.width) {
iframe.style.width = data.width + 'px';
}
}
else if (data.event == 'ready') {
iframe._ready = true;
focusHandler();
for (var i = 0; i < iframe._readyQueue.length; i++) {
var queue_item = iframe._readyQueue[i];
postMessageToIframe(iframe, queue_item[0], queue_item[1], queue_item[2]);
}
iframe._readyQueue = [];
}
else if (data.event == 'visible_off') {
removeEvent(window, 'scroll', visibilityHandler);
removeEvent(window, 'resize', visibilityHandler);
}
else if (data.event == 'get_coords') {
postMessageToIframe(iframe, 'callback', {
_cb: data._cb,
value: getCurCoords(iframe)
});
}
else if (data.event == 'scroll_to') {
try {
window.scrollTo(data.x || 0, data.y || 0);
} catch(e) {}
}
else if (data.event == 'auth_user') {
if (data.init) {
onInitAuthUser && onInitAuthUser(data.auth_data);
} else {
onAuthUser && onAuthUser(data.auth_data);
}
}
else if (data.event == 'unauthorized') {
onUnauth && onUnauth();
}
else if (data.event == 'callback') {
var cb_data = null;
if (cb_data = window.Telegram.__WidgetCallbacks[data._cb]) {
if (cb_data.iframe === iframe) {
cb_data.callback(data.value);
delete window.Telegram.__WidgetCallbacks[data._cb];
}
} else {
console.warn('Callback #' + data._cb + ' not found');
}
}
}
var iframe = document.createElement('iframe');
iframe.id = widgetElId;
iframe.src = src;
iframe.width = defWidth;
iframe.height = defHeight;
iframe.setAttribute('frameborder', '0');
if (!scrollable) {
iframe.setAttribute('scrolling', 'no');
iframe.style.overflow = 'hidden';
}
iframe.style.colorScheme = 'light dark';
iframe.style.border = 'none';
for (var prop in styles) {
iframe.style[prop] = styles[prop];
}
if (widgetEl.parentNode) {
widgetEl.parentNode.insertBefore(iframe, widgetEl);
if (widgetEl.tagName.toUpperCase() == 'BLOCKQUOTE') {
widgetEl.parentNode.removeChild(widgetEl);
}
}
iframe._ready = false;
iframe._readyQueue = [];
widgetEl._iframe = iframe;
addEvent(iframe, 'load', function() {
removeEvent(iframe, 'load', visibilityHandler);
addEvent(window, 'scroll', visibilityHandler);
addEvent(window, 'resize', visibilityHandler);
visibilityHandler();
});
addEvent(window, 'focus blur', focusHandler);
addEvent(window, 'message', postMessageHandler);
return iframe;
}
function isVisible(el, padding) {
var node = el, val;
var visibility = getCssProperty(node, 'visibility');
if (visibility == 'hidden') return false;
while (node) {
if (node === document.documentElement) break;
var display = getCssProperty(node, 'display');
if (display == 'none') return false;
var opacity = getCssProperty(node, 'opacity');
if (opacity !== null && opacity < 0.1) return false;
node = node.parentNode;
}
if (el.getBoundingClientRect) {
padding = +padding || 0;
var rect = el.getBoundingClientRect();
var html = document.documentElement;
if (rect.bottom < padding ||
rect.right < padding ||
rect.top > (window.innerHeight || html.clientHeight) - padding ||
rect.left > (window.innerWidth || html.clientWidth) - padding) {
return false;
}
}
return true;
}
function getAllWidgets() {
var widgets = [];
if (document.querySelectorAll) {
widgets = document.querySelectorAll('script[data-telegram-post],blockquote.telegram-post,script[data-telegram-discussion],script[data-telegram-login],script[data-telegram-share-url]');
} else {
widgets = Array.prototype.slice.apply(document.getElementsByTagName('SCRIPT'));
widgets = widgets.concat(Array.prototype.slice.apply(document.getElementsByTagName('BLOCKQUOTE')));
}
return widgets;
}
function getWidgetInfo(el_or_id, callback) {
var e = null, iframe = null;
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe && callback) {
postMessageToIframe(iframe, 'get_info', {}, callback);
}
}
}
function setWidgetOptions(options, el_or_id) {
var e = null, iframe = null;
if (typeof el_or_id === 'undefined') {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
if (iframe = widgets[i]._iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
} else {
if (el = geById(el_or_id)) {
if (el.tagName &&
el.tagName.toUpperCase() == 'IFRAME') {
iframe = el;
} else if (el._iframe) {
iframe = el._iframe;
}
if (iframe) {
postMessageToIframe(iframe, 'set_options', {options: options});
}
}
}
}
if (!document.currentScript ||
!initWidget(document.currentScript)) {
var widgets = getAllWidgets();
for (var i = 0; i < widgets.length; i++) {
initWidget(widgets[i]);
}
}
var TelegramLogin = {
popups: {},
options: null,
auth_callback: null,
_init: function(options, auth_callback) {
TelegramLogin.options = options;
TelegramLogin.auth_callback = auth_callback;
var auth_result = haveTgAuthResult();
if (auth_result && auth_callback) {
auth_callback(auth_result);
}
},
_open: function(callback) {
TelegramLogin._auth(TelegramLogin.options, function(authData) {
if (TelegramLogin.auth_callback) {
TelegramLogin.auth_callback(authData);
}
if (callback) {
callback(authData);
}
});
},
_auth: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var width = 550;
var height = 470;
var left = Math.max(0, (screen.width - width) / 2) + (screen.availLeft | 0),
top = Math.max(0, (screen.height - height) / 2) + (screen.availTop | 0);
var onMessage = function (event) {
try {
var data = JSON.parse(event.data);
} catch(e) {
var data = {};
}
if (!TelegramLogin.popups[bot_id]) return;
if (event.source !== TelegramLogin.popups[bot_id].window) return;
if (data.event == 'auth_result') {
onAuthDone(data.result);
}
};
var onAuthDone = function (authData) {
if (!TelegramLogin.popups[bot_id]) return;
if (TelegramLogin.popups[bot_id].authFinished) return;
callback && callback(authData);
TelegramLogin.popups[bot_id].authFinished = true;
removeEvent(window, 'message', onMessage);
};
var checkClose = function(bot_id) {
if (!TelegramLogin.popups[bot_id]) return;
if (!TelegramLogin.popups[bot_id].window ||
TelegramLogin.popups[bot_id].window.closed) {
return TelegramLogin.getAuthData(options, function(origin, authData) {
onAuthDone(authData);
});
}
setTimeout(checkClose, 100, bot_id);
}
var popup_url = Telegram.Login.widgetsOrigin + '/auth?bot_id=' + encodeURIComponent(options.bot_id) + '&origin=' + encodeURIComponent(location.origin || location.protocol + '//' + location.hostname) + (options.request_access ? '&request_access=' + encodeURIComponent(options.request_access) : '') + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : '') + '&return_to=' + encodeURIComponent(location.href);
var popup = window.open(popup_url, 'telegram_oauth_bot' + bot_id, 'width=' + width + ',height=' + height + ',left=' + left + ',top=' + top + ',status=0,location=0,menubar=0,toolbar=0');
TelegramLogin.popups[bot_id] = {
window: popup,
authFinished: false
};
if (popup) {
addEvent(window, 'message', onMessage);
popup.focus();
checkClose(bot_id);
}
},
getAuthData: function(options, callback) {
var bot_id = parseInt(options.bot_id);
if (!bot_id) {
throw new Error('Bot id required');
}
var xhr = getXHR();
var url = Telegram.Login.widgetsOrigin + '/auth/get';
xhr.open('POST', url);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (typeof xhr.responseBody == 'undefined' && xhr.responseText) {
try {
var result = JSON.parse(xhr.responseText);
} catch(e) {
var result = {};
}
if (result.user) {
callback(result.origin, result.user);
} else {
callback(result.origin, false);
}
} else {
callback('*', false);
}
}
};
xhr.onerror = function() {
callback('*', false);
};
xhr.withCredentials = true;
xhr.send('bot_id=' + encodeURIComponent(options.bot_id) + (options.lang ? '&lang=' + encodeURIComponent(options.lang) : ''));
}
};
window.Telegram.getWidgetInfo = getWidgetInfo;
window.Telegram.setWidgetOptions = setWidgetOptions;
window.Telegram.Login = {
init: TelegramLogin._init,
open: TelegramLogin._open,
auth: TelegramLogin._auth,
widgetsOrigin: getWidgetsOrigin('https://oauth.telegram.org', 'https://oauth.tg.dev')
};
}(window));
})(window);
+26 -1
View File
@@ -1,5 +1,7 @@
import asyncio
import logging
from contextlib import suppress
from aiohttp import web
from aiogram import Bot, Dispatcher
from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application
@@ -7,6 +9,8 @@ from sqlalchemy.orm import sessionmaker
from config.settings import Settings
TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60
def _inject_shared_instances(
app: web.Application,
@@ -111,8 +115,13 @@ async def build_and_start_web_app(
f"AIOHTTP server started on http://{settings.WEB_SERVER_HOST}:{settings.WEB_SERVER_PORT}"
)
telegram_web_app_sdk_refresh_task = None
if settings.WEBAPP_ENABLED:
from bot.app.web.subscription_webapp import create_subscription_webapp_application
from bot.app.web.subscription_webapp import (
create_subscription_webapp_application,
refresh_telegram_login_widget_sdk,
refresh_telegram_web_app_sdk,
)
subscription_app = create_subscription_webapp_application(
dp,
@@ -135,9 +144,25 @@ async def build_and_start_web_app(
settings.WEBAPP_SERVER_PORT,
)
async def _refresh_telegram_web_assets_forever() -> None:
while True:
await refresh_telegram_web_app_sdk()
await refresh_telegram_login_widget_sdk()
await asyncio.sleep(TELEGRAM_WEB_APP_SDK_REFRESH_INTERVAL_SECONDS)
telegram_web_app_sdk_refresh_task = asyncio.create_task(
_refresh_telegram_web_assets_forever(),
name="TelegramWebAssetsRefreshTask",
)
try:
await asyncio.Event().wait()
finally:
if telegram_web_app_sdk_refresh_task is not None:
telegram_web_app_sdk_refresh_task.cancel()
with suppress(asyncio.CancelledError):
await telegram_web_app_sdk_refresh_task
for runner in reversed(runners):
try:
await runner.cleanup()
+51 -41
View File
@@ -3,9 +3,7 @@ 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
@@ -14,15 +12,6 @@ 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("=")
@@ -134,40 +123,61 @@ def validate_telegram_webapp_init_data(
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 validate_telegram_login_widget_data(
auth_data: Any,
bot_token: str,
*,
max_age_seconds: int,
) -> Optional[Dict[str, Any]]:
"""Validate Telegram Login Widget data and return the trusted user payload."""
try:
if isinstance(auth_data, str):
parsed_data = dict(parse_qsl(auth_data or "", keep_blank_values=True))
elif isinstance(auth_data, dict):
parsed_data = {
str(key): str(value)
for key, value in auth_data.items()
if value is not None
}
else:
return 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
received_hash = str(parsed_data.pop("hash", "") or "")
if not received_hash:
return None
data_check_string = "\n".join(
f"{key}={value}" for key, value in sorted(parsed_data.items())
)
secret_key = hashlib.sha256(bot_token.encode("utf-8")).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 Login Widget hash mismatch.")
return None
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
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 Login Widget auth_date is stale.")
return None
user_id_raw = parsed_data.get("id")
if not user_id_raw:
return None
int(user_id_raw)
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:
if not parsed_data.get("first_name"):
return None
return parsed_data
except Exception as exc:
logger.warning("Failed to validate Telegram Login Widget data: %s", exc)
return None
_PENDING_AUTH_TOKENS.pop(token, None)
return int(pending.user_id)
+1 -16
View File
@@ -26,8 +26,6 @@ from config.settings import Settings
from bot.middlewares.i18n import JsonI18n
from bot.utils.text_sanitizer import sanitize_username, sanitize_display_name
from bot.utils.callback_answer import safe_answer_callback
from bot.app.web.webapp_auth import authorize_pending_webapp_auth_token
router = Router(name="user_start_router")
@@ -308,7 +306,6 @@ 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"^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,
@@ -321,8 +318,7 @@ 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,
webapp_auth_match: Optional[re.Match] = None):
admin_user_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")
@@ -388,7 +384,6 @@ 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)
@@ -414,9 +409,6 @@ 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}")
@@ -572,13 +564,6 @@ 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
)
+8 -2
View File
@@ -243,8 +243,14 @@ async def run_bot(settings_param: Settings):
actual_bot_username = "your_bot_username"
try:
bot_info = await bot.get_me()
actual_bot_username = bot_info.username
logging.info(f"Bot username resolved: @{actual_bot_username}")
if bot_info.username:
actual_bot_username = bot_info.username
dp["bot_username"] = actual_bot_username
logging.info(f"Bot username resolved: @{actual_bot_username}")
else:
logging.warning(
"Bot username is empty; Telegram Login Widget will be unavailable."
)
except Exception as e:
logging.error(
f"Failed to get bot info (e.g., for YooKassa default URL): {e}. Using fallback: {actual_bot_username}"
+57
View File
@@ -0,0 +1,57 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.request import Request, urlopen
SOURCE_URL = "https://telegram.org/js/telegram-web-app.js"
TARGET_PATH = (
Path(__file__).resolve().parents[1]
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "telegram-web-app.js"
)
def _download(source_url: str) -> bytes:
request = Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
)
with urlopen(request, timeout=30) as response:
return response.read()
def main() -> int:
parser = argparse.ArgumentParser(
description="Download the latest Telegram Web App SDK into the local templates directory."
)
parser.add_argument(
"--source-url",
default=SOURCE_URL,
help="Telegram Web App SDK URL to download from.",
)
parser.add_argument(
"--target",
type=Path,
default=TARGET_PATH,
help="Output path for the vendored SDK.",
)
args = parser.parse_args()
data = _download(args.source_url)
args.target.parent.mkdir(parents=True, exist_ok=True)
args.target.write_bytes(data)
print(f"Wrote {len(data)} bytes to {args.target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import argparse
import sys
from pathlib import Path
from urllib.request import Request, urlopen
SOURCE_URL = "https://telegram.org/js/telegram-widget.js?23"
TARGET_PATH = (
Path(__file__).resolve().parents[1]
/ "bot"
/ "app"
/ "web"
/ "templates"
/ "telegram-widget.js"
)
_UNPATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n }\n"""
_PATCHED_WIDGET_ORIGIN_SNIPPET = """ if (origin == 'https://telegram.org') {\n origin = default_origin;\n } else if (origin == 'https://telegram-js.azureedge.net' || origin == 'https://tg.dev') {\n origin = dev_origin;\n } else {\n origin = default_origin;\n }\n"""
def _download(source_url: str) -> bytes:
request = Request(
source_url,
headers={
"User-Agent": "Mozilla/5.0",
"Accept": "application/javascript,text/javascript,*/*;q=0.8",
},
)
with urlopen(request, timeout=30) as response:
return response.read()
def _normalize_widget_sdk(data: bytes) -> bytes:
# Keep the vendored widget pointing to Telegram's OAuth host instead of the local origin.
text = data.decode("utf-8")
normalized = text.replace(
_UNPATCHED_WIDGET_ORIGIN_SNIPPET,
_PATCHED_WIDGET_ORIGIN_SNIPPET,
1,
)
return normalized.encode("utf-8")
def main() -> int:
parser = argparse.ArgumentParser(
description="Download the latest Telegram Login Widget SDK into the local templates directory."
)
parser.add_argument(
"--source-url",
default=SOURCE_URL,
help="Telegram Login Widget SDK URL to download from.",
)
parser.add_argument(
"--target",
type=Path,
default=TARGET_PATH,
help="Output path for the vendored SDK.",
)
args = parser.parse_args()
data = _normalize_widget_sdk(_download(args.source_url))
args.target.parent.mkdir(parents=True, exist_ok=True)
args.target.write_bytes(data)
print(f"Wrote {len(data)} bytes to {args.target}")
return 0
if __name__ == "__main__":
raise SystemExit(main())