diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd702d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Keep Docker build contexts small (the connector builds from the repo root). +.git +**/node_modules +ui/dist +ui/test-results +ui/playwright-report diff --git a/.gitea/workflows/go-unit.yaml b/.gitea/workflows/go-unit.yaml index 051a23b..7d1a4f3 100644 --- a/.gitea/workflows/go-unit.yaml +++ b/.gitea/workflows/go-unit.yaml @@ -11,6 +11,7 @@ on: - 'backend/**' - 'gateway/**' - 'pkg/**' + - 'platform/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/go-unit.yaml' @@ -20,6 +21,7 @@ on: - 'backend/**' - 'gateway/**' - 'pkg/**' + - 'platform/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/go-unit.yaml' @@ -56,10 +58,10 @@ jobs: fi - name: vet - run: go vet ./backend/... ./pkg/... ./gateway/... + run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... - name: build - run: go build ./backend/... ./pkg/... ./gateway/... + run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... - name: test # -count=1 disables the test cache so a green run never depends on a @@ -67,4 +69,4 @@ jobs: # tests at the committed DAWGs in the sibling checkout. env: BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg - run: go test -count=1 ./backend/... ./pkg/... ./gateway/... + run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... diff --git a/CLAUDE.md b/CLAUDE.md index 10e9cf0..3676bba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,8 @@ backend/ # module scrabble/backend internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes internal/inttest/ # //go:build integration Postgres-backed tests docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md -gateway/ ui/ pkg/ platform/ # added by their stages +gateway/ ui/ pkg/ # added by their stages +platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API ``` ## Build & test @@ -121,6 +122,7 @@ go build ./backend/... # per module ('./...' from the root won't span t go vet ./backend/... gofmt -l . # must print nothing go test -count=1 ./backend/... +go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9) go run ./backend/cmd/backend # /healthz, /readyz on :8080 cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) diff --git a/PLAN.md b/PLAN.md index db14e9a..3bba161 100644 --- a/PLAN.md +++ b/PLAN.md @@ -42,7 +42,7 @@ independent (see ARCHITECTURE §9.1). | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | -| 9 | Telegram integration (bot side-service, deep-link, push) | todo | +| 9 | Telegram integration (bot side-service, deep-link, push) | **done** | | 10 | Admin & dictionary ops (complaint review, version reload) | todo | | 11 | Account linking & merge | todo | | 12 | Polish (observability, perf with evidence, deploy) | todo | @@ -605,6 +605,94 @@ Open details: deployment target/host; dashboards; load expectations. OS and can't be forced to match, and a select also avoids the iOS "clear" button that would empty a time field. +- **Stage 9** (interview + implementation): + - **Connector as its own container** (interview): the Telegram side-service is a + standalone module `platform/telegram` (binary `cmd/telegram`) holding the bot + token **only there**; gateway and backend reach it by **unauthenticated gRPC** + on the trusted internal network, and it egresses to `api.telegram.org` through a + **VPN sidecar** (`deploy/docker-compose.yml`, mirroring `../15-puzzle`). Bot + library **`github.com/go-telegram/bot`** (one new dep), **long-poll** updates. + - **initData validation moved off the gateway** (interview): the gateway's HMAC + validator was **relocated** into the connector (`internal/initdata`, now also + returning `language_code`); the gateway calls `connector.ValidateInitData` over + gRPC during `auth.telegram`. The hop is negligible (loopback gRPC, once per + login). `GATEWAY_TELEGRAM_BOT_TOKEN` is gone; `GATEWAY_CONNECTOR_ADDR` replaces + it. The `gateway/internal/auth` package was deleted. + - **Connector gRPC API** (`pkg/proto/telegram/v1`, service `Telegram`): the + generic methods are **platform-agnostic**, keyed by the identity `external_id` + (so a future VK/MAX connector reuses them); only `ValidateInitData` is + Telegram-specific. Methods: `ValidateInitData`, **`Notify`** (the out-of-app push + — renders a localized message + a Mini App deep-link button from the FlatBuffers + payload), `SendToUser` and `SendToGameChannel` (arbitrary admin messages — built + and unit-tested now, **wired to the admin surface in Stage 10**; the game channel + id lives only in connector config). + - **Push = fallback, gateway-routed, de-dup by presence** (interview): the gateway + already consumes the firehose and knows in-app presence (`push.Hub.HasSubscribers`), + so it decides in-app vs out-of-app **atomically**: for a recipient with **no live + in-app stream** it fetches a new backend `/internal/push-target` + (`{external_id, language, notifications_in_app_only}`) and calls `connector.Notify` + only when they have a Telegram identity and have **not** set the new flag. Push + set: `your_turn`, `nudge`, `match_found`, and the `notify` sub-kinds `invitation`/ + `friend_request` (the connector skips the rest). Delivery runs in a goroutine so a + slow connector never stalls the firehose; best-effort (no cursor resume — single + instance, §10). + - **Profile flag `notifications_in_app_only`** (interview, **default true** → push + is **opt-in**): migration `00007` (+ jetgen), threaded through + `account.Profile`/`UpdateProfile`, the REST DTOs, the fbs `Profile`/ + `UpdateProfileRequest` (default `true` in the schema so an unset field reads + conservatively), and a Profile-screen toggle. Flagged at review: the channel is + silent until a user turns it off. + - **Language seeding from the platform** (discharges the Stage 8 forward-note): + `account.ProvisionTelegram` seeds a **brand-new** account's `preferred_language` + from the Telegram `language_code` and its display name from `first_name`/ + `username` (existing accounts untouched); the UI's `adoptSession` already adopts + the server language when the user has not locked a locale, so no extra UI seeding + was needed. The gateway forwards the fields from `ValidateInitData`. + - **Mini App = `/telegram/` + guard** (interview): the gateway serves the one SPA + build under `/telegram/` (Vite **relative base**; the hash router is + path-agnostic). The UI detects a Telegram launch by `Telegram.WebApp.initData`, + applies `themeParams`, authenticates via the existing `auth.telegram` op (UI + `authTelegram` codec/client/transport/mock added), and routes the deep-link + `start_param` (`g`/`i`/`f` → game / lobby-invitation / friend-code redeem). On the + `/telegram/` path **without** initData it redirects to the site root. The official + `telegram-web-app.js` loads from `index.html` (harmless outside Telegram). + - **Deep-link scheme** (shared Go `platform/telegram/internal/deeplink` ↔ TS + `ui/src/lib/deeplink.ts`): `g` / `i` / `f<6-digit + code>` / empty = lobby. A friend-code **share-to-Telegram** link is shown when + `VITE_TELEGRAM_LINK` is configured (**partially discharges TODO-5**; QR still + open). The `Notify` button and the bot `/start` reply both wrap the payload as + `?startapp=`. + - **Test environment** (interview nuance): the Bot API base is overridable for + Telegram's test environment — `TELEGRAM_TEST_ENV=true` suffixes the token with + `/test` so the client hits `/bot/test/METHOD` (`TELEGRAM_API_BASE_URL` + overrides the host for a mock/self-hosted server). + - **Deploy groundwork** (interview): `platform/telegram/Dockerfile` (builds the + connector standalone — drops backend/gateway and the solver replace from a copy + of `go.work`, validated with `docker build`) + the connector-scoped compose with + the VPN sidecar; a root `.dockerignore`. **No public ingress** for the connector + (long-poll + sidecar egress); the host reverse proxy routes only to the gateway + port, which serves the Mini App. The full multi-service deploy is **Stage 12**. + - **Wire/codegen/CI**: new proto `pkg/proto/telegram/v1` (committed Go); fbs + `Profile`/`UpdateProfileRequest` gained `notifications_in_app_only` (committed Go + + TS). `go.work` gains `use ./platform/telegram`; deps via `go mod edit` + + `go work sync` (no-tidy). `go-unit.yaml` gained the `platform/**` path filter and + builds/vets/tests `./platform/telegram/...`. UI grows to ~86 KB gzip JS (budget + 100 KB). The connector's unit tests use an httptest fake Bot API; a Playwright + smoke drives the Mini App launch + guard with an injected `window.Telegram`. + - **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/ + `SendToGameChannel` (backend gains its own connector client) for operator + broadcasts to a user and the game channel. + - **Verification-time fixes** (caught by the CI gate): (1) the gateway transcode + dropped `notifications_in_app_only` in four places (`ProfileResp`, `encodeProfile`, + `profileUpdateHandler`, the `UpdateProfile` body) so the toggle never reached the + backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made + **hermetic** (a shared `ui/e2e/fixtures.ts` blocks the real `telegram-web-app.js`): + the render-blocking CDN ` { setLocale(guess); } + // Telegram Mini App launch: apply the platform theme, authenticate via initData, + // and route any deep-link start parameter. On the dedicated /telegram/ entry path + // outside Telegram (no initData), refuse to render and send the visitor to the + // site root. + if (onTelegramPath() && !insideTelegram()) { + if (typeof location !== 'undefined') location.replace('/'); + return; + } + if (insideTelegram()) { + const launch = telegramLaunch(); + if (launch.theme) applyTelegramTheme(launch.theme); + try { + await adoptSession(await gateway.authTelegram(launch.initData)); + await routeStartParam(launch.startParam); + } catch (err) { + handleError(err); + navigate('/login'); + } + app.ready = true; + return; + } + const saved = await loadSession(); if (saved) { await adoptSession(saved); @@ -154,6 +178,32 @@ export async function bootstrap(): Promise { app.ready = true; } +/** + * routeStartParam navigates a Telegram deep-link start parameter to its target: a + * specific game, the friends screen with a friend-code redemption, or the lobby + * (where invitations surface as a badge). + */ +async function routeStartParam(param: string): Promise { + const link = parseStartParam(param); + switch (link.kind) { + case 'game': + navigate(`/game/${link.id}`); + return; + case 'friendCode': + navigate('/friends'); + try { + const friend = await gateway.friendCodeRedeem(link.code); + showToast(t('friends.added', { name: friend.displayName })); + void refreshNotifications(); + } catch (err) { + handleError(err); + } + return; + default: + navigate('/'); + } +} + export async function loginGuest(): Promise { try { const s = await gateway.authGuest(app.locale); @@ -233,6 +283,7 @@ async function persistLanguageToServer(locale: Locale): Promise { awayEnd: p.awayEnd, blockChat: p.blockChat, blockFriendRequests: p.blockFriendRequests, + notificationsInAppOnly: p.notificationsInAppOnly, }); } catch { // The client locale already changed; the server sync is best-effort. diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 318ec51..51802db 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -52,6 +52,7 @@ export type Unsubscribe = () => void; export interface GatewayClient { // --- auth (unauthenticated) --- + authTelegram(initData: string): Promise; authGuest(locale?: string): Promise; authEmailRequest(email: string): Promise; authEmailLogin(email: string, code: string): Promise; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 5148151..330dc2c 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -146,6 +146,14 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array { return finish(b, fb.ChatPostRequest.endChatPostRequest(b)); } +export function encodeTelegramLogin(initData: string): Uint8Array { + const b = new Builder(512); + const d = b.createString(initData); + fb.TelegramLoginRequest.startTelegramLoginRequest(b); + fb.TelegramLoginRequest.addInitData(b, d); + return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b)); +} + export function encodeGuestLogin(locale: string): Uint8Array { const b = new Builder(64); const l = b.createString(locale); @@ -264,6 +272,7 @@ export function decodeProfile(buf: Uint8Array): Profile { blockChat: p.blockChat(), blockFriendRequests: p.blockFriendRequests(), isGuest: p.isGuest(), + notificationsInAppOnly: p.notificationsInAppOnly(), }; } @@ -444,6 +453,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array { fb.UpdateProfileRequest.addAwayEnd(b, ae); fb.UpdateProfileRequest.addBlockChat(b, p.blockChat); fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests); + fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly); return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); } diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts new file mode 100644 index 0000000..b0d2718 --- /dev/null +++ b/ui/src/lib/deeplink.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; + +describe('parseStartParam', () => { + it('classifies game / invitation / friend code', () => { + expect(parseStartParam('g7c9e6679')).toEqual({ kind: 'game', id: '7c9e6679' }); + expect(parseStartParam('iabc-123')).toEqual({ kind: 'invitation', id: 'abc-123' }); + expect(parseStartParam('f123456')).toEqual({ kind: 'friendCode', code: '123456' }); + }); + + it('falls back to the lobby for empty / unknown / value-less params', () => { + expect(parseStartParam('')).toEqual({ kind: 'lobby' }); + expect(parseStartParam(undefined)).toEqual({ kind: 'lobby' }); + expect(parseStartParam(null)).toEqual({ kind: 'lobby' }); + expect(parseStartParam('x-nope')).toEqual({ kind: 'lobby' }); + expect(parseStartParam('g')).toEqual({ kind: 'lobby' }); + }); + + it('round-trips the build helpers', () => { + expect(parseStartParam(gameParam('id1'))).toEqual({ kind: 'game', id: 'id1' }); + expect(parseStartParam(invitationParam('id2'))).toEqual({ kind: 'invitation', id: 'id2' }); + expect(parseStartParam(friendCodeParam('654321'))).toEqual({ kind: 'friendCode', code: '654321' }); + }); +}); + +describe('shareLink', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns null without a configured base', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', ''); + expect(shareLink('gx')).toBeNull(); + }); + + it('wraps a payload in a startapp link', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); + expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); + }); +}); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts new file mode 100644 index 0000000..b75ad45 --- /dev/null +++ b/ui/src/lib/deeplink.ts @@ -0,0 +1,49 @@ +// Telegram Mini App deep-link "start parameters", mirroring the connector's Go +// scheme (platform/telegram/internal/deeplink): a one-character kind prefix plus a +// value — +// g open that game +// i open that invitation +// f<6-digit code> redeem that friend code +// An empty or unrecognised parameter opens the lobby. + +export type DeepLink = + | { kind: 'lobby' } + | { kind: 'game'; id: string } + | { kind: 'invitation'; id: string } + | { kind: 'friendCode'; code: string }; + +/** parseStartParam classifies a Telegram start parameter into a routing target. */ +export function parseStartParam(param: string | undefined | null): DeepLink { + if (!param) return { kind: 'lobby' }; + const value = param.slice(1); + if (!value) return { kind: 'lobby' }; + switch (param[0]) { + case 'g': + return { kind: 'game', id: value }; + case 'i': + return { kind: 'invitation', id: value }; + case 'f': + return { kind: 'friendCode', code: value }; + default: + return { kind: 'lobby' }; + } +} + +/** gameParam builds the start parameter that opens a game. */ +export const gameParam = (id: string): string => 'g' + id; +/** invitationParam builds the start parameter that opens an invitation. */ +export const invitationParam = (id: string): string => 'i' + id; +/** friendCodeParam builds the start parameter that redeems a friend code. */ +export const friendCodeParam = (code: string): string => 'f' + code; + +/** + * shareLink wraps a deep-link start parameter in a t.me Mini App link, using the + * VITE_TELEGRAM_LINK base (e.g. https://t.me//). It returns null when the + * base is not configured, so callers can hide the share affordance. + */ +export function shareLink(param: string): string | null { + const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined; + if (!base) return null; + const sep = base.includes('?') ? '&' : '?'; + return `${base}${sep}startapp=${encodeURIComponent(param)}`; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9296c5e..4f1677c 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -110,6 +110,7 @@ export const en = { 'profile.to': 'To', 'profile.blockChat': 'Disable chat', 'profile.blockFriendRequests': 'Disable friend requests', + 'profile.notificationsInAppOnly': 'Notifications in the app only', 'profile.email': 'Email', 'profile.bindEmail': 'Bind email', 'profile.emailCode': 'Confirmation code', @@ -180,6 +181,7 @@ export const en = { 'friends.redeem': 'Add', 'friends.copy': 'Copy', 'friends.codeCopied': 'Code copied.', + 'friends.shareTelegram': 'Share via Telegram', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b992967..56396a2 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -111,6 +111,7 @@ export const ru: Record = { 'profile.to': 'До', 'profile.blockChat': 'Отключить чат', 'profile.blockFriendRequests': 'Отключить заявки в друзья', + 'profile.notificationsInAppOnly': 'Уведомления только в приложении', 'profile.email': 'Эл. почта', 'profile.bindEmail': 'Привязать почту', 'profile.emailCode': 'Код подтверждения', @@ -181,6 +182,7 @@ export const ru: Record = { 'friends.redeem': 'Добавить', 'friends.copy': 'Копировать', 'friends.codeCopied': 'Код скопирован.', + 'friends.shareTelegram': 'Поделиться через Telegram', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index fd8ae7b..45f7d44 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -100,6 +100,9 @@ export class MockGateway implements GatewayClient { } // --- auth --- + async authTelegram(): Promise { + return { ...SESSION, isGuest: false }; + } async authGuest(): Promise { return { ...SESSION }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 82bc2cf..dacc485 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -36,6 +36,7 @@ export const PROFILE: Profile = { blockChat: false, blockFriendRequests: false, isGuest: false, + notificationsInAppOnly: true, }; // Seed social/account data for the mock (pnpm start + Playwright). The mock profile diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 2d4cfb8..20e3ddd 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -107,6 +107,8 @@ export interface Profile { blockChat: boolean; blockFriendRequests: boolean; isGuest: boolean; + /** Confine notifications to the in-app stream (no out-of-app platform push). */ + notificationsInAppOnly: boolean; } /** The full editable profile sent to profileUpdate (overwrites every field). */ @@ -118,6 +120,7 @@ export interface ProfileUpdate { awayEnd: string; blockChat: boolean; blockFriendRequests: boolean; + notificationsInAppOnly: boolean; } /** A referenced account with its display name (friend, blocked user, invitee). */ diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts new file mode 100644 index 0000000..b08d91f --- /dev/null +++ b/ui/src/lib/telegram.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { insideTelegram, telegramLaunch } from './telegram'; + +function stubWebApp(initData: string, startParam?: string) { + vi.stubGlobal('window', { + Telegram: { + WebApp: { + initData, + initDataUnsafe: startParam ? { start_param: startParam } : {}, + themeParams: { bg_color: '#101418' }, + ready: () => {}, + expand: () => {}, + }, + }, + }); +} + +describe('telegram launch detection', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('is not inside Telegram without a window', () => { + expect(insideTelegram()).toBe(false); + }); + + it('is inside Telegram only with non-empty initData', () => { + stubWebApp(''); + expect(insideTelegram()).toBe(false); + stubWebApp('query_id=abc'); + expect(insideTelegram()).toBe(true); + }); + + it('telegramLaunch returns initData, start param and theme', () => { + stubWebApp('query_id=abc', 'g123'); + const launch = telegramLaunch(); + expect(launch.initData).toBe('query_id=abc'); + expect(launch.startParam).toBe('g123'); + expect(launch.theme?.bg_color).toBe('#101418'); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts new file mode 100644 index 0000000..a20b25e --- /dev/null +++ b/ui/src/lib/telegram.ts @@ -0,0 +1,67 @@ +// 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; + ready?: () => void; + expand?: () => 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 }; +} + +/** + * 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/'); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 5d7e5c4..d1181f5 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -54,6 +54,9 @@ export function createTransport(baseUrl: string): GatewayClient { token = t; }, + async authTelegram(initData) { + return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData))); + }, async authGuest(locale) { return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); }, diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index eaec81e..fd5611e 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -4,6 +4,7 @@ import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { gateway } from '../lib/gateway'; import { t } from '../lib/i18n/index.svelte'; + import { friendCodeParam, shareLink } from '../lib/deeplink'; import type { AccountRef, FriendCode } from '../lib/model'; let friends = $state([]); @@ -97,6 +98,7 @@ {#if code} + {@const tg = shareLink(friendCodeParam(code.code))}
@@ -105,6 +107,9 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} + {#if tg} + {t('friends.shareTelegram')} + {/if}
{:else} diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 7982be8..a3483e6 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -25,6 +25,7 @@ let endM = $state('00'); let blockChat = $state(false); let blockFriendRequests = $state(false); + let notificationsInAppOnly = $state(true); let emailInput = $state(''); let codeInput = $state(''); let emailSent = $state(false); @@ -47,6 +48,7 @@ [endH, endM] = splitTime(p.awayEnd); blockChat = p.blockChat; blockFriendRequests = p.blockFriendRequests; + notificationsInAppOnly = p.notificationsInAppOnly; editing = true; } @@ -68,6 +70,7 @@ awayEnd, blockChat, blockFriendRequests, + notificationsInAppOnly, }); editing = false; showToast(t('profile.saved')); @@ -143,6 +146,10 @@ {t('profile.blockFriendRequests')} +
diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8b61f6f..58e463f 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -9,6 +9,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'; const RPC_PREFIX = '/scrabble.edge.v1.Gateway'; export default defineConfig(({ mode }) => ({ + // Relative asset base so the one build serves under any path — the gateway maps the + // Telegram Mini App to /telegram/ (the hash router is path-agnostic). + base: './', plugins: [svelte()], server: { port: 5173,