6e03ce0131
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s
Accept real money via Telegram Stars (XTR) — the third intake rail alongside Robokassa (direct) and VK Votes. Only the bot reaches Telegram, so the rail funnels through the reverse mTLS bot-link: - the gateway mints the invoice on a CreateInvoice command (the bot calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice); - the bot gates each pre_checkout_query via a ValidatePreCheckout unary (the order must exist, be still creditable and not already paid — the reusable-invoice double-pay guard; the decline reason is localised to the order account's language); - a completed successful_payment is queued in a durable pure-Go SQLite outbox and forwarded via a ForwardPayment unary, credited once (idempotent on telegram_payment_charge_id, honours an expired order), re-driven on restart and every 30s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays inert until a chip pack carries an XTR price, so seeding a Stars price in the admin is the go-live. Tests: backend integration (order->forward->credit once, duplicate, pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) + executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the platform/telegram README, PLAN.
706 lines
30 KiB
TypeScript
706 lines
30 KiB
TypeScript
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded dynamically with a
|
|
// timeout by loadTelegramSDK — not a render-blocking <script> in index.html, so a network that
|
|
// blocks telegram.org cannot hang the page) exposes window.Telegram.WebApp; this wraps the subset
|
|
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
|
|
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
|
|
|
|
import type { TelegramThemeParams } from './theme';
|
|
|
|
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
|
|
export interface TelegramPopupButton {
|
|
id?: string;
|
|
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
|
text?: string;
|
|
}
|
|
|
|
/** TelegramPopupParams configures a native showPopup (title optional, message required, up to 3 buttons). */
|
|
export interface TelegramPopupParams {
|
|
title?: string;
|
|
message: string;
|
|
buttons?: TelegramPopupButton[];
|
|
}
|
|
|
|
interface TelegramWebApp {
|
|
initData: string;
|
|
initDataUnsafe?: { start_param?: string };
|
|
platform?: string;
|
|
version?: string;
|
|
themeParams?: TelegramThemeParams;
|
|
colorScheme?: 'light' | 'dark';
|
|
isFullscreen?: boolean;
|
|
isExpanded?: boolean;
|
|
viewportHeight?: number;
|
|
viewportStableHeight?: number;
|
|
exitFullscreen?: () => void;
|
|
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
|
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
|
ready?: () => void;
|
|
expand?: () => void;
|
|
close?: () => void;
|
|
openTelegramLink?: (url: string) => void;
|
|
openLink?: (url: string) => void;
|
|
openInvoice?: (url: string, callback?: (status: string) => void) => void;
|
|
onEvent?: (event: string, handler: () => void) => void;
|
|
setHeaderColor?: (color: string) => void;
|
|
setBackgroundColor?: (color: string) => void;
|
|
setBottomBarColor?: (color: string) => void;
|
|
disableVerticalSwipes?: () => void;
|
|
HapticFeedback?: {
|
|
impactOccurred?: (style: string) => void;
|
|
notificationOccurred?: (type: string) => void;
|
|
selectionChanged?: () => void;
|
|
};
|
|
BackButton?: {
|
|
isVisible?: boolean;
|
|
show?: () => void;
|
|
hide?: () => void;
|
|
onClick?: (cb: () => void) => void;
|
|
offClick?: (cb: () => void) => void;
|
|
};
|
|
SettingsButton?: {
|
|
show?: () => void;
|
|
hide?: () => void;
|
|
onClick?: (cb: () => void) => void;
|
|
offClick?: (cb: () => void) => void;
|
|
};
|
|
CloudStorage?: {
|
|
getItem?: (key: string, cb: (err: string | null, value?: string) => void) => void;
|
|
setItem?: (key: string, value: string, cb?: (err: string | null, ok?: boolean) => void) => void;
|
|
};
|
|
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
|
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
|
downloadFile?: (params: { url: string; file_name: string }, cb?: (accepted: boolean) => void) => void;
|
|
}
|
|
|
|
function webApp(): TelegramWebApp | undefined {
|
|
if (typeof window === 'undefined') return undefined;
|
|
return (window as unknown as { Telegram?: { WebApp?: TelegramWebApp } }).Telegram?.WebApp;
|
|
}
|
|
|
|
/**
|
|
* insideTelegram reports whether the app launched as a Telegram Mini App — the SDK
|
|
* is present and carries non-empty initData (an ordinary browser tab has neither).
|
|
*/
|
|
export function insideTelegram(): boolean {
|
|
const w = webApp();
|
|
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
|
|
}
|
|
|
|
/** telegramClose closes the Mini App (Telegram.WebApp.close), used on the terminal
|
|
* account-deleted screen. A no-op outside Telegram. */
|
|
export function telegramClose(): void {
|
|
webApp()?.close?.();
|
|
}
|
|
|
|
// The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the
|
|
// version the official Mini Apps page currently recommends so newer client features (fullscreen,
|
|
// safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs.
|
|
const sdkScriptSrc = 'https://telegram.org/js/telegram-web-app.js?62';
|
|
|
|
/**
|
|
* TelegramSdkOutcome records how the dynamic telegram-web-app.js load resolved, surfaced on the
|
|
* launch-error screen to tell the failure modes apart — notably 'error' / 'timeout', which mean the
|
|
* network could not reach telegram.org (the script blocked or hung):
|
|
*
|
|
* not-attempted — loadTelegramSDK was never called (a plain web / native entry)
|
|
* present — window.Telegram.WebApp was already there (a cached load or native injection)
|
|
* loaded — the script loaded and defined window.Telegram.WebApp
|
|
* no-webapp — the script loaded (HTTP 200) but did not define window.Telegram.WebApp
|
|
* error — the script failed to load (telegram.org unreachable / blocked, a fast failure)
|
|
* timeout — the script neither loaded nor failed within the timeout (a blocked, hanging fetch)
|
|
*/
|
|
export type TelegramSdkOutcome = 'not-attempted' | 'present' | 'loaded' | 'no-webapp' | 'error' | 'timeout';
|
|
|
|
let sdkLoadOutcome: TelegramSdkOutcome = 'not-attempted';
|
|
|
|
/** telegramSdkOutcome returns how the last loadTelegramSDK attempt resolved (see TelegramSdkOutcome). */
|
|
export function telegramSdkOutcome(): TelegramSdkOutcome {
|
|
return sdkLoadOutcome;
|
|
}
|
|
|
|
/**
|
|
* loadTelegramSDK injects the official telegram-web-app.js and resolves true once
|
|
* window.Telegram.WebApp is available, or false if the script errors or does not load within
|
|
* timeoutMs. It is loaded dynamically — not a render-blocking <script> in index.html — so a network
|
|
* that blocks telegram.org (common where Telegram itself reaches users only over a proxy) cannot
|
|
* hang the page and strand the app or the launch-error screen. Resolves true immediately when the
|
|
* SDK is already present (a cached load, or a future native injection); a fast connection failure
|
|
* resolves via the error event without waiting out the timeout, so the timeout only bounds a true
|
|
* hang.
|
|
*/
|
|
export function loadTelegramSDK(timeoutMs: number): Promise<boolean> {
|
|
if (typeof document === 'undefined') return Promise.resolve(false);
|
|
if (webApp()) {
|
|
sdkLoadOutcome = 'present';
|
|
return Promise.resolve(true);
|
|
}
|
|
return new Promise<boolean>((resolve) => {
|
|
let done = false;
|
|
const finish = (outcome: TelegramSdkOutcome): void => {
|
|
if (done) return;
|
|
done = true;
|
|
sdkLoadOutcome = outcome;
|
|
resolve(outcome === 'loaded');
|
|
};
|
|
const timer = setTimeout(() => finish(webApp() ? 'loaded' : 'timeout'), timeoutMs);
|
|
const s = document.createElement('script');
|
|
s.src = sdkScriptSrc;
|
|
s.async = true;
|
|
s.onload = () => {
|
|
clearTimeout(timer);
|
|
finish(webApp() ? 'loaded' : 'no-webapp');
|
|
};
|
|
s.onerror = () => {
|
|
clearTimeout(timer);
|
|
finish('error');
|
|
};
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
|
|
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
|
|
* outside Telegram or when the SDK lacks the method, so the caller can fall back to a plain
|
|
* window.open.
|
|
*/
|
|
export function telegramOpenLink(url: string): boolean {
|
|
const w = webApp();
|
|
if (!w?.openTelegramLink) return false;
|
|
w.openTelegramLink(url);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* telegramOpenInvoice opens a Telegram Stars invoice inside the Mini App (WebApp.openInvoice) and
|
|
* calls onStatus with the outcome ('paid' | 'cancelled' | 'failed' | 'pending'). Returns false
|
|
* outside Telegram or when the SDK lacks the method. The invoice url is the createInvoiceLink the
|
|
* bot minted for the order; the chips are credited server-side by the forwarded payment, so 'paid'
|
|
* only signals to refresh the wallet — the live push is the authoritative update.
|
|
*/
|
|
export function telegramOpenInvoice(url: string, onStatus?: (status: string) => void): boolean {
|
|
const w = webApp();
|
|
if (!w?.openInvoice) return false;
|
|
w.openInvoice(url, onStatus);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink,
|
|
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
|
|
* this link?" confirmation that a plain target=_blank navigation triggers. Returns false
|
|
* outside Telegram or when the SDK lacks the method, so the caller can fall back to a normal
|
|
* anchor.
|
|
*/
|
|
export function telegramOpenExternalLink(url: string): boolean {
|
|
const w = webApp();
|
|
if (!w?.openLink) return false;
|
|
w.openLink(url);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* isExternalHttpUrl reports whether href is an absolute http(s) URL on a different origin than the
|
|
* app — so a same-origin or root-relative in-app (SPA) link is left to normal navigation, only a
|
|
* genuinely external link is routed through the host platform's browser (the Telegram router here
|
|
* and the VK router in vk.ts share it).
|
|
*/
|
|
export function isExternalHttpUrl(href: string): boolean {
|
|
try {
|
|
const u = new URL(href, typeof location === 'undefined' ? undefined : location.href);
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
|
|
return typeof location === 'undefined' || u.origin !== location.origin;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* routeExternalLinkInTelegram decides whether a clicked anchor should be opened through the Mini
|
|
* App SDK rather than the WebView's default navigation: only inside Telegram, and only for an
|
|
* external http(s) link opened in a new tab (target=_blank) that is not a t.me link (those use
|
|
* telegramOpenLink/openTelegramLink). Returns true when it opened the link through openLink — the
|
|
* caller should then preventDefault — and false to let the browser handle the click normally.
|
|
*/
|
|
export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean {
|
|
if (!insideTelegram()) return false;
|
|
if (anchor.target !== '_blank') return false;
|
|
if (anchor.href.startsWith('https://t.me/')) return false;
|
|
if (!isExternalHttpUrl(anchor.href)) return false;
|
|
return telegramOpenExternalLink(anchor.href);
|
|
}
|
|
|
|
/**
|
|
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
|
|
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
|
|
* Telegram or when the SDK lacks the method, so the caller can fall back to the Web
|
|
* Share API. Works consistently on iOS and Android within Telegram.
|
|
*/
|
|
export function shareTelegramLink(url: string, text: string): boolean {
|
|
return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
|
|
}
|
|
|
|
/** TelegramLaunch is the data a Mini App launch carries. */
|
|
export interface TelegramLaunch {
|
|
initData: string;
|
|
startParam: string;
|
|
theme: TelegramThemeParams | undefined;
|
|
}
|
|
|
|
/**
|
|
* telegramLaunch readies the Mini App (full-height, ready signal) and returns its
|
|
* launch data: the raw initData (for auth.telegram), the deep-link start parameter
|
|
* (from the SDK or, for a bot web_app button, the page URL), and the theme params.
|
|
*/
|
|
export function telegramLaunch(): TelegramLaunch {
|
|
const w = webApp();
|
|
if (!w) return { initData: '', startParam: startParamFromURL(), theme: undefined };
|
|
w.ready?.();
|
|
w.expand?.();
|
|
const startParam = w.initDataUnsafe?.start_param ?? startParamFromURL();
|
|
return { initData: w.initData, startParam, theme: w.themeParams };
|
|
}
|
|
|
|
/**
|
|
* telegramOnEvent subscribes to a Telegram WebApp lifecycle event (e.g. 'activated' /
|
|
* 'deactivated', added in Bot API 8.0). It is a no-op outside Telegram or on a client
|
|
* that predates the event, so callers can register defensively.
|
|
*/
|
|
export function telegramOnEvent(event: string, handler: () => void): void {
|
|
webApp()?.onEvent?.(event, handler);
|
|
}
|
|
|
|
/**
|
|
* telegramColorScheme returns Telegram's active colour scheme ('light' | 'dark'),
|
|
* or undefined outside Telegram. Inside the Mini App this — not the OS
|
|
* prefers-color-scheme — is the authoritative theme: on some clients (Telegram
|
|
* Desktop) the OS scheme leaks into the webview and fights Telegram's own setting,
|
|
* so the app forces this value on launch.
|
|
*/
|
|
export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
|
return webApp()?.colorScheme;
|
|
}
|
|
|
|
/**
|
|
* telegramThemeParams returns Telegram's current theme palette (WebApp.themeParams), or undefined
|
|
* outside Telegram. It reads the live value rather than a launch snapshot, so the themeChanged
|
|
* event can re-apply the palette when the user switches Telegram's light/dark theme mid-session.
|
|
*/
|
|
export function telegramThemeParams(): TelegramThemeParams | undefined {
|
|
return webApp()?.themeParams;
|
|
}
|
|
|
|
/**
|
|
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
|
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
|
* outside Telegram or on a client predating a given setter.
|
|
*/
|
|
export function telegramSetChrome(header: string, background: string, bottom: string): void {
|
|
const w = webApp();
|
|
if (header) w?.setHeaderColor?.(header);
|
|
if (background) w?.setBackgroundColor?.(background);
|
|
if (bottom) w?.setBottomBarColor?.(bottom);
|
|
}
|
|
|
|
/**
|
|
* telegramContentSafeAreaTop returns the height (px) Telegram's own UI overlays at the top of
|
|
* the viewport in fullscreen (its nav band; the content-safe area, Bot API 8.0). It is 0
|
|
* outside Telegram or on clients predating it, so callers can pad/position defensively.
|
|
*/
|
|
export function telegramContentSafeAreaTop(): number {
|
|
return webApp()?.contentSafeAreaInset?.top ?? 0;
|
|
}
|
|
|
|
/**
|
|
* telegramSafeAreaInset returns the device safe-area insets (px) — the notch / status bar (top),
|
|
* the home indicator (bottom) and, in landscape, the notch sides (left / right) — from the SDK's
|
|
* safeAreaInset (Bot API 8.0). All 0 outside Telegram or on a client predating it, so callers can
|
|
* pad defensively. Telegram's own nav controls sit in the band between the top inset and
|
|
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them.
|
|
*/
|
|
export function telegramSafeAreaInset(): { top: number; bottom: number; left: number; right: number } {
|
|
const i = webApp()?.safeAreaInset;
|
|
return { top: i?.top ?? 0, bottom: i?.bottom ?? 0, left: i?.left ?? 0, right: i?.right ?? 0 };
|
|
}
|
|
|
|
/**
|
|
* telegramDisableVerticalSwipes turns off Telegram's swipe-down-to-minimise gesture so
|
|
* it does not fight tile drag-and-drop or the board's vertical scroll.
|
|
*/
|
|
export function telegramDisableVerticalSwipes(): void {
|
|
webApp()?.disableVerticalSwipes?.();
|
|
}
|
|
|
|
/**
|
|
* telegramShowSettingsButton reveals Telegram's native Settings button (in the Mini App's ⋮ menu,
|
|
* Bot API 7.0) and routes its taps to handler. A no-op outside Telegram or on a client predating
|
|
* the button, so the app's own in-app settings entry stays the primary path. The app registers it
|
|
* once per launch (Telegram hides the button when the Mini App closes), so there is no offClick.
|
|
*/
|
|
export function telegramShowSettingsButton(handler: () => void): void {
|
|
const b = webApp()?.SettingsButton;
|
|
if (!b?.show) return;
|
|
b.onClick?.(handler);
|
|
b.show();
|
|
}
|
|
|
|
/** telegramCloudAvailable reports whether Telegram CloudStorage (Bot API 6.9) is usable. */
|
|
export function telegramCloudAvailable(): boolean {
|
|
return !!webApp()?.CloudStorage?.getItem;
|
|
}
|
|
|
|
/**
|
|
* telegramCloudGet reads a value from Telegram CloudStorage, resolving null when the key is absent,
|
|
* CloudStorage is unavailable (outside Telegram / a client predating Bot API 6.9), or the read
|
|
* errors — so the caller can fall back to the local value.
|
|
*/
|
|
export function telegramCloudGet(key: string): Promise<string | null> {
|
|
const cs = webApp()?.CloudStorage;
|
|
if (!cs?.getItem) return Promise.resolve(null);
|
|
return new Promise((resolve) => {
|
|
cs.getItem!(key, (err, value) => resolve(err ? null : (value ?? null)));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* telegramCloudSet writes a value to Telegram CloudStorage, resolving once the write settles. It is
|
|
* best-effort: a no-op outside Telegram / on an older client, and it swallows write errors, since
|
|
* the local store remains the source of truth.
|
|
*/
|
|
export function telegramCloudSet(key: string, value: string): Promise<void> {
|
|
const cs = webApp()?.CloudStorage;
|
|
if (!cs?.setItem) return Promise.resolve();
|
|
return new Promise((resolve) => {
|
|
cs.setItem!(key, value, () => resolve());
|
|
});
|
|
}
|
|
|
|
/** telegramDialogsAvailable reports whether Telegram's native dialogs (showConfirm / showPopup, Bot
|
|
* API 6.2) are usable, so a caller can choose the native path over its own modal. */
|
|
export function telegramDialogsAvailable(): boolean {
|
|
return !!webApp()?.showPopup;
|
|
}
|
|
|
|
/**
|
|
* telegramShowConfirm shows Telegram's native confirm dialog and resolves true when the user
|
|
* accepts. Resolves false outside Telegram or on a client predating the dialog, so callers should
|
|
* gate on telegramDialogsAvailable and fall back to their own modal otherwise.
|
|
*/
|
|
export function telegramShowConfirm(message: string): Promise<boolean> {
|
|
const w = webApp();
|
|
if (!w?.showConfirm) return Promise.resolve(false);
|
|
return new Promise((resolve) => w.showConfirm!(message, (ok) => resolve(!!ok)));
|
|
}
|
|
|
|
/**
|
|
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
|
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
|
* predating the popup, so callers can fall back to their own modal. NOTE: the callback runs with
|
|
* no user activation — never lead from it into navigator.share or a clipboard write.
|
|
*/
|
|
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
|
const w = webApp();
|
|
if (!w?.showPopup) return Promise.resolve(null);
|
|
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
|
}
|
|
|
|
/** telegramCanDownloadFile reports whether the native download dialog exists (Bot API 8.0). */
|
|
export function telegramCanDownloadFile(): boolean {
|
|
return !!webApp()?.downloadFile;
|
|
}
|
|
|
|
/** telegramPlatform returns the SDK's platform tag ('ios', 'android', 'tdesktop', …) or ''
|
|
* outside Telegram — the export delivery branches on it (iOS gets the OS share sheet). */
|
|
export function telegramPlatform(): string {
|
|
return webApp()?.platform ?? '';
|
|
}
|
|
|
|
/**
|
|
* telegramDownloadFile asks Telegram to download url as fileName through its native
|
|
* dialog — the Mini App file delivery that works on every Telegram platform (a webview
|
|
* <a download> does not). Returns false outside Telegram or on a client predating it.
|
|
*/
|
|
export function telegramDownloadFile(url: string, fileName: string): boolean {
|
|
const w = webApp();
|
|
if (!w?.downloadFile) return false;
|
|
w.downloadFile({ url, file_name: fileName });
|
|
return true;
|
|
}
|
|
|
|
/** Haptic is the set of feedbacks the app triggers. */
|
|
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
|
|
|
/** telegramHaptic fires a Telegram haptic; a no-op outside Telegram or on older clients. */
|
|
export function telegramHaptic(kind: Haptic): void {
|
|
const h = webApp()?.HapticFeedback;
|
|
if (!h) return;
|
|
if (kind === 'select') h.selectionChanged?.();
|
|
else if (kind === 'success' || kind === 'error' || kind === 'warning') h.notificationOccurred?.(kind);
|
|
else h.impactOccurred?.(kind);
|
|
}
|
|
|
|
/**
|
|
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
|
* launch button carries the deep-link there rather than in initDataUnsafe.
|
|
*/
|
|
function startParamFromURL(): string {
|
|
if (typeof location === 'undefined') return '';
|
|
return new URLSearchParams(location.search).get('startapp') ?? '';
|
|
}
|
|
|
|
/**
|
|
* onTelegramPath reports whether the app is served under the dedicated Telegram
|
|
* entry path (/telegram/); outside Telegram on that path the app refuses to render.
|
|
*/
|
|
export function onTelegramPath(): boolean {
|
|
if (typeof location === 'undefined') return false;
|
|
return location.pathname.startsWith('/telegram/');
|
|
}
|
|
|
|
/** hasLaunchFragment reports whether the URL fragment carries Telegram launch params (tgWebApp…),
|
|
* the form Telegram appends when opening a Mini App — so the SDK is loaded for a Mini App opened
|
|
* at the site root too, not only the /telegram/ path. */
|
|
export function hasLaunchFragment(): boolean {
|
|
if (typeof location === 'undefined') return false;
|
|
return location.hash.includes('tgWebApp');
|
|
}
|
|
|
|
// --- Launch diagnostics (the /telegram/ entry without sign-in data) ---
|
|
|
|
/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal
|
|
* the launch-error screen reports. Only field names are ever inspected, never their values. */
|
|
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
|
|
|
|
interface uaBrand {
|
|
brand: string;
|
|
version: string;
|
|
}
|
|
|
|
interface uaDataValue {
|
|
platform?: string;
|
|
mobile?: boolean;
|
|
brands?: uaBrand[];
|
|
}
|
|
|
|
/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android Telegram
|
|
* webview), or undefined where it is unavailable (iOS / Safari / Firefox). */
|
|
function uaData(): uaDataValue | undefined {
|
|
if (typeof navigator === 'undefined') return undefined;
|
|
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
|
|
}
|
|
|
|
/** launchFragmentData returns the raw tgWebAppData carried in the URL fragment (the form Telegram
|
|
* appends on launch), or '' when absent. With no SDK present a non-empty value means Telegram
|
|
* delivered the data but telegram-web-app.js never ran to parse it. */
|
|
function launchFragmentData(): string {
|
|
if (typeof location === 'undefined') return '';
|
|
const frag = location.hash.replace(/^#/, '');
|
|
if (!frag) return '';
|
|
try {
|
|
return new URLSearchParams(frag).get('tgWebAppData') ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
/** initDataFieldNames parses a Telegram initData (or raw fragment data) query string and returns
|
|
* only its field NAMES — never the values, since the hash / signature are auth material. */
|
|
function initDataFieldNames(raw: string): string[] {
|
|
if (!raw) return [];
|
|
try {
|
|
return [...new URLSearchParams(raw).keys()];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* TelegramDiag is a privacy-safe snapshot of why a Mini App launch lacked sign-in data, taken at
|
|
* the moment of failure and rendered on the launch-error screen so a stuck user can share it with
|
|
* the developer. It carries no secret values — only presence flags, client / OS identification and
|
|
* the field NAMES of the launch data, never the signed initData itself, and never an IP.
|
|
*/
|
|
export interface TelegramDiag {
|
|
/** Whether window.Telegram (the telegram-web-app.js script) is present at all. */
|
|
hasSDK: boolean;
|
|
/** Whether window.Telegram.WebApp is present. */
|
|
hasWebApp: boolean;
|
|
/** How the dynamic telegram-web-app.js load resolved (see TelegramSdkOutcome) — 'error' /
|
|
* 'timeout' mean telegram.org was unreachable, the prime suspect for an empty launch. */
|
|
sdkLoad: TelegramSdkOutcome;
|
|
/** Telegram's own platform string (ios | android | android_x | tdesktop | web | …), or ''. */
|
|
platform: string;
|
|
/** The Bot API version the client reports, or ''. */
|
|
version: string;
|
|
/** The length of WebApp.initData — 0 is the failure this screen reports. */
|
|
initDataLen: number;
|
|
/** Whether the URL fragment still carried tgWebAppData at launch; true with hasSDK false means
|
|
* Telegram delivered the data but the SDK script did not load to parse it. */
|
|
hashHadTgData: boolean;
|
|
/** The field names present in the launch data (from initData, or the raw fragment when the SDK
|
|
* left initData empty); values are never included. */
|
|
fieldsPresent: string[];
|
|
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
|
|
fieldsMissing: string[];
|
|
/** The OS / platform per User-Agent Client Hints (else navigator.platform), e.g. 'Android', ''. */
|
|
osPlatform: string;
|
|
/** Whether the client reports itself mobile per Client Hints: 'yes' | 'no' | '' (unknown). */
|
|
mobile: string;
|
|
/** The browser brands + major versions per Client Hints (Chromium only), or ''. */
|
|
browser: string;
|
|
/** The full User-Agent string — the catch-all that also carries the OS version and webview build. */
|
|
userAgent: string;
|
|
}
|
|
|
|
/**
|
|
* collectTelegramDiag captures a TelegramDiag snapshot of the current launch state. Call it at the
|
|
* point a /telegram/ launch is found to lack sign-in data, so the snapshot reflects that moment —
|
|
* sign-in data arriving late would otherwise mask the failure.
|
|
*/
|
|
export function collectTelegramDiag(): TelegramDiag {
|
|
const w = webApp();
|
|
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
|
|
const initData = w?.initData ?? '';
|
|
const fragData = initData ? '' : launchFragmentData();
|
|
const present = initDataFieldNames(initData || fragData);
|
|
const ua = uaData();
|
|
const navPlatform =
|
|
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
|
|
return {
|
|
hasSDK: sdk,
|
|
hasWebApp: !!w,
|
|
sdkLoad: sdkLoadOutcome,
|
|
platform: w?.platform ?? '',
|
|
version: w?.version ?? '',
|
|
initDataLen: initData.length,
|
|
hashHadTgData: fragData.length > 0,
|
|
fieldsPresent: present,
|
|
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
|
|
osPlatform: ua?.platform ?? navPlatform,
|
|
mobile: ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no',
|
|
browser: (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', '),
|
|
userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
|
|
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
|
|
* back-button state, UA). It feeds the hidden debug panel (components/DebugPanel), opened by tapping
|
|
* the header title ten times. No secrets: no initData values, no IP.
|
|
*/
|
|
export function telegramChromeDiag(): string {
|
|
const w = webApp();
|
|
if (!w) return 'no telegram';
|
|
const win = typeof window === 'undefined' ? undefined : window;
|
|
const scr = typeof screen === 'undefined' ? undefined : screen;
|
|
const vv = win?.visualViewport ?? undefined;
|
|
const bar = typeof document === 'undefined' ? null : (document.querySelector('.bar')?.getBoundingClientRect() ?? null);
|
|
const sa = w.safeAreaInset;
|
|
const csa = w.contentSafeAreaInset;
|
|
const n = (v: number | undefined): string => (v === undefined ? '—' : String(Math.round(v)));
|
|
return [
|
|
`platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`,
|
|
`isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`,
|
|
`inTG: ${insideTelegram()} sdkLoad: ${sdkLoadOutcome} backPresent: ${!!w.BackButton} backVisible: ${w.BackButton?.isVisible}`,
|
|
`innerH: ${n(win?.innerHeight)} outerH: ${n(win?.outerHeight)} screenH: ${n(scr?.height)} availH: ${n(scr?.availHeight)}`,
|
|
`screenY: ${n(win?.screenY)} vv.offTop: ${n(vv?.offsetTop)} vv.h: ${n(vv?.height)}`,
|
|
`tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`,
|
|
`safeArea T/B: ${n(sa?.top)}/${n(sa?.bottom)} contentSafe T/B: ${n(csa?.top)}/${n(csa?.bottom)}`,
|
|
`appHeader top/h: ${bar ? Math.round(bar.top) : '—'}/${bar ? Math.round(bar.height) : '—'}`,
|
|
].join('\n');
|
|
}
|
|
|
|
// --- Login Widget (web sign-in for account linking) ---
|
|
|
|
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
|
|
// attach a Telegram identity to an existing account from a browser; inside the Mini
|
|
// App the session is already a Telegram identity. It needs the bot id (numeric,
|
|
// VITE_TELEGRAM_BOT_ID) and, in production, the site domain registered with BotFather
|
|
// (/setdomain) — without that Telegram refuses to render. The connector validates the
|
|
// returned data (HMAC under SHA-256(bot_token)).
|
|
|
|
const widgetScriptSrc = 'https://telegram.org/js/telegram-widget.js?22';
|
|
|
|
interface telegramAuthUser {
|
|
id: number;
|
|
first_name?: string;
|
|
last_name?: string;
|
|
username?: string;
|
|
photo_url?: string;
|
|
auth_date: number;
|
|
hash: string;
|
|
}
|
|
|
|
interface telegramLoginSDK {
|
|
auth(opts: { bot_id: string; request_access?: string }, cb: (user: telegramAuthUser | false) => void): void;
|
|
}
|
|
|
|
function isMock(): boolean {
|
|
return import.meta.env.MODE === 'mock';
|
|
}
|
|
|
|
function botID(): string {
|
|
return (import.meta.env.VITE_TELEGRAM_BOT_ID as string | undefined) ?? '';
|
|
}
|
|
|
|
/**
|
|
* loginWidgetAvailable reports whether the "Link Telegram" control should be shown:
|
|
* not already inside the Mini App, and either the mock build or a configured bot id.
|
|
*/
|
|
export function loginWidgetAvailable(): boolean {
|
|
if (insideTelegram()) return false;
|
|
return isMock() || botID() !== '';
|
|
}
|
|
|
|
let widgetLoad: Promise<void> | null = null;
|
|
|
|
function loadWidget(): Promise<void> {
|
|
if (typeof document === 'undefined') return Promise.reject(new Error('telegram: no document'));
|
|
const sdk = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login;
|
|
if (sdk) return Promise.resolve();
|
|
if (!widgetLoad) {
|
|
widgetLoad = new Promise<void>((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = widgetScriptSrc;
|
|
s.async = true;
|
|
s.onload = () => resolve();
|
|
s.onerror = () => reject(new Error('telegram: widget load failed'));
|
|
document.head.appendChild(s);
|
|
});
|
|
}
|
|
return widgetLoad;
|
|
}
|
|
|
|
/**
|
|
* requestTelegramLogin drives the Login Widget popup and resolves with the auth data
|
|
* serialized as a URL query string (id=...&auth_date=...&hash=...) — the form the
|
|
* connector validates — or null when the user cancels. In the mock build it returns
|
|
* a fixed payload without loading the real widget (telegram.org is blocked in tests).
|
|
*/
|
|
export async function requestTelegramLogin(): Promise<string | null> {
|
|
if (isMock()) {
|
|
return `id=42&first_name=Telegram&auth_date=${Math.floor(Date.now() / 1000)}&hash=mock`;
|
|
}
|
|
await loadWidget();
|
|
const login = (window as unknown as { Telegram?: { Login?: telegramLoginSDK } }).Telegram?.Login;
|
|
if (!login) throw new Error('telegram: login unavailable');
|
|
const user = await new Promise<telegramAuthUser | false>((resolve) => {
|
|
login.auth({ bot_id: botID(), request_access: 'write' }, resolve);
|
|
});
|
|
if (!user) return null;
|
|
return serializeTelegramAuth(user);
|
|
}
|
|
|
|
function serializeTelegramAuth(u: telegramAuthUser): string {
|
|
const params = new URLSearchParams();
|
|
params.set('id', String(u.id));
|
|
if (u.first_name) params.set('first_name', u.first_name);
|
|
if (u.last_name) params.set('last_name', u.last_name);
|
|
if (u.username) params.set('username', u.username);
|
|
if (u.photo_url) params.set('photo_url', u.photo_url);
|
|
params.set('auth_date', String(u.auth_date));
|
|
params.set('hash', u.hash);
|
|
return params.toString();
|
|
}
|