feat: tune webapp visual, use telegram widget for login
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -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. Клонирование репозитория
|
||||
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(value).replaceAll('`', '`');
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user