// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in // index.html) 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'; interface TelegramWebApp { initData: string; initDataUnsafe?: { start_param?: string }; themeParams?: TelegramThemeParams; colorScheme?: 'light' | 'dark'; isFullscreen?: boolean; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; ready?: () => void; expand?: () => void; onEvent?: (event: string, handler: () => void) => void; setHeaderColor?: (color: string) => void; setBackgroundColor?: (color: string) => void; setBottomBarColor?: (color: string) => void; disableVerticalSwipes?: () => void; enableClosingConfirmation?: () => void; disableClosingConfirmation?: () => void; HapticFeedback?: { impactOccurred?: (style: string) => void; notificationOccurred?: (type: string) => void; selectionChanged?: () => void; }; BackButton?: { show?: () => void; hide?: () => void; onClick?: (cb: () => void) => void; offClick?: (cb: () => 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; } /** 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; } /** * 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; } /** * 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?.(); } /** 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); } /** * telegramClosingConfirmation toggles the confirmation Telegram shows when the user * swipes the Mini App closed — enabled during an active game so it is not lost by accident. */ export function telegramClosingConfirmation(on: boolean): void { const w = webApp(); if (on) w?.enableClosingConfirmation?.(); else w?.disableClosingConfirmation?.(); } let backHandler: (() => void) | null = null; /** * telegramBackButton shows or hides Telegram's native header back button, wiring its * click to onClick (replacing any previous handler). The app hides its own back chevron * inside Telegram so only the native control shows. */ export function telegramBackButton(show: boolean, onClick?: () => void): void { const b = webApp()?.BackButton; if (!b) return; if (backHandler) b.offClick?.(backHandler); backHandler = null; if (show) { if (onClick) { backHandler = onClick; b.onClick?.(onClick); } b.show?.(); } else { b.hide?.(); } } /** * 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/'); } // --- Login Widget (web sign-in for account linking, Stage 11) --- // 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 | null = null; function loadWidget(): Promise { 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((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 { 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((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(); }