From b9cb1fec0635e0dcc25619225d78248d84e98bb4 Mon Sep 17 00:00:00 2001 From: 3252a8 <3252a8@proton.me> Date: Thu, 23 Apr 2026 14:06:21 +0300 Subject: [PATCH] feat: tune webapp visual, use telegram widget for login --- .env.example | 2 +- README.md | 21 +- bot/app/web/subscription_webapp.py | 329 +- bot/app/web/templates/subscription_webapp.css | 836 ++++ .../web/templates/subscription_webapp.html | 1161 +----- bot/app/web/templates/subscription_webapp.js | 888 +++++ bot/app/web/templates/telegram-web-app.js | 3392 +++++++++++++++++ bot/app/web/templates/telegram-widget.js | 575 +++ bot/app/web/web_server.py | 27 +- bot/app/web/webapp_auth.py | 92 +- bot/handlers/user/start.py | 17 +- bot/main_bot.py | 10 +- scripts/update_telegram_web_app_js.py | 57 + scripts/update_telegram_widget_js.py | 70 + 14 files changed, 6265 insertions(+), 1212 deletions(-) create mode 100644 bot/app/web/templates/subscription_webapp.css create mode 100644 bot/app/web/templates/subscription_webapp.js create mode 100644 bot/app/web/templates/telegram-web-app.js create mode 100644 bot/app/web/templates/telegram-widget.js create mode 100644 scripts/update_telegram_web_app_js.py create mode 100644 scripts/update_telegram_widget_js.py diff --git a/.env.example b/.env.example index 0d9320a..aed8619 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 58b76c7..6007701 100644 --- a/README.md +++ b/README.md @@ -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. Клонирование репозитория diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index 731091c..691b7c2 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -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 = "" +DEV_MOCK_START_MARKER = "" +DEV_MOCK_END_MARKER = "" 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, + ( + "" + ), ) 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)}" diff --git a/bot/app/web/templates/subscription_webapp.css b/bot/app/web/templates/subscription_webapp.css new file mode 100644 index 0000000..2748170 --- /dev/null +++ b/bot/app/web/templates/subscription_webapp.css @@ -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%; + } + } diff --git a/bot/app/web/templates/subscription_webapp.html b/bot/app/web/templates/subscription_webapp.html index 7fa2019..152c803 100644 --- a/bot/app/web/templates/subscription_webapp.html +++ b/bot/app/web/templates/subscription_webapp.html @@ -3,1176 +3,155 @@ - + Моя подписка - - + + -
Загрузка...
+
Загрузка...
-
/
-
-
/tnnl
-
Моя подписка
-
+ +
Моя подписка
- - + +
-
subscription
+
Подписка
...

...

-

Ваша ссылка подключения и срок доступа

+

Ваша ссылка подключения и срок доступа

-
ends_at
+
Окончание
...
-
traffic
+
Трафик
...
- - + +
- +
+ +
-
+ +
-
telegram_auth
- - - Открыть Telegram - + + + +
+
- + + - diff --git a/bot/app/web/templates/subscription_webapp.js b/bot/app/web/templates/subscription_webapp.js new file mode 100644 index 0000000..58569f9 --- /dev/null +++ b/bot/app/web/templates/subscription_webapp.js @@ -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 = '
' + escapeHtml(t('no_plans')) + '
'; + 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 ` + + `; + }).join(''); + } + + function renderMethods() { + const methods = document.getElementById('methods'); + const createBtn = document.getElementById('create-payment-btn'); + if (!state.selectedPlan) { + methods.innerHTML = '
' + escapeHtml(t('choose_plan_first')) + '
'; + 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 = '
' + escapeHtml(t('no_methods')) + '
'; + 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 ` + + `; + }).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('`', '`'); + } diff --git a/bot/app/web/templates/telegram-web-app.js b/bot/app/web/templates/telegram-web-app.js new file mode 100644 index 0000000..6c8051c --- /dev/null +++ b/bot/app/web/templates/telegram-web-app.js @@ -0,0 +1,3392 @@ +// WebView +(function () { + var eventHandlers = {}; + + var locationHash = ''; + try { + locationHash = location.hash.toString(); + } catch (e) {} + + var initParams = urlParseHashParams(locationHash); + var storedParams = sessionStorageGet('initParams'); + if (storedParams) { + for (var key in storedParams) { + if (typeof initParams[key] === 'undefined') { + initParams[key] = storedParams[key]; + } + } + } + sessionStorageSet('initParams', initParams); + + var isIframe = false, iFrameStyle; + try { + isIframe = (window.parent != null && window != window.parent); + if (isIframe) { + window.addEventListener('message', function (event) { + if (event.source !== window.parent) return; + try { + var dataParsed = JSON.parse(event.data); + } catch (e) { + return; + } + if (!dataParsed || !dataParsed.eventType) { + return; + } + if (dataParsed.eventType == 'set_custom_style') { + if (event.origin === 'https://web.telegram.org') { + iFrameStyle.innerHTML = dataParsed.eventData; + } + } else if (dataParsed.eventType == 'reload_iframe') { + try { + window.parent.postMessage(JSON.stringify({eventType: 'iframe_will_reload'}), '*'); + } catch (e) {} + location.reload(); + } else { + receiveEvent(dataParsed.eventType, dataParsed.eventData); + } + }); + iFrameStyle = document.createElement('style'); + document.head.appendChild(iFrameStyle); + try { + window.parent.postMessage(JSON.stringify({eventType: 'iframe_ready', eventData: {reload_supported: true}}), '*'); + } catch (e) {} + } + } catch (e) {} + + function urlSafeDecode(urlencoded) { + try { + urlencoded = urlencoded.replace(/\+/g, '%20'); + return decodeURIComponent(urlencoded); + } catch (e) { + return urlencoded; + } + } + + function urlParseHashParams(locationHash) { + locationHash = locationHash.replace(/^#/, ''); + var params = {}; + if (!locationHash.length) { + return params; + } + if (locationHash.indexOf('=') < 0 && locationHash.indexOf('?') < 0) { + params._path = urlSafeDecode(locationHash); + return params; + } + var qIndex = locationHash.indexOf('?'); + if (qIndex >= 0) { + var pathParam = locationHash.substr(0, qIndex); + params._path = urlSafeDecode(pathParam); + locationHash = locationHash.substr(qIndex + 1); + } + var query_params = urlParseQueryString(locationHash); + for (var k in query_params) { + params[k] = query_params[k]; + } + return params; + } + + function urlParseQueryString(queryString) { + var params = {}; + if (!queryString.length) { + return params; + } + var queryStringParams = queryString.split('&'); + var i, param, paramName, paramValue; + for (i = 0; i < queryStringParams.length; i++) { + param = queryStringParams[i].split('='); + paramName = urlSafeDecode(param[0]); + paramValue = param[1] == null ? null : urlSafeDecode(param[1]); + params[paramName] = paramValue; + } + return params; + } + + // Telegram apps will implement this logic to add service params (e.g. tgShareScoreUrl) to game URL + function urlAppendHashParams(url, addHash) { + // url looks like 'https://game.com/path?query=1#hash' + // addHash looks like 'tgShareScoreUrl=' + encodeURIComponent('tgb://share_game_score?hash=very_long_hash123') + + var ind = url.indexOf('#'); + if (ind < 0) { + // https://game.com/path -> https://game.com/path#tgShareScoreUrl=etc + return url + '#' + addHash; + } + var curHash = url.substr(ind + 1); + if (curHash.indexOf('=') >= 0 || curHash.indexOf('?') >= 0) { + // https://game.com/#hash=1 -> https://game.com/#hash=1&tgShareScoreUrl=etc + // https://game.com/#path?query -> https://game.com/#path?query&tgShareScoreUrl=etc + return url + '&' + addHash; + } + // https://game.com/#hash -> https://game.com/#hash?tgShareScoreUrl=etc + if (curHash.length > 0) { + return url + '?' + addHash; + } + // https://game.com/# -> https://game.com/#tgShareScoreUrl=etc + return url + addHash; + } + + function postEvent(eventType, callback, eventData) { + if (!callback) { + callback = function () {}; + } + if (eventData === undefined) { + eventData = ''; + } + console.log('[Telegram.WebView] > postEvent', eventType, eventData); + + if (window.TelegramWebviewProxy !== undefined) { + TelegramWebviewProxy.postEvent(eventType, JSON.stringify(eventData)); + callback(); + } + else if (window.external && 'notify' in window.external) { + window.external.notify(JSON.stringify({eventType: eventType, eventData: eventData})); + callback(); + } + else if (isIframe) { + try { + var trustedTarget = 'https://web.telegram.org'; + // For now we don't restrict target, for testing purposes + trustedTarget = '*'; + window.parent.postMessage(JSON.stringify({eventType: eventType, eventData: eventData}), trustedTarget); + callback(); + } catch (e) { + callback(e); + } + } + else { + callback({notAvailable: true}); + } + }; + + function receiveEvent(eventType, eventData) { + console.log('[Telegram.WebView] < receiveEvent', eventType, eventData); + callEventCallbacks(eventType, function(callback) { + callback(eventType, eventData); + }); + } + + function callEventCallbacks(eventType, func) { + var curEventHandlers = eventHandlers[eventType]; + if (curEventHandlers === undefined || + !curEventHandlers.length) { + return; + } + for (var i = 0; i < curEventHandlers.length; i++) { + try { + func(curEventHandlers[i]); + } catch (e) {} + } + } + + function onEvent(eventType, callback) { + if (eventHandlers[eventType] === undefined) { + eventHandlers[eventType] = []; + } + var index = eventHandlers[eventType].indexOf(callback); + if (index === -1) { + eventHandlers[eventType].push(callback); + } + }; + + function offEvent(eventType, callback) { + if (eventHandlers[eventType] === undefined) { + return; + } + var index = eventHandlers[eventType].indexOf(callback); + if (index === -1) { + return; + } + eventHandlers[eventType].splice(index, 1); + }; + + function openProtoUrl(url) { + if (!url.match(/^(web\+)?tgb?:\/\/./)) { + return false; + } + var useIframe = navigator.userAgent.match(/iOS|iPhone OS|iPhone|iPod|iPad/i) ? true : false; + if (useIframe) { + var iframeContEl = document.getElementById('tgme_frame_cont') || document.body; + var iframeEl = document.createElement('iframe'); + iframeContEl.appendChild(iframeEl); + var pageHidden = false; + var enableHidden = function () { + pageHidden = true; + }; + window.addEventListener('pagehide', enableHidden, false); + window.addEventListener('blur', enableHidden, false); + if (iframeEl !== null) { + iframeEl.src = url; + } + setTimeout(function() { + if (!pageHidden) { + window.location = url; + } + window.removeEventListener('pagehide', enableHidden, false); + window.removeEventListener('blur', enableHidden, false); + }, 2000); + } + else { + window.location = url; + } + return true; + } + + function sessionStorageSet(key, value) { + try { + window.sessionStorage.setItem('__telegram__' + key, JSON.stringify(value)); + return true; + } catch(e) {} + return false; + } + function sessionStorageGet(key) { + try { + return JSON.parse(window.sessionStorage.getItem('__telegram__' + key)); + } catch(e) {} + return null; + } + + if (!window.Telegram) { + window.Telegram = {}; + } + window.Telegram.WebView = { + initParams: initParams, + isIframe: isIframe, + onEvent: onEvent, + offEvent: offEvent, + postEvent: postEvent, + receiveEvent: receiveEvent, + callEventCallbacks: callEventCallbacks + }; + + window.Telegram.Utils = { + urlSafeDecode: urlSafeDecode, + urlParseQueryString: urlParseQueryString, + urlParseHashParams: urlParseHashParams, + urlAppendHashParams: urlAppendHashParams, + sessionStorageSet: sessionStorageSet, + sessionStorageGet: sessionStorageGet + }; + + // For Windows Phone app + window.TelegramGameProxy_receiveEvent = receiveEvent; + + // App backward compatibility + window.TelegramGameProxy = { + receiveEvent: receiveEvent + }; +})(); + +// WebApp +(function () { + var Utils = window.Telegram.Utils; + var WebView = window.Telegram.WebView; + var initParams = WebView.initParams; + var isIframe = WebView.isIframe; + + var WebApp = {}; + var webAppInitData = '', webAppInitDataUnsafe = {}; + var themeParams = {}, colorScheme = 'light'; + var webAppVersion = '6.0'; + var webAppPlatform = 'unknown'; + var webAppIsActive = true; + var webAppIsFullscreen = false; + var webAppIsOrientationLocked = false; + var webAppBackgroundColor = 'bg_color'; + var webAppHeaderColorKey = 'bg_color'; + var webAppHeaderColor = null; + + if (initParams.tgWebAppData && initParams.tgWebAppData.length) { + webAppInitData = initParams.tgWebAppData; + webAppInitDataUnsafe = Utils.urlParseQueryString(webAppInitData); + for (var key in webAppInitDataUnsafe) { + var val = webAppInitDataUnsafe[key]; + try { + if (val.substr(0, 1) == '{' && val.substr(-1) == '}' || + val.substr(0, 1) == '[' && val.substr(-1) == ']') { + webAppInitDataUnsafe[key] = JSON.parse(val); + } + } catch (e) {} + } + } + var stored_theme_params = Utils.sessionStorageGet('themeParams'); + if (initParams.tgWebAppThemeParams && initParams.tgWebAppThemeParams.length) { + var themeParamsRaw = initParams.tgWebAppThemeParams; + try { + var theme_params = JSON.parse(themeParamsRaw); + if (theme_params) { + setThemeParams(theme_params); + } + } catch (e) {} + } + if (stored_theme_params) { + setThemeParams(stored_theme_params); + } + var stored_def_colors = Utils.sessionStorageGet('defaultColors'); + if (initParams.tgWebAppDefaultColors && initParams.tgWebAppDefaultColors.length) { + var defColorsRaw = initParams.tgWebAppDefaultColors; + try { + var def_colors = JSON.parse(defColorsRaw); + if (def_colors) { + setDefaultColors(def_colors); + } + } catch (e) {} + } + if (stored_def_colors) { + setDefaultColors(stored_def_colors); + } + if (initParams.tgWebAppVersion) { + webAppVersion = initParams.tgWebAppVersion; + } + if (initParams.tgWebAppPlatform) { + webAppPlatform = initParams.tgWebAppPlatform; + } + + var stored_fullscreen = Utils.sessionStorageGet('isFullscreen'); + if (initParams.tgWebAppFullscreen) { + setFullscreen(true); + } + if (stored_fullscreen) { + setFullscreen(stored_fullscreen == 'yes'); + } + + var stored_orientation_lock = Utils.sessionStorageGet('isOrientationLocked'); + if (stored_orientation_lock) { + setOrientationLock(stored_orientation_lock == 'yes'); + } + + function onThemeChanged(eventType, eventData) { + if (eventData.theme_params) { + setThemeParams(eventData.theme_params); + window.Telegram.WebApp.MainButton.setParams({}); + window.Telegram.WebApp.SecondaryButton.setParams({}); + updateHeaderColor(); + updateBackgroundColor(); + updateBottomBarColor(); + receiveWebViewEvent('themeChanged'); + } + } + + var lastWindowHeight = window.innerHeight; + function onViewportChanged(eventType, eventData) { + if (eventData.height) { + window.removeEventListener('resize', onWindowResize); + setViewportHeight(eventData); + } + } + + function onWindowResize(e) { + if (lastWindowHeight != window.innerHeight) { + lastWindowHeight = window.innerHeight; + receiveWebViewEvent('viewportChanged', { + isStateStable: true + }); + } + } + + function onSafeAreaChanged(eventType, eventData) { + if (eventData) { + setSafeAreaInset(eventData); + } + } + function onContentSafeAreaChanged(eventType, eventData) { + if (eventData) { + setContentSafeAreaInset(eventData); + } + } + + function onVisibilityChanged(eventType, eventData) { + if (eventData.is_visible) { + webAppIsActive = true; + receiveWebViewEvent('activated'); + } else { + webAppIsActive = false; + receiveWebViewEvent('deactivated'); + } + } + + function linkHandler(e) { + if (e.metaKey || e.ctrlKey) return; + var el = e.target; + while (el.tagName != 'A' && el.parentNode) { + el = el.parentNode; + } + if (el.tagName == 'A' && + el.target != '_blank' && + (el.protocol == 'http:' || el.protocol == 'https:') && + el.hostname == 't.me') { + WebApp.openTelegramLink(el.href); + e.preventDefault(); + } + } + + function strTrim(str) { + return str.toString().replace(/^\s+|\s+$/g, ''); + } + + function receiveWebViewEvent(eventType) { + var args = Array.prototype.slice.call(arguments); + eventType = args.shift(); + WebView.callEventCallbacks('webview:' + eventType, function(callback) { + callback.apply(WebApp, args); + }); + } + + function onWebViewEvent(eventType, callback) { + WebView.onEvent('webview:' + eventType, callback); + }; + + function offWebViewEvent(eventType, callback) { + WebView.offEvent('webview:' + eventType, callback); + }; + + function setCssProperty(name, value) { + var root = document.documentElement; + if (root && root.style && root.style.setProperty) { + root.style.setProperty('--tg-' + name, value); + } + } + + function setFullscreen(is_fullscreen) { + webAppIsFullscreen = !!is_fullscreen; + Utils.sessionStorageSet('isFullscreen', webAppIsFullscreen ? 'yes' : 'no'); + } + + function setOrientationLock(is_locked) { + webAppIsOrientationLocked = !!is_locked; + Utils.sessionStorageSet('isOrientationLocked', webAppIsOrientationLocked ? 'yes' : 'no'); + } + + function setThemeParams(theme_params) { + // temp iOS fix + if (theme_params.bg_color == '#1c1c1d' && + theme_params.bg_color == theme_params.secondary_bg_color) { + theme_params.secondary_bg_color = '#2c2c2e'; + } + var color; + for (var key in theme_params) { + if (color = parseColorToHex(theme_params[key])) { + themeParams[key] = color; + if (key == 'bg_color') { + colorScheme = isColorDark(color) ? 'dark' : 'light' + setCssProperty('color-scheme', colorScheme); + } + key = 'theme-' + key.split('_').join('-'); + setCssProperty(key, color); + } + } + Utils.sessionStorageSet('themeParams', themeParams); + } + + function setDefaultColors(def_colors) { + if (colorScheme == 'dark') { + if (def_colors.bg_dark_color) { + webAppBackgroundColor = def_colors.bg_dark_color; + } + if (def_colors.header_dark_color) { + webAppHeaderColorKey = null; + webAppHeaderColor = def_colors.header_dark_color; + } + } else { + if (def_colors.bg_color) { + webAppBackgroundColor = def_colors.bg_color; + } + if (def_colors.header_color) { + webAppHeaderColorKey = null; + webAppHeaderColor = def_colors.header_color; + } + } + Utils.sessionStorageSet('defaultColors', def_colors); + } + + var webAppCallbacks = {}; + function generateCallbackId(len) { + var tries = 100; + while (--tries) { + var id = '', chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', chars_len = chars.length; + for (var i = 0; i < len; i++) { + id += chars[Math.floor(Math.random() * chars_len)]; + } + if (!webAppCallbacks[id]) { + webAppCallbacks[id] = {}; + return id; + } + } + throw Error('WebAppCallbackIdGenerateFailed'); + } + + var viewportHeight = false, viewportStableHeight = false, isExpanded = true; + function setViewportHeight(data) { + if (typeof data !== 'undefined') { + isExpanded = !!data.is_expanded; + viewportHeight = data.height; + if (data.is_state_stable) { + viewportStableHeight = data.height; + } + receiveWebViewEvent('viewportChanged', { + isStateStable: !!data.is_state_stable + }); + } + var height, stable_height; + if (viewportHeight !== false) { + height = (viewportHeight - bottomBarHeight) + 'px'; + } else { + height = bottomBarHeight ? 'calc(100vh - ' + bottomBarHeight + 'px)' : '100vh'; + } + if (viewportStableHeight !== false) { + stable_height = (viewportStableHeight - bottomBarHeight) + 'px'; + } else { + stable_height = bottomBarHeight ? 'calc(100vh - ' + bottomBarHeight + 'px)' : '100vh'; + } + setCssProperty('viewport-height', height); + setCssProperty('viewport-stable-height', stable_height); + } + + var safeAreaInset = {top: 0, bottom: 0, left: 0, right: 0}; + function setSafeAreaInset(data) { + if (typeof data !== 'undefined') { + if (typeof data.top !== 'undefined') { + safeAreaInset.top = data.top; + } + if (typeof data.bottom !== 'undefined') { + safeAreaInset.bottom = data.bottom; + } + if (typeof data.left !== 'undefined') { + safeAreaInset.left = data.left; + } + if (typeof data.right !== 'undefined') { + safeAreaInset.right = data.right; + } + receiveWebViewEvent('safeAreaChanged'); + } + setCssProperty('safe-area-inset-top', safeAreaInset.top + 'px'); + setCssProperty('safe-area-inset-bottom', safeAreaInset.bottom + 'px'); + setCssProperty('safe-area-inset-left', safeAreaInset.left + 'px'); + setCssProperty('safe-area-inset-right', safeAreaInset.right + 'px'); + } + + var contentSafeAreaInset = {top: 0, bottom: 0, left: 0, right: 0}; + function setContentSafeAreaInset(data) { + if (typeof data !== 'undefined') { + if (typeof data.top !== 'undefined') { + contentSafeAreaInset.top = data.top; + } + if (typeof data.bottom !== 'undefined') { + contentSafeAreaInset.bottom = data.bottom; + } + if (typeof data.left !== 'undefined') { + contentSafeAreaInset.left = data.left; + } + if (typeof data.right !== 'undefined') { + contentSafeAreaInset.right = data.right; + } + receiveWebViewEvent('contentSafeAreaChanged'); + } + setCssProperty('content-safe-area-inset-top', contentSafeAreaInset.top + 'px'); + setCssProperty('content-safe-area-inset-bottom', contentSafeAreaInset.bottom + 'px'); + setCssProperty('content-safe-area-inset-left', contentSafeAreaInset.left + 'px'); + setCssProperty('content-safe-area-inset-right', contentSafeAreaInset.right + 'px'); + } + + var isClosingConfirmationEnabled = false; + function setClosingConfirmation(need_confirmation) { + if (!versionAtLeast('6.2')) { + console.warn('[Telegram.WebApp] Closing confirmation is not supported in version ' + webAppVersion); + return; + } + isClosingConfirmationEnabled = !!need_confirmation; + WebView.postEvent('web_app_setup_closing_behavior', false, {need_confirmation: isClosingConfirmationEnabled}); + } + + var isVerticalSwipesEnabled = true; + function toggleVerticalSwipes(enable_swipes) { + if (!versionAtLeast('7.7')) { + console.warn('[Telegram.WebApp] Changing swipes behavior is not supported in version ' + webAppVersion); + return; + } + isVerticalSwipesEnabled = !!enable_swipes; + WebView.postEvent('web_app_setup_swipe_behavior', false, {allow_vertical_swipe: isVerticalSwipesEnabled}); + } + + function onFullscreenChanged(eventType, eventData) { + setFullscreen(eventData.is_fullscreen); + receiveWebViewEvent('fullscreenChanged'); + } + function onFullscreenFailed(eventType, eventData) { + if (eventData.error == 'ALREADY_FULLSCREEN' && !webAppIsFullscreen) { + setFullscreen(true); + } + receiveWebViewEvent('fullscreenFailed', { + error: eventData.error + }); + } + + function toggleOrientationLock(locked) { + if (!versionAtLeast('8.0')) { + console.warn('[Telegram.WebApp] Orientation locking is not supported in version ' + webAppVersion); + return; + } + setOrientationLock(locked); + WebView.postEvent('web_app_toggle_orientation_lock', false, {locked: webAppIsOrientationLocked}); + } + + var homeScreenCallbacks = []; + function onHomeScreenAdded(eventType, eventData) { + receiveWebViewEvent('homeScreenAdded'); + } + function onHomeScreenChecked(eventType, eventData) { + var status = eventData.status || 'unknown'; + if (homeScreenCallbacks.length > 0) { + for (var i = 0; i < homeScreenCallbacks.length; i++) { + var callback = homeScreenCallbacks[i]; + callback(status); + } + homeScreenCallbacks = []; + } + receiveWebViewEvent('homeScreenChecked', { + status: status + }); + } + + var WebAppShareMessageOpened = false; + function onPreparedMessageSent(eventType, eventData) { + if (WebAppShareMessageOpened) { + var requestData = WebAppShareMessageOpened; + WebAppShareMessageOpened = false; + if (requestData.callback) { + requestData.callback(true); + } + receiveWebViewEvent('shareMessageSent'); + } + } + function onPreparedMessageFailed(eventType, eventData) { + if (WebAppShareMessageOpened) { + var requestData = WebAppShareMessageOpened; + WebAppShareMessageOpened = false; + if (requestData.callback) { + requestData.callback(false); + } + receiveWebViewEvent('shareMessageFailed', { + error: eventData.error + }); + } + } + + var WebAppRequestChatOpened = false; + function onRequestedChatSent(eventType, eventData) { + if (WebAppRequestChatOpened) { + var requestData = WebAppRequestChatOpened; + WebAppRequestChatOpened = false; + if (requestData.callback) { + requestData.callback(true); + } + receiveWebViewEvent('requestedChatSent'); + } + } + function onRequestedChatFailed(eventType, eventData) { + if (WebAppRequestChatOpened) { + var requestData = WebAppRequestChatOpened; + WebAppRequestChatOpened = false; + if (requestData.callback) { + requestData.callback(false); + } + receiveWebViewEvent('requestedChatFailed', { + error: eventData.error + }); + } + } + + var WebAppEmojiStatusRequested = false; + function onEmojiStatusSet(eventType, eventData) { + if (WebAppEmojiStatusRequested) { + var requestData = WebAppEmojiStatusRequested; + WebAppEmojiStatusRequested = false; + if (requestData.callback) { + requestData.callback(true); + } + receiveWebViewEvent('emojiStatusSet'); + } + } + function onEmojiStatusFailed(eventType, eventData) { + if (WebAppEmojiStatusRequested) { + var requestData = WebAppEmojiStatusRequested; + WebAppEmojiStatusRequested = false; + if (requestData.callback) { + requestData.callback(false); + } + receiveWebViewEvent('emojiStatusFailed', { + error: eventData.error + }); + } + } + var WebAppEmojiStatusAccessRequested = false; + function onEmojiStatusAccessRequested(eventType, eventData) { + if (WebAppEmojiStatusAccessRequested) { + var requestData = WebAppEmojiStatusAccessRequested; + WebAppEmojiStatusAccessRequested = false; + if (requestData.callback) { + requestData.callback(eventData.status == 'allowed'); + } + receiveWebViewEvent('emojiStatusAccessRequested', { + status: eventData.status + }); + } + } + + var webAppPopupOpened = false; + function onPopupClosed(eventType, eventData) { + if (webAppPopupOpened) { + var popupData = webAppPopupOpened; + webAppPopupOpened = false; + var button_id = null; + if (typeof eventData.button_id !== 'undefined') { + button_id = eventData.button_id; + } + if (popupData.callback) { + popupData.callback(button_id); + } + receiveWebViewEvent('popupClosed', { + button_id: button_id + }); + } + } + + + function getHeaderColor() { + if (webAppHeaderColorKey == 'secondary_bg_color') { + return themeParams.secondary_bg_color; + } else if (webAppHeaderColorKey == 'bg_color') { + return themeParams.bg_color; + } + return webAppHeaderColor; + } + function setHeaderColor(color) { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] Header color is not supported in version ' + webAppVersion); + return; + } + if (!versionAtLeast('6.9')) { + if (themeParams.bg_color && + themeParams.bg_color == color) { + color = 'bg_color'; + } else if (themeParams.secondary_bg_color && + themeParams.secondary_bg_color == color) { + color = 'secondary_bg_color'; + } + } + var head_color = null, color_key = null; + if (color == 'bg_color' || color == 'secondary_bg_color') { + color_key = color; + } else if (versionAtLeast('6.9')) { + head_color = parseColorToHex(color); + if (!head_color) { + console.error('[Telegram.WebApp] Header color format is invalid', color); + throw Error('WebAppHeaderColorInvalid'); + } + } + if (!versionAtLeast('6.9') && + color_key != 'bg_color' && + color_key != 'secondary_bg_color') { + console.error('[Telegram.WebApp] Header color key should be one of Telegram.WebApp.themeParams.bg_color, Telegram.WebApp.themeParams.secondary_bg_color, \'bg_color\', \'secondary_bg_color\'', color); + throw Error('WebAppHeaderColorKeyInvalid'); + } + webAppHeaderColorKey = color_key; + webAppHeaderColor = head_color; + updateHeaderColor(); + } + var appHeaderColorKey = null, appHeaderColor = null; + function updateHeaderColor() { + if (appHeaderColorKey != webAppHeaderColorKey || + appHeaderColor != webAppHeaderColor) { + appHeaderColorKey = webAppHeaderColorKey; + appHeaderColor = webAppHeaderColor; + if (appHeaderColor) { + WebView.postEvent('web_app_set_header_color', false, {color: webAppHeaderColor}); + } else { + WebView.postEvent('web_app_set_header_color', false, {color_key: webAppHeaderColorKey}); + } + } + } + + function getBackgroundColor() { + if (webAppBackgroundColor == 'secondary_bg_color') { + return themeParams.secondary_bg_color; + } else if (webAppBackgroundColor == 'bg_color') { + return themeParams.bg_color; + } + return webAppBackgroundColor; + } + function setBackgroundColor(color) { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] Background color is not supported in version ' + webAppVersion); + return; + } + var bg_color; + if (color == 'bg_color' || color == 'secondary_bg_color') { + bg_color = color; + } else { + bg_color = parseColorToHex(color); + if (!bg_color) { + console.error('[Telegram.WebApp] Background color format is invalid', color); + throw Error('WebAppBackgroundColorInvalid'); + } + } + webAppBackgroundColor = bg_color; + updateBackgroundColor(); + } + var appBackgroundColor = null; + function updateBackgroundColor() { + var color = getBackgroundColor(); + if (appBackgroundColor != color) { + appBackgroundColor = color; + WebView.postEvent('web_app_set_background_color', false, {color: color}); + } + } + + var bottomBarColor = 'bottom_bar_bg_color'; + function getBottomBarColor() { + if (bottomBarColor == 'bottom_bar_bg_color') { + return themeParams.bottom_bar_bg_color || themeParams.secondary_bg_color || '#ffffff'; + } else if (bottomBarColor == 'secondary_bg_color') { + return themeParams.secondary_bg_color; + } else if (bottomBarColor == 'bg_color') { + return themeParams.bg_color; + } + return bottomBarColor; + } + function setBottomBarColor(color) { + if (!versionAtLeast('7.10')) { + console.warn('[Telegram.WebApp] Bottom bar color is not supported in version ' + webAppVersion); + return; + } + var bg_color; + if (color == 'bg_color' || color == 'secondary_bg_color' || color == 'bottom_bar_bg_color') { + bg_color = color; + } else { + bg_color = parseColorToHex(color); + if (!bg_color) { + console.error('[Telegram.WebApp] Bottom bar color format is invalid', color); + throw Error('WebAppBottomBarColorInvalid'); + } + } + bottomBarColor = bg_color; + updateBottomBarColor(); + window.Telegram.WebApp.SecondaryButton.setParams({}); + } + var appBottomBarColor = null; + function updateBottomBarColor() { + var color = getBottomBarColor(); + if (appBottomBarColor != color) { + appBottomBarColor = color; + WebView.postEvent('web_app_set_bottom_bar_color', false, {color: color}); + } + if (initParams.tgWebAppDebug) { + updateDebugBottomBar(); + } + } + + + function parseColorToHex(color) { + color += ''; + var match; + if (match = /^\s*#([0-9a-f]{6})\s*$/i.exec(color)) { + return '#' + match[1].toLowerCase(); + } + else if (match = /^\s*#([0-9a-f])([0-9a-f])([0-9a-f])\s*$/i.exec(color)) { + return ('#' + match[1] + match[1] + match[2] + match[2] + match[3] + match[3]).toLowerCase(); + } + else if (match = /^\s*rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*(\d+\.{0,1}\d*))?\)\s*$/.exec(color)) { + var r = parseInt(match[1]), g = parseInt(match[2]), b = parseInt(match[3]); + r = (r < 16 ? '0' : '') + r.toString(16); + g = (g < 16 ? '0' : '') + g.toString(16); + b = (b < 16 ? '0' : '') + b.toString(16); + return '#' + r + g + b; + } + return false; + } + + function isColorDark(rgb) { + rgb = rgb.replace(/[\s#]/g, ''); + if (rgb.length == 3) { + rgb = rgb[0] + rgb[0] + rgb[1] + rgb[1] + rgb[2] + rgb[2]; + } + var r = parseInt(rgb.substr(0, 2), 16); + var g = parseInt(rgb.substr(2, 2), 16); + var b = parseInt(rgb.substr(4, 2), 16); + var hsp = Math.sqrt(0.299 * (r * r) + 0.587 * (g * g) + 0.114 * (b * b)); + return hsp < 120; + } + + function versionCompare(v1, v2) { + if (typeof v1 !== 'string') v1 = ''; + if (typeof v2 !== 'string') v2 = ''; + v1 = v1.replace(/^\s+|\s+$/g, '').split('.'); + v2 = v2.replace(/^\s+|\s+$/g, '').split('.'); + var a = Math.max(v1.length, v2.length), i, p1, p2; + for (i = 0; i < a; i++) { + p1 = parseInt(v1[i]) || 0; + p2 = parseInt(v2[i]) || 0; + if (p1 == p2) continue; + if (p1 > p2) return 1; + return -1; + } + return 0; + } + + function versionAtLeast(ver) { + return versionCompare(webAppVersion, ver) >= 0; + } + + function byteLength(str) { + if (window.Blob) { + try { return new Blob([str]).size; } catch (e) {} + } + var s = str.length; + for (var i=str.length-1; i>=0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) s++; + else if (code > 0x7ff && code <= 0xffff) s+=2; + if (code >= 0xdc00 && code <= 0xdfff) i--; + } + return s; + } + + var BackButton = (function() { + var isVisible = false; + + var backButton = {}; + Object.defineProperty(backButton, 'isVisible', { + set: function(val){ setParams({is_visible: val}); }, + get: function(){ return isVisible; }, + enumerable: true + }); + + var curButtonState = null; + + WebView.onEvent('back_button_pressed', onBackButtonPressed); + + function onBackButtonPressed() { + receiveWebViewEvent('backButtonClicked'); + } + + function buttonParams() { + return {is_visible: isVisible}; + } + + function buttonState(btn_params) { + if (typeof btn_params === 'undefined') { + btn_params = buttonParams(); + } + return JSON.stringify(btn_params); + } + + function buttonCheckVersion() { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] BackButton is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function updateButton() { + var btn_params = buttonParams(); + var btn_state = buttonState(btn_params); + if (curButtonState === btn_state) { + return; + } + curButtonState = btn_state; + WebView.postEvent('web_app_setup_back_button', false, btn_params); + } + + function setParams(params) { + if (!buttonCheckVersion()) { + return backButton; + } + if (typeof params.is_visible !== 'undefined') { + isVisible = !!params.is_visible; + } + updateButton(); + return backButton; + } + + backButton.onClick = function(callback) { + if (buttonCheckVersion()) { + onWebViewEvent('backButtonClicked', callback); + } + return backButton; + }; + backButton.offClick = function(callback) { + if (buttonCheckVersion()) { + offWebViewEvent('backButtonClicked', callback); + } + return backButton; + }; + backButton.show = function() { + return setParams({is_visible: true}); + }; + backButton.hide = function() { + return setParams({is_visible: false}); + }; + return backButton; + })(); + + var debugBottomBar = null, debugBottomBarBtns = {}, bottomBarHeight = 0; + if (initParams.tgWebAppDebug) { + debugBottomBar = document.createElement('tg-bottom-bar'); + var debugBottomBarStyle = { + display: 'flex', + gap: '7px', + font: '600 14px/18px sans-serif', + width: '100%', + background: getBottomBarColor(), + position: 'fixed', + left: '0', + right: '0', + bottom: '0', + margin: '0', + padding: '7px', + textAlign: 'center', + boxSizing: 'border-box', + zIndex: '10000' + }; + for (var k in debugBottomBarStyle) { + debugBottomBar.style[k] = debugBottomBarStyle[k]; + } + document.addEventListener('DOMContentLoaded', function onDomLoaded(event) { + document.removeEventListener('DOMContentLoaded', onDomLoaded); + document.body.appendChild(debugBottomBar); + }); + var animStyle = document.createElement('style'); + animStyle.innerHTML = 'tg-bottom-button.shine { position: relative; overflow: hidden; } tg-bottom-button.shine:before { content:""; position: absolute; top: 0; width: 100%; height: 100%; background: linear-gradient(120deg, transparent, rgba(255, 255, 255, .2), transparent); animation: tg-bottom-button-shine 5s ease-in-out infinite; } @-webkit-keyframes tg-bottom-button-shine { 0% {left: -100%;} 12%,100% {left: 100%}} @keyframes tg-bottom-button-shine { 0% {left: -100%;} 12%,100% {left: 100%}}'; + debugBottomBar.appendChild(animStyle); + } + function updateDebugBottomBar() { + var mainBtn = debugBottomBarBtns.main._bottomButton; + var secondaryBtn = debugBottomBarBtns.secondary._bottomButton; + if (mainBtn.isVisible || secondaryBtn.isVisible) { + debugBottomBar.style.display = 'flex'; + bottomBarHeight = 58; + if (mainBtn.isVisible && secondaryBtn.isVisible) { + if (secondaryBtn.position == 'top') { + debugBottomBar.style.flexDirection = 'column-reverse'; + bottomBarHeight += 51; + } else if (secondaryBtn.position == 'bottom') { + debugBottomBar.style.flexDirection = 'column'; + bottomBarHeight += 51; + } else if (secondaryBtn.position == 'left') { + debugBottomBar.style.flexDirection = 'row-reverse'; + } else if (secondaryBtn.position == 'right') { + debugBottomBar.style.flexDirection = 'row'; + } + } + } else { + debugBottomBar.style.display = 'none'; + bottomBarHeight = 0; + } + debugBottomBar.style.background = getBottomBarColor(); + if (document.documentElement) { + document.documentElement.style.boxSizing = 'border-box'; + document.documentElement.style.paddingBottom = bottomBarHeight + 'px'; + } + setViewportHeight(); + } + + + var BottomButtonConstructor = function(type) { + var isMainButton = (type == 'main'); + if (isMainButton) { + var setupFnName = 'web_app_setup_main_button'; + var tgEventName = 'main_button_pressed'; + var webViewEventName = 'mainButtonClicked'; + var buttonTextDefault = 'Continue'; + var buttonColorDefault = function(){ return themeParams.button_color || '#2481cc'; }; + var buttonTextColorDefault = function(){ return themeParams.button_text_color || '#ffffff'; }; + } else { + var setupFnName = 'web_app_setup_secondary_button'; + var tgEventName = 'secondary_button_pressed'; + var webViewEventName = 'secondaryButtonClicked'; + var buttonTextDefault = 'Cancel'; + var buttonColorDefault = function(){ return getBottomBarColor(); }; + var buttonTextColorDefault = function(){ return themeParams.button_color || '#2481cc'; }; + } + + var isVisible = false; + var isActive = true; + var hasShineEffect = false; + var isProgressVisible = false; + var iconCustomEmojiId = false; + var buttonType = type; + var buttonText = buttonTextDefault; + var buttonColor = false; + var buttonTextColor = false; + var buttonPosition = 'left'; + + var bottomButton = {}; + Object.defineProperty(bottomButton, 'type', { + get: function(){ return buttonType; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'iconCustomEmojiId', { + set: function(val){ bottomButton.setParams({icon_custom_emoji_id: val}); }, + get: function(){ return iconCustomEmojiId; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'text', { + set: function(val){ bottomButton.setParams({text: val}); }, + get: function(){ return buttonText; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'color', { + set: function(val){ bottomButton.setParams({color: val}); }, + get: function(){ return buttonColor || buttonColorDefault(); }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'textColor', { + set: function(val){ bottomButton.setParams({text_color: val}); }, + get: function(){ return buttonTextColor || buttonTextColorDefault(); }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'isVisible', { + set: function(val){ bottomButton.setParams({is_visible: val}); }, + get: function(){ return isVisible; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'isProgressVisible', { + get: function(){ return isProgressVisible; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'isActive', { + set: function(val){ bottomButton.setParams({is_active: val}); }, + get: function(){ return isActive; }, + enumerable: true + }); + Object.defineProperty(bottomButton, 'hasShineEffect', { + set: function(val){ bottomButton.setParams({has_shine_effect: val}); }, + get: function(){ return hasShineEffect; }, + enumerable: true + }); + if (!isMainButton) { + Object.defineProperty(bottomButton, 'position', { + set: function(val){ bottomButton.setParams({position: val}); }, + get: function(){ return buttonPosition; }, + enumerable: true + }); + } + + var curButtonState = null; + + WebView.onEvent(tgEventName, onBottomButtonPressed); + + var debugBtn = null; + if (initParams.tgWebAppDebug) { + debugBtn = document.createElement('tg-bottom-button'); + var debugBtnStyle = { + display: 'none', + width: '100%', + height: '44px', + borderRadius: '0', + background: 'no-repeat right center', + padding: '13px 15px', + textAlign: 'center', + boxSizing: 'border-box' + }; + for (var k in debugBtnStyle) { + debugBtn.style[k] = debugBtnStyle[k]; + } + debugBottomBar.appendChild(debugBtn); + debugBtn.addEventListener('click', onBottomButtonPressed, false); + debugBtn._bottomButton = bottomButton; + debugBottomBarBtns[type] = debugBtn; + } + + function onBottomButtonPressed() { + if (isActive) { + receiveWebViewEvent(webViewEventName); + } + } + + function buttonParams() { + var color = bottomButton.color; + var text_color = bottomButton.textColor; + if (isVisible) { + var params = { + is_visible: true, + is_active: isActive, + is_progress_visible: isProgressVisible, + icon_custom_emoji_id: iconCustomEmojiId, + text: buttonText, + color: color, + text_color: text_color, + has_shine_effect: hasShineEffect && isActive && !isProgressVisible + }; + if (!isMainButton) { + params.position = buttonPosition; + } + } else { + var params = { + is_visible: false + }; + } + return params; + } + + function buttonState(btn_params) { + if (typeof btn_params === 'undefined') { + btn_params = buttonParams(); + } + return JSON.stringify(btn_params); + } + + function updateButton() { + var btn_params = buttonParams(); + var btn_state = buttonState(btn_params); + if (curButtonState === btn_state) { + return; + } + curButtonState = btn_state; + WebView.postEvent(setupFnName, false, btn_params); + if (initParams.tgWebAppDebug) { + updateDebugButton(btn_params); + } + } + + function updateDebugButton(btn_params) { + if (btn_params.is_visible) { + debugBtn.style.display = 'block'; + + debugBtn.style.opacity = btn_params.is_active ? '1' : '0.8'; + debugBtn.style.cursor = btn_params.is_active ? 'pointer' : 'auto'; + debugBtn.disabled = !btn_params.is_active; + debugBtn.innerText = btn_params.text; + debugBtn.className = btn_params.has_shine_effect ? 'shine' : ''; + debugBtn.style.backgroundImage = btn_params.is_progress_visible ? "url('data:image/svg+xml," + encodeURIComponent('') + "')" : 'none'; + debugBtn.style.backgroundColor = btn_params.color; + debugBtn.style.color = btn_params.text_color; + } else { + debugBtn.style.display = 'none'; + } + updateDebugBottomBar(); + } + + function setParams(params) { + if (typeof params.icon_custom_emoji_id !== 'undefined') { + var emoji_id = params.icon_custom_emoji_id; + if (emoji_id === false || emoji_id === null) { + emoji_id = ''; + } + if (emoji_id !== '' && !/^[0-9]{10,20}$/.test(emoji_id)) { + console.error('[Telegram.WebApp] Bottom button icon custom emoji is invalid', params.icon_custom_emoji_id); + throw Error('WebAppBottomButtonParamInvalid'); + } + iconCustomEmojiId = emoji_id; + } + if (typeof params.text !== 'undefined') { + var text = strTrim(params.text); + if (!text.length && !iconCustomEmojiId) { + console.error('[Telegram.WebApp] Bottom button text is required', params.text); + throw Error('WebAppBottomButtonParamInvalid'); + } + if (text.length > 64) { + console.error('[Telegram.WebApp] Bottom button text is too long', text); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonText = text; + } + if (typeof params.color !== 'undefined') { + if (params.color === false || + params.color === null) { + buttonColor = false; + } else { + var color = parseColorToHex(params.color); + if (!color) { + console.error('[Telegram.WebApp] Bottom button color format is invalid', params.color); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonColor = color; + } + } + if (typeof params.text_color !== 'undefined') { + if (params.text_color === false || + params.text_color === null) { + buttonTextColor = false; + } else { + var text_color = parseColorToHex(params.text_color); + if (!text_color) { + console.error('[Telegram.WebApp] Bottom button text color format is invalid', params.text_color); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonTextColor = text_color; + } + } + if (typeof params.is_visible !== 'undefined') { + if (params.is_visible && + !bottomButton.text.length) { + console.error('[Telegram.WebApp] Bottom button text is required'); + throw Error('WebAppBottomButtonParamInvalid'); + } + isVisible = !!params.is_visible; + } + if (typeof params.has_shine_effect !== 'undefined') { + hasShineEffect = !!params.has_shine_effect; + } + if (!isMainButton && typeof params.position !== 'undefined') { + if (params.position != 'left' && params.position != 'right' && + params.position != 'top' && params.position != 'bottom') { + console.error('[Telegram.WebApp] Bottom button posiition is invalid', params.position); + throw Error('WebAppBottomButtonParamInvalid'); + } + buttonPosition = params.position; + } + if (typeof params.is_active !== 'undefined') { + isActive = !!params.is_active; + } + updateButton(); + return bottomButton; + } + + bottomButton.setText = function(text) { + return bottomButton.setParams({text: text}); + }; + bottomButton.onClick = function(callback) { + onWebViewEvent(webViewEventName, callback); + return bottomButton; + }; + bottomButton.offClick = function(callback) { + offWebViewEvent(webViewEventName, callback); + return bottomButton; + }; + bottomButton.show = function() { + return bottomButton.setParams({is_visible: true}); + }; + bottomButton.hide = function() { + return bottomButton.setParams({is_visible: false}); + }; + bottomButton.enable = function() { + return bottomButton.setParams({is_active: true}); + }; + bottomButton.disable = function() { + return bottomButton.setParams({is_active: false}); + }; + bottomButton.showProgress = function(leaveActive) { + isActive = !!leaveActive; + isProgressVisible = true; + updateButton(); + return bottomButton; + }; + bottomButton.hideProgress = function() { + if (!bottomButton.isActive) { + isActive = true; + } + isProgressVisible = false; + updateButton(); + return bottomButton; + } + bottomButton.setParams = setParams; + return bottomButton; + }; + var MainButton = BottomButtonConstructor('main'); + var SecondaryButton = BottomButtonConstructor('secondary'); + + var SettingsButton = (function() { + var isVisible = false; + + var settingsButton = {}; + Object.defineProperty(settingsButton, 'isVisible', { + set: function(val){ setParams({is_visible: val}); }, + get: function(){ return isVisible; }, + enumerable: true + }); + + var curButtonState = null; + + WebView.onEvent('settings_button_pressed', onSettingsButtonPressed); + + function onSettingsButtonPressed() { + receiveWebViewEvent('settingsButtonClicked'); + } + + function buttonParams() { + return {is_visible: isVisible}; + } + + function buttonState(btn_params) { + if (typeof btn_params === 'undefined') { + btn_params = buttonParams(); + } + return JSON.stringify(btn_params); + } + + function buttonCheckVersion() { + if (!versionAtLeast('6.10')) { + console.warn('[Telegram.WebApp] SettingsButton is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function updateButton() { + var btn_params = buttonParams(); + var btn_state = buttonState(btn_params); + if (curButtonState === btn_state) { + return; + } + curButtonState = btn_state; + WebView.postEvent('web_app_setup_settings_button', false, btn_params); + } + + function setParams(params) { + if (!buttonCheckVersion()) { + return settingsButton; + } + if (typeof params.is_visible !== 'undefined') { + isVisible = !!params.is_visible; + } + updateButton(); + return settingsButton; + } + + settingsButton.onClick = function(callback) { + if (buttonCheckVersion()) { + onWebViewEvent('settingsButtonClicked', callback); + } + return settingsButton; + }; + settingsButton.offClick = function(callback) { + if (buttonCheckVersion()) { + offWebViewEvent('settingsButtonClicked', callback); + } + return settingsButton; + }; + settingsButton.show = function() { + return setParams({is_visible: true}); + }; + settingsButton.hide = function() { + return setParams({is_visible: false}); + }; + return settingsButton; + })(); + + var HapticFeedback = (function() { + var hapticFeedback = {}; + + function triggerFeedback(params) { + if (!versionAtLeast('6.1')) { + console.warn('[Telegram.WebApp] HapticFeedback is not supported in version ' + webAppVersion); + return hapticFeedback; + } + if (params.type == 'impact') { + if (params.impact_style != 'light' && + params.impact_style != 'medium' && + params.impact_style != 'heavy' && + params.impact_style != 'rigid' && + params.impact_style != 'soft') { + console.error('[Telegram.WebApp] Haptic impact style is invalid', params.impact_style); + throw Error('WebAppHapticImpactStyleInvalid'); + } + } else if (params.type == 'notification') { + if (params.notification_type != 'error' && + params.notification_type != 'success' && + params.notification_type != 'warning') { + console.error('[Telegram.WebApp] Haptic notification type is invalid', params.notification_type); + throw Error('WebAppHapticNotificationTypeInvalid'); + } + } else if (params.type == 'selection_change') { + // no params needed + } else { + console.error('[Telegram.WebApp] Haptic feedback type is invalid', params.type); + throw Error('WebAppHapticFeedbackTypeInvalid'); + } + WebView.postEvent('web_app_trigger_haptic_feedback', false, params); + return hapticFeedback; + } + + hapticFeedback.impactOccurred = function(style) { + return triggerFeedback({type: 'impact', impact_style: style}); + }; + hapticFeedback.notificationOccurred = function(type) { + return triggerFeedback({type: 'notification', notification_type: type}); + }; + hapticFeedback.selectionChanged = function() { + return triggerFeedback({type: 'selection_change'}); + }; + return hapticFeedback; + })(); + + var CloudStorage = (function() { + var cloudStorage = {}; + + function invokeStorageMethod(method, params, callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] CloudStorage is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + invokeCustomMethod(method, params, callback); + return cloudStorage; + } + + cloudStorage.setItem = function(key, value, callback) { + return invokeStorageMethod('saveStorageValue', {key: key, value: value}, callback); + }; + cloudStorage.getItem = function(key, callback) { + return cloudStorage.getItems([key], callback ? function(err, res) { + if (err) callback(err); + else callback(null, res[key]); + } : null); + }; + cloudStorage.getItems = function(keys, callback) { + return invokeStorageMethod('getStorageValues', {keys: keys}, callback); + }; + cloudStorage.removeItem = function(key, callback) { + return cloudStorage.removeItems([key], callback); + }; + cloudStorage.removeItems = function(keys, callback) { + return invokeStorageMethod('deleteStorageValues', {keys: keys}, callback); + }; + cloudStorage.getKeys = function(callback) { + return invokeStorageMethod('getStorageKeys', {}, callback); + }; + return cloudStorage; + })(); + + var DeviceStorage = (function() { + var deviceStorage = {}; + + WebView.onEvent('device_storage_key_saved', onDeviceStorageEvent); + WebView.onEvent('device_storage_key_received', onDeviceStorageEvent); + WebView.onEvent('device_storage_cleared', onDeviceStorageEvent); + WebView.onEvent('device_storage_failed', onDeviceStorageEvent); + + function onDeviceStorageEvent(eventType, eventData) { + if (eventData.req_id && webAppCallbacks[eventData.req_id]) { + var requestData = webAppCallbacks[eventData.req_id]; + delete webAppCallbacks[eventData.req_id]; + var res = null, err = null; + if (eventType == 'device_storage_failed') { + err = eventData.error || 'UNKNOWN_ERROR'; + } else if (eventType == 'device_storage_key_received') { + res = eventData.value; + } else { + res = true; + } + if (requestData.callback) { + requestData.callback(err, res); + } + } + } + + function invokeStorageMethod(method, params, callback) { + if (!versionAtLeast('9.0')) { + console.error('[Telegram.WebApp] DeviceStorage is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var req_id = generateCallbackId(16); + var req_params = {req_id: req_id}; + for (var k in params) { + req_params[k] = params[k]; + } + webAppCallbacks[req_id] = { + callback: callback + }; + WebView.postEvent(method, false, req_params); + return deviceStorage; + } + + deviceStorage.setItem = function(key, value, callback) { + return invokeStorageMethod('web_app_device_storage_save_key', {key: key, value: value}, callback); + }; + deviceStorage.getItem = function(key, callback) { + return invokeStorageMethod('web_app_device_storage_get_key', {key: key}, callback); + }; + deviceStorage.removeItem = function(key, callback) { + return invokeStorageMethod('web_app_device_storage_save_key', {key: key, value: null}, callback); + }; + deviceStorage.clear = function(callback) { + return invokeStorageMethod('web_app_device_storage_clear', {}, callback); + }; + return deviceStorage; + })(); + + var SecureStorage = (function() { + var secureStorage = {}; + + WebView.onEvent('secure_storage_key_saved', onSecureStorageEvent); + WebView.onEvent('secure_storage_key_received', onSecureStorageEvent); + WebView.onEvent('secure_storage_key_restored', onSecureStorageEvent); + WebView.onEvent('secure_storage_cleared', onSecureStorageEvent); + WebView.onEvent('secure_storage_failed', onSecureStorageEvent); + + function onSecureStorageEvent(eventType, eventData) { + if (eventData.req_id && webAppCallbacks[eventData.req_id]) { + var requestData = webAppCallbacks[eventData.req_id]; + delete webAppCallbacks[eventData.req_id]; + var res = null, err = null, can_restore = null; + if (eventType == 'secure_storage_failed') { + err = eventData.error || 'UNKNOWN_ERROR'; + } else if (eventType == 'secure_storage_key_received') { + res = eventData.value; + if (eventData.can_restore) { + can_restore = true; + } + } else if (eventType == 'secure_storage_key_restored') { + res = eventData.value; + } else { + res = true; + } + if (requestData.callback) { + requestData.callback(err, res, can_restore); + } + } + } + + function invokeStorageMethod(method, params, callback) { + if (!versionAtLeast('9.0')) { + console.error('[Telegram.WebApp] SecureStorage is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var req_id = generateCallbackId(16); + var req_params = {req_id: req_id}; + for (var k in params) { + req_params[k] = params[k]; + } + webAppCallbacks[req_id] = { + callback: callback + }; + WebView.postEvent(method, false, req_params); + return secureStorage; + } + + secureStorage.setItem = function(key, value, callback) { + return invokeStorageMethod('web_app_secure_storage_save_key', {key: key, value: value}, callback); + }; + secureStorage.getItem = function(key, callback) { + return invokeStorageMethod('web_app_secure_storage_get_key', {key: key}, callback); + }; + secureStorage.restoreItem = function(key, callback) { + return invokeStorageMethod('web_app_secure_storage_restore_key', {key: key}, callback); + }; + secureStorage.removeItem = function(key, callback) { + return invokeStorageMethod('web_app_secure_storage_save_key', {key: key, value: null}, callback); + }; + secureStorage.clear = function(callback) { + return invokeStorageMethod('web_app_secure_storage_clear', {}, callback); + }; + return secureStorage; + })(); + + var BiometricManager = (function() { + var isInited = false; + var isBiometricAvailable = false; + var biometricType = 'unknown'; + var isAccessRequested = false; + var isAccessGranted = false; + var isBiometricTokenSaved = false; + var deviceId = ''; + + var biometricManager = {}; + Object.defineProperty(biometricManager, 'isInited', { + get: function(){ return isInited; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isBiometricAvailable', { + get: function(){ return isInited && isBiometricAvailable; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'biometricType', { + get: function(){ return biometricType || 'unknown'; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isAccessRequested', { + get: function(){ return isAccessRequested; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isAccessGranted', { + get: function(){ return isAccessRequested && isAccessGranted; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'isBiometricTokenSaved', { + get: function(){ return isBiometricTokenSaved; }, + enumerable: true + }); + Object.defineProperty(biometricManager, 'deviceId', { + get: function(){ return deviceId || ''; }, + enumerable: true + }); + + var initRequestState = {callbacks: []}; + var accessRequestState = false; + var authRequestState = false; + var tokenRequestState = false; + + WebView.onEvent('biometry_info_received', onBiometryInfoReceived); + WebView.onEvent('biometry_auth_requested', onBiometryAuthRequested); + WebView.onEvent('biometry_token_updated', onBiometryTokenUpdated); + + function onBiometryInfoReceived(eventType, eventData) { + isInited = true; + if (eventData.available) { + isBiometricAvailable = true; + biometricType = eventData.type || 'unknown'; + if (eventData.access_requested) { + isAccessRequested = true; + isAccessGranted = !!eventData.access_granted; + isBiometricTokenSaved = !!eventData.token_saved; + } else { + isAccessRequested = false; + isAccessGranted = false; + isBiometricTokenSaved = false; + } + } else { + isBiometricAvailable = false; + biometricType = 'unknown'; + isAccessRequested = false; + isAccessGranted = false; + isBiometricTokenSaved = false; + } + deviceId = eventData.device_id || ''; + + if (initRequestState.callbacks.length > 0) { + for (var i = 0; i < initRequestState.callbacks.length; i++) { + var callback = initRequestState.callbacks[i]; + callback(); + } + initRequestState.callbacks = []; + } + if (accessRequestState) { + var state = accessRequestState; + accessRequestState = false; + if (state.callback) { + state.callback(isAccessGranted); + } + } + receiveWebViewEvent('biometricManagerUpdated'); + } + function onBiometryAuthRequested(eventType, eventData) { + var isAuthenticated = (eventData.status == 'authorized'), + biometricToken = eventData.token || ''; + if (authRequestState) { + var state = authRequestState; + authRequestState = false; + if (state.callback) { + state.callback(isAuthenticated, isAuthenticated ? biometricToken : null); + } + } + receiveWebViewEvent('biometricAuthRequested', isAuthenticated ? { + isAuthenticated: true, + biometricToken: biometricToken + } : { + isAuthenticated: false + }); + } + function onBiometryTokenUpdated(eventType, eventData) { + var applied = false; + if (isBiometricAvailable && + isAccessRequested) { + if (eventData.status == 'updated') { + isBiometricTokenSaved = true; + applied = true; + } + else if (eventData.status == 'removed') { + isBiometricTokenSaved = false; + applied = true; + } + } + if (tokenRequestState) { + var state = tokenRequestState; + tokenRequestState = false; + if (state.callback) { + state.callback(applied); + } + } + receiveWebViewEvent('biometricTokenUpdated', { + isUpdated: applied + }); + } + + function checkVersion() { + if (!versionAtLeast('7.2')) { + console.warn('[Telegram.WebApp] BiometricManager is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function checkInit() { + if (!isInited) { + console.error('[Telegram.WebApp] BiometricManager should be inited before using.'); + throw Error('WebAppBiometricManagerNotInited'); + } + return true; + } + + biometricManager.init = function(callback) { + if (!checkVersion()) { + return biometricManager; + } + if (isInited) { + return biometricManager; + } + if (callback) { + initRequestState.callbacks.push(callback); + } + WebView.postEvent('web_app_biometry_get_info', false); + return biometricManager; + }; + biometricManager.requestAccess = function(params, callback) { + if (!checkVersion()) { + return biometricManager; + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (accessRequestState) { + console.error('[Telegram.WebApp] Access is already requested'); + throw Error('WebAppBiometricManagerAccessRequested'); + } + var popup_params = {}; + if (typeof params.reason !== 'undefined') { + var reason = strTrim(params.reason); + if (reason.length > 128) { + console.error('[Telegram.WebApp] Biometric reason is too long', reason); + throw Error('WebAppBiometricRequestAccessParamInvalid'); + } + if (reason.length > 0) { + popup_params.reason = reason; + } + } + + accessRequestState = { + callback: callback + }; + WebView.postEvent('web_app_biometry_request_access', false, popup_params); + return biometricManager; + }; + biometricManager.authenticate = function(params, callback) { + if (!checkVersion()) { + return biometricManager; + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (!isAccessGranted) { + console.error('[Telegram.WebApp] Biometric access was not granted by the user.'); + throw Error('WebAppBiometricManagerBiometricAccessNotGranted'); + } + if (authRequestState) { + console.error('[Telegram.WebApp] Authentication request is already in progress.'); + throw Error('WebAppBiometricManagerAuthenticationRequested'); + } + var popup_params = {}; + if (typeof params.reason !== 'undefined') { + var reason = strTrim(params.reason); + if (reason.length > 128) { + console.error('[Telegram.WebApp] Biometric reason is too long', reason); + throw Error('WebAppBiometricRequestAccessParamInvalid'); + } + if (reason.length > 0) { + popup_params.reason = reason; + } + } + + authRequestState = { + callback: callback + }; + WebView.postEvent('web_app_biometry_request_auth', false, popup_params); + return biometricManager; + }; + biometricManager.updateBiometricToken = function(token, callback) { + if (!checkVersion()) { + return biometricManager; + } + token = token || ''; + if (token.length > 1024) { + console.error('[Telegram.WebApp] Token is too long', token); + throw Error('WebAppBiometricManagerTokenInvalid'); + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (!isAccessGranted) { + console.error('[Telegram.WebApp] Biometric access was not granted by the user.'); + throw Error('WebAppBiometricManagerBiometricAccessNotGranted'); + } + if (tokenRequestState) { + console.error('[Telegram.WebApp] Token request is already in progress.'); + throw Error('WebAppBiometricManagerTokenUpdateRequested'); + } + tokenRequestState = { + callback: callback + }; + WebView.postEvent('web_app_biometry_update_token', false, {token: token}); + return biometricManager; + }; + biometricManager.openSettings = function() { + if (!checkVersion()) { + return biometricManager; + } + checkInit(); + if (!isBiometricAvailable) { + console.error('[Telegram.WebApp] Biometrics is not available on this device.'); + throw Error('WebAppBiometricManagerBiometricsNotAvailable'); + } + if (!isAccessRequested) { + console.error('[Telegram.WebApp] Biometric access was not requested yet.'); + throw Error('WebAppBiometricManagerBiometricsAccessNotRequested'); + } + if (isAccessGranted) { + console.warn('[Telegram.WebApp] Biometric access was granted by the user, no need to go to settings.'); + return biometricManager; + } + WebView.postEvent('web_app_biometry_open_settings', false); + return biometricManager; + }; + return biometricManager; + })(); + + var LocationManager = (function() { + var isInited = false; + var isLocationAvailable = false; + var isAccessRequested = false; + var isAccessGranted = false; + + var locationManager = {}; + Object.defineProperty(locationManager, 'isInited', { + get: function(){ return isInited; }, + enumerable: true + }); + Object.defineProperty(locationManager, 'isLocationAvailable', { + get: function(){ return isInited && isLocationAvailable; }, + enumerable: true + }); + Object.defineProperty(locationManager, 'isAccessRequested', { + get: function(){ return isAccessRequested; }, + enumerable: true + }); + Object.defineProperty(locationManager, 'isAccessGranted', { + get: function(){ return isAccessRequested && isAccessGranted; }, + enumerable: true + }); + + var initRequestState = {callbacks: []}; + var getRequestState = {callbacks: []}; + + WebView.onEvent('location_checked', onLocationChecked); + WebView.onEvent('location_requested', onLocationRequested); + + function onLocationChecked(eventType, eventData) { + isInited = true; + if (eventData.available) { + isLocationAvailable = true; + if (eventData.access_requested) { + isAccessRequested = true; + isAccessGranted = !!eventData.access_granted; + } else { + isAccessRequested = false; + isAccessGranted = false; + } + } else { + isLocationAvailable = false; + isAccessRequested = false; + isAccessGranted = false; + } + + if (initRequestState.callbacks.length > 0) { + for (var i = 0; i < initRequestState.callbacks.length; i++) { + var callback = initRequestState.callbacks[i]; + callback(); + } + initRequestState.callbacks = []; + } + receiveWebViewEvent('locationManagerUpdated'); + } + function onLocationRequested(eventType, eventData) { + if (!eventData.available) { + locationData = null; + } else { + var locationData = { + latitude: eventData.latitude, + longitude: eventData.longitude, + altitude: null, + course: null, + speed: null, + horizontal_accuracy: null, + vertical_accuracy: null, + course_accuracy: null, + speed_accuracy: null, + }; + if (typeof eventData.altitude !== 'undefined' && eventData.altitude !== null) { + locationData.altitude = eventData.altitude; + } + if (typeof eventData.course !== 'undefined' && eventData.course !== null) { + locationData.course = eventData.course % 360; + } + if (typeof eventData.speed !== 'undefined' && eventData.speed !== null) { + locationData.speed = eventData.speed; + } + if (typeof eventData.horizontal_accuracy !== 'undefined' && eventData.horizontal_accuracy !== null) { + locationData.horizontal_accuracy = eventData.horizontal_accuracy; + } + if (typeof eventData.vertical_accuracy !== 'undefined' && eventData.vertical_accuracy !== null) { + locationData.vertical_accuracy = eventData.vertical_accuracy; + } + if (typeof eventData.course_accuracy !== 'undefined' && eventData.course_accuracy !== null) { + locationData.course_accuracy = eventData.course_accuracy; + } + if (typeof eventData.speed_accuracy !== 'undefined' && eventData.speed_accuracy !== null) { + locationData.speed_accuracy = eventData.speed_accuracy; + } + } + if (!eventData.available || + !isLocationAvailable || + !isAccessRequested || + !isAccessGranted) { + initRequestState.callbacks.push(function() { + locationResponse(locationData); + }); + WebView.postEvent('web_app_check_location', false); + } else { + locationResponse(locationData); + } + } + function locationResponse(response) { + if (getRequestState.callbacks.length > 0) { + for (var i = 0; i < getRequestState.callbacks.length; i++) { + var callback = getRequestState.callbacks[i]; + callback(response); + } + getRequestState.callbacks = []; + } + if (response !== null) { + receiveWebViewEvent('locationRequested', { + locationData: response + }); + } + } + + function checkVersion() { + if (!versionAtLeast('8.0')) { + console.warn('[Telegram.WebApp] LocationManager is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + function checkInit() { + if (!isInited) { + console.error('[Telegram.WebApp] LocationManager should be inited before using.'); + throw Error('WebAppLocationManagerNotInited'); + } + return true; + } + + locationManager.init = function(callback) { + if (!checkVersion()) { + return locationManager; + } + if (isInited) { + return locationManager; + } + if (callback) { + initRequestState.callbacks.push(callback); + } + WebView.postEvent('web_app_check_location', false); + return locationManager; + }; + locationManager.getLocation = function(callback) { + if (!checkVersion()) { + return locationManager; + } + checkInit(); + if (!isLocationAvailable) { + console.error('[Telegram.WebApp] Location is not available on this device.'); + throw Error('WebAppLocationManagerLocationNotAvailable'); + } + + getRequestState.callbacks.push(callback); + WebView.postEvent('web_app_request_location'); + return locationManager; + }; + locationManager.openSettings = function() { + if (!checkVersion()) { + return locationManager; + } + checkInit(); + if (!isLocationAvailable) { + console.error('[Telegram.WebApp] Location is not available on this device.'); + throw Error('WebAppLocationManagerLocationNotAvailable'); + } + if (!isAccessRequested) { + console.error('[Telegram.WebApp] Location access was not requested yet.'); + throw Error('WebAppLocationManagerLocationAccessNotRequested'); + } + if (isAccessGranted) { + console.warn('[Telegram.WebApp] Location access was granted by the user, no need to go to settings.'); + return locationManager; + } + WebView.postEvent('web_app_open_location_settings', false); + return locationManager; + }; + return locationManager; + })(); + + var Accelerometer = (function() { + var isStarted = false; + var valueX = null, valueY = null, valueZ = null; + var startCallbacks = [], stopCallbacks = []; + + var accelerometer = {}; + Object.defineProperty(accelerometer, 'isStarted', { + get: function(){ return isStarted; }, + enumerable: true + }); + Object.defineProperty(accelerometer, 'x', { + get: function(){ return valueX; }, + enumerable: true + }); + Object.defineProperty(accelerometer, 'y', { + get: function(){ return valueY; }, + enumerable: true + }); + Object.defineProperty(accelerometer, 'z', { + get: function(){ return valueZ; }, + enumerable: true + }); + + WebView.onEvent('accelerometer_started', onAccelerometerStarted); + WebView.onEvent('accelerometer_stopped', onAccelerometerStopped); + WebView.onEvent('accelerometer_changed', onAccelerometerChanged); + WebView.onEvent('accelerometer_failed', onAccelerometerFailed); + + function onAccelerometerStarted(eventType, eventData) { + isStarted = true; + if (startCallbacks.length > 0) { + for (var i = 0; i < startCallbacks.length; i++) { + var callback = startCallbacks[i]; + callback(true); + } + startCallbacks = []; + } + receiveWebViewEvent('accelerometerStarted'); + } + function onAccelerometerStopped(eventType, eventData) { + isStarted = false; + if (stopCallbacks.length > 0) { + for (var i = 0; i < stopCallbacks.length; i++) { + var callback = stopCallbacks[i]; + callback(true); + } + stopCallbacks = []; + } + receiveWebViewEvent('accelerometerStopped'); + } + function onAccelerometerChanged(eventType, eventData) { + valueX = eventData.x; + valueY = eventData.y; + valueZ = eventData.z; + receiveWebViewEvent('accelerometerChanged'); + } + function onAccelerometerFailed(eventType, eventData) { + if (startCallbacks.length > 0) { + for (var i = 0; i < startCallbacks.length; i++) { + var callback = startCallbacks[i]; + callback(false); + } + startCallbacks = []; + } + receiveWebViewEvent('accelerometerFailed', { + error: eventData.error + }); + } + + function checkVersion() { + if (!versionAtLeast('8.0')) { + console.warn('[Telegram.WebApp] Accelerometer is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + accelerometer.start = function(params, callback) { + params = params || {}; + if (!checkVersion()) { + return accelerometer; + } + var req_params = {}; + var refresh_rate = parseInt(params.refresh_rate || 1000); + if (isNaN(refresh_rate) || refresh_rate < 20 || refresh_rate > 1000) { + console.warn('[Telegram.WebApp] Accelerometer refresh_rate is invalid', refresh_rate); + } else { + req_params.refresh_rate = refresh_rate; + } + + if (callback) { + startCallbacks.push(callback); + } + WebView.postEvent('web_app_start_accelerometer', false, req_params); + return accelerometer; + }; + accelerometer.stop = function(callback) { + if (!checkVersion()) { + return accelerometer; + } + if (callback) { + stopCallbacks.push(callback); + } + WebView.postEvent('web_app_stop_accelerometer'); + return accelerometer; + }; + return accelerometer; + })(); + + var DeviceOrientation = (function() { + var isStarted = false; + var valueAlpha = null, valueBeta = null, valueGamma = null, valueAbsolute = false; + var startCallbacks = [], stopCallbacks = []; + + var deviceOrientation = {}; + Object.defineProperty(deviceOrientation, 'isStarted', { + get: function(){ return isStarted; }, + enumerable: true + }); + Object.defineProperty(deviceOrientation, 'absolute', { + get: function(){ return valueAbsolute; }, + enumerable: true + }); + Object.defineProperty(deviceOrientation, 'alpha', { + get: function(){ return valueAlpha; }, + enumerable: true + }); + Object.defineProperty(deviceOrientation, 'beta', { + get: function(){ return valueBeta; }, + enumerable: true + }); + Object.defineProperty(deviceOrientation, 'gamma', { + get: function(){ return valueGamma; }, + enumerable: true + }); + + WebView.onEvent('device_orientation_started', onDeviceOrientationStarted); + WebView.onEvent('device_orientation_stopped', onDeviceOrientationStopped); + WebView.onEvent('device_orientation_changed', onDeviceOrientationChanged); + WebView.onEvent('device_orientation_failed', onDeviceOrientationFailed); + + function onDeviceOrientationStarted(eventType, eventData) { + isStarted = true; + if (startCallbacks.length > 0) { + for (var i = 0; i < startCallbacks.length; i++) { + var callback = startCallbacks[i]; + callback(true); + } + startCallbacks = []; + } + receiveWebViewEvent('deviceOrientationStarted'); + } + function onDeviceOrientationStopped(eventType, eventData) { + isStarted = false; + if (stopCallbacks.length > 0) { + for (var i = 0; i < stopCallbacks.length; i++) { + var callback = stopCallbacks[i]; + callback(true); + } + stopCallbacks = []; + } + receiveWebViewEvent('deviceOrientationStopped'); + } + function onDeviceOrientationChanged(eventType, eventData) { + valueAbsolute = !!eventData.absolute; + valueAlpha = eventData.alpha; + valueBeta = eventData.beta; + valueGamma = eventData.gamma; + receiveWebViewEvent('deviceOrientationChanged'); + } + function onDeviceOrientationFailed(eventType, eventData) { + if (startCallbacks.length > 0) { + for (var i = 0; i < startCallbacks.length; i++) { + var callback = startCallbacks[i]; + callback(false); + } + startCallbacks = []; + } + receiveWebViewEvent('deviceOrientationFailed', { + error: eventData.error + }); + } + + function checkVersion() { + if (!versionAtLeast('8.0')) { + console.warn('[Telegram.WebApp] DeviceOrientation is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + deviceOrientation.start = function(params, callback) { + params = params || {}; + if (!checkVersion()) { + return deviceOrientation; + } + var req_params = {}; + var refresh_rate = parseInt(params.refresh_rate || 1000); + if (isNaN(refresh_rate) || refresh_rate < 20 || refresh_rate > 1000) { + console.warn('[Telegram.WebApp] DeviceOrientation refresh_rate is invalid', refresh_rate); + } else { + req_params.refresh_rate = refresh_rate; + } + req_params.need_absolute = !!params.need_absolute; + + if (callback) { + startCallbacks.push(callback); + } + WebView.postEvent('web_app_start_device_orientation', false, req_params); + return deviceOrientation; + }; + deviceOrientation.stop = function(callback) { + if (!checkVersion()) { + return deviceOrientation; + } + if (callback) { + stopCallbacks.push(callback); + } + WebView.postEvent('web_app_stop_device_orientation'); + return deviceOrientation; + }; + return deviceOrientation; + })(); + + var Gyroscope = (function() { + var isStarted = false; + var valueX = null, valueY = null, valueZ = null; + var startCallbacks = [], stopCallbacks = []; + + var gyroscope = {}; + Object.defineProperty(gyroscope, 'isStarted', { + get: function(){ return isStarted; }, + enumerable: true + }); + Object.defineProperty(gyroscope, 'x', { + get: function(){ return valueX; }, + enumerable: true + }); + Object.defineProperty(gyroscope, 'y', { + get: function(){ return valueY; }, + enumerable: true + }); + Object.defineProperty(gyroscope, 'z', { + get: function(){ return valueZ; }, + enumerable: true + }); + + WebView.onEvent('gyroscope_started', onGyroscopeStarted); + WebView.onEvent('gyroscope_stopped', onGyroscopeStopped); + WebView.onEvent('gyroscope_changed', onGyroscopeChanged); + WebView.onEvent('gyroscope_failed', onGyroscopeFailed); + + function onGyroscopeStarted(eventType, eventData) { + isStarted = true; + if (startCallbacks.length > 0) { + for (var i = 0; i < startCallbacks.length; i++) { + var callback = startCallbacks[i]; + callback(true); + } + startCallbacks = []; + } + receiveWebViewEvent('gyroscopeStarted'); + } + function onGyroscopeStopped(eventType, eventData) { + isStarted = false; + if (stopCallbacks.length > 0) { + for (var i = 0; i < stopCallbacks.length; i++) { + var callback = stopCallbacks[i]; + callback(true); + } + stopCallbacks = []; + } + receiveWebViewEvent('gyroscopeStopped'); + } + function onGyroscopeChanged(eventType, eventData) { + valueX = eventData.x; + valueY = eventData.y; + valueZ = eventData.z; + receiveWebViewEvent('gyroscopeChanged'); + } + function onGyroscopeFailed(eventType, eventData) { + if (startCallbacks.length > 0) { + for (var i = 0; i < startCallbacks.length; i++) { + var callback = startCallbacks[i]; + callback(false); + } + startCallbacks = []; + } + receiveWebViewEvent('gyroscopeFailed', { + error: eventData.error + }); + } + + function checkVersion() { + if (!versionAtLeast('8.0')) { + console.warn('[Telegram.WebApp] Gyroscope is not supported in version ' + webAppVersion); + return false; + } + return true; + } + + gyroscope.start = function(params, callback) { + params = params || {}; + if (!checkVersion()) { + return gyroscope; + } + var req_params = {}; + var refresh_rate = parseInt(params.refresh_rate || 1000); + if (isNaN(refresh_rate) || refresh_rate < 20 || refresh_rate > 1000) { + console.warn('[Telegram.WebApp] Gyroscope refresh_rate is invalid', refresh_rate); + } else { + req_params.refresh_rate = refresh_rate; + } + + if (callback) { + startCallbacks.push(callback); + } + WebView.postEvent('web_app_start_gyroscope', false, req_params); + return gyroscope; + }; + gyroscope.stop = function(callback) { + if (!checkVersion()) { + return gyroscope; + } + if (callback) { + stopCallbacks.push(callback); + } + WebView.postEvent('web_app_stop_gyroscope'); + return gyroscope; + }; + return gyroscope; + })(); + + var webAppInvoices = {}; + function onInvoiceClosed(eventType, eventData) { + if (eventData.slug && webAppInvoices[eventData.slug]) { + var invoiceData = webAppInvoices[eventData.slug]; + delete webAppInvoices[eventData.slug]; + if (invoiceData.callback) { + invoiceData.callback(eventData.status); + } + receiveWebViewEvent('invoiceClosed', { + url: invoiceData.url, + status: eventData.status + }); + } + } + + var webAppPopupOpened = false; + function onPopupClosed(eventType, eventData) { + if (webAppPopupOpened) { + var popupData = webAppPopupOpened; + webAppPopupOpened = false; + var button_id = null; + if (typeof eventData.button_id !== 'undefined') { + button_id = eventData.button_id; + } + if (popupData.callback) { + popupData.callback(button_id); + } + receiveWebViewEvent('popupClosed', { + button_id: button_id + }); + } + } + + var webAppScanQrPopupOpened = false; + function onQrTextReceived(eventType, eventData) { + if (webAppScanQrPopupOpened) { + var popupData = webAppScanQrPopupOpened; + var data = null; + if (typeof eventData.data !== 'undefined') { + data = eventData.data; + } + if (popupData.callback) { + if (popupData.callback(data)) { + webAppScanQrPopupOpened = false; + WebView.postEvent('web_app_close_scan_qr_popup', false); + } + } + receiveWebViewEvent('qrTextReceived', { + data: data + }); + } + } + function onScanQrPopupClosed(eventType, eventData) { + webAppScanQrPopupOpened = false; + receiveWebViewEvent('scanQrPopupClosed'); + } + + function onClipboardTextReceived(eventType, eventData) { + if (eventData.req_id && webAppCallbacks[eventData.req_id]) { + var requestData = webAppCallbacks[eventData.req_id]; + delete webAppCallbacks[eventData.req_id]; + var data = null; + if (typeof eventData.data !== 'undefined') { + data = eventData.data; + } + if (requestData.callback) { + requestData.callback(data); + } + receiveWebViewEvent('clipboardTextReceived', { + data: data + }); + } + } + + var WebAppWriteAccessRequested = false; + function onWriteAccessRequested(eventType, eventData) { + if (WebAppWriteAccessRequested) { + var requestData = WebAppWriteAccessRequested; + WebAppWriteAccessRequested = false; + if (requestData.callback) { + requestData.callback(eventData.status == 'allowed'); + } + receiveWebViewEvent('writeAccessRequested', { + status: eventData.status + }); + } + } + + function getRequestedContact(callback, timeout) { + var reqTo, fallbackTo, reqDelay = 0; + var reqInvoke = function() { + invokeCustomMethod('getRequestedContact', {}, function(err, res) { + if (res.substr(0, 1) == '"' && res.substr(-1) == '"') { // macos fix + res = JSON.parse(res); + } + if (res && res.length) { + clearTimeout(fallbackTo); + callback(res); + } else { + reqDelay += 50; + reqTo = setTimeout(reqInvoke, reqDelay); + } + }); + }; + var fallbackInvoke = function() { + clearTimeout(reqTo); + callback(''); + }; + fallbackTo = setTimeout(fallbackInvoke, timeout); + reqInvoke(); + } + + var WebAppContactRequested = false; + function onPhoneRequested(eventType, eventData) { + if (WebAppContactRequested) { + var requestData = WebAppContactRequested; + WebAppContactRequested = false; + var requestSent = eventData.status == 'sent'; + var webViewEvent = { + status: eventData.status + }; + if (requestSent) { + getRequestedContact(function(res) { + if (res && res.length) { + webViewEvent.response = res; + webViewEvent.responseUnsafe = Utils.urlParseQueryString(res); + for (var key in webViewEvent.responseUnsafe) { + var val = webViewEvent.responseUnsafe[key]; + try { + if (val.substr(0, 1) == '{' && val.substr(-1) == '}' || + val.substr(0, 1) == '[' && val.substr(-1) == ']') { + webViewEvent.responseUnsafe[key] = JSON.parse(val); + } + } catch (e) {} + } + } + if (requestData.callback) { + requestData.callback(requestSent, webViewEvent); + } + receiveWebViewEvent('contactRequested', webViewEvent); + }, 3000); + } else { + if (requestData.callback) { + requestData.callback(requestSent, webViewEvent); + } + receiveWebViewEvent('contactRequested', webViewEvent); + } + } + } + + var webAppDownloadFileRequested = false; + function onFileDownloadRequested(eventType, eventData) { + if (webAppDownloadFileRequested) { + var requestData = webAppDownloadFileRequested; + webAppDownloadFileRequested = false; + var isDownloading = eventData.status == 'downloading'; + if (requestData.callback) { + requestData.callback(isDownloading); + } + receiveWebViewEvent('fileDownloadRequested', { + status: isDownloading ? 'downloading' : 'cancelled' + }); + } + } + + function onCustomMethodInvoked(eventType, eventData) { + if (eventData.req_id && webAppCallbacks[eventData.req_id]) { + var requestData = webAppCallbacks[eventData.req_id]; + delete webAppCallbacks[eventData.req_id]; + var res = null, err = null; + if (typeof eventData.result !== 'undefined') { + res = eventData.result; + } + if (typeof eventData.error !== 'undefined') { + err = eventData.error; + } + if (requestData.callback) { + requestData.callback(err, res); + } + } + } + + function invokeCustomMethod(method, params, callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] Method invokeCustomMethod is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var req_id = generateCallbackId(16); + var req_params = {req_id: req_id, method: method, params: params || {}}; + webAppCallbacks[req_id] = { + callback: callback + }; + WebView.postEvent('web_app_invoke_custom_method', false, req_params); + }; + + if (!window.Telegram) { + window.Telegram = {}; + } + + Object.defineProperty(WebApp, 'initData', { + get: function(){ return webAppInitData; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'initDataUnsafe', { + get: function(){ return webAppInitDataUnsafe; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'version', { + get: function(){ return webAppVersion; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'platform', { + get: function(){ return webAppPlatform; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'colorScheme', { + get: function(){ return colorScheme; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'themeParams', { + get: function(){ return themeParams; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isExpanded', { + get: function(){ return isExpanded; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'viewportHeight', { + get: function(){ return (viewportHeight === false ? window.innerHeight : viewportHeight) - bottomBarHeight; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'viewportStableHeight', { + get: function(){ return (viewportStableHeight === false ? window.innerHeight : viewportStableHeight) - bottomBarHeight; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'safeAreaInset', { + get: function(){ return safeAreaInset; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'contentSafeAreaInset', { + get: function(){ return contentSafeAreaInset; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isClosingConfirmationEnabled', { + set: function(val){ setClosingConfirmation(val); }, + get: function(){ return isClosingConfirmationEnabled; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isVerticalSwipesEnabled', { + set: function(val){ toggleVerticalSwipes(val); }, + get: function(){ return isVerticalSwipesEnabled; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isFullscreen', { + get: function(){ return webAppIsFullscreen; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isOrientationLocked', { + set: function(val){ toggleOrientationLock(val); }, + get: function(){ return webAppIsOrientationLocked; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'isActive', { + get: function(){ return webAppIsActive; }, + enumerable: true + }); + Object.defineProperty(WebApp, 'headerColor', { + set: function(val){ setHeaderColor(val); }, + get: function(){ return getHeaderColor(); }, + enumerable: true + }); + Object.defineProperty(WebApp, 'backgroundColor', { + set: function(val){ setBackgroundColor(val); }, + get: function(){ return getBackgroundColor(); }, + enumerable: true + }); + Object.defineProperty(WebApp, 'bottomBarColor', { + set: function(val){ setBottomBarColor(val); }, + get: function(){ return getBottomBarColor(); }, + enumerable: true + }); + Object.defineProperty(WebApp, 'BackButton', { + value: BackButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'MainButton', { + value: MainButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'SecondaryButton', { + value: SecondaryButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'SettingsButton', { + value: SettingsButton, + enumerable: true + }); + Object.defineProperty(WebApp, 'HapticFeedback', { + value: HapticFeedback, + enumerable: true + }); + Object.defineProperty(WebApp, 'CloudStorage', { + value: CloudStorage, + enumerable: true + }); + Object.defineProperty(WebApp, 'DeviceStorage', { + value: DeviceStorage, + enumerable: true + }); + Object.defineProperty(WebApp, 'SecureStorage', { + value: SecureStorage, + enumerable: true + }); + Object.defineProperty(WebApp, 'BiometricManager', { + value: BiometricManager, + enumerable: true + }); + Object.defineProperty(WebApp, 'Accelerometer', { + value: Accelerometer, + enumerable: true + }); + Object.defineProperty(WebApp, 'DeviceOrientation', { + value: DeviceOrientation, + enumerable: true + }); + Object.defineProperty(WebApp, 'Gyroscope', { + value: Gyroscope, + enumerable: true + }); + Object.defineProperty(WebApp, 'LocationManager', { + value: LocationManager, + enumerable: true + }); + WebApp.isVersionAtLeast = function(ver) { + return versionAtLeast(ver); + }; + WebApp.setHeaderColor = function(color_key) { + WebApp.headerColor = color_key; + }; + WebApp.setBackgroundColor = function(color) { + WebApp.backgroundColor = color; + }; + WebApp.setBottomBarColor = function(color) { + WebApp.bottomBarColor = color; + }; + WebApp.enableClosingConfirmation = function() { + WebApp.isClosingConfirmationEnabled = true; + }; + WebApp.disableClosingConfirmation = function() { + WebApp.isClosingConfirmationEnabled = false; + }; + WebApp.enableVerticalSwipes = function() { + WebApp.isVerticalSwipesEnabled = true; + }; + WebApp.disableVerticalSwipes = function() { + WebApp.isVerticalSwipesEnabled = false; + }; + WebApp.lockOrientation = function() { + WebApp.isOrientationLocked = true; + }; + WebApp.unlockOrientation = function() { + WebApp.isOrientationLocked = false; + }; + WebApp.requestFullscreen = function() { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method requestFullscreen is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + WebView.postEvent('web_app_request_fullscreen'); + }; + WebApp.exitFullscreen = function() { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method exitFullscreen is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + WebView.postEvent('web_app_exit_fullscreen'); + }; + WebApp.addToHomeScreen = function() { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method addToHomeScreen is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + WebView.postEvent('web_app_add_to_home_screen'); + }; + WebApp.checkHomeScreenStatus = function(callback) { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method checkHomeScreenStatus is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (callback) { + homeScreenCallbacks.push(callback); + } + WebView.postEvent('web_app_check_home_screen'); + }; + WebApp.onEvent = function(eventType, callback) { + onWebViewEvent(eventType, callback); + }; + WebApp.offEvent = function(eventType, callback) {offWebViewEvent(eventType, callback); + }; + WebApp.sendData = function (data) { + if (!data || !data.length) { + console.error('[Telegram.WebApp] Data is required', data); + throw Error('WebAppDataInvalid'); + } + if (byteLength(data) > 4096) { + console.error('[Telegram.WebApp] Data is too long', data); + throw Error('WebAppDataInvalid'); + } + WebView.postEvent('web_app_data_send', false, {data: data}); + }; + WebApp.switchInlineQuery = function (query, choose_chat_types) { + if (!versionAtLeast('6.6')) { + console.error('[Telegram.WebApp] Method switchInlineQuery is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (!initParams.tgWebAppBotInline) { + console.error('[Telegram.WebApp] Inline mode is disabled for this bot. Read more about inline mode: https://core.telegram.org/bots/inline'); + throw Error('WebAppInlineModeDisabled'); + } + query = query || ''; + if (query.length > 256) { + console.error('[Telegram.WebApp] Inline query is too long', query); + throw Error('WebAppInlineQueryInvalid'); + } + var chat_types = []; + if (choose_chat_types) { + if (!Array.isArray(choose_chat_types)) { + console.error('[Telegram.WebApp] Choose chat types should be an array', choose_chat_types); + throw Error('WebAppInlineChooseChatTypesInvalid'); + } + var good_types = {users: 1, bots: 1, groups: 1, channels: 1}; + for (var i = 0; i < choose_chat_types.length; i++) { + var chat_type = choose_chat_types[i]; + if (!good_types[chat_type]) { + console.error('[Telegram.WebApp] Choose chat type is invalid', chat_type); + throw Error('WebAppInlineChooseChatTypeInvalid'); + } + if (good_types[chat_type] != 2) { + good_types[chat_type] = 2; + chat_types.push(chat_type); + } + } + } + WebView.postEvent('web_app_switch_inline_query', false, {query: query, chat_types: chat_types}); + }; + WebApp.openLink = function (url, options) { + var a = document.createElement('A'); + a.href = url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Url protocol is not supported', url); + throw Error('WebAppTgUrlInvalid'); + } + var url = a.href; + options = options || {}; + if (versionAtLeast('6.1')) { + var req_params = {url: url}; + if (versionAtLeast('6.4') && options.try_instant_view) { + req_params.try_instant_view = true; + } + if (versionAtLeast('7.6') && options.try_browser) { + req_params.try_browser = options.try_browser; + } + WebView.postEvent('web_app_open_link', false, req_params); + } else { + window.open(url, '_blank'); + } + }; + WebApp.openTelegramLink = function (url, options) { + var a = document.createElement('A'); + a.href = url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Url protocol is not supported', url); + throw Error('WebAppTgUrlInvalid'); + } + if (a.hostname != 't.me') { + console.error('[Telegram.WebApp] Url host is not supported', url); + throw Error('WebAppTgUrlInvalid'); + } + var path_full = a.pathname + a.search; + options = options || {}; + if (isIframe || versionAtLeast('6.1')) { + var req_params = {path_full: path_full}; + if (options.force_request) { + req_params.force_request = true; + } + WebView.postEvent('web_app_open_tg_link', false, req_params); + } else { + location.href = 'https://t.me' + path_full; + } + }; + WebApp.openInvoice = function (url, callback) { + var a = document.createElement('A'), match, slug; + a.href = url; + if (a.protocol != 'http:' && + a.protocol != 'https:' || + a.hostname != 't.me' || + !(match = a.pathname.match(/^\/(\$|invoice\/)([A-Za-z0-9\-_=]+)$/)) || + !(slug = match[2])) { + console.error('[Telegram.WebApp] Invoice url is invalid', url); + throw Error('WebAppInvoiceUrlInvalid'); + } + if (!versionAtLeast('6.1')) { + console.error('[Telegram.WebApp] Method openInvoice is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppInvoices[slug]) { + console.error('[Telegram.WebApp] Invoice is already opened'); + throw Error('WebAppInvoiceOpened'); + } + webAppInvoices[slug] = { + url: url, + callback: callback + }; + WebView.postEvent('web_app_open_invoice', false, {slug: slug}); + }; + WebApp.showPopup = function (params, callback) { + if (!versionAtLeast('6.2')) { + console.error('[Telegram.WebApp] Method showPopup is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppPopupOpened) { + console.error('[Telegram.WebApp] Popup is already opened'); + throw Error('WebAppPopupOpened'); + } + var title = ''; + var message = ''; + var buttons = []; + var popup_buttons = {}; + var popup_params = {}; + if (typeof params.title !== 'undefined') { + title = strTrim(params.title); + if (title.length > 64) { + console.error('[Telegram.WebApp] Popup title is too long', title); + throw Error('WebAppPopupParamInvalid'); + } + if (title.length > 0) { + popup_params.title = title; + } + } + if (typeof params.message !== 'undefined') { + message = strTrim(params.message); + } + if (!message.length) { + console.error('[Telegram.WebApp] Popup message is required', params.message); + throw Error('WebAppPopupParamInvalid'); + } + if (message.length > 256) { + console.error('[Telegram.WebApp] Popup message is too long', message); + throw Error('WebAppPopupParamInvalid'); + } + popup_params.message = message; + if (typeof params.buttons !== 'undefined') { + if (!Array.isArray(params.buttons)) { + console.error('[Telegram.WebApp] Popup buttons should be an array', params.buttons); + throw Error('WebAppPopupParamInvalid'); + } + for (var i = 0; i < params.buttons.length; i++) { + var button = params.buttons[i]; + var btn = {}; + var id = ''; + if (typeof button.id !== 'undefined') { + id = button.id.toString(); + if (id.length > 64) { + console.error('[Telegram.WebApp] Popup button id is too long', id); + throw Error('WebAppPopupParamInvalid'); + } + } + btn.id = id; + var button_type = button.type; + if (typeof button_type === 'undefined') { + button_type = 'default'; + } + btn.type = button_type; + if (button_type == 'ok' || + button_type == 'close' || + button_type == 'cancel') { + // no params needed + } else if (button_type == 'default' || + button_type == 'destructive') { + var text = ''; + if (typeof button.text !== 'undefined') { + text = strTrim(button.text); + } + if (!text.length) { + console.error('[Telegram.WebApp] Popup button text is required for type ' + button_type, button.text); + throw Error('WebAppPopupParamInvalid'); + } + if (text.length > 64) { + console.error('[Telegram.WebApp] Popup button text is too long', text); + throw Error('WebAppPopupParamInvalid'); + } + btn.text = text; + } else { + console.error('[Telegram.WebApp] Popup button type is invalid', button_type); + throw Error('WebAppPopupParamInvalid'); + } + buttons.push(btn); + } + } else { + buttons.push({id: '', type: 'close'}); + } + if (buttons.length < 1) { + console.error('[Telegram.WebApp] Popup should have at least one button'); + throw Error('WebAppPopupParamInvalid'); + } + if (buttons.length > 3) { + console.error('[Telegram.WebApp] Popup should not have more than 3 buttons'); + throw Error('WebAppPopupParamInvalid'); + } + popup_params.buttons = buttons; + + webAppPopupOpened = { + callback: callback + }; + WebView.postEvent('web_app_open_popup', false, popup_params); + }; + WebApp.showAlert = function (message, callback) { + WebApp.showPopup({ + message: message + }, callback ? function(){ callback(); } : null); + }; + WebApp.showConfirm = function (message, callback) { + WebApp.showPopup({ + message: message, + buttons: [ + {type: 'ok', id: 'ok'}, + {type: 'cancel'} + ] + }, callback ? function (button_id) { + callback(button_id == 'ok'); + } : null); + }; + WebApp.showScanQrPopup = function (params, callback) { + if (!versionAtLeast('6.4')) { + console.error('[Telegram.WebApp] Method showScanQrPopup is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppScanQrPopupOpened) { + console.error('[Telegram.WebApp] Popup is already opened'); + throw Error('WebAppScanQrPopupOpened'); + } + var text = ''; + var popup_params = {}; + if (typeof params.text !== 'undefined') { + text = strTrim(params.text); + if (text.length > 64) { + console.error('[Telegram.WebApp] Scan QR popup text is too long', text); + throw Error('WebAppScanQrPopupParamInvalid'); + } + if (text.length > 0) { + popup_params.text = text; + } + } + + webAppScanQrPopupOpened = { + callback: callback + }; + WebView.postEvent('web_app_open_scan_qr_popup', false, popup_params); + }; + WebApp.closeScanQrPopup = function () { + if (!versionAtLeast('6.4')) { + console.error('[Telegram.WebApp] Method closeScanQrPopup is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + + webAppScanQrPopupOpened = false; + WebView.postEvent('web_app_close_scan_qr_popup', false); + }; + WebApp.readTextFromClipboard = function (callback) { + if (!versionAtLeast('6.4')) { + console.error('[Telegram.WebApp] Method readTextFromClipboard is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var req_id = generateCallbackId(16); + var req_params = {req_id: req_id}; + webAppCallbacks[req_id] = { + callback: callback + }; + WebView.postEvent('web_app_read_text_from_clipboard', false, req_params); + }; + WebApp.requestWriteAccess = function (callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] Method requestWriteAccess is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppWriteAccessRequested) { + console.error('[Telegram.WebApp] Write access is already requested'); + throw Error('WebAppWriteAccessRequested'); + } + WebAppWriteAccessRequested = { + callback: callback + }; + WebView.postEvent('web_app_request_write_access'); + }; + WebApp.requestContact = function (callback) { + if (!versionAtLeast('6.9')) { + console.error('[Telegram.WebApp] Method requestContact is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppContactRequested) { + console.error('[Telegram.WebApp] Contact is already requested'); + throw Error('WebAppContactRequested'); + } + WebAppContactRequested = { + callback: callback + }; + WebView.postEvent('web_app_request_phone'); + }; + WebApp.downloadFile = function (params, callback) { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method downloadFile is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (webAppDownloadFileRequested) { + console.error('[Telegram.WebApp] Popup is already opened'); + throw Error('WebAppDownloadFilePopupOpened'); + } + var a = document.createElement('A'); + + var dl_params = {}; + if (!params || !params.url || !params.url.length) { + console.error('[Telegram.WebApp] Url is required'); + throw Error('WebAppDownloadFileParamInvalid'); + } + a.href = params.url; + if (a.protocol != 'https:') { + console.error('[Telegram.WebApp] Url protocol is not supported', url); + throw Error('WebAppDownloadFileParamInvalid'); + } + dl_params.url = a.href; + + if (!params || !params.file_name || !params.file_name.length) { + console.error('[Telegram.WebApp] File name is required'); + throw Error('WebAppDownloadFileParamInvalid'); + } + dl_params.file_name = params.file_name; + + webAppDownloadFileRequested = { + callback: callback + }; + WebView.postEvent('web_app_request_file_download', false, dl_params); + }; + WebApp.shareToStory = function (media_url, params) { + params = params || {}; + if (!versionAtLeast('7.8')) { + console.error('[Telegram.WebApp] Method shareToStory is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var a = document.createElement('A'); + a.href = media_url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Media url protocol is not supported', url); + throw Error('WebAppMediaUrlInvalid'); + } + var share_params = {}; + share_params.media_url = a.href; + if (typeof params.text !== 'undefined') { + var text = strTrim(params.text); + if (text.length > 2048) { + console.error('[Telegram.WebApp] Text is too long', text); + throw Error('WebAppShareToStoryParamInvalid'); + } + if (text.length > 0) { + share_params.text = text; + } + } + if (typeof params.widget_link !== 'undefined') { + params.widget_link = params.widget_link || {}; + a.href = params.widget_link.url; + if (a.protocol != 'http:' && + a.protocol != 'https:') { + console.error('[Telegram.WebApp] Link protocol is not supported', url); + throw Error('WebAppShareToStoryParamInvalid'); + } + var widget_link = { + url: a.href + }; + if (typeof params.widget_link.name !== 'undefined') { + var link_name = strTrim(params.widget_link.name); + if (link_name.length > 48) { + console.error('[Telegram.WebApp] Link name is too long', link_name); + throw Error('WebAppShareToStoryParamInvalid'); + } + if (link_name.length > 0) { + widget_link.name = link_name; + } + } + share_params.widget_link = widget_link; + } + + WebView.postEvent('web_app_share_to_story', false, share_params); + }; + WebApp.shareMessage = function (msg_id, callback) { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method shareMessage is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppShareMessageOpened) { + console.error('[Telegram.WebApp] Share message is already opened'); + throw Error('WebAppShareMessageOpened'); + } + WebAppShareMessageOpened = { + callback: callback + }; + WebView.postEvent('web_app_send_prepared_message', false, {id: msg_id}); + }; + WebApp.requestChat = function (req_id, callback) { + if (!versionAtLeast('9.6')) { + console.error('[Telegram.WebApp] Method requestChat is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppRequestChatOpened) { + console.error('[Telegram.WebApp] Request chat is already opened'); + throw Error('WebAppRequestChatOpened'); + } + WebAppRequestChatOpened = { + callback: callback + }; + WebView.postEvent('web_app_request_chat', false, {req_id: req_id}); + }; + WebApp.setEmojiStatus = function (custom_emoji_id, params, callback) { + params = params || {}; + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method setEmojiStatus is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + var status_params = {}; + status_params.custom_emoji_id = custom_emoji_id; + if (typeof params.duration !== 'undefined') { + status_params.duration = params.duration; + } + if (WebAppEmojiStatusRequested) { + console.error('[Telegram.WebApp] Emoji status is already requested'); + throw Error('WebAppEmojiStatusRequested'); + } + WebAppEmojiStatusRequested = { + callback: callback + }; + WebView.postEvent('web_app_set_emoji_status', false, status_params); + }; + WebApp.requestEmojiStatusAccess = function (callback) { + if (!versionAtLeast('8.0')) { + console.error('[Telegram.WebApp] Method requestEmojiStatusAccess is not supported in version ' + webAppVersion); + throw Error('WebAppMethodUnsupported'); + } + if (WebAppEmojiStatusAccessRequested) { + console.error('[Telegram.WebApp] Emoji status permission is already requested'); + throw Error('WebAppEmojiStatusAccessRequested'); + } + WebAppEmojiStatusAccessRequested = { + callback: callback + }; + WebView.postEvent('web_app_request_emoji_status_access'); + }; + WebApp.invokeCustomMethod = function (method, params, callback) { + invokeCustomMethod(method, params, callback); + }; + WebApp.hideKeyboard = function () { + WebView.postEvent('web_app_hide_keyboard'); + }; + WebApp.ready = function () { + WebView.postEvent('web_app_ready'); + }; + WebApp.expand = function () { + WebView.postEvent('web_app_expand'); + }; + WebApp.close = function (options) { + options = options || {}; + var req_params = {}; + if (versionAtLeast('7.6') && options.return_back) { + req_params.return_back = true; + } + WebView.postEvent('web_app_close', false, req_params); + }; + + window.Telegram.WebApp = WebApp; + + updateHeaderColor(); + updateBackgroundColor(); + updateBottomBarColor(); + setViewportHeight(); + if (initParams.tgWebAppShowSettings) { + SettingsButton.show(); + } + + window.addEventListener('resize', onWindowResize); + if (isIframe) { + document.addEventListener('click', linkHandler); + } + + WebView.onEvent('theme_changed', onThemeChanged); + WebView.onEvent('viewport_changed', onViewportChanged); + WebView.onEvent('safe_area_changed', onSafeAreaChanged); + WebView.onEvent('content_safe_area_changed', onContentSafeAreaChanged); + WebView.onEvent('visibility_changed', onVisibilityChanged); + WebView.onEvent('invoice_closed', onInvoiceClosed); + WebView.onEvent('popup_closed', onPopupClosed); + WebView.onEvent('qr_text_received', onQrTextReceived); + WebView.onEvent('scan_qr_popup_closed', onScanQrPopupClosed); + WebView.onEvent('clipboard_text_received', onClipboardTextReceived); + WebView.onEvent('write_access_requested', onWriteAccessRequested); + WebView.onEvent('phone_requested', onPhoneRequested); + WebView.onEvent('file_download_requested', onFileDownloadRequested); + WebView.onEvent('custom_method_invoked', onCustomMethodInvoked); + WebView.onEvent('fullscreen_changed', onFullscreenChanged); + WebView.onEvent('fullscreen_failed', onFullscreenFailed); + WebView.onEvent('home_screen_added', onHomeScreenAdded); + WebView.onEvent('home_screen_checked', onHomeScreenChecked); + WebView.onEvent('prepared_message_sent', onPreparedMessageSent); + WebView.onEvent('prepared_message_failed', onPreparedMessageFailed); + WebView.onEvent('requested_chat_sent', onRequestedChatSent); + WebView.onEvent('requested_chat_failed', onRequestedChatFailed); + WebView.onEvent('emoji_status_set', onEmojiStatusSet); + WebView.onEvent('emoji_status_failed', onEmojiStatusFailed); + WebView.onEvent('emoji_status_access_requested', onEmojiStatusAccessRequested); + WebView.postEvent('web_app_request_theme'); + WebView.postEvent('web_app_request_viewport'); + WebView.postEvent('web_app_request_safe_area'); + WebView.postEvent('web_app_request_content_safe_area'); + +})(); diff --git a/bot/app/web/templates/telegram-widget.js b/bot/app/web/templates/telegram-widget.js new file mode 100644 index 0000000..43b8ed9 --- /dev/null +++ b/bot/app/web/templates/telegram-widget.js @@ -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); diff --git a/bot/app/web/web_server.py b/bot/app/web/web_server.py index 82f440a..9ca2aeb 100644 --- a/bot/app/web/web_server.py +++ b/bot/app/web/web_server.py @@ -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() diff --git a/bot/app/web/webapp_auth.py b/bot/app/web/webapp_auth.py index d7a80c6..20f64db 100644 --- a/bot/app/web/webapp_auth.py +++ b/bot/app/web/webapp_auth.py @@ -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) diff --git a/bot/handlers/user/start.py b/bot/handlers/user/start.py index 6c7a260..927925e 100644 --- a/bot/handlers/user/start.py +++ b/bot/handlers/user/start.py @@ -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 ) diff --git a/bot/main_bot.py b/bot/main_bot.py index b236e53..192c1ad 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -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}" diff --git a/scripts/update_telegram_web_app_js.py b/scripts/update_telegram_web_app_js.py new file mode 100644 index 0000000..d984e7f --- /dev/null +++ b/scripts/update_telegram_web_app_js.py @@ -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()) diff --git a/scripts/update_telegram_widget_js.py b/scripts/update_telegram_widget_js.py new file mode 100644 index 0000000..1e6b85c --- /dev/null +++ b/scripts/update_telegram_widget_js.py @@ -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())