Compare commits

..

52 Commits

Author SHA1 Message Date
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer b03e012011 Merge pull request 'feat(telegram): native dialogs (experimental, stacked on #136)' (#137) from feature/telegram-native-dialogs into development
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / unit (pull_request) Successful in 10s
2026-06-24 11:41:28 +00:00
developer 2495446a47 Merge pull request 'feat(telegram): Mini App embedding enhancements' (#136) from feature/telegram-embedding into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-24 11:41:15 +00:00
Ilia Denisov 35705f7d1e fix(telegram): defer deep-link notices until the loading cover clears
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
The stale-invite / welcome-on-redeem notices are raised during boot, so the native
popup fired over the loading splash. Gate both the native popup and the in-app Modal
on the current route's loading cover being gone — the tile splash on the lobby
(splashDone), the plain loading screen elsewhere (app.ready) — so the notice appears
with the settled screen on every build (Telegram and native/web alike).
2026-06-24 13:25:14 +02:00
Ilia Denisov 4f0cc81dfb fix(telegram): evaluate native-dialog availability at fire time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The deep-link info modals (stale invite, welcome-on-redeem) captured
insideTelegram() && telegramDialogsAvailable() in a const at component init, but they
mount in App before bootstrap loads the SDK, so the value was always false and they
fell back to the in-app Modal even inside Telegram. Evaluate it at fire time (in the
effect and the {#if} guard), as the destructive confirms already do at click time.
2026-06-24 13:16:11 +02:00
Ilia Denisov 2ba7cc3086 feat(telegram): native dialogs for confirms and deep-link notices
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.

Adds showConfirm/showPopup wrappers to telegram.ts and a pure popup-params builder
(nativedialogs.ts) with unit tests; the deep-link modal components choose native vs
in-app via an effect gated on insideTelegram + dialog availability.
2026-06-24 12:36:52 +02:00
Ilia Denisov 29b6c7e4d8 docs(telegram): document the Mini App embedding enhancements
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Record the current state in ARCHITECTURE and FUNCTIONAL (+ _ru mirror): inside the
Mini App the client tracks Telegram's live theme switch, fits the full device
safe-area, exposes a native Settings button into the in-app settings, and syncs the
device-independent display prefs (theme, reduce-motion, board labels — not the
interface language) across the user's Telegram devices via CloudStorage; the
validator denies a bot user (is_bot) before provisioning an account.
2026-06-24 12:19:50 +02:00
Ilia Denisov 8de9fb1ecd feat(telegram): deny bot users at initData validation
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The validator parsed only id/username/first_name/language_code from the signed
Telegram user, so a WebAppUser flagged is_bot would have been provisioned a normal
account. The HMAC already proves Telegram signed the payload, so is_bot==true is
Telegram itself attesting the launching principal is a bot.

Parse is_bot and reject it (ErrInvalidInitData -> gateway 4xx -> launch error). A
real user opening the Mini App never carries it, so this is a defensive deny. Tests
cover both the denied (is_bot true) and allowed (is_bot false) paths.
2026-06-24 11:55:30 +02:00
Ilia Denisov 8a5a5d6c4d feat(telegram): sync client display prefs via CloudStorage
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Theme, reduce-motion and board labels lived only in local IndexedDB/localStorage,
so they did not follow the user across devices and could be lost when the Telegram
WebView cleared storage.

Mirror these three device-independent prefs to Telegram CloudStorage (Bot API 6.9)
from the single local-persist point (persistPrefs), and reconcile them from
CloudStorage in the background on launch (reconcileCloudPrefs) so a change made on
another device follows the user here. The local store stays the instant-render
cache; the interface language is intentionally excluded (it syncs via the durable
account). Pure encode/decode extracted to cloudprefs.ts with unit tests; the
CloudStorage transport wrappers are added to telegram.ts. No-op outside Telegram or
on a client predating CloudStorage.
2026-06-24 11:39:29 +02:00
Ilia Denisov ea931c6680 feat(telegram): native SettingsButton opens our Settings screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
Inside the Mini App, reveal Telegram's native Settings button (Bot API 7.0)
and route its taps to the Settings screen — the standard Mini App affordance.
The in-app gear entry stays the primary path (two entry points by design).
No-op outside Telegram or on a client predating the button.
2026-06-24 10:47:05 +02:00
Ilia Denisov d0f60ee41d fix(telegram): paint the home-indicator strip with the bottom bar's colour
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m15s
The safe-area bottom inset was reserved on the Screen wrapper, so the strip
that holds space for the home indicator showed the content background and read
as detached from the coloured bottom bar / header above it.

Move the bottom inset onto the bottom bar: the Screen .tabbar wrapper now paints
--bg-elev and pads itself by --tg-safe-bottom, so the strip continues the
TabBar's chrome; a screen with no tab bar pads its bottom-most content
(.content:last-child) instead, so the strip takes that content's own colour.
2026-06-24 10:41:26 +02:00
Ilia Denisov b84bd1297e feat(telegram): clear bottom and side safe-area insets
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Only the device safe-area TOP inset was mirrored, so on phones with a home
indicator the rack / bottom bar sat under it, and in landscape the notch
clipped the screen edges.

Mirror the full device safe-area inset (bottom / left / right) into new
--tg-safe-bottom / --tg-safe-left / --tg-safe-right CSS vars (0 outside
Telegram) and pad the shared Screen wrapper by them, so every screen clears the
home indicator and the landscape notch; the top inset stays owned by the header.
Replace telegramSafeAreaTop with telegramSafeAreaInset (the full inset object),
with a unit test.
2026-06-24 10:24:56 +02:00
Ilia Denisov 0fb6004a8b feat(telegram): re-apply theme live on themeChanged
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The Mini App read Telegram's themeParams/colorScheme only once at launch, so
switching Telegram's light/dark theme (or its auto day/night) while the app was
open left the SPA on stale colours until a relaunch.

Subscribe to the themeChanged WebApp event and re-apply the theme live. Extract
the launch-time token + colour-scheme + chrome application into syncTelegramTheme
(reading live themeParams when no launch snapshot is passed) and call it from both
applyTelegramChrome (launch) and the new event handler. Add a telegramThemeParams
live getter (+ unit test). Drop the stale 'immersive fullscreen' note from
applyTelegramChrome — the app deliberately does not request fullscreen.
2026-06-24 09:20:41 +02:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer c1d1c1624b Merge pull request 'feat(telegram): promo deep-link seeds EN variant (+ UI: share label, header padding)' (#134) from feature/promo-deeplink-and-ui-nits into development
CI / unit (push) Successful in 10s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 16s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 20:50:14 +00:00
Ilia Denisov 9207664fbd docs(telegram): note the promo body @username is a deep link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
2026-06-23 22:45:54 +02:00
Ilia Denisov a4581663f4 feat(telegram): link the promo body @username to the Mini App deep link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
The promo message body now renders "@<bot>" as an HTML text_link to the same ?startapp deep link the button uses (ParseMode HTML), so tapping the mention opens the seeded Mini App instead of the bot profile. Same payload (campaign start-param, else the forwarded /start payload) backs both the button and the mention.
2026-06-23 22:40:52 +02:00
Ilia Denisov 03dfc29a54 feat(telegram): promo deep-link seeds English Scrabble for new users
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 50s
The promo bot button carries a configurable variant-seed start-param (default verudit_ru-scrabble_en). The gateway parses start_param from the validated initData and forwards it; the backend, on first contact only, seeds the new account variant_preferences from it (English Scrabble alongside the default Erudit).

No schema change (the scrabble_en CHECK is already in the baseline) and the gateway<->backend REST field is additive, so the rolling deploy is safe in either order. TELEGRAM_PROMO_START_PARAM configures the payload (empty forwards the user own /start payload). Covered by account unit tests, a gateway transcode test, and an integration test asserting new-only seeding.
2026-06-23 21:51:52 +02:00
Ilia Denisov c02262fcf7 style(ui): halve custom header top/bottom padding
The custom title bar was too tall. Halve the standard bar padding (10->5px) and, in the Telegram path, the notch gap (16->8px) and bottom (6->3px); the notch safe-area inset is unchanged. Title and back chevron stay vertically centred.
2026-06-23 21:51:52 +02:00
Ilia Denisov f8fab4a4c2 fix(ui): rename Friends "Share via Telegram" to "Share"
The share button uses the OS Web Share sheet outside Telegram (the TG share picker only inside it), so "via Telegram" was misleading. Rename to a neutral "Share"/"Поделиться" and drop the now-unused tgshare class.
2026-06-23 21:51:52 +02:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 10264e10c8 Merge pull request 'fix(telegram): support relay — text_mention card + reopen deleted topic' (#132) from feature/telegram-support-card-mention into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:51:04 +00:00
Ilia Denisov fc1715128e fix(telegram): reopen a deleted support topic instead of losing it to General
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Telegram silently routes a copyMessage aimed at a deleted forum topic
into the chat's General topic with NO error, so the bot's error-based
recreate never fired — the user's next message landed in General with no
card. Confirmed against the prod bot logs: a relay after a topic deletion
logged neither "relay to topic failed" nor "topic gone, reopening".

Probe topic liveness before reusing it: re-applying the info card's reply
markup is a no-op that errors only when the card (hence the topic) is
gone, so the bot detects the deletion and reopens the topic + card rather
than relying on the (absent) copy error. The post-copy recreate stays as
a race backstop.
2026-06-23 18:45:39 +02:00
Ilia Denisov bb18dc362b fix(telegram): support card — text_mention name, no command/dead link
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The topic info card built the profile link as `tg://user?id=`, which
clients render as dead text for a user they don't already know (the
test-vs-prod difference an operator saw), and it put the raw display
name through HTML — so a name starting with "/" was auto-detected as a
tappable bot command that fired when tapped.

Render the card with message entities instead: the display name is
covered by a `text_mention` entity (the reliable way to mention a
username-less user the bot has already seen — it messaged the bot),
which links the profile AND, because the name sits inside an entity,
suppresses the "/command" and "@mention" auto-detection on it. Drop the
HTML parse mode and the tg:// link. Entity offsets are UTF-16.
2026-06-23 18:33:14 +02:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer d86e022373 Merge pull request 'feat(telegram): bot support relay — per-user forum topics' (#130) from feature/telegram-support-relay into development
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m22s
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-23 16:08:18 +00:00
Ilia Denisov 6a602aefae feat(telegram): bot support relay — per-user forum topics
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Users who DM the bot anything but /start are relayed into a private
forum supergroup, one topic per user. Operators (the chat's admins)
reply in the topic and the bot copies it back to the user; an info card
opening each topic carries a Block/Unblock toggle and a Clear button.
State is a small JSON file on a new /data volume — the bot host has no
database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is
unchanged until the operator sets the chat id and adds the bot as a
forum admin.

- internal/support: concurrency-safe JSON store (topic map, block list,
  relayed message ids) with field-targeted mutators and atomic save
- bot/support.go: relay both ways via copyMessage, short-TTL admin
  cache, callback buttons, per-user topic-create lock, loop guard
  (skip the bot's own posts), reopen a deleted topic on the next message
- config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per
  contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data
  pre-owned by UID 65532 so a fresh volume is writable under distroless
- docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
2026-06-23 17:47:00 +02:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer e6277dcd43 Merge pull request 'fix(ui): Android Telegram nav — windowed mode, no close guard' (#128) from feature/telegram-android-nav-fixes into development
CI / changes (push) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / changes (pull_request) Successful in 2s
CI / deploy (pull_request) Has been skipped
2026-06-23 13:14:04 +00:00
Ilia Denisov 37070c3cb7 feat(ui): drop Telegram fullscreen; own back chevron everywhere; hidden debug panel
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Finalises the Telegram Mini App navigation work after on-device testing (Pixel
10 / Android 17 + iOS, fresh beta clients):

- Remove requestFullscreen entirely. Immersive fullscreen hid Telegram's native
  header (and its BackButton) and the Android system swipe-back minimised the
  app; the owner prefers the windowed full-size (expand) presentation, so the
  app never requests fullscreen on any platform now.
- The app's own back chevron (Header, showBack = !!back) drives back-navigation
  on every platform; the native Telegram BackButton is dropped — it does not
  render in the windowed Mini App (backVisible=false on iOS and Android), so
  relying on it lost back navigation (notably none on iOS).
- Replace the temporary always-on diagnostic overlay with a hidden debug panel
  (components/DebugPanel): ten quick taps on the header title open it; it shows a
  privacy-safe client diagnostic snapshot (app version, locale, online, userId,
  Telegram chrome / viewport / SDK state — no secrets, no IP) and shares it via
  the OS share sheet / clipboard; a tap anywhere except Share dismisses it.
- Drop the now-dead telegramRequestFullscreen / telegramBackButton /
  isTelegramAndroid helpers and the iOS-fullscreen unit test.

Telegram has no native non-modal notification API (only modal showPopup /
showAlert), so in-app toasts stay ours. Docs: UI_DESIGN.md.
2026-06-23 15:05:13 +02:00
Ilia Denisov 53d6883ffd fix(ui): own back chevron in Telegram on all platforms; drop the native BackButton
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The Telegram native BackButton does not render in the windowed Mini App (the
owner's emulator + a fresh beta TG report backVisible=false on both iOS and
Android), so relying on it lost back navigation — iOS had no back affordance at
all. Show the app's own back chevron whenever there is a back target, on every
platform (Header showBack = !!back), and drop the now-dead native BackButton
effect (App.svelte). The native close control stays — a windowed Mini App
cannot hide it (no Telegram API).

WIP: the temp lobby diagnostic overlay and the requestFullscreen no-op remain
for the owner's emulator test; finalize after confirmation.
2026-06-23 14:37:43 +02:00
Ilia Denisov 93c57b3558 test(ui): TEMP-skip the iOS fullscreen unit test (requestFullscreen is a no-op for the owner test; restore with the iOS path)
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
2026-06-23 14:09:40 +02:00
Ilia Denisov 6f00c2f41d chore(ui): TEMP disable fullscreen for testing + Android back chevron + BackButton diag
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 8s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
WIP for the Android nav investigation (TEMP bits reverted before merge):
- telegramRequestFullscreen: temporarily a no-op (incl. iOS) so the owner can
  confirm the Android "fullscreen look" is Telegram's own Mini App presentation,
  not our requestFullscreen (isFullscreen is already false on Android).
- Header: show the app's own back chevron in Telegram on Android, where the
  native BackButton does not render — a reliable tap-back. [keep]
- Diagnostic overlay moved app-wide (pointer-events:none) and now reports
  BackButton state (req/present/visible) + viewport geometry, to see whether the
  native BackButton can capture the Android system swipe-back.
2026-06-23 14:04:21 +02:00
Ilia Denisov 79766438a2 chore(ui): TEMP lobby diagnostic for the Android fullscreen issue
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Renders Telegram viewport/fullscreen state (isFullscreen, isExpanded, viewport
heights, innerH vs screenH, safe-area insets) in the lobby, inside Telegram
only, to diagnose why the app still opens fullscreen on Android with
requestFullscreen now iOS-only and no persisted state (fresh TG + test account).
REVERT before merge.
2026-06-23 13:36:54 +02:00
Ilia Denisov 6aa5023b24 fix(ui): Android Telegram nav — windowed mode, no close guard
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
Three Android Mini App issues, all in the Telegram chrome:

- Entering a game showed no native back button, and the Android system
  swipe-back minimised the app instead of navigating. Root cause: immersive
  fullscreen (requestFullscreen). On Android, fullscreen replaces the native
  header — and its BackButton, which also captures the system back — with a bare
  close/menu pill, so back navigation has no control to land on. Request
  fullscreen on iOS only; Android stays windowed, keeping the native header +
  BackButton (and the system swipe-back that routes to it). iOS is unchanged.

- Closing the game board always prompted "changes that you made may not be
  saved", even on a board just opened and untouched. The close-confirmation was
  armed unconditionally on game mount. Remove it entirely: move drafts auto-save
  (debounced during play + flushed on destroy), so nothing is lost on close.

Drops the now-unused telegramClosingConfirmation + isMobilePlatform helpers and
their SDK interface fields. Docs: UI_DESIGN.md.
2026-06-23 11:48:38 +02:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer 508dc870ec Merge pull request 'feat(ui): diagnostic screen on /telegram/ instead of the landing bounce' (#126) from feature/telegram-launch-diagnostic into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / deploy (pull_request) Has been skipped
2026-06-23 08:32:18 +00:00
Ilia Denisov e3899d4755 feat(ui): record the SDK load outcome in the launch diagnostic
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
The diagnostic showed only sdk: yes/no (window.Telegram presence), not why the
SDK was absent. Capture how the dynamic telegram-web-app.js load resolved —
present / loaded / no-webapp / error / timeout — and surface it as
"sdk-load: <outcome>". error/timeout pinpoint a blocked or hanging telegram.org
(the prime suspect for an empty launch); no-webapp a loaded-but-broken script.

loadTelegramSDK records the outcome (telegramSdkOutcome); collectTelegramDiag
carries it into the screen. Unit tests cover each outcome; the blocked-script
e2e now asserts sdk-load: error.
2026-06-23 10:18:02 +02:00
Ilia Denisov ae5090b851 fix(ui): load telegram-web-app.js dynamically with a timeout
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The Telegram SDK was a render-blocking <script src="telegram.org/..."> in the
shared index.html shell, so it ran on every entry (/telegram/, /app/, native).
On a network that blocks telegram.org — common where Telegram itself reaches
users only over a proxy — the script hangs forever, stranding the whole page,
including the launch-diagnostic screen meant to surface exactly this failure.
This is the likely root cause of the Android "won't open" reports (all launch
methods fail identically; iOS on a different network works).

Remove the head <script> and load the SDK dynamically (lib/telegram.ts
loadTelegramSDK) with a 10s timeout, only on a Telegram entry (the /telegram/
path or a tgWebApp launch fragment). The SPA — served from our own reachable
origin — boots first and controls the load: on a block/error it falls through
to the diagnostic screen (reporting sdk: no) instead of hanging, and Retry
re-attempts. /app/ and the native build no longer touch telegram.org.

Pin the SDK to the version the official page recommends (?62) for the newer
client features the app already uses (fullscreen, safe areas, swipe guard).

Tests: loadTelegramSDK unit tests (present / error / timeout); an e2e that
aborts the script fetch and asserts the diagnostic still renders.
2026-06-23 09:59:24 +02:00
Ilia Denisov 0c5d3808d7 feat(ui): diagnostic screen on /telegram/ instead of the landing bounce
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A Mini App launch on /telegram/ without sign-in data (empty initData) used to
location.replace('/') — bouncing the visitor to the marketing landing. That
destroyed all diagnosability and, on some Android clients that reach the entry
with no initData, simply looked like the app refusing to open.

Replace the bounce with a compact, privacy-safe launch-error screen
(screens/TelegramLaunchError.svelte) that renders a one-screenshot diagnostic
snapshot captured at the moment of failure: SDK/WebApp presence, Telegram
platform/version, whether initData is empty, whether the URL fragment carried
tgWebAppData, the initData field NAMES present/missing (never the signed
values, never an IP), and OS/mobile/browser via User-Agent Client Hints plus
the full User-Agent. A Share button delivers it through the OS share sheet
(clipboard copy on desktop, reusing the GCG no-webview-strand guard); Retry
re-checks in place to recover a late initData without a reload (which would
discard the launch fragment).

This is the instrument to root-cause the Android empty-initData failure, which
is not reproducible in Playwright.

Tests: collectTelegramDiag + shareText/pickTextShare unit tests; the /telegram/
e2e now asserts the diagnostic screen, not a redirect. Docs: ARCHITECTURE.md +
UI_DESIGN.md updated.
2026-06-23 09:37:36 +02:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer 18db62e19d Merge pull request 'feat(ui): friends list as lobby-style rows with kebab + confirm modals' (#123) from feature/friends-list-kebab-confirm into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / deploy (push) Successful in 1m18s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 22:28:25 +00:00
Ilia Denisov d4e34efa80 test(ui): e2e for the friends-list kebab, confirm modals and outside-tap
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Cover the reworked Settings -> Friends interactions in the mock e2e
(Chromium + WebKit): the row kebab slides open the block/remove icons and an
outside tap collapses it; blocking confirms (naming the friend) and moves them
to Blocked; removing confirms and drops the friendship.
2026-06-23 00:24:33 +02:00
Ilia Denisov 12ff6dad86 feat(ui): close the friends kebab on an outside tap
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A slid-open friend row now collapses when the user taps anywhere outside its
action buttons (taps on a kebab are skipped so its own toggle still drives the
open/close). Uses the same capture-phase window pointerdown idiom as Screen,
active only while a row is revealed.
2026-06-23 00:20:09 +02:00
Ilia Denisov 5a80696fe8 feat(ui): friends list as lobby-style rows with kebab + confirm modals
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m31s
Settings -> Friends previously rendered each friend as a bordered card with
two always-visible text buttons (Remove / Block) that fired immediately. Rework
the whole screen to the lobby's visual language: one-line rows split by
hairline separators across all three sections (friends, incoming requests,
blocked).

Each friend row gains a right-hand kebab that slides the row open to reveal two
icon actions split by a vertical divider -- block (no-entry) and remove (cross)
-- mirroring the lobby's slide-to-reveal. Both actions now require a
confirmation modal; since the slide moves a short name off-screen, the modal
keeps a generic title and shows the friend's name in the body, above the
buttons, so a long name cannot stretch the sheet. Incoming keeps its
accept/decline buttons and blocked keeps unblock, inline on their rows.

Add the friends.actions / friends.blockConfirm / friends.unfriendConfirm keys
to both i18n catalogs and document the flow in FUNCTIONAL (+_ru).
2026-06-23 00:09:39 +02:00
developer d6401bb76c Merge pull request 'fix(deploy): force-recreate caddy on its roll so config-only changes apply' (#122) from feature/prod-deploy-force-recreate-caddy into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
2026-06-22 20:09:43 +00:00
Ilia Denisov e0a5753f1a fix(deploy): force-recreate caddy on its roll so config-only changes apply
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
The prod rolling deploy rolls each service with `compose up -d --no-deps <svc>`.
For caddy that is a no-op on a config-only release: its image is pinned
(caddy:2-alpine, no $TAG), so the compose definition is unchanged between
releases, compose treats the container as current and does not recreate it, and
admin is off so there is no hot reload. The new bind-mounted Caddyfile is seeded
to the host but never loaded -- the v1.2.2 `Alt-Svc: clear` edge fix deployed
green yet did not take effect until caddy was restarted by hand.

Force a recreate for caddy on its roll (every other service already recreates on
its new $TAG image), so a bind-mounted Caddyfile change always applies. Costs a
~1-2s caddy blip per deploy, acceptable for the infrequent manual prod rollout.
2026-06-22 22:03:35 +02:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer ba57687430 Merge pull request 'fix(grafana): real byte thresholds for the Database size stat' (#120) from feature/grafana-db-size-thresholds into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 19:41:42 +00:00
developer 6cb88b28c4 Merge pull request 'fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear' (#119) from feature/edge-suppress-http3-altsvc into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
2026-06-22 19:41:16 +00:00
Ilia Denisov 46d569720c fix(grafana): give "Database size" stat real byte thresholds
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The "Database size" stat had no thresholds, so Grafana applied its stat
default (green base, red at >=80). The query is pg_database_size_bytes, so a
healthy ~9 MiB database (9.4M >> 80) rendered permanently RED on the
Scrabble - Resources dashboard (test + prod), reading as a false alert; the
neighbouring percentunit cache-hit stat stayed green only because its 0..1
values fall under 80.

Add absolute byte thresholds sized to the 40 GiB prod disk (4.6 GiB used,
observability bounded -- Tempo <=1 GiB, Prometheus 7d -- so the DB is the
only unbounded grower): green up to 8 GiB, yellow at 8 GiB (~20% of disk),
red at 16 GiB (~40%), an early warning with ample runway before the disk
tightens, not a panic line. Cosmetic panel coloring only; there are no
Grafana alert rules provisioned.
2026-06-22 21:35:48 +02:00
Ilia Denisov 9253b1bdca fix(edge): suppress dead HTTP/3 advert with Alt-Svc: clear
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Caddy enables HTTP/3 by default on any TLS listener and emits
Alt-Svc: h3=":443"; ma=2592000, but UDP/443 is never reachable: the prod
compose maps only "443:443" (TCP) and ufw opens 443/tcp (test contour: the
host caddy publishes only :443/tcp). A client that cached the 30-day advert
tries QUIC first on later opens, gets no response, and waits for the QUIC
attempt to time out before falling back to h2 -- which surfaced as the
Telegram Mini App intermittently hanging on load (a barely-noticeable pause
up to a blank window). The h2/TCP serving path itself is healthy (~10ms TTFB).

Emit Alt-Svc: clear site-wide at the contour caddy so clients actively drop
any cached alternative and stay on h2/h1. This caddy terminates TLS in prod
(the fix target); in the test contour it serves plain :80 and the host caddy
re-stamps its own Alt-Svc, so the live test fix lives in the host caddy. Add
docs/EDGE_HTTP3.md (symptom, diagnosis method, verify, and option B -- serving
h3 for real -- if it recurs) and link it from ARCHITECTURE.md.
2026-06-22 21:20:31 +02:00
60 changed files with 3638 additions and 326 deletions
+1
View File
@@ -267,6 +267,7 @@ jobs:
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
+2
View File
@@ -179,6 +179,7 @@ jobs:
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
@@ -200,6 +201,7 @@ jobs:
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
+60
View File
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
return out, nil
}
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
// canonical variant labels joined by "-". It is deliberately distinct from the routing
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
// router falls through to the lobby for it.
const variantSeedPrefix = "v"
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
// returns nil for any payload that is not a variant-seed link or that fails validation
// against the known variants, so a malformed, empty or unrelated start-param simply
// leaves the account on its default preferences rather than failing the login.
func SeedVariantsFromStartParam(startParam string) []string {
if !strings.HasPrefix(startParam, variantSeedPrefix) {
return nil
}
body := strings.TrimPrefix(startParam, variantSeedPrefix)
if body == "" {
return nil
}
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
if err != nil {
return nil
}
return prefs
}
// SetVariantPreferences overwrites only the variant-preference set of the account,
// cleaning it to a deduplicated, canonically ordered subset of the known variants
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
// without disturbing its other profile fields.
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
clean, err := validateVariantPreferences(prefs)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE(
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
).SET(
// clean is validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
// UpdateProfile.
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
}
return modelToAccount(row), nil
}
// UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id.
@@ -0,0 +1,37 @@
package account
import (
"slices"
"testing"
)
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
// to the canonical order and deduplicated, while anything that is not a variant-seed link
// or that names an unknown variant yields nil (leaving the account on its defaults).
func TestSeedVariantsFromStartParam(t *testing.T) {
tests := []struct {
name string
param string
want []string
}{
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
{"empty", "", nil},
{"prefix only", "v", nil},
{"routing game link is not a seed", "g0190abcd", nil},
{"friend code link is not a seed", "f123456", nil},
{"unknown variant rejected", "vscrabble_de", nil},
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := SeedVariantsFromStartParam(tc.param)
if !slices.Equal(got, tc.want) {
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
}
})
}
}
@@ -0,0 +1,80 @@
//go:build integration
package inttest
import (
"context"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/google/uuid"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/server"
"scrabble/backend/internal/session"
)
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
// to confirm a promo deep-link start-param seeds a brand-new account's variant
// preferences (English Scrabble alongside the default Erudit), that a new account with no
// such payload keeps the Erudit-only default, and that an existing account is never
// re-seeded on a later login (the new-user-only contract).
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
Notifier: &captureNotifier{},
})
h := srv.Handler()
post := func(ext, startParam string) {
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
if startParam != "" {
body += `,"start_param":"` + startParam + `"`
}
body += `}`
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
}
}
store := account.NewStore(testDB)
reload := func(ext string) []string {
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
if err != nil {
t.Fatalf("lookup %s: %v", ext, err)
}
return acc.VariantPreferences
}
// A brand-new account reached through a promo deep-link is seeded with English
// Scrabble alongside the default Erudit.
promoExt := "tg-" + uuid.NewString()
post(promoExt, "verudit_ru-scrabble_en")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("promo new account variants = %v, want %v", got, want)
}
// A brand-new account with no promo payload keeps the Erudit-only default.
plainExt := "tg-" + uuid.NewString()
post(plainExt, "")
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
t.Errorf("plain new account variants = %v, want %v", got, want)
}
// A later login of the promo account, even via a different payload, must not re-seed:
// the seed is first-contact only.
post(promoExt, "vscrabble_ru")
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
}
}
+15 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
@@ -19,13 +20,16 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
// deep-link payload, which may seed the new account's variant preferences (first
// contact only).
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -47,6 +51,16 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
// joined the chat before registering is granted on the spot (no chat_member
// event fires on registration).
s.publishChatAccessChange(acc.ID)
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
// English Scrabble alongside the default Erudit). Best-effort: an absent or
// malformed payload leaves the account on its defaults, and a write failure must
// not block the session mint.
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
s.log.Warn("telegram: seed variant preferences failed",
zap.String("account", acc.ID.String()), zap.Error(err))
}
}
}
s.mintSession(c, acc)
}
+2
View File
@@ -47,9 +47,11 @@ AWG_CONF= # required; AmneziaWG sidecar config (the
TELEGRAM_BOT_TOKEN= # required
TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating
TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the support relay (topic per user); empty disables it
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
TELEGRAM_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
+3 -1
View File
@@ -150,7 +150,9 @@ Re-run `ansible/` after a host resize — it is idempotent.
workflow manually (Gitea → Actions → prod-deploy → run from `master`, input
`confirm=deploy`). It builds + pushes the images to the registry, ships the
compose/config/certs/env over SSH, deploys the main host with `prod-deploy.sh` (rolling,
health-gated, **auto-rollback to the previous tag**), then the bot host, then probes the
health-gated, **auto-rollback to the previous tag**; caddy is force-recreated on its roll so
a bind-mounted `Caddyfile` change applies — its image is pinned and admin is off, so neither a
new tag nor a hot reload would pick it up), then the bot host, then probes the
public site. After `master` is green this workflow is the **only** thing that touches
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
+12
View File
@@ -21,6 +21,18 @@
}
{$CADDY_SITE_ADDRESS::80} {
# HTTP/3 is advertised by default whenever this caddy terminates TLS (prod:
# CADDY_SITE_ADDRESS is the domain). But UDP/443 is never reachable — the prod
# compose maps only "443:443" (TCP) and ufw opens 443/tcp — so a client that cached
# the `Alt-Svc: h3` advert (sticky for ma=2592000s) stalls on the dead QUIC path
# before falling back to h2, which surfaced as the Telegram Mini App intermittently
# hanging on load. `Alt-Svc: clear` actively drops any cached alternative and pins
# clients to h2/h1; it is applied site-wide so every route is covered. In the test
# contour this caddy serves plain :80 (no h3 to advertise) and the host caddy
# re-stamps its own Alt-Svc, so the live test fix lives in the host caddy — here it
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
header Alt-Svc clear
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/*
handle @gm {
+11
View File
@@ -23,9 +23,15 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# Support relay: a private forum supergroup the bot relays direct user messages
# into (one topic per user). 0/unset disables it; the bot needs admin there with
# the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
TELEGRAM_TEST_ENV: "false"
@@ -46,8 +52,13 @@ services:
GOMAXPROCS: "1"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# Support relay state (topic mapping, block list); survives redeploys.
- bot-state:/data
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
volumes:
bot-state:
+8
View File
@@ -292,6 +292,11 @@ services:
# only restricts (mutes the ineligible) — and the bot must be an admin there with the
# "Ban users" right; chat_member updates are delivered only to a chat admin.
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# The private forum supergroup the bot relays direct user messages into (one topic
# per user) and reads operator replies from. Empty disables the support relay; when
# set the bot must be an admin there with the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data
# The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
@@ -325,6 +330,8 @@ services:
GOMAXPROCS: "1"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# Support relay state (topic mapping, block list); survives redeploys.
- bot-state:/data
deploy:
resources:
limits:
@@ -496,3 +503,4 @@ volumes:
prometheus-data:
tempo-data:
grafana-data:
bot-state:
+15 -1
View File
@@ -36,7 +36,21 @@
"type": "stat",
"title": "Database size",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 8589934592 },
{ "color": "red", "value": 17179869184 }
]
}
},
"overrides": []
},
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(pg_database_size_bytes{datname=\"scrabble\"})" }]
},
+7 -1
View File
@@ -74,7 +74,13 @@ health_running() { # health_running <container>: running, not restarting, stable
roll() { # roll <service> <health-cmd...>
local svc="$1"; shift
echo ">>> rolling $svc -> $TAG"
dc up -d --no-build --no-deps "$svc" || return 1
# caddy's image is pinned (caddy:2-alpine, no $TAG) and its Caddyfile is bind-mounted, so a
# config-only change leaves the compose definition unchanged: `up -d` treats the container as
# current and does not recreate it, and admin is off so there is no hot reload — the new
# Caddyfile would never load. Force a recreate for caddy so config changes always apply; every
# other service already recreates on its new $TAG image.
local recreate=(); [ "$svc" = caddy ] && recreate=(--force-recreate)
dc up -d --no-build --no-deps "${recreate[@]}" "$svc" || return 1
"$@" || { echo "!!! $svc failed health check"; return 1; }
echo "<<< $svc healthy"
}
+41 -7
View File
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
users, a weighted fair rotation — §10),
and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
Telegram's native **Settings** button into the in-app settings, and syncs the
device-independent display preferences (theme, reduce-motion, board labels — **not** the
interface language) across the user's Telegram devices via **CloudStorage**.
- **`platform/telegram`** — the Telegram side-service (module
`scrabble/platform/telegram`), split into two binaries that share the bot token
(**one bot**, one optional game channel, §3):
@@ -152,7 +157,8 @@ arrive from a platform rather than completing a mandatory registration).
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
game channel), split into a home **validator** and a remote **bot** that share the
token. `ValidateInitData` (the validator) validates `initData` against that single
token and returns only the Telegram user identity — there is no per-bot "service
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
user identity — there is no per-bot "service
language" and no supported-languages set on the wire. The bot's chat messages and
out-of-app push are
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
@@ -204,7 +210,7 @@ arrive from a platform rather than completing a mandatory registration).
> recipient's interface language (`preferred_language`), with no per-bot routing. New
> Game variant gating moved off the login language onto a per-user profile setting
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
> paths; an invited friend may still accept any variant). The per-bot env vars and
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile.
@@ -972,7 +978,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
| Concern | Enforced by |
| --- | --- |
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
@@ -1040,8 +1046,9 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
Single public origin, path-routed. The Vite build has two entries: a lightweight
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
redirects to the root — the client-side guard); a stray hit on the gateway's `/`
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away); a stray hit on the gateway's `/`
308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
@@ -1098,7 +1105,10 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
the **main host** runs the full stack (`docker-compose.yml` + `docker-compose.prod.yml`),
the **bot host** runs only the bot (`docker-compose.bot.yml`, no VPN — native Bot API
egress, telemetry off). There is no host caddy, so the contour caddy terminates TLS —
`CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. The gateway **publishes**
`CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. Caddy advertises HTTP/3 by default, but UDP/443 is not exposed (the
compose maps only TCP and ufw opens 443/tcp), so the edge emits `Alt-Svc: clear` to keep
clients on h2/h1 rather than stall on a dead QUIC path — see [`EDGE_HTTP3.md`](EDGE_HTTP3.md).
The gateway **publishes**
the bot-link `:9443`; the remote bot dials it over mTLS (certs from `PROD_BOTLINK_*`,
ServerName `gateway`, so TLS validation is independent of the public dial address), holds
no inbound port, and login is unaffected if that host or the link is down.
@@ -1196,3 +1206,27 @@ blocks **only** feedback submission (unlike a suspension, the whole-account bloc
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
migration.
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct
support channel for users who message it on Telegram. Any message other than `/start` is relayed
into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a
dedicated **forum topic** whose first message is an info card (the name is a tappable profile
mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
button; every message is then
copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over).
Any **administrator** of the support chat who writes in a user's topic has their message copied
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
user's incoming messages (the topic stays); Clear deletes the relayed messages, keeping the info
card; a topic the operators delete is reopened on the next message. State (user→topic map, block
list, relayed message ids) is a small JSON file on a writable volume — the bot host has no database
and cannot reach Postgres. The relay is **bot-local**: it touches neither the backend, the gateway,
nor `feedback_messages`. The bot must be an administrator in the forum group with the manage-topics
and delete-messages rights.
> **Decision (2026-06-23) — bot-local support relay over forum topics.** The direct Telegram
> support channel lives entirely in the bot (no backend, no bot-link command), because the bot host
> has no database. One forum **topic per user** (not a reply-to-header thread) gives native per-user
> separation and survives message deletion; the topic id, not a fragile header message, anchors the
> mapping. Operators are the support chat's administrators (no separate owner id); messages relay
> both ways with `copyMessage`. State persists as JSON on a dedicated volume.
+111
View File
@@ -0,0 +1,111 @@
# Edge HTTP/3 (`Alt-Svc`) policy
## TL;DR
The edge **advertises HTTP/3 but does not actually serve it** (UDP/443 is not exposed),
so we suppress the advert with `Alt-Svc: clear`. Advertising QUIC on `:443/udp` while
that port is unreachable makes clients — notably the Telegram Mini App webview — stall
on a dead QUIC connection before falling back to h2, which shows up as the app "hanging
on load".
## Symptom
Opening the Mini App intermittently hangs on load: from a barely-noticeable pause to
several seconds, sometimes a blank window that never finishes downloading `index.html`.
Intermittent, worse after the first successful visit, reproduced on both the test
contour and prod.
## Root cause
Caddy enables HTTP/3 by default on any TLS listener and emits
`Alt-Svc: h3=":443"; ma=2592000` — telling every client "reach me over QUIC/UDP 443"
and to cache that for 30 days. But UDP/443 is **never reachable end to end**:
- **Test contour**: the host caddy publishes only `:443/tcp` (`docker port caddy` shows
no `udp`); QUIC packets from the internet are dropped.
- **Prod**: `deploy/docker-compose.prod.yml` maps `"443:443"` (Docker = **TCP only**)
and `deploy/ansible/roles/main/tasks/main.yml` opens 443 `proto: tcp`. UDP/443 is
dropped at both the publish and the firewall.
Caddy *does* bind `udp/443` inside the container and h3 works container-to-container
(verified `http=3 code=200`), so the listener is healthy — it is simply not exposed.
A client that cached the advert tries QUIC first on later opens, gets no response, and
waits for the QUIC attempt to time out before falling back to TCP/h2. That wait is the
stall. The very first visit (no cached `Alt-Svc`) uses h2 and is fast.
The h2/TCP serving path itself is healthy: 30 fresh-TLS requests through the full path
(host caddy -> contour caddy -> gateway) measured TTFB ~9.5 ms, total ~9.8 ms, no tail;
`index.html` is ~1 KB.
## Fix in place (option A — suppress the advert)
Emit `Alt-Svc: clear`, which actively drops any cached alternative (better than merely
deleting the header, which leaves the sticky 30-day cache in place):
- **Prod / repo**: `deploy/caddy/Caddyfile` — a site-level `header Alt-Svc clear` (this
caddy terminates TLS in prod).
- **Test contour**: the host caddy terminates TLS, so the fix lives there (homelab
config, outside this repo): `header Alt-Svc clear` on the `scrabble.*` site. The
in-compose caddy serves plain `:80` in test and never advertises h3, so the repo
directive is a harmless no-op there (the host caddy re-stamps the header).
`header Alt-Svc clear` overrides Caddy's auto-advert (verified) and is site-scoped.
### Verify
The runner/prod host shell cannot reach the Docker bridge IPs directly, so probe from a
container on the relevant network, using `--resolve` to hit the TLS-terminating caddy by
its bridge IP (this also bypasses the public-IP NAT hairpin):
```sh
# <edge-ip> = the TLS-terminating caddy's IP on its network (docker inspect ... )
docker run --rm --network edge curlimages/curl:latest -sS -D - -o /dev/null \
--resolve <host>:443:<edge-ip> https://<host>/telegram/ | grep -iE '^HTTP|^alt-svc'
# expect: HTTP/2 200, and NO `alt-svc: h3=...` (the header is absent or `alt-svc: clear`)
```
## If it recurs — alternatives to try
So we do not re-derive the diagnosis from scratch:
1. **Re-confirm the advert is actually suppressed** with the verify command above. A
redeploy or a Caddy upgrade could regress it, or a client may still hold a cached
`h3` entry that has not yet been replaced by a `clear` (it needs one successful h2
response to receive the `clear`).
2. **Option B — serve HTTP/3 for real** instead of suppressing it. Worth it only if we
actually want QUIC (the benefit is marginal for a ~1 KB shell plus hash-immutable
cached assets, and it adds UDP/QUIC attack surface):
- Publish UDP: add `"443:443/udp"` next to the TCP map in
`deploy/docker-compose.prod.yml` (and publish udp/443 on the test host caddy too).
- Open the firewall: add a `443 proto: udp` rule in
`deploy/ansible/roles/main/tasks/main.yml`.
- Drop the `header Alt-Svc clear` so Caddy advertises h3 again.
- Verify with an h3 client from inside the network:
`docker run --rm --network edge ymuski/curl-http3 curl --http3-only ...` should
return `http=3 code=200`.
3. **Look past the edge** if the advert is suppressed and stalls persist. The h2 path is
fast server-side, so a remaining stall is most likely the client network / RTT / the
provider, not our stack. Re-run the timing loop (below) to confirm the server is
still <~10 ms TTFB before chasing the client side.
## How this was diagnosed (method, to repeat)
- The runner/prod host shell cannot reach the Docker bridge subnets, so all probing runs
from a throwaway container on the target network (`docker run --network <net>
curlimages/curl`), using `--resolve <host>:443:<edge-ip>` to bypass the public-IP NAT
hairpin and exercise the real TLS path.
- Compare a fresh-connection timing loop (worst case, full TLS each time) against a
keepalive batch to separate handshake cost from serving cost:
```sh
docker run --rm --network edge curlimages/curl:latest sh -c '
for i in $(seq 1 30); do
curl -sS -o /dev/null --resolve <host>:443:<edge-ip> \
-w "http=%{http_version} code=%{http_code} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
https://<host>/telegram/
done'
```
- `docker port <caddy>` shows whether `udp/443` is actually published; the response
`Alt-Svc` header shows what the edge advertises. The two disagreeing is the bug.
+21 -4
View File
@@ -30,7 +30,8 @@ A player arrives from a platform (Telegram first), via email login, or as an
ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
App** launch authenticates from the platform's signed `initData`, themes the UI to
the Telegram colours, and — on first contact — seeds the new account's interface
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
the device safe-area, and — on first contact — seeds the new account's interface
language from the Telegram client. If a launch cannot reach the backend (for example during a
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
@@ -213,6 +214,9 @@ block **overrides but does not delete** an existing friendship (so you may block
they keep seeing you as one); active games are never interrupted — you can finish them, with
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
On Settings → Friends each friend is a one-line row whose right-hand kebab (⋮) slides open
**block 🚫** and **remove ✖️** icon actions, and each action is gated by a confirmation
that names the friend (*Block this player?* / *Remove from friends?*).
Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game
(struck name, hidden composer) and lists the blocked opponent under the name you saw, but is
recorded only against that game — the disguise holds, the shared robot is never globally
@@ -246,12 +250,16 @@ is first created — so robot games are timed correctly before you ever open thi
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge".
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your
Telegram devices.
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
least one** selected. This list is exactly what **New Game** offers when you start a game
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
**at least one** selected. This list is exactly what **New Game** offers when you start a game
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
invited friend may accept an invitation in **any** variant, and you can always open and play
@@ -269,6 +277,15 @@ marked read once the screen shows it and disappears a week later. A badge on the
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
barred from feedback (a role, not a full account block) sees the send control disabled.
### Telegram support chat
A user can also reach the operators straight from the Telegram bot: anything they send the bot
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
private support group, grouped into a per-user thread. The user sees no automatic reply; an
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
Feedback above — it serves people who write to the bot directly rather than through the app.
### History & statistics
Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished**, and never for an
+23 -4
View File
@@ -31,7 +31,9 @@ top-1 подсказку, безлимитную проверку слова с
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
интерфейса нового аккаунта по
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
@@ -218,6 +220,9 @@ _Вход сейчас только через провайдера, поэто
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
@@ -252,12 +257,17 @@ UTC; при создании аккаунта она подставляется
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
@@ -275,6 +285,15 @@ UTC; при создании аккаунта она подставляется
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
канал для тех, кто пишет боту напрямую, а не через приложение.
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** и никогда — для
+17 -9
View File
@@ -8,7 +8,13 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App
launch that delivered none, as seen on some Android clients) the app renders a compact,
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
never load it.
## Layout shell (`components/Screen.svelte`)
@@ -91,14 +97,16 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
native header **BackButton** drives back-navigation (the app's chevron is hidden in
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window;
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
auto-save); **vertical swipes** (swipe-to-minimise)
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
app's **own back chevron** (Header) drives back-navigation on every platform — the native
Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
**HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
the native header (and its BackButton) and the Android system swipe-back then minimised the app,
so it stays windowed with Telegram's thin native header (close) above the app's own header; move
drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
the board scroll; **external links** (the word-check
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
confirmation a plain `target=_blank` triggers; and a live stream dropped
+5 -4
View File
@@ -184,10 +184,10 @@ type ChatResp struct {
}
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
// brand-new account's display name and language from the validated launch fields and
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
// contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
// brand-new account's display name and language from the validated launch fields, its
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
// validated launch deep-link startParam, its variant preferences (first contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{
@@ -196,6 +196,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"username": username,
"first_name": firstName,
"browser_tz": browserTz,
"start_param": startParam,
}, &out)
return out, err
}
+11 -2
View File
@@ -9,6 +9,7 @@ import (
"context"
"encoding/json"
"errors"
"net/url"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector"
@@ -154,11 +155,19 @@ func DomainCode(err error) (string, bool) {
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
user, err := tg.ValidateInitData(ctx, string(in.InitData()))
initData := string(in.InitData())
user, err := tg.ValidateInitData(ctx, initData)
if err != nil {
return nil, err
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
// start_param rides inside the signed initData validated just above, so the launch
// deep-link payload can be trusted here without a separate wire field; the backend
// uses it to seed a brand-new account's variant preferences.
startParam := ""
if q, perr := url.ParseQuery(initData); perr == nil {
startParam = q.Get("start_param")
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
if err != nil {
return nil, err
}
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Fatal("auth.telegram not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")})
if err != nil {
t.Fatalf("handler: %v", err)
}
@@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
}
// start_param is parsed out of the validated initData and forwarded so the backend can
// seed the new account's variant preferences from a promo deep link.
if gotBody["start_param"] != "verudit_ru-scrabble_en" {
t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"])
}
}
func TestTelegramAuthInvalidInitData(t *testing.T) {
+6
View File
@@ -24,6 +24,11 @@ ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/validator ./platform/telegram/cmd/validator
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/bot ./platform/telegram/cmd/bot
# The bot's support relay writes its JSON state under /data. Stage an empty directory
# owned by the distroless nonroot UID so a fresh named volume mounted there inherits
# writable ownership (Docker copies the image dir's mode/owner into an empty volume).
RUN mkdir -p /out/data
# --- validator (home) --------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS validator
COPY --from=build /out/validator /usr/local/bin/validator
@@ -32,4 +37,5 @@ ENTRYPOINT ["/usr/local/bin/validator"]
# --- bot (remote) ------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS bot
COPY --from=build /out/bot /usr/local/bin/bot
COPY --from=build --chown=65532:65532 /out/data /data
ENTRYPOINT ["/usr/local/bin/bot"]
+23 -1
View File
@@ -63,13 +63,32 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
name is a tappable profile mention via a `text_mention` entity — which also stops a name starting
with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle
and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content
— text, media, voice, files — carries over). Any **administrator** of the support chat who writes
in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
(the loop guard). **Block** drops a user's incoming messages (the topic stays); **Clear** deletes
the relayed messages, keeping the info card; a topic the operators delete is reopened on the next
message. State (user→topic map, block list, relayed ids) is a JSON file under
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
would sign the initData), which the main bot's validator rejects. It is fully
self-contained: no bot-link, no gateway, no game.
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
alongside the default Erudit). The message body also renders the bot's `@username` as that
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
the seeded Mini App rather than the bot profile.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits.
@@ -132,9 +151,12 @@ Bot (`cmd/bot`):
| `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert |
| `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
+19
View File
@@ -10,6 +10,7 @@ import (
"context"
"log"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
@@ -23,6 +24,7 @@ import (
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
@@ -65,6 +67,19 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
// The support relay keeps its per-user state in a JSON file on a writable volume
// (the bot host has no database). A corrupt file is logged and the relay starts
// empty rather than crash-looping the bot.
var supportStore *support.Store
if cfg.SupportChatID != 0 {
statePath := filepath.Join(cfg.SupportStateDir, "support.json")
supportStore, err = support.Open(statePath)
if err != nil {
logger.Warn("support: state load failed, starting empty", zap.String("path", statePath), zap.Error(err))
supportStore = support.New(statePath)
}
}
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
@@ -73,6 +88,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID,
GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
}, logger)
if err != nil {
return err
@@ -114,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL,
StartParam: cfg.PromoStartParam,
SendRatePerSecond: cfg.SendRatePerSecond,
}, logger)
if err != nil {
@@ -128,6 +146,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
zap.Bool("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv),
zap.Bool("chat_gating", cfg.ChatID != 0),
zap.Bool("support_relay", cfg.SupportChatID != 0),
zap.Bool("promo_bot", promo != nil))
var wg sync.WaitGroup
+61 -7
View File
@@ -15,6 +15,8 @@ import (
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/support"
)
// Config configures the bot wrapper.
@@ -39,6 +41,14 @@ type Config struct {
// GameChannelID is the game channel whose public @username the /start welcome links
// to (resolved from this id via getChat at startup); 0 omits that follow link.
GameChannelID int64
// SupportChatID is the private forum supergroup the bot relays direct user messages
// into (one topic per user) and reads operator replies from; 0 disables the support
// relay. The bot must be an administrator there with the manage-topics and
// delete-messages rights.
SupportChatID int64
// SupportStore persists the support relay's state (topic mapping, block list,
// relayed message ids); required when SupportChatID is set, ignored otherwise.
SupportStore *support.Store
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -66,11 +76,21 @@ type Bot struct {
channelUsername string
chatUsername string
// botID is the bot's own Telegram user id (resolved at startup); it skips the
// chat_member updates the bot's own restrict actions generate — the grant loop guard.
// chat_member updates the bot's own restrict actions generate — the grant loop guard
// and the bot's own relayed copies in the support chat.
botID int64
// eligibility resolves a joining user's chat write eligibility; nil leaves a
// joiner muted (fail-closed) until it is wired.
eligibility EligibilityResolver
// supportChatID is the support relay's forum supergroup (0 disables the relay).
supportChatID int64
// support persists the support relay's per-user state; nil when the relay is off.
support *support.Store
// supportLocks serialises per-user topic creation so a burst of a new user's
// messages opens exactly one topic.
supportLocks *keyedMutex
// admins caches the support chat's administrator ids (who may reply and act).
admins *adminCache
}
// New builds the bot wrapper, registering the /start handler and a default handler
@@ -80,7 +100,14 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID}
t := &Bot{
miniAppURL: cfg.MiniAppURL,
log: log,
chatID: cfg.ChatID,
channelID: cfg.GameChannelID,
supportChatID: cfg.SupportChatID,
support: cfg.SupportStore,
}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
@@ -89,15 +116,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
}
if t.supportEnabled() {
t.supportLocks = newKeyedMutex()
t.admins = &adminCache{}
// The info-card buttons are callback_query updates; route them to the support
// callback handler by their "sup:" data prefix.
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
}
// Allowed updates default to "all except chat_member". Specify an explicit set only
// when we need chat_member (moderated chat) — and then re-add callback_query (which
// the explicit set would otherwise drop) when the support relay needs it.
if cfg.ChatID != 0 {
// chat_member updates are off by default; subscribe explicitly (alongside
// messages) so the bot sees joins in the moderated chat. The bot must also be an
// administrator there for Telegram to deliver them.
opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{
allowed := tgbot.AllowedUpdates{
models.AllowedUpdateMessage,
models.AllowedUpdateMyChatMember,
models.AllowedUpdateChatMember,
}))
}
if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
}
if cfg.TestEnv {
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
@@ -134,6 +172,9 @@ func (t *Bot) Run(ctx context.Context) {
if t.chatID != 0 {
t.logChatAdminStatus(ctx)
}
if t.supportEnabled() {
t.initSupport(ctx)
}
t.resolveWelcomeHandles(ctx)
t.api.Start(ctx)
}
@@ -305,6 +346,19 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleChatMember(ctx, update.ChatMember)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply.
if t.supportEnabled() && update.Message != nil {
switch {
case update.Message.Chat.ID == t.supportChatID:
t.handleSupportGroupMessage(ctx, update.Message)
return
case update.Message.Chat.Type == models.ChatTypePrivate:
t.handleSupportUserMessage(ctx, update.Message)
return
}
}
t.handleStart(ctx, api, update)
}
+484
View File
@@ -0,0 +1,484 @@
package bot
import (
"context"
"fmt"
"strconv"
"strings"
"sync"
"time"
"unicode/utf16"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"scrabble/platform/telegram/internal/support"
)
// Support relay: the bot forwards a user's direct messages into a per-user forum
// topic in the operators' private support chat, and relays operator replies in that
// topic back to the user. The info card opening each topic carries a Block/Unblock
// toggle and a Clear button. State (topic mapping, block list, relayed message ids)
// lives in a small JSON file via internal/support.
const (
// supportCallbackPrefix namespaces the info-card button callbacks
// ("sup:<action>:<userID>").
supportCallbackPrefix = "sup:"
supportActionBlock = "block"
supportActionUnblock = "unblock"
supportActionClear = "clear"
// supportAdminTTL is how long the support chat's administrator set is cached.
supportAdminTTL = time.Minute
// supportTopicNameMax bounds the forum topic name (the Telegram limit is 128).
supportTopicNameMax = 128
// supportDeleteBatch is the maximum message ids per deleteMessages call (the
// Telegram limit is 100).
supportDeleteBatch = 100
)
// supportEnabled reports whether the support relay is configured.
func (t *Bot) supportEnabled() bool {
return t.supportChatID != 0 && t.support != nil
}
// initSupport prepares the support relay at startup: it resolves the bot's own user
// id (getMe) for the loop guard, unless the chat-gating self-check already did, and
// logs readiness. The loop guard also tests From.IsBot, so an unresolved id is not
// fatal.
func (t *Bot) initSupport(ctx context.Context) {
if t.botID == 0 {
if me, err := t.api.GetMe(ctx); err != nil {
t.log.Warn("support: getMe failed; relying on is_bot for the loop guard", zap.Error(err))
} else {
t.botID = me.ID
}
}
t.log.Info("support relay ready", zap.Int64("support_chat_id", t.supportChatID))
}
// keyedMutex serialises work per int64 key (here, per user id) so two concurrent
// updates from the same user — the go-telegram/bot library runs handlers in
// goroutines — cannot both open a forum topic.
type keyedMutex struct {
mu sync.Mutex
m map[int64]*sync.Mutex
}
func newKeyedMutex() *keyedMutex { return &keyedMutex{m: map[int64]*sync.Mutex{}} }
// lock acquires the per-key mutex and returns its unlock function.
func (k *keyedMutex) lock(key int64) func() {
k.mu.Lock()
mu, ok := k.m[key]
if !ok {
mu = &sync.Mutex{}
k.m[key] = mu
}
k.mu.Unlock()
mu.Lock()
return mu.Unlock
}
// adminCache caches the support chat's administrator ids for a short TTL, so the bot
// does not call getChatAdministrators on every operator action.
type adminCache struct {
mu sync.Mutex
ids map[int64]bool
fetchedAt time.Time
}
// isSupportAdmin reports whether userID administers the support chat, refreshing the
// cache when stale. On a refresh failure it falls back to the last known set
// (fail-closed when nothing is cached yet).
func (t *Bot) isSupportAdmin(ctx context.Context, userID int64) bool {
t.admins.mu.Lock()
if t.admins.ids != nil && time.Since(t.admins.fetchedAt) < supportAdminTTL {
ok := t.admins.ids[userID]
t.admins.mu.Unlock()
return ok
}
t.admins.mu.Unlock()
members, err := t.api.GetChatAdministrators(ctx, &tgbot.GetChatAdministratorsParams{ChatID: t.supportChatID})
if err != nil {
t.log.Warn("support: getChatAdministrators failed; using cached admin set", zap.Error(err))
t.admins.mu.Lock()
defer t.admins.mu.Unlock()
return t.admins.ids[userID] // a nil map yields false (fail-closed)
}
ids := make(map[int64]bool, len(members))
for _, m := range members {
if u := chatMemberUser(m); u != nil {
ids[u.ID] = true
}
}
t.admins.mu.Lock()
t.admins.ids = ids
t.admins.fetchedAt = time.Now()
t.admins.mu.Unlock()
return ids[userID]
}
// handleSupportUserMessage relays a user's direct message into their forum topic in
// the support chat, opening the topic (and its info card) on first contact. A blocked
// user's message is dropped silently, and the user receives no reply — operators
// answer from the topic.
func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
if m.From == nil || m.From.IsBot {
return
}
uid := m.From.ID
unlock := t.supportLocks.lock(uid)
defer unlock()
rec, _ := t.support.Get(uid)
if rec.Blocked {
return
}
topicID, err := t.ensureTopic(ctx, m.From, rec)
if err != nil {
t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err))
return
}
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
if err != nil && isTopicMissingErr(err) {
// Backstop: the topic vanished between the liveness probe and the copy.
t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
}
}
if err != nil {
t.log.Warn("support: relay to topic failed", zap.Int64("user_id", uid), zap.Error(err))
return
}
if err := t.support.AppendMsg(uid, newID); err != nil {
t.log.Warn("support: persist relayed id failed", zap.Int64("user_id", uid), zap.Error(err))
}
}
// handleSupportGroupMessage relays an operator's reply in a user's topic back to that
// user. It ignores the bot's own posts (the loop guard), messages outside a topic,
// unknown topics, and non-administrators. The operator's message is tracked too, so a
// later clear removes it.
func (t *Bot) handleSupportGroupMessage(ctx context.Context, m *models.Message) {
if m.From == nil || m.From.IsBot {
return // the bot's own relayed copies (and other bots) — never loop them back
}
if t.botID != 0 && m.From.ID == t.botID {
return
}
if m.MessageThreadID == 0 {
return // the chat's general area, not a user's topic
}
uid, ok := t.support.ByTopic(m.MessageThreadID)
if !ok {
return
}
if !t.isSupportAdmin(ctx, m.From.ID) {
return // only operators reach the user
}
if err := t.support.AppendMsg(uid, m.ID); err != nil {
t.log.Warn("support: persist operator msg id failed", zap.Int64("user_id", uid), zap.Error(err))
}
if _, err := t.copyToUser(ctx, m.ID, uid); err != nil {
t.log.Warn("support: relay reply to user failed", zap.Int64("user_id", uid), zap.Error(err))
}
}
// handleSupportCallback handles the info-card buttons (block/unblock/clear). Only
// support-chat administrators may act; others get a denial. It always answers the
// callback query so the client's spinner clears.
func (t *Bot) handleSupportCallback(ctx context.Context, _ *tgbot.Bot, update *models.Update) {
cq := update.CallbackQuery
if cq == nil {
return
}
action, uid, ok := parseSupportCallback(cq.Data)
if !ok {
t.answerSupportCallback(ctx, cq.ID, "")
return
}
if !t.isSupportAdmin(ctx, cq.From.ID) {
t.answerSupportCallback(ctx, cq.ID, "Недостаточно прав")
return
}
switch action {
case supportActionBlock:
t.setSupportBlocked(ctx, cq, uid, true)
case supportActionUnblock:
t.setSupportBlocked(ctx, cq, uid, false)
case supportActionClear:
t.clearSupportTopic(ctx, cq, uid)
default:
t.answerSupportCallback(ctx, cq.ID, "")
}
}
// setSupportBlocked sets the user's block state, flips the info-card toggle to match,
// and answers the callback. Blocking does not delete the topic — it stays.
func (t *Bot) setSupportBlocked(ctx context.Context, cq *models.CallbackQuery, uid int64, blocked bool) {
if err := t.support.SetBlocked(uid, blocked); err != nil {
t.log.Warn("support: set blocked failed", zap.Int64("user_id", uid), zap.Error(err))
t.answerSupportCallback(ctx, cq.ID, "Ошибка")
return
}
if rec, ok := t.support.Get(uid); ok && rec.HeaderMsgID != 0 {
if _, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
ChatID: t.supportChatID,
MessageID: rec.HeaderMsgID,
ReplyMarkup: supportCardMarkup(uid, blocked),
}); err != nil {
t.log.Debug("support: update card markup failed", zap.Error(err))
}
}
msg := "Разблокирован"
if blocked {
msg = "Заблокирован"
}
t.answerSupportCallback(ctx, cq.ID, msg)
}
// clearSupportTopic deletes the messages relayed into the user's topic (keeping the
// info card) and answers the callback.
func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, uid int64) {
ids, err := t.support.ClearMsgs(uid)
if err != nil {
t.log.Warn("support: clear failed", zap.Int64("user_id", uid), zap.Error(err))
t.answerSupportCallback(ctx, cq.ID, "Ошибка")
return
}
t.deleteSupportMessages(ctx, ids)
t.answerSupportCallback(ctx, cq.ID, "Очищено")
}
// ensureTopic returns a live forum-topic id for the user, opening a fresh topic (and
// info card) when none exists or the previous one was deleted. Telegram silently
// routes a copy aimed at a deleted topic into the chat's General topic (no error), so
// the bot cannot rely on copyMessage failing; instead it probes the info card —
// re-applying the card's reply markup is a no-op that errors only when the card (hence
// the topic) is gone — and reopens on that signal. The probe also keeps the card's
// block button in sync with the stored state.
func (t *Bot) ensureTopic(ctx context.Context, u *models.User, rec support.User) (int, error) {
if rec.TopicID == 0 || rec.HeaderMsgID == 0 {
return t.openSupportTopic(ctx, u)
}
_, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
ChatID: t.supportChatID,
MessageID: rec.HeaderMsgID,
ReplyMarkup: supportCardMarkup(u.ID, rec.Blocked),
})
if isCardMissingErr(err) {
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", u.ID), zap.Int("topic_id", rec.TopicID))
return t.openSupportTopic(ctx, u)
}
// Any other outcome (success, or a benign "message is not modified") means the
// topic is alive; reuse it.
return rec.TopicID, nil
}
// openSupportTopic creates a forum topic for the user and posts its info card with
// the block/clear buttons, persisting both. It returns the new topic id. A failure to
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error) {
topic, err := t.api.CreateForumTopic(ctx, &tgbot.CreateForumTopicParams{
ChatID: t.supportChatID,
Name: supportTopicName(u),
})
if err != nil {
return 0, fmt.Errorf("create forum topic: %w", err)
}
headerID := 0
cardText, cardEntities := supportCard(u)
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: t.supportChatID,
MessageThreadID: topic.MessageThreadID,
Text: cardText,
Entities: cardEntities,
ReplyMarkup: supportCardMarkup(u.ID, false),
})
if err != nil {
t.log.Warn("support: post info card failed (topic opened without it)", zap.Error(err))
} else {
headerID = header.ID
}
if err := t.support.SetTopic(u.ID, topic.MessageThreadID, headerID, u.FirstName, u.LastName, u.Username); err != nil {
t.log.Warn("support: persist topic state failed (kept in memory)", zap.Int64("user_id", u.ID), zap.Error(err))
}
return topic.MessageThreadID, nil
}
// copyToTopic copies a message into the user's forum topic and returns the new
// message id.
func (t *Bot) copyToTopic(ctx context.Context, fromChatID int64, messageID, topicID int) (int, error) {
if err := t.throttle(ctx); err != nil {
return 0, err
}
res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
ChatID: t.supportChatID,
MessageThreadID: topicID,
FromChatID: fromChatID,
MessageID: messageID,
})
if err != nil {
return 0, err
}
return res.ID, nil
}
// copyToUser copies a support-chat message to the user's private chat and returns the
// new message id.
func (t *Bot) copyToUser(ctx context.Context, messageID int, userID int64) (int, error) {
if err := t.throttle(ctx); err != nil {
return 0, err
}
res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
ChatID: userID,
FromChatID: t.supportChatID,
MessageID: messageID,
})
if err != nil {
return 0, err
}
return res.ID, nil
}
// deleteSupportMessages best-effort deletes the given support-chat messages in
// batches; individual failures (for example a message already removed) are ignored.
func (t *Bot) deleteSupportMessages(ctx context.Context, ids []int) {
for start := 0; start < len(ids); start += supportDeleteBatch {
end := min(start+supportDeleteBatch, len(ids))
if _, err := t.api.DeleteMessages(ctx, &tgbot.DeleteMessagesParams{
ChatID: t.supportChatID,
MessageIDs: ids[start:end],
}); err != nil {
t.log.Debug("support: delete batch failed", zap.Error(err))
}
}
}
// answerSupportCallback answers a callback query, logging a failure at debug (the
// only effect of a miss is a lingering client spinner).
func (t *Bot) answerSupportCallback(ctx context.Context, id, text string) {
if _, err := t.api.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{
CallbackQueryID: id,
Text: text,
}); err != nil {
t.log.Debug("support: answer callback failed", zap.Error(err))
}
}
// parseSupportCallback parses "sup:<action>:<userID>" callback data.
func parseSupportCallback(data string) (action string, userID int64, ok bool) {
rest, found := strings.CutPrefix(data, supportCallbackPrefix)
if !found {
return "", 0, false
}
action, idStr, found := strings.Cut(rest, ":")
if !found {
return "", 0, false
}
userID, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
return "", 0, false
}
return action, userID, true
}
// supportCardMarkup builds the info-card buttons: a Block/Unblock toggle reflecting
// the current state, and a Clear button.
func supportCardMarkup(userID int64, blocked bool) *models.InlineKeyboardMarkup {
toggleText, toggleAction := "Заблокировать", supportActionBlock
if blocked {
toggleText, toggleAction = "Разблокировать", supportActionUnblock
}
return &models.InlineKeyboardMarkup{
InlineKeyboard: [][]models.InlineKeyboardButton{{
{Text: toggleText, CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, toggleAction, userID)},
{Text: "Очистить", CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, supportActionClear, userID)},
}},
}
}
// supportTopicName builds the forum topic title for a user: full name and @username,
// trimmed to the Telegram length limit.
func supportTopicName(u *models.User) string {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" {
name = "user " + strconv.FormatInt(u.ID, 10)
}
if u.Username != "" {
name += " @" + u.Username
}
if r := []rune(name); len(r) > supportTopicNameMax {
return string(r[:supportTopicNameMax])
}
return name
}
// supportCard renders the topic info card describing the user and the message
// entities for it. The display name is covered by a text_mention entity: it links to
// the user's profile (the reliable way to mention a user who has no public username —
// the bot has seen them, since they messaged it) and, because the name sits inside an
// entity, Telegram does not auto-detect a leading "/" as a bot command or a stray
// "@handle" inside the name as a mention. The remaining lines are plain text; a public
// @username, when present, is left for Telegram to auto-link. Entity offsets are in
// UTF-16 code units, as the Bot API requires; the name is at offset 0.
func supportCard(u *models.User) (string, []models.MessageEntity) {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" {
name = "user " + strconv.FormatInt(u.ID, 10)
}
username := "—"
if u.Username != "" {
username = "@" + u.Username
}
lang := u.LanguageCode
if lang == "" {
lang = "—"
}
premium := "нет"
if u.IsPremium {
premium = "да"
}
var b strings.Builder
b.WriteString(name)
fmt.Fprintf(&b, "\nUsername: %s", username)
fmt.Fprintf(&b, "\nID: %d", u.ID)
fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium)
entities := []models.MessageEntity{{
Type: models.MessageEntityTypeTextMention,
Offset: 0,
Length: len(utf16.Encode([]rune(name))),
User: &models.User{ID: u.ID},
}}
return b.String(), entities
}
// isCardMissingErr reports whether err means the info-card message no longer exists
// (the operators deleted the topic), as opposed to a benign "message is not modified"
// no-op when the card is still there.
func isCardMissingErr(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "message to edit not found") ||
strings.Contains(s, "message can't be edited") ||
strings.Contains(s, "message_id_invalid")
}
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
// error, signalling the topic must be reopened.
func isTopicMissingErr(err error) bool {
if err == nil {
return false
}
s := strings.ToLower(err.Error())
return strings.Contains(s, "thread not found") ||
strings.Contains(s, "thread_not_found") ||
strings.Contains(s, "topic deleted") ||
strings.Contains(s, "topic_deleted")
}
@@ -0,0 +1,473 @@
package bot
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"scrabble/platform/telegram/internal/support"
)
const supportChatID = -1001000000000
// copyRec records one copyMessage call.
type copyRec struct {
chatID, fromChatID, threadID, messageID string
}
// supportAPI is a fake Bot API for the support-relay handlers: it answers the
// methods they call with incrementing ids and records the requests for assertions.
type supportAPI struct {
mu sync.Mutex
adminIDs []int64
nextThread int
nextMsg int
topicsOpened int
copies []copyRec
headerThread string // message_thread_id of the last header sendMessage
deleted [][]int
editedMsgIDs []string
answers []string
// editErr, when set, makes editMessageReplyMarkup return that Telegram error
// description (simulating a deleted info card / topic).
editErr string
}
func newSupportAPI(adminIDs ...int64) *supportAPI {
return &supportAPI{adminIDs: adminIDs, nextThread: 1000, nextMsg: 5000}
}
func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
path := r.URL.Path
switch {
case strings.HasSuffix(path, "/getMe"):
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"bot","username":"b"}}`)
case strings.HasSuffix(path, "/createForumTopic"):
s.topicsOpened++
tid := s.nextThread
s.nextThread++
fmt.Fprintf(w, `{"ok":true,"result":{"message_thread_id":%d,"name":%q}}`, tid, r.FormValue("name"))
case strings.HasSuffix(path, "/sendMessage"):
s.headerThread = r.FormValue("message_thread_id")
mid := s.nextMsg
s.nextMsg++
fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid)
case strings.HasSuffix(path, "/copyMessage"):
s.copies = append(s.copies, copyRec{
chatID: r.FormValue("chat_id"),
fromChatID: r.FormValue("from_chat_id"),
threadID: r.FormValue("message_thread_id"),
messageID: r.FormValue("message_id"),
})
mid := s.nextMsg
s.nextMsg++
fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid)
case strings.HasSuffix(path, "/deleteMessages"):
var ids []int
_ = json.Unmarshal([]byte(r.FormValue("message_ids")), &ids)
s.deleted = append(s.deleted, ids)
io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(path, "/editMessageReplyMarkup"):
if s.editErr != "" {
fmt.Fprintf(w, `{"ok":false,"error_code":400,"description":%q}`, s.editErr)
return
}
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
case strings.HasSuffix(path, "/answerCallbackQuery"):
s.answers = append(s.answers, r.FormValue("text"))
io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(path, "/getChatAdministrators"):
var b strings.Builder
b.WriteString(`{"ok":true,"result":[`)
for i, id := range s.adminIDs {
if i > 0 {
b.WriteString(",")
}
fmt.Fprintf(&b, `{"status":"administrator","user":{"id":%d,"is_bot":false}}`, id)
}
b.WriteString(`]}`)
io.WriteString(w, b.String())
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
}
func (s *supportAPI) copyCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.copies)
}
// newSupportBot builds a support-enabled bot against the fake API and returns it with
// its backing store.
func newSupportBot(t *testing.T, api http.Handler) (*Bot, *support.Store) {
t.Helper()
srv := httptest.NewServer(api)
t.Cleanup(srv.Close)
st := support.New(filepath.Join(t.TempDir(), "support.json"))
b, err := New(Config{
Token: "123:ABC",
APIBaseURL: srv.URL,
MiniAppURL: "https://example.com/telegram/",
SupportChatID: supportChatID,
SupportStore: st,
}, zap.NewNop())
if err != nil {
t.Fatalf("new support bot: %v", err)
}
return b, st
}
// userMsg builds a private direct message from the given user.
func userMsg(userID int64, text string) *models.Message {
return &models.Message{
ID: int(userID)*10 + 1,
Chat: models.Chat{ID: userID, Type: models.ChatTypePrivate},
From: &models.User{ID: userID, FirstName: "Ann", Username: "annlee", LanguageCode: "ru"},
Text: text,
}
}
func TestSupportFirstContactOpensTopicAndRelays(t *testing.T) {
api := newSupportAPI()
b, st := newSupportBot(t, api)
b.handleSupportUserMessage(context.Background(), userMsg(7, "hello"))
if api.topicsOpened != 1 {
t.Fatalf("topics opened = %d, want 1", api.topicsOpened)
}
rec, ok := st.Get(7)
if !ok || rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
t.Fatalf("store = %+v ok=%v, want topic 1000 / header 5000", rec, ok)
}
if api.headerThread != "1000" {
t.Errorf("header posted to thread %q, want 1000", api.headerThread)
}
if len(api.copies) != 1 {
t.Fatalf("copies = %d, want 1", len(api.copies))
}
c := api.copies[0]
if c.threadID != "1000" || c.fromChatID != "7" || c.chatID != fmt.Sprint(supportChatID) {
t.Errorf("copy = %+v, want thread 1000 from 7 into the support chat", c)
}
if len(rec.RelayedMsgIDs) != 1 {
t.Errorf("relayed ids = %v, want 1 tracked", rec.RelayedMsgIDs)
}
}
func TestSupportSubsequentReusesTopic(t *testing.T) {
api := newSupportAPI()
b, st := newSupportBot(t, api)
b.handleSupportUserMessage(context.Background(), userMsg(7, "first"))
b.handleSupportUserMessage(context.Background(), userMsg(7, "second"))
if api.topicsOpened != 1 {
t.Errorf("topics opened = %d, want 1 (topic reused)", api.topicsOpened)
}
if len(api.copies) != 2 {
t.Errorf("copies = %d, want 2", len(api.copies))
}
rec, _ := st.Get(7)
if len(rec.RelayedMsgIDs) != 2 {
t.Errorf("relayed ids = %v, want 2", rec.RelayedMsgIDs)
}
}
// TestSupportTopicRecreatedWhenDeleted covers the case the operator hit in prod:
// after they delete a user's whole topic, Telegram routes a copy aimed at it into
// General with no error, so the bot must proactively detect the dead topic (the card
// probe fails) and reopen it instead of silently losing the message to General.
func TestSupportTopicRecreatedWhenDeleted(t *testing.T) {
api := newSupportAPI()
api.editErr = "message to edit not found" // the operators deleted the topic + its card
b, st := newSupportBot(t, api)
// Seed a topic that has since been deleted in Telegram.
if err := st.SetTopic(7, 999, 4999, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
b.handleSupportUserMessage(context.Background(), userMsg(7, "still there?"))
if api.topicsOpened != 1 {
t.Fatalf("topics opened = %d, want 1 (reopened after deletion)", api.topicsOpened)
}
rec, _ := st.Get(7)
if rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
t.Errorf("store = topic %d / header %d, want the reopened 1000 / 5000", rec.TopicID, rec.HeaderMsgID)
}
if len(api.copies) != 1 || api.copies[0].threadID != "1000" {
t.Errorf("relay copies = %+v, want one into the reopened topic 1000", api.copies)
}
if _, ok := st.ByTopic(999); ok {
t.Error("stale topic 999 still indexed after reopen")
}
}
func TestSupportBlockedUserDropped(t *testing.T) {
api := newSupportAPI()
b, st := newSupportBot(t, api)
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
if err := st.SetBlocked(7, true); err != nil {
t.Fatalf("block: %v", err)
}
b.handleSupportUserMessage(context.Background(), userMsg(7, "hello?"))
if api.copyCount() != 0 {
t.Errorf("a blocked user's message was relayed (%d copies)", api.copyCount())
}
if api.topicsOpened != 0 {
t.Errorf("a blocked user opened a topic (%d)", api.topicsOpened)
}
}
// supportGroupMsg builds an operator message inside a topic of the support chat.
func supportGroupMsg(fromID int64, threadID int, isBot bool) *models.Message {
return &models.Message{
ID: 700 + threadID,
Chat: models.Chat{ID: supportChatID, Type: models.ChatTypeSupergroup},
From: &models.User{ID: fromID, IsBot: isBot},
MessageThreadID: threadID,
Text: "operator reply",
}
}
func TestSupportOperatorReplyRelayed(t *testing.T) {
api := newSupportAPI(50) // user 50 is an admin
b, st := newSupportBot(t, api)
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
b.handleSupportGroupMessage(context.Background(), supportGroupMsg(50, 1000, false))
if len(api.copies) != 1 {
t.Fatalf("copies = %d, want 1 (reply relayed to user)", len(api.copies))
}
if api.copies[0].chatID != "7" || api.copies[0].fromChatID != fmt.Sprint(supportChatID) {
t.Errorf("copy = %+v, want from the support chat to user 7", api.copies[0])
}
rec, _ := st.Get(7)
if len(rec.RelayedMsgIDs) != 1 {
t.Errorf("operator message not tracked for clear: %v", rec.RelayedMsgIDs)
}
}
func TestSupportGroupMessageIgnored(t *testing.T) {
cases := []struct {
name string
msg *models.Message
admins []int64
}{
{"bot's own copy (loop guard)", supportGroupMsg(1, 1000, true), []int64{50}},
{"non-admin sender", supportGroupMsg(999, 1000, false), []int64{50}},
{"outside any topic", supportGroupMsg(50, 0, false), []int64{50}},
{"unknown topic", supportGroupMsg(50, 4242, false), []int64{50}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api := newSupportAPI(tc.admins...)
b, st := newSupportBot(t, api)
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
b.handleSupportGroupMessage(context.Background(), tc.msg)
if api.copyCount() != 0 {
t.Errorf("message relayed (%d copies); it should be ignored", api.copyCount())
}
})
}
}
// supportCallback builds a callback-query update for the given data and presser.
func supportCallback(data string, fromID int64) *models.Update {
return &models.Update{CallbackQuery: &models.CallbackQuery{
ID: "cb1",
From: models.User{ID: fromID},
Data: data,
}}
}
func TestSupportCallbackBlockToggle(t *testing.T) {
api := newSupportAPI(50)
b, st := newSupportBot(t, api)
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 50))
if !st.Blocked(7) {
t.Error("user not blocked after sup:block")
}
if len(api.editedMsgIDs) != 1 || api.editedMsgIDs[0] != "5000" {
t.Errorf("edited markup msg ids = %v, want [5000]", api.editedMsgIDs)
}
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:unblock:7", 50))
if st.Blocked(7) {
t.Error("user still blocked after sup:unblock")
}
if len(api.answers) != 2 || api.answers[0] != "Заблокирован" || api.answers[1] != "Разблокирован" {
t.Errorf("answers = %v, want [Заблокирован Разблокирован]", api.answers)
}
}
func TestSupportCallbackNonAdminDenied(t *testing.T) {
api := newSupportAPI(50)
b, st := newSupportBot(t, api)
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 999))
if st.Blocked(7) {
t.Error("a non-admin managed to block the user")
}
if len(api.answers) != 1 || api.answers[0] != "Недостаточно прав" {
t.Errorf("answers = %v, want a permission denial", api.answers)
}
}
func TestSupportCallbackClearDeletesTracked(t *testing.T) {
api := newSupportAPI(50)
b, st := newSupportBot(t, api)
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
t.Fatalf("seed topic: %v", err)
}
for _, id := range []int{501, 502, 503} {
if err := st.AppendMsg(7, id); err != nil {
t.Fatalf("append: %v", err)
}
}
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:clear:7", 50))
if len(api.deleted) != 1 || len(api.deleted[0]) != 3 {
t.Fatalf("deleted batches = %v, want one batch of 3", api.deleted)
}
rec, _ := st.Get(7)
if len(rec.RelayedMsgIDs) != 0 {
t.Errorf("relayed ids after clear = %v, want empty", rec.RelayedMsgIDs)
}
if rec.HeaderMsgID != 5000 {
t.Errorf("clear disturbed the header (%d)", rec.HeaderMsgID)
}
}
func TestParseSupportCallback(t *testing.T) {
cases := []struct {
data string
wantAction string
wantUID int64
wantOK bool
}{
{"sup:block:7", "block", 7, true},
{"sup:clear:-100", "clear", -100, true},
{"sup:unblock:42", "unblock", 42, true},
{"block:7", "", 0, false},
{"sup:block", "", 0, false},
{"sup:block:notanumber", "", 0, false},
}
for _, tc := range cases {
t.Run(tc.data, func(t *testing.T) {
action, uid, ok := parseSupportCallback(tc.data)
if action != tc.wantAction || uid != tc.wantUID || ok != tc.wantOK {
t.Errorf("parseSupportCallback(%q) = %q,%d,%v; want %q,%d,%v",
tc.data, action, uid, ok, tc.wantAction, tc.wantUID, tc.wantOK)
}
})
}
}
func TestSupportCard(t *testing.T) {
t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
text, ents := supportCard(u)
// The name is rendered verbatim (no HTML, no escaping) at the start of the card.
if !strings.HasPrefix(text, "/start\n") {
t.Errorf("card = %q, want it to start with the raw name", text)
}
// A text_mention entity covers exactly the name with the user id — this both
// suppresses the "/start" command auto-detection and links the profile.
if len(ents) != 1 {
t.Fatalf("entities = %d, want 1", len(ents))
}
e := ents[0]
if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") {
t.Errorf("entity = %+v, want a text_mention over the name", e)
}
if e.User == nil || e.User.ID != 7 {
t.Errorf("entity user = %+v, want id 7", e.User)
}
if strings.Contains(text, "tg://") || strings.Contains(text, "<") {
t.Errorf("card = %q, want no tg:// link and no HTML", text)
}
if !strings.Contains(text, "Premium: да") || !strings.Contains(text, "@ann") {
t.Errorf("card = %q, want premium + @username", text)
}
})
t.Run("entity length is UTF-16, not runes", func(t *testing.T) {
// "😀A" is 2 runes but 3 UTF-16 code units (the emoji is a surrogate pair).
u := &models.User{ID: 9, FirstName: "😀A"}
_, ents := supportCard(u)
if len(ents) != 1 || ents[0].Length != 3 {
t.Fatalf("entity = %+v, want length 3 UTF-16 units", ents)
}
})
t.Run("nameless user falls back to an id label", func(t *testing.T) {
u := &models.User{ID: 42}
text, ents := supportCard(u)
if !strings.HasPrefix(text, "user 42") {
t.Errorf("card = %q, want the id fallback name", text)
}
if ents[0].Length != len("user 42") {
t.Errorf("entity length = %d, want it over the fallback name", ents[0].Length)
}
})
}
func TestSupportTopicName(t *testing.T) {
if got := supportTopicName(&models.User{ID: 7, FirstName: "Ann", LastName: "Lee", Username: "annlee"}); got != "Ann Lee @annlee" {
t.Errorf("topic name = %q, want %q", got, "Ann Lee @annlee")
}
if got := supportTopicName(&models.User{ID: 7}); got != "user 7" {
t.Errorf("nameless topic = %q, want %q", got, "user 7")
}
long := strings.Repeat("x", 200)
if got := supportTopicName(&models.User{ID: 7, FirstName: long}); len([]rune(got)) != supportTopicNameMax {
t.Errorf("topic name length = %d, want %d (trimmed)", len([]rune(got)), supportTopicNameMax)
}
}
func TestSupportDisabledFallsThrough(t *testing.T) {
// With no SupportChatID the relay is off and supportEnabled is false.
srv := httptest.NewServer(&supportAPI{nextThread: 1000, nextMsg: 5000})
t.Cleanup(srv.Close)
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/"}, zap.NewNop())
if err != nil {
t.Fatalf("new bot: %v", err)
}
if b.supportEnabled() {
t.Error("supportEnabled() = true without a SupportChatID")
}
}
@@ -42,6 +42,16 @@ type BotConfig struct {
// (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin
// there with the "Ban users" right, and "chat_member" in its allowed updates.
ChatID int64
// SupportChatID is the chat id of the private forum supergroup the bot relays
// direct user messages into — one forum topic per user — and reads operator
// replies from (TELEGRAM_SUPPORT_CHAT_ID, optional; 0 disables the support relay).
// The bot must be an administrator there with the manage-topics and
// delete-messages rights. Distinct from ChatID (the moderated discussion chat).
SupportChatID int64
// SupportStateDir is the directory holding the support relay's JSON state file
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
// container user (UID 65532) and backed by a persistent volume.
SupportStateDir string
// PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
@@ -55,6 +65,12 @@ type BotConfig struct {
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string
// PromoStartParam is the promo button's launch payload, appended as
// ?startapp=<PromoStartParam> (TELEGRAM_PROMO_START_PARAM, default
// "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to
// seed a brand-new user's variant preferences; its labels must match the backend's
// known variants. Empty falls back to forwarding the user's own /start payload.
PromoStartParam string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
@@ -103,6 +119,10 @@ const (
defaultValidatorGRPCAddr = ":9091"
defaultBotReconnectDelay = 2 * time.Second
defaultSendRatePerSecond = 25
// defaultPromoStartParam is the promo button's default launch payload: a variant-seed
// deep link the backend decodes to add English Scrabble to a brand-new user's variant
// preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM.
defaultPromoStartParam = "verudit_ru-scrabble_en"
)
// LoadValidator reads the validator configuration from the environment.
@@ -135,6 +155,8 @@ func LoadBot() (BotConfig, error) {
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
@@ -152,6 +174,9 @@ func LoadBot() (BotConfig, error) {
if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SupportChatID, err = envInt64("TELEGRAM_SUPPORT_CHAT_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
return BotConfig{}, err
}
@@ -151,6 +151,48 @@ func TestLoadBotChatAndPromo(t *testing.T) {
})
}
// TestLoadBotSupportRelay verifies the support-relay chat id parses, the state
// directory defaults to /data, and the relay is disabled (chat id 0) by default.
func TestLoadBotSupportRelay(t *testing.T) {
t.Run("parsed", func(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "-1001234567890")
t.Setenv("TELEGRAM_SUPPORT_STATE_DIR", "/srv/state")
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
}
if c.SupportChatID != -1001234567890 {
t.Errorf("SupportChatID = %d, want -1001234567890", c.SupportChatID)
}
if c.SupportStateDir != "/srv/state" {
t.Errorf("SupportStateDir = %q, want /srv/state", c.SupportStateDir)
}
})
t.Run("defaults", func(t *testing.T) {
setBotRequired(t)
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
}
if c.SupportChatID != 0 {
t.Errorf("SupportChatID = %d, want 0 (relay disabled)", c.SupportChatID)
}
if c.SupportStateDir != "/data" {
t.Errorf("SupportStateDir = %q, want /data", c.SupportStateDir)
}
})
t.Run("malformed chat id rejected", func(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "not-a-number")
if _, err := LoadBot(); err == nil {
t.Fatal("LoadBot: expected an error for a malformed support chat id, got nil")
}
})
}
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
// fails validation (the validator path).
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
@@ -18,7 +18,8 @@ import (
)
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
// the hash, is malformed, or is older than the freshness window.
// the hash, is malformed, is older than the freshness window, or identifies a bot
// user (is_bot), which is denied.
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
// defaultMaxAge bounds how old a validated initData payload may be.
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
}
var u struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
return User{}, ErrInvalidInitData
}
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
// never carries it, so reject defensively rather than provision an account for a bot.
if u.IsBot {
return User{}, ErrInvalidInitData
}
return User{
ExternalID: strconv.FormatInt(u.ID, 10),
Username: u.Username,
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
}
})
}
func TestValidateBotUser(t *testing.T) {
t.Run("is_bot true is denied", func(t *testing.T) {
initData := signInitData(testToken, map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
})
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
t.Errorf("err = %v, want ErrInvalidInitData", err)
}
})
t.Run("is_bot false is allowed", func(t *testing.T) {
initData := signInitData(testToken, map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
})
u, err := NewHMACValidator(testToken).Validate(initData)
if err != nil {
t.Fatalf("validate: %v", err)
}
if u.ExternalID != "42" || u.FirstName != "Thomas" {
t.Errorf("user = %+v, want {42 Thomas}", u)
}
})
}
+32 -14
View File
@@ -9,7 +9,9 @@
package promobot
import (
"cmp"
"context"
"html"
"net/url"
"strings"
@@ -33,6 +35,11 @@ type Config struct {
// BotLinkURL is the main bot's Mini App direct link; the button appends
// ?startapp=<payload> to it.
BotLinkURL string
// StartParam is the campaign payload appended as ?startapp=<StartParam> to the launch
// button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend
// decodes to seed a brand-new user's variant preferences. Empty falls back to
// forwarding the user's own /start payload.
StartParam string
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
// disables the limiter. The burst equals the per-second rate.
SendRatePerSecond int
@@ -40,11 +47,12 @@ type Config struct {
// Bot is the promo bot wrapper around a Telegram Bot API client.
type Bot struct {
api *tgbot.Bot
username string
linkURL string
log *zap.Logger
limiter *rate.Limiter
api *tgbot.Bot
username string
linkURL string
startParam string
log *zap.Logger
limiter *rate.Limiter
}
// New builds the promo bot, registering a /start (and default) handler that replies
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
@@ -87,7 +95,8 @@ func (t *Bot) Run(ctx context.Context) {
}
// handleStart replies to any message (typically /start) with the localized promo text
// and a button that opens the main bot's Mini App, forwarding any /start payload.
// and a button that opens the main bot's Mini App at the configured campaign payload
// (falling back to forwarding any /start payload the user arrived with).
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil {
return
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
text, button := promoText(lang, t.username)
// The configured campaign payload (a variant-seed deep link) takes precedence; absent
// one, fall back to forwarding any /start payload the user arrived with. The same
// payload backs both the inline button and the @username link in the body.
param := cmp.Or(t.startParam, startPayload(update.Message.Text))
text, button := promoText(lang, t.username, t.launchURL(param))
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: text,
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
ParseMode: models.ParseModeHTML,
ReplyMarkup: t.launchMarkup(button, param),
}); err != nil {
t.log.Warn("promo: reply to start failed", zap.Error(err))
}
@@ -159,11 +173,15 @@ func startPayload(text string) string {
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
}
// promoText returns the localized message body and button label, naming the main bot
// (Russian for a "ru" language code, English otherwise).
func promoText(lang, username string) (text, button string) {
// promoText returns the localized message body and button label. The body names the main
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
// target as the button), so tapping the mention opens the seeded Mini App rather than the
// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the
// static sentences need no escaping. Russian for a "ru" language code, English otherwise.
func promoText(lang, username, launchURL string) (text, button string) {
mention := `<a href="` + html.EscapeString(launchURL) + `">@` + html.EscapeString(username) + `</a>`
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
}
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!"
}
@@ -13,25 +13,30 @@ import (
)
func TestPromoTextLocalization(t *testing.T) {
en, enBtn := promoText("en", "ScrabbleBot")
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
// The @username is rendered as a clickable deep link (the same target as the button),
// not a plain mention, so tapping it opens the seeded Mini App.
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
en, enBtn := promoText("en", "ScrabbleBot", url)
if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") {
t.Errorf("en text = %q", en)
}
if enBtn != "🤩 I want to play!" {
t.Errorf("en button = %q", enBtn)
}
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
t.Errorf("ru text = %q", ru)
}
if ruBtn != "🤩 Хочу играть!" {
t.Errorf("ru button = %q", ruBtn)
}
// An unknown language falls back to English.
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
t.Errorf("fallback text = %q, want English", got)
// An unknown language falls back to English, still with the linked mention.
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
t.Errorf("fallback text = %q, want English with a linked mention", got)
}
}
@@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) {
if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID)
}
if !strings.Contains(api.text, "@ScrabbleBot") {
t.Errorf("text = %q, want the @mention", api.text)
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
}
if strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
+227
View File
@@ -0,0 +1,227 @@
// Package support persists the Telegram bot's support-relay state. For each user
// who direct-messages the bot it records the dedicated forum topic the bot opened
// in the operators' support chat, whether the user is blocked, and the ids of the
// messages relayed into that topic — so an operator's "clear" can delete them while
// keeping the topic's info card. The state is a small JSON file on a writable
// volume: the bot host has no database.
//
// The store is concurrency-safe. The go-telegram/bot library dispatches each update
// in its own goroutine, so every exported method locks. Mutators update only their
// own fields (SetTopic never touches Blocked, SetBlocked never touches the relayed
// ids) so a topic recreate cannot clobber a concurrent block toggle.
package support
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// User is one user's support-relay state.
type User struct {
// UserID is the user's Telegram user id (the private chat id the bot relays to).
UserID int64 `json:"user_id"`
// TopicID is the forum topic's message_thread_id in the support chat; 0 means no
// topic has been created yet.
TopicID int `json:"topic_id"`
// HeaderMsgID is the info-card message that opens the topic and carries the
// block/clear buttons; it is never deleted by a clear.
HeaderMsgID int `json:"header_msg_id"`
// Blocked reports whether the bot drops this user's incoming messages.
Blocked bool `json:"blocked"`
// FirstName, LastName and Username snapshot the user's Telegram profile at the
// time the topic was created or last refreshed (for the topic name and info card).
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
// RelayedMsgIDs are the ids of the messages posted into the topic after the header
// (the user's relayed messages and the operators' replies); a clear deletes them.
RelayedMsgIDs []int `json:"relayed_msg_ids,omitempty"`
// CreatedAt is when the topic was first opened for this user.
CreatedAt time.Time `json:"created_at"`
}
// Store is the concurrency-safe, JSON-backed support-relay state. The zero value is
// not usable; build one with Open or New.
type Store struct {
mu sync.Mutex
path string
users map[int64]*User
byTopic map[int]int64 // TopicID -> UserID, for resolving operator replies
}
// file is the on-disk shape: a flat list of users. A JSON object keyed by user id
// would force the int64 keys through strings; a list keeps the ids typed.
type file struct {
Users []*User `json:"users"`
}
// New returns an empty store that persists to path, creating the parent directory
// when missing. Use it as the fallback when Open reports a corrupt file.
func New(path string) *Store {
_ = os.MkdirAll(filepath.Dir(path), 0o755)
return &Store{path: path, users: map[int64]*User{}, byTopic: map[int]int64{}}
}
// Open loads the store from path. A missing file yields an empty store and no
// error; an unreadable or malformed file returns an error so the caller can decide
// whether to start empty.
func Open(path string) (*Store, error) {
s := New(path)
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, fmt.Errorf("support: read state %s: %w", path, err)
}
var f file
if err := json.Unmarshal(b, &f); err != nil {
return nil, fmt.Errorf("support: parse state %s: %w", path, err)
}
for _, u := range f.Users {
if u == nil || u.UserID == 0 {
continue
}
s.users[u.UserID] = u
if u.TopicID != 0 {
s.byTopic[u.TopicID] = u.UserID
}
}
return s, nil
}
// Get returns a detached copy of the user's state and whether it exists.
func (s *Store) Get(userID int64) (User, bool) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
return User{}, false
}
cp := *u
cp.RelayedMsgIDs = append([]int(nil), u.RelayedMsgIDs...)
return cp, true
}
// ByTopic returns the user id owning the given forum topic, and whether one is known.
func (s *Store) ByTopic(topicID int) (int64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
uid, ok := s.byTopic[topicID]
return uid, ok
}
// Blocked reports whether the user's incoming messages are dropped.
func (s *Store) Blocked(userID int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
if u, ok := s.users[userID]; ok {
return u.Blocked
}
return false
}
// SetTopic records the forum topic and info-card message opened for the user and
// refreshes the profile snapshot, creating the record on first contact. It rebuilds
// the topic index when the topic changes (a recreate) and preserves the block state
// and the relayed-message ids. It persists the result.
func (s *Store) SetTopic(userID int64, topicID, headerMsgID int, firstName, lastName, username string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
s.users[userID] = u
} else if u.TopicID != 0 && u.TopicID != topicID {
delete(s.byTopic, u.TopicID)
}
u.TopicID = topicID
u.HeaderMsgID = headerMsgID
u.FirstName, u.LastName, u.Username = firstName, lastName, username
if topicID != 0 {
s.byTopic[topicID] = userID
}
return s.save()
}
// SetBlocked sets the user's blocked flag, creating a minimal record when none
// exists, and persists the result.
func (s *Store) SetBlocked(userID int64, blocked bool) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
s.users[userID] = u
}
u.Blocked = blocked
return s.save()
}
// AppendMsg records a message posted into the user's topic (for a later clear) and
// persists the result. It is a no-op when the user has no record yet.
func (s *Store) AppendMsg(userID int64, msgID int) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
return nil
}
u.RelayedMsgIDs = append(u.RelayedMsgIDs, msgID)
return s.save()
}
// ClearMsgs empties and returns the user's relayed-message ids (the info card is not
// among them) and persists the result, so the caller can delete them from the chat.
func (s *Store) ClearMsgs(userID int64) ([]int, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok || len(u.RelayedMsgIDs) == 0 {
return nil, nil
}
ids := u.RelayedMsgIDs
u.RelayedMsgIDs = nil
if err := s.save(); err != nil {
// Roll back so the ids are not lost if the write fails.
u.RelayedMsgIDs = ids
return nil, err
}
return ids, nil
}
// save writes the state atomically (temp file in the same directory, then rename).
// The caller must hold s.mu.
func (s *Store) save() error {
f := file{Users: make([]*User, 0, len(s.users))}
for _, u := range s.users {
f.Users = append(f.Users, u)
}
sort.Slice(f.Users, func(i, j int) bool { return f.Users[i].UserID < f.Users[j].UserID })
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return fmt.Errorf("support: marshal state: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".support-*.json")
if err != nil {
return fmt.Errorf("support: create temp state: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds
if _, err := tmp.Write(b); err != nil {
_ = tmp.Close()
return fmt.Errorf("support: write temp state: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("support: close temp state: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("support: replace state %s: %w", s.path, err)
}
return nil
}
@@ -0,0 +1,178 @@
package support
import (
"os"
"path/filepath"
"sync"
"testing"
)
// statePath returns a path inside a fresh temp directory for a store file.
func statePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "support.json")
}
func TestOpenMissingReturnsEmpty(t *testing.T) {
s, err := Open(statePath(t))
if err != nil {
t.Fatalf("Open missing: %v", err)
}
if _, ok := s.Get(42); ok {
t.Error("Get on an empty store returned a record")
}
}
func TestOpenCorruptReturnsError(t *testing.T) {
path := statePath(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatalf("write corrupt: %v", err)
}
if _, err := Open(path); err == nil {
t.Fatal("Open on a corrupt file: expected an error, got nil")
}
}
func TestSetTopicPersistsAndReloads(t *testing.T) {
path := statePath(t)
s := New(path)
if err := s.SetTopic(7, 100, 101, "Ann", "Lee", "annlee"); err != nil {
t.Fatalf("SetTopic: %v", err)
}
// In-memory view.
got, ok := s.Get(7)
if !ok || got.TopicID != 100 || got.HeaderMsgID != 101 || got.Username != "annlee" {
t.Fatalf("Get = %+v ok=%v, want topic 100 / header 101 / annlee", got, ok)
}
if got.CreatedAt.IsZero() {
t.Error("CreatedAt not set on first contact")
}
if uid, ok := s.ByTopic(100); !ok || uid != 7 {
t.Errorf("ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
}
// Reloaded from disk.
reload, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, ok = reload.Get(7)
if !ok || got.TopicID != 100 || got.FirstName != "Ann" {
t.Fatalf("reloaded Get = %+v ok=%v, want topic 100 / Ann", got, ok)
}
if uid, ok := reload.ByTopic(100); !ok || uid != 7 {
t.Errorf("reloaded ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
}
}
func TestSetTopicRecreateReindexes(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.SetTopic(7, 200, 201, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic recreate: %v", err)
}
if _, ok := s.ByTopic(100); ok {
t.Error("ByTopic(100) still resolves after recreate; the stale topic was not dropped")
}
if uid, ok := s.ByTopic(200); !ok || uid != 7 {
t.Errorf("ByTopic(200) = %d ok=%v, want 7/true", uid, ok)
}
}
// TestBlockSurvivesTopicRecreate is the field-isolation guarantee: a topic recreate
// (SetTopic) must not clear a block set concurrently from the buttons.
func TestBlockSurvivesTopicRecreate(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", "annlee"); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.SetBlocked(7, true); err != nil {
t.Fatalf("SetBlocked: %v", err)
}
if err := s.SetTopic(7, 200, 201, "Ann", "", "annlee"); err != nil {
t.Fatalf("SetTopic recreate: %v", err)
}
if !s.Blocked(7) {
t.Error("block was cleared by a topic recreate")
}
}
func TestAppendAndClearMsgs(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
for _, id := range []int{500, 501, 502} {
if err := s.AppendMsg(7, id); err != nil {
t.Fatalf("AppendMsg %d: %v", id, err)
}
}
got, _ := s.Get(7)
if len(got.RelayedMsgIDs) != 3 {
t.Fatalf("RelayedMsgIDs = %v, want 3 ids", got.RelayedMsgIDs)
}
ids, err := s.ClearMsgs(7)
if err != nil {
t.Fatalf("ClearMsgs: %v", err)
}
if len(ids) != 3 || ids[0] != 500 || ids[2] != 502 {
t.Errorf("ClearMsgs = %v, want [500 501 502]", ids)
}
got, _ = s.Get(7)
if len(got.RelayedMsgIDs) != 0 {
t.Errorf("RelayedMsgIDs after clear = %v, want empty", got.RelayedMsgIDs)
}
if got.HeaderMsgID != 101 || got.TopicID != 100 {
t.Errorf("clear disturbed the topic/header: %+v", got)
}
// A second clear is a no-op.
if ids, err := s.ClearMsgs(7); err != nil || ids != nil {
t.Errorf("second ClearMsgs = %v, %v, want nil, nil", ids, err)
}
}
func TestGetReturnsDetachedCopy(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.AppendMsg(7, 500); err != nil {
t.Fatalf("AppendMsg: %v", err)
}
got, _ := s.Get(7)
got.RelayedMsgIDs[0] = 999 // mutate the copy
again, _ := s.Get(7)
if again.RelayedMsgIDs[0] != 500 {
t.Error("Get returned a slice that aliases the store's state")
}
}
// TestConcurrentMutationsSafe exercises the mutex under parallel writers; run with
// -race to catch data races.
func TestConcurrentMutationsSafe(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(n int) {
defer wg.Done()
_ = s.AppendMsg(7, 1000+n)
_ = s.SetBlocked(7, n%2 == 0)
_, _ = s.Get(7)
_, _ = s.ByTopic(100)
}(i)
}
wg.Wait()
got, ok := s.Get(7)
if !ok || len(got.RelayedMsgIDs) != 20 {
t.Errorf("after concurrent appends: %d ids, want 20", len(got.RelayedMsgIDs))
}
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { test as base } from '@playwright/test';
// All e2e specs run hermetically against the mock transport. Neutralise the real
// telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks
// on telegram.org — it is unreachable from the CI runner, and a render-blocking
// <script> to it would hang every page load. Specs that exercise the Telegram launch
// inject their own window.Telegram via addInitScript before navigating.
// telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the
// suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise
// the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the
// dynamic load short-circuits on the already-present SDK.
export const test = base.extend({
page: async ({ page }, use) => {
await page.route('**/telegram-web-app.js', (route) =>
await page.route('**/telegram-web-app.js*', (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
);
await use(page);
+54
View File
@@ -44,6 +44,60 @@ test('friends: issue a code, accept an incoming request, redeem a code', async (
await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible();
});
test('friends: the row kebab reveals block/remove and an outside tap closes it', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
// A friend row slides open on its kebab (like the lobby), exposing two icon actions.
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await expect(kaya).toHaveClass(/revealed/);
await expect(kaya.locator('.acts').getByRole('button', { name: 'Block' })).toBeVisible();
await expect(kaya.locator('.acts').getByRole('button', { name: 'Remove' })).toBeVisible();
// A tap anywhere outside the action buttons collapses the row again.
await page.getByRole('heading', { name: 'Your friends' }).click();
await expect(kaya).not.toHaveClass(/revealed/);
});
test('friends: blocking from the list confirms (naming the friend) and moves them to Blocked', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await kaya.locator('.acts').getByRole('button', { name: 'Block' }).click();
// The confirmation keeps a generic title and names the friend in the body.
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Block this player?')).toBeVisible();
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
await dialog.getByRole('button', { name: 'Block' }).click();
// The block applied: Kaya leaves the friends list and shows under Blocked players.
await expect(page.getByText('No friends yet.')).toBeVisible();
const blocked = page.locator('.rowwrap', { hasText: 'Kaya' });
await expect(blocked.getByRole('button', { name: 'Unblock' })).toBeVisible();
});
test('friends: removing from the list confirms (naming the friend) and drops them', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await kaya.locator('.acts').getByRole('button', { name: 'Remove' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Remove from friends?')).toBeVisible();
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
await dialog.getByRole('button', { name: 'Remove' }).click();
// Unfriending just drops the friendship — Kaya is gone and not blocked.
await expect(page.getByText('No friends yet.')).toBeVisible();
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0);
});
test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
await loginLobby(page);
await expect(page.getByText('Invitations')).toBeVisible();
+24 -5
View File
@@ -107,11 +107,30 @@ test('inside Telegram, a failed launch shows the retry screen, not the web login
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
});
test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a redirect', async ({
page,
}) => {
await page.goto('/telegram/');
// The guard sends a non-Telegram visitor back to the root, where the normal
// (guest / email) login is shown.
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
await expect(page).not.toHaveURL(/\/telegram\//);
// The entry no longer bounces a visitor without Telegram sign-in data to the marketing landing;
// it shows a compact diagnostic screen (Share + Retry) that helps pinpoint why initData was
// absent — notably the Android empty-initData failure.
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
// It stays on /telegram/ (no redirect) and never shows the web (guest) login.
await expect(page).toHaveURL(/\/telegram\//);
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
// fetch must not strand the page — the diagnostic screen still renders, reporting no SDK. (This
// route overrides the fixture's empty-body fulfill; the later registration wins.)
await page.route('**/telegram-web-app.js*', (route) => route.abort());
await page.goto('/telegram/');
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// The diagnostic names the load outcome: a failed fetch reads as sdk-load: error.
await expect(page.getByText('sdk-load: error')).toBeVisible();
});
+5 -3
View File
@@ -2,9 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside
Telegram (initData is empty), so it loads on every entry. -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
render-blocking <script> to telegram.org hangs the whole page on a network that blocks
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
the launch-diagnostic screen. The app loads it dynamically, with a timeout, only on a
Telegram entry — see lib/telegram.ts loadTelegramSDK and lib/app.svelte.ts bootstrap. -->
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta
+13 -16
View File
@@ -2,13 +2,13 @@
import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte';
import { navigate, router, type RouteName } from './lib/router.svelte';
import { router, type RouteName } from './lib/router.svelte';
import { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte';
import Splash from './components/Splash.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte';
@@ -19,6 +19,7 @@
import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte';
import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => {
void bootstrap();
@@ -29,19 +30,6 @@
// another screen is not covered.
const routeIsLobby = $derived(router.route.name === 'lobby');
// Inside Telegram, drive its native header back button: show it on any sub-screen
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
$effect(() => {
if (!insideTelegram()) return;
const r = router.route;
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
let target = '/';
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
});
// Screen transitions: the lobby is the navigation root. Entering a screen from the
// lobby slides it in from the right (forward); returning to the lobby slides the
// screen out to the right and reveals the lobby (back). Transitions are local, so
@@ -84,6 +72,11 @@
{#if !routeIsLobby}
<div class="splash">{t('common.loading')}</div>
{/if}
{:else if app.launchError}
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
of bouncing to the marketing landing (also the probe for the empty-initData failure on
some Android clients). -->
<TelegramLaunchError />
{:else if app.bootError}
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
show the retry screen instead of falling back to the web login. -->
@@ -128,10 +121,14 @@
<StaleInviteModal />
<WelcomeRedeemModal />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError}
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash />
{/if}
{#if app.debugOpen}
<DebugPanel />
{/if}
<style>
.splash {
height: 100%;
+5
View File
@@ -49,6 +49,11 @@
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
--tg-safe-top: 0px;
/* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere —
the screen pads its bottom and left/right edges by these so content clears the cut-outs. */
--tg-safe-bottom: 0px;
--tg-safe-left: 0px;
--tg-safe-right: 0px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
+71
View File
@@ -0,0 +1,71 @@
<script lang="ts">
// Hidden on-device debug panel, opened by tapping the header title ten times (Header.svelte) and
// closed by tapping anywhere except the Share control. It shows a privacy-safe client diagnostic
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
import { app, closeDebug } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { shareText } from '../lib/share';
import { telegramChromeDiag } from '../lib/telegram';
const report = [
`app: ${__APP_VERSION__}`,
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
telegramChromeDiag(),
].join('\n');
let label = $state('Share');
async function share(e: MouseEvent): Promise<void> {
e.stopPropagation(); // a tap on Share shares; it must not also close the panel
const r = await shareText(report, `Scrabble debug ${__APP_VERSION__}`);
if (r === 'copied') {
label = 'Copied';
setTimeout(() => (label = 'Share'), 1500);
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={closeDebug}>
<button class="share" onclick={share}>{label}</button>
<pre class="body">{report}</pre>
</div>
<style>
.overlay {
position: fixed;
inset: 0;
z-index: 10000;
/* Drawn from the top; the content clears the app header (~56px + the device safe-area). */
padding: calc(var(--tg-safe-top, 0px) + 56px) 12px 16px;
background: rgba(0, 0, 0, 0.82);
overflow: auto;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.share {
flex: 0 0 auto;
padding: 7px 16px;
border: 1px solid var(--accent);
background: var(--accent);
color: var(--accent-text);
border-radius: var(--radius-sm);
font-size: 0.95rem;
}
.body {
margin: 0;
width: 100%;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.45;
color: #d6e6ff;
}
</style>
+27 -12
View File
@@ -1,17 +1,30 @@
<script lang="ts">
import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.svelte';
import { app } from '../lib/app.svelte';
import { app, openDebug } from '../lib/app.svelte';
import Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte';
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
// Inside Telegram the native header back button (App.svelte) is the back control, so
// the app's own chevron is hidden to avoid two back affordances.
const showBack = $derived(!!back && !insideTelegram());
// The app always shows its own back chevron when there is a back target — on every platform, in
// and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
// the windowed Mini App (relying on it would lose back navigation there).
const showBack = $derived(!!back);
// Ten quick taps on the title open the hidden debug panel (components/DebugPanel) — a support aid.
let titleTaps = 0;
let lastTitleTap = 0;
function onTitleTap(): void {
const now = Date.now();
titleTaps = now - lastTitleTap < 400 ? titleTaps + 1 : 1;
lastTitleTap = now;
if (titleTaps >= 10) {
titleTaps = 0;
openDebug();
}
}
</script>
<header class="nav" class:grow>
@@ -24,7 +37,9 @@
<span class="spacer"></span>
{/if}
{#if connection.online}
<h1>{title}</h1>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<h1 onclick={onTitleTap}>{title}</h1>
{:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if}
@@ -66,7 +81,7 @@
display: flex;
align-items: center;
gap: var(--gap);
padding: 10px var(--pad);
padding: 5px var(--pad);
}
h1 {
font-size: 1.05rem;
@@ -124,15 +139,15 @@
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
native controls stays constant if the user scales up the font (the title then grows
downward and the bar with it). (Owner-tunable: the 10px.) */
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
Telegram's native controls stays constant if the user scales up the font (the title then
grows downward and the bar with it). (Owner-tunable: the 8px.) */
min-height: var(--tg-content-top);
box-sizing: border-box;
align-items: center;
justify-content: center;
padding-top: calc(var(--tg-safe-top) + 16px);
padding-bottom: 6px;
padding-top: calc(var(--tg-safe-top) + 8px);
padding-bottom: 3px;
}
:global(html.tg-fullscreen) .spacer {
display: none;
+17
View File
@@ -101,6 +101,12 @@
bottom input — chat, word-check — stays above an open soft keyboard without the page
scrolling; falls back to the full height where the var is unset. */
height: var(--vvh, 100%);
/* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the
header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its
own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so
the strip takes the bar's colour rather than the detached content background. */
padding-left: var(--tg-safe-left, 0px);
padding-right: var(--tg-safe-right, 0px);
}
.content {
flex: 0 1 auto;
@@ -116,7 +122,18 @@
display: flex;
flex-direction: column;
}
/* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset
so it clears the cut-out, the strip taking the content's own background. With a tab bar the
.tabbar owns that inset instead (and content is not the last child, so this does not apply). */
.content:last-child {
padding-bottom: var(--tg-safe-bottom, 0px);
}
.tabbar {
flex: 0 0 auto;
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar
instead of the content background showing through. */
background: var(--bg-elev);
padding-bottom: var(--tg-safe-bottom, 0px);
}
</style>
+25 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { app, dismissStaleInvite } from '../lib/app.svelte';
import { router } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { telegramOpenLink } from '../lib/telegram';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte';
// The single bot's @username, for the deep link.
@@ -19,9 +21,30 @@
}
dismissStaleInvite();
}
// Native path: when the notice fires inside the Mini App with native popups, present Telegram's
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears the
// notice; the `shown` guard fires it once per notice.
// The notice is raised during boot; wait until the loading cover for the current route is gone —
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
// the native popup never appears over the splash.
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
let shown = false;
$effect(() => {
if (app.staleInvite && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
shown = true;
void telegramShowPopup(
botInfoPopup(t('friends.staleInviteTitle'), t('friends.staleInvite'), username ?? '', t('common.ok')),
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissStaleInvite()));
} else if (!app.staleInvite) {
shown = false;
}
});
</script>
{#if app.staleInvite}
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
+25 -2
View File
@@ -1,8 +1,10 @@
<script lang="ts">
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
import { router } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink';
import { telegramOpenLink } from '../lib/telegram';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte';
// The single bot's @username, for the deep link.
@@ -22,9 +24,30 @@
}
dismissWelcomeRedeem();
}
// Native path: when the greeting fires inside the Mini App with native popups, present Telegram's
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears it;
// the `shown` guard fires it once per greeting.
// The greeting is raised during boot; wait until the loading cover for the current route is gone —
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
// the native popup never appears over the splash.
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
let shown = false;
$effect(() => {
if (app.welcomeRedeem && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
shown = true;
void telegramShowPopup(
botInfoPopup(t('friends.welcomeRedeemTitle'), t('friends.welcomeRedeem', { name }), username ?? '', t('common.ok')),
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissWelcomeRedeem()));
} else if (!app.welcomeRedeem) {
shown = false;
}
});
</script>
{#if app.welcomeRedeem}
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
+12 -5
View File
@@ -24,7 +24,7 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram';
import {
BLANK,
newPlacement,
@@ -228,8 +228,6 @@
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
}
onMount(() => {
// Guard against an accidental swipe-close losing the open game (Telegram).
telegramClosingConfirmation(true);
// Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id);
@@ -579,7 +577,6 @@
clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
}
telegramClosingConfirmation(false);
});
function onCell(row: number, col: number) {
@@ -715,6 +712,16 @@
busy = false;
}
}
// onResignClick: inside the Mini App (and online) confirm with Telegram's native dialog and resign
// on accept; otherwise open the in-app confirm modal (which also carries the offline-disabled action).
async function onResignClick(): Promise<void> {
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
if (await telegramShowConfirm(t('game.confirmResign'))) doResign();
return;
}
resignOpen = true;
}
async function doResign() {
resignOpen = false;
busy = true;
@@ -1152,7 +1159,7 @@
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{/if}
{:else}
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
+166 -29
View File
@@ -9,21 +9,30 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
import {
insideTelegram,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
telegramColorScheme,
telegramThemeParams,
telegramContentSafeAreaTop,
telegramSafeAreaTop,
telegramSafeAreaInset,
telegramDisableVerticalSwipes,
telegramShowSettingsButton,
telegramHaptic,
telegramLaunch,
type TelegramLaunch,
telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome,
telegramCloudAvailable,
telegramCloudGet,
telegramCloudSet,
} from './telegram';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
@@ -47,6 +56,14 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) a shareable probe for why Telegram
* delivered no initData (seen on some Android clients) instead of bouncing to the landing. */
launchError: TelegramDiag | null;
/** Whether the hidden on-device debug panel (components/DebugPanel) is open toggled by tapping
* the header title ten times in quick succession. A support aid; carries no secrets. */
debugOpen: boolean;
/** Whether the lobby's first cold load has settled (success or error). The loading splash
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean;
@@ -96,6 +113,8 @@ export const app = $state<{
}>({
ready: false,
bootError: false,
launchError: null,
debugOpen: false,
lobbyReady: false,
splashDone: false,
streamAlive: false,
@@ -187,6 +206,16 @@ export function dismissWelcomeRedeem(): void {
app.welcomeRedeem = false;
}
/** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened
* by tapping the header title ten times a support aid that shows and shares client diagnostics. */
export function openDebug(): void {
app.debugOpen = true;
}
export function closeDebug(): void {
app.debugOpen = false;
}
/**
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
@@ -504,17 +533,25 @@ function syncTelegramChrome(): void {
}
/**
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
* syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top
* (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top
* (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the
* title in its band), and the device safe-area insets notch / status bar (top), home indicator
* (bottom) and the landscape notch sides (left / right) into --tg-safe-top / --tg-safe-bottom /
* --tg-safe-left / --tg-safe-right, so the header, rack and screen edges clear the device cut-outs.
* Called on launch and on Telegram's safe-area / fullscreen change events.
*/
function syncTelegramSafeArea(): void {
if (typeof document === 'undefined') return;
const root = document.documentElement;
const top = telegramContentSafeAreaTop();
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
const safe = telegramSafeAreaInset();
root.style.setProperty('--tg-content-top', `${top}px`);
root.style.setProperty('--tg-safe-top', `${safe.top}px`);
root.style.setProperty('--tg-safe-bottom', `${safe.bottom}px`);
root.style.setProperty('--tg-safe-left', `${safe.left}px`);
root.style.setProperty('--tg-safe-right', `${safe.right}px`);
root.classList.toggle('tg-fullscreen', top > 0);
}
/**
@@ -530,6 +567,42 @@ function syncViewportHeight(): void {
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
}
/**
* syncTelegramTheme re-applies Telegram's theme integration the themeParams token overrides,
* Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's
* current themeParams when omitted. Called on launch with the launch snapshot and live on the
* themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up
* without a relaunch.
*/
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void {
if (theme) applyTelegramTheme(theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
// back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
syncTelegramChrome();
}
/**
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* colour scheme and theme (syncTelegramTheme), the matching header / background / bottom chrome,
* the safe-area insets, and the swipe-down-to-minimise guard. It is idempotent, so both the
* initial bootstrap and a manual launch retry call it.
*/
function applyTelegramChrome(launch: TelegramLaunch): void {
syncTelegramTheme(launch.theme);
// Mirror the safe-area insets and stop Telegram's swipe-down-to-minimise from fighting tile drag
// / board scroll.
syncTelegramSafeArea();
telegramDisableVerticalSwipes();
}
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
* the launch-error screen. A network that blocks telegram.org makes the script hang rather than
* fail fast (a connection refusal resolves immediately via the script's error event), so this only
* bounds a true hang; it is generous enough not to misfire on a slow but working network. */
const TELEGRAM_SDK_TIMEOUT_MS = 10000;
export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto';
@@ -553,33 +626,39 @@ export async function bootstrap(): Promise<void> {
window.visualViewport.addEventListener('scroll', syncViewportHeight);
}
// 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.
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
// longer 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 diagnostic screen below. Skipped on a plain web / native entry, which never needs it.
if (onTelegramPath() || hasLaunchFragment()) {
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
// clients), render the compact launch-error screen with a diagnostic snapshot the user can share
// with the developer, instead of bouncing the visitor to the marketing landing.
if (onTelegramPath() && !insideTelegram()) {
if (typeof location !== 'undefined') location.replace('/');
app.launchError = collectTelegramDiag();
app.ready = true;
return;
}
if (insideTelegram()) {
const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
// fighting tile drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
applyTelegramChrome(launch);
// Pull the device-independent display prefs (theme / reduce-motion / board labels) from
// CloudStorage in the background so a change on another device follows the user here; the local
// values applied above render instantly, so this reconciles without blocking launch.
void reconcileCloudPrefs();
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
// window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
// Re-apply the theme live when the user switches Telegram's light/dark mode while the app is open.
telegramOnEvent('themeChanged', () => syncTelegramTheme());
// Telegram's native Settings button (Bot API 7.0) opens our Settings screen; the in-app gear
// entry stays the primary path. No-op on clients predating the button.
telegramShowSettingsButton(() => navigate('/settings'));
await bootTelegram(launch);
app.ready = true;
return;
@@ -646,6 +725,28 @@ export async function retryTelegramBoot(): Promise<void> {
app.ready = true;
}
/**
* retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If
* sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs
* the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the
* current state. It never reloads telegram-web-app.js consumes the launch fragment on first load,
* so a reload could discard the very data we are waiting for.
*/
export async function retryTelegramLaunch(): Promise<void> {
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
if (!insideTelegram()) {
app.launchError = collectTelegramDiag();
return;
}
app.launchError = null;
app.ready = false;
const launch = telegramLaunch();
applyTelegramChrome(launch);
await bootTelegram(launch);
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
@@ -744,6 +845,42 @@ function persistPrefs(): void {
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
});
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
// syncs via the durable account (Profile.preferredLanguage) instead.
void telegramCloudSet(
CLOUD_PREFS_KEY,
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
);
}
/**
* reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
* labels) from Telegram CloudStorage and applies any that differ from the current values, so a
* change made on another Telegram device follows the user here. The local store is the
* instant-render cache (read synchronously at boot); this runs once on launch after it and persists
* what it applied. Theme is not re-applied visually inside Telegram the colour scheme is
* Telegram's to decide only its stored value is updated. A no-op outside Telegram or when
* CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
*/
async function reconcileCloudPrefs(): Promise<void> {
if (!telegramCloudAvailable()) return;
const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
let changed = false;
if (cloud.theme !== undefined && cloud.theme !== app.theme) {
app.theme = cloud.theme;
changed = true;
}
if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
app.reduceMotion = cloud.reduceMotion;
applyReduceMotion(app.reduceMotion);
changed = true;
}
if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
app.boardLabels = cloud.boardLabels;
changed = true;
}
if (changed) persistPrefs();
}
export function setTheme(theme: ThemePref): void {
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
describe('cloudprefs', () => {
it('round-trips the synced client prefs', () => {
const p = { theme: 'dark', reduceMotion: true, boardLabels: 'classic' } as const;
expect(decodeClientPrefs(encodeClientPrefs(p))).toEqual(p);
});
it('never encodes the locale (it syncs via the durable account instead)', () => {
const raw = encodeClientPrefs({ theme: 'light', reduceMotion: false, boardLabels: 'none' });
expect(raw).not.toContain('locale');
});
it('returns an empty partial for missing or malformed input', () => {
expect(decodeClientPrefs(null)).toEqual({});
expect(decodeClientPrefs(undefined)).toEqual({});
expect(decodeClientPrefs('')).toEqual({});
expect(decodeClientPrefs('not json')).toEqual({});
expect(decodeClientPrefs('[1,2,3]')).toEqual({});
});
it('keeps only valid fields and drops unknown or mistyped ones', () => {
const raw = JSON.stringify({ theme: 'neon', reduceMotion: 'yes', boardLabels: 'classic', locale: 'ru' });
expect(decodeClientPrefs(raw)).toEqual({ boardLabels: 'classic' });
});
it('exposes the CloudStorage key', () => {
expect(CLOUD_PREFS_KEY).toBe('prefs');
});
});
+49
View File
@@ -0,0 +1,49 @@
// Telegram CloudStorage sync for the device-independent client display preferences — theme,
// reduce-motion and board labels — so they follow the user across their Telegram devices. The
// interface language is intentionally excluded: it has its own server-side sync
// (Profile.preferredLanguage) plus an on-launch reconciler, and mixing it in here would fight that.
// The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the node
// environment; the CloudStorage transport wrappers live in telegram.ts and the wiring (mirror on
// save, reconcile on launch) in app.svelte.ts.
import type { ThemePref } from './theme';
import type { BoardLabelMode } from './boardlabels';
/** ClientPrefs is the subset of preferences synced across devices via Telegram CloudStorage. */
export interface ClientPrefs {
theme: ThemePref;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
}
/** CLOUD_PREFS_KEY is the Telegram CloudStorage key holding the JSON-encoded ClientPrefs. */
export const CLOUD_PREFS_KEY = 'prefs';
/** encodeClientPrefs serialises the synced client prefs (and only those — never the locale). */
export function encodeClientPrefs(p: ClientPrefs): string {
return JSON.stringify({ theme: p.theme, reduceMotion: p.reduceMotion, boardLabels: p.boardLabels });
}
/**
* decodeClientPrefs parses a CloudStorage payload into a partial ClientPrefs, keeping only valid
* fields and dropping anything unknown, mistyped or malformed so a value written by a newer or
* older build, or a corrupt entry, never throws and never applies a bad setting. A missing field
* stays absent, so the caller leaves the corresponding local value untouched.
*/
export function decodeClientPrefs(raw: string | null | undefined): Partial<ClientPrefs> {
if (!raw) return {};
let o: Record<string, unknown>;
try {
o = JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
if (!o || typeof o !== 'object') return {};
const out: Partial<ClientPrefs> = {};
if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme;
if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion;
if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') {
out.boardLabels = o.boardLabels;
}
return out;
}
+9 -1
View File
@@ -14,6 +14,11 @@ export const en = {
'boot.errorTitle': "Couldn't load the game",
'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
'common.back': 'Back',
'common.cancel': 'Cancel',
'common.ok': 'OK',
@@ -227,6 +232,9 @@ export const en = {
'friends.decline': 'Decline',
'friends.unfriend': 'Remove',
'friends.block': 'Block',
'friends.actions': 'Actions',
'friends.blockConfirm': 'Block this player?',
'friends.unfriendConfirm': 'Remove from friends?',
'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player',
@@ -239,7 +247,7 @@ export const en = {
'friends.redeem': 'Add',
'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram',
'friends.shareTelegram': 'Share',
'friends.inviteText': "Let's play Scrabble!",
'friends.linkCopied': 'Link copied.',
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
+9 -1
View File
@@ -15,6 +15,11 @@ export const ru: Record<MessageKey, string> = {
'boot.errorTitle': 'Не удалось загрузить игру',
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
'common.back': 'Назад',
'common.cancel': 'Отмена',
'common.ok': 'ОК',
@@ -228,6 +233,9 @@ export const ru: Record<MessageKey, string> = {
'friends.decline': 'Отклонить',
'friends.unfriend': 'Удалить',
'friends.block': 'Заблокировать',
'friends.actions': 'Действия',
'friends.blockConfirm': 'Заблокировать?',
'friends.unfriendConfirm': 'Удалить из друзей?',
'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья',
'friends.blockFromGame': 'Заблокировать',
@@ -240,7 +248,7 @@ export const ru: Record<MessageKey, string> = {
'friends.redeem': 'Добавить',
'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram',
'friends.shareTelegram': 'Поделиться',
'friends.inviteText': 'Давай играть в Эрудит!',
'friends.linkCopied': 'Ссылка скопирована.',
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
describe('botInfoPopup', () => {
it('inlines the bot handle and adds an open-bot button', () => {
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', 'erudit_bot', 'OK');
expect(p.title).toBe('Title');
expect(p.message).toBe('Open the bot @erudit_bot to play.');
expect(p.buttons).toEqual([
{ id: BOT_BUTTON_ID, text: '@erudit_bot' },
{ id: 'ok', text: 'OK' },
]);
});
it('drops the token and the open-bot button when no username is known', () => {
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', '', 'OK');
expect(p.message).toBe('Open the bot to play.');
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
});
it('preserves newlines in the message', () => {
const p = botInfoPopup('Hi', 'Welcome!\n\nUse {bot} now.', 'b', 'OK');
expect(p.message).toBe('Welcome!\n\nUse @b now.');
});
});
+24
View File
@@ -0,0 +1,24 @@
// Pure builder for the Telegram native popup (showPopup) used by the deep-link info modals
// (StaleInviteModal / WelcomeRedeemModal) inside the Mini App. Kept free of the SDK and the DOM so
// it unit-tests in the node environment; the showPopup transport wrapper lives in telegram.ts and
// the wiring (native inside Telegram, the in-app Modal elsewhere) in the modal components.
import type { TelegramPopupParams } from './telegram';
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
export const BOT_BUTTON_ID = 'bot';
/**
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
* in-app Modal), plus an "open bot" button when a username is known and a closing OK button. With
* no username it is the message (token removed) and OK only.
*/
export function botInfoPopup(title: string, message: string, username: string, okText: string): TelegramPopupParams {
const handle = username ? `@${username}` : '';
const text = (username ? message.replace('{bot}', handle) : message.replace('{bot}', '').replace(' ', ' ')).trim();
const buttons = username
? [{ id: BOT_BUTTON_ID, text: handle }, { id: 'ok', text: okText }]
: [{ id: 'ok', text: okText }];
return { title, message: text, buttons };
}
+40 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery, shareOrDownloadGcg } from './share';
import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
import type { GcgExport } from './model';
const file = {} as File;
@@ -60,3 +60,42 @@ describe('shareOrDownloadGcg', () => {
expect(anchor.click).toHaveBeenCalledOnce();
});
});
describe('pickTextShare', () => {
it('shares when Web Share is available', () => {
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
});
it('shares when share exists without canShare (text needs no file capability check)', () => {
expect(pickTextShare({ share: async () => {} })).toBe('share');
});
it('copies when there is no Web Share (desktop)', () => {
expect(pickTextShare(undefined)).toBe('copy');
expect(pickTextShare({} as never)).toBe('copy');
});
});
describe('shareText', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses the OS share sheet when available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'title', text: 'diag' });
});
it('copies to the clipboard when Web Share is absent (desktop)', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
expect(await shareText('diag', 'title')).toBe('copied');
expect(writeText).toHaveBeenCalledWith('diag');
});
it('reports failure without a fallback when the share is cancelled', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('failed');
});
});
+39
View File
@@ -51,3 +51,42 @@ function downloadFile(content: string, filename: string): void {
a.remove();
URL.revokeObjectURL(url);
}
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**
* pickTextShare decides how to deliver a plain-text payload: through the OS share sheet (Web Share,
* available on mobile including the Telegram Mini App) or, on a desktop browser without it, a
* clipboard copy. Pure, so it is unit-tested with a mock navigator.
*/
export function pickTextShare(nav: TextShareNav | undefined): 'share' | 'copy' {
if (nav && typeof nav.share === 'function' && (typeof nav.canShare !== 'function' || nav.canShare({ text: 'x' }))) {
return 'share';
}
return 'copy';
}
/**
* shareText delivers text through the OS share sheet where supported, else copies it to the
* clipboard. It reports the path taken 'shared', 'copied', or 'failed' (a cancelled share or an
* unavailable clipboard) so the caller can confirm a silent copy to the user. Like
* shareOrDownloadGcg it never strands the webview: a cancelled share simply does nothing.
*/
export async function shareText(text: string, title: string): Promise<'shared' | 'copied' | 'failed'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (!nav) return 'failed';
if (pickTextShare(nav) === 'share') {
try {
await nav.share({ title, text });
return 'shared';
} catch {
return 'failed';
}
}
try {
await nav.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
+172 -61
View File
@@ -1,11 +1,18 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
collectTelegramDiag,
insideTelegram,
loadTelegramSDK,
telegramSdkOutcome,
routeExternalLinkInTelegram,
telegramClosingConfirmation,
telegramLaunch,
telegramOpenExternalLink,
telegramRequestFullscreen,
telegramThemeParams,
telegramSafeAreaInset,
telegramShowSettingsButton,
telegramDialogsAvailable,
telegramShowConfirm,
telegramShowPopup,
} from './telegram';
function stubWebApp(initData: string, startParam?: string) {
@@ -43,68 +50,19 @@ describe('telegram launch detection', () => {
expect(launch.startParam).toBe('g123');
expect(launch.theme?.bg_color).toBe('#101418');
});
});
// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated
// chrome toggles, so the platform gate can be asserted without a real Telegram client.
function stubClient(platform?: string) {
const enable = vi.fn();
const disable = vi.fn();
const requestFullscreen = vi.fn();
vi.stubGlobal('window', {
Telegram: {
WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen },
},
});
return { enable, disable, requestFullscreen };
}
const mobilePlatforms = ['ios', 'android', 'android_x'];
const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
describe('telegramClosingConfirmation', () => {
afterEach(() => vi.unstubAllGlobals());
it('arms the close guard on mobile clients', () => {
for (const p of mobilePlatforms) {
const { enable } = stubClient(p);
telegramClosingConfirmation(true);
expect(enable, `platform=${p}`).toHaveBeenCalledOnce();
}
it('telegramThemeParams reads the live palette (undefined outside Telegram)', () => {
expect(telegramThemeParams()).toBeUndefined();
stubWebApp('query_id=abc');
expect(telegramThemeParams()?.bg_color).toBe('#101418');
});
it('skips the close guard on desktop clients (the dialog there is just noise)', () => {
for (const p of desktopPlatforms) {
const { enable } = stubClient(p);
telegramClosingConfirmation(true);
expect(enable, `platform=${p}`).not.toHaveBeenCalled();
}
});
it('always lifts the guard on leave, regardless of platform', () => {
const { disable } = stubClient('tdesktop');
telegramClosingConfirmation(false);
expect(disable).toHaveBeenCalledOnce();
});
});
describe('telegramRequestFullscreen', () => {
afterEach(() => vi.unstubAllGlobals());
it('goes immersive fullscreen on mobile clients', () => {
for (const p of mobilePlatforms) {
const { requestFullscreen } = stubClient(p);
telegramRequestFullscreen();
expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce();
}
});
it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => {
for (const p of desktopPlatforms) {
const { requestFullscreen } = stubClient(p);
telegramRequestFullscreen();
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
}
it('telegramSafeAreaInset returns zeros outside Telegram and the SDK insets inside', () => {
expect(telegramSafeAreaInset()).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
vi.stubGlobal('window', {
Telegram: { WebApp: { initData: 'x', safeAreaInset: { top: 59, bottom: 34, left: 0, right: 0 } } },
});
expect(telegramSafeAreaInset()).toEqual({ top: 59, bottom: 34, left: 0, right: 0 });
});
});
@@ -123,6 +81,56 @@ describe('telegramOpenExternalLink', () => {
});
});
describe('telegramShowSettingsButton', () => {
afterEach(() => vi.unstubAllGlobals());
it('shows the native Settings button and wires its click inside Telegram', () => {
const onClick = vi.fn();
const show = vi.fn();
const handler = vi.fn();
vi.stubGlobal('window', { Telegram: { WebApp: { SettingsButton: { onClick, show } } } });
telegramShowSettingsButton(handler);
expect(onClick).toHaveBeenCalledWith(handler);
expect(show).toHaveBeenCalled();
});
it('is a no-op without the SDK button (older client / outside Telegram)', () => {
expect(() => telegramShowSettingsButton(() => {})).not.toThrow();
});
});
describe('native dialogs', () => {
afterEach(() => vi.unstubAllGlobals());
it('telegramDialogsAvailable reflects showPopup presence', () => {
expect(telegramDialogsAvailable()).toBe(false);
vi.stubGlobal('window', { Telegram: { WebApp: { showPopup: () => {} } } });
expect(telegramDialogsAvailable()).toBe(true);
});
it('telegramShowConfirm resolves the user choice inside Telegram', async () => {
vi.stubGlobal('window', {
Telegram: { WebApp: { showConfirm: (_m: string, cb: (ok: boolean) => void) => cb(true) } },
});
await expect(telegramShowConfirm('Sure?')).resolves.toBe(true);
});
it('telegramShowConfirm resolves false without the SDK dialog', async () => {
await expect(telegramShowConfirm('Sure?')).resolves.toBe(false);
});
it('telegramShowPopup resolves the pressed button id', async () => {
vi.stubGlobal('window', {
Telegram: { WebApp: { showPopup: (_p: unknown, cb: (id: string) => void) => cb('bot') } },
});
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBe('bot');
});
it('telegramShowPopup resolves null without the SDK', async () => {
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBeNull();
});
});
describe('routeExternalLinkInTelegram', () => {
afterEach(() => vi.unstubAllGlobals());
@@ -155,3 +163,106 @@ describe('routeExternalLinkInTelegram', () => {
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false);
});
});
describe('collectTelegramDiag', () => {
afterEach(() => vi.unstubAllGlobals());
it('reports a missing SDK outside Telegram', () => {
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(false);
expect(d.hasWebApp).toBe(false);
expect(d.initDataLen).toBe(0);
expect(d.fieldsPresent).toEqual([]);
expect(d.fieldsMissing).toEqual(['user', 'auth_date', 'hash', 'signature']);
});
it('reads field NAMES (never values) from a non-empty initData', () => {
stubWebApp('query_id=abc&user=%7B%7D&auth_date=1&hash=deadbeef');
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.hasWebApp).toBe(true);
expect(d.initDataLen).toBeGreaterThan(0);
expect(d.fieldsPresent).toEqual(['query_id', 'user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
it('recovers field names from the URL fragment when the SDK left initData empty', () => {
// Telegram passed launch data in the fragment, but WebApp.initData is empty (the Android
// failure this screen diagnoses): the names come from the raw fragment instead, and
// hashHadTgData flags that the data did arrive in the URL.
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '', platform: 'android' } } });
vi.stubGlobal('location', {
hash: '#tgWebAppData=user%3D%257B%257D%26auth_date%3D1%26hash%3Ddeadbeef&tgWebAppVersion=7.0',
pathname: '/telegram/',
});
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.platform).toBe('android');
expect(d.initDataLen).toBe(0);
expect(d.hashHadTgData).toBe(true);
expect(d.fieldsPresent).toEqual(['user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
});
describe('loadTelegramSDK', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('resolves true and records "present" when the SDK is already there', async () => {
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '' } } });
vi.stubGlobal('document', { createElement: vi.fn(), head: { appendChild: vi.fn() } });
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('present');
});
it('records "loaded" when the script defines the WebApp', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: {
appendChild: () => {
(window as unknown as { Telegram: unknown }).Telegram = { WebApp: { initData: '' } };
(script.onload as () => void)();
},
},
});
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('loaded');
});
it('records "no-webapp" when the script loads but defines nothing', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onload as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('no-webapp');
});
it('records "error" when the script fails to load (telegram.org unreachable)', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onerror as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('error');
});
it('records "timeout" when the script neither loads nor fails in time', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', {});
vi.stubGlobal('document', { createElement: () => ({}), head: { appendChild: vi.fn() } });
const p = loadTelegramSDK(10000);
await vi.advanceTimersByTimeAsync(10000);
await expect(p).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('timeout');
});
});
+345 -72
View File
@@ -1,22 +1,41 @@
// 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,
// 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;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void;
@@ -24,19 +43,30 @@ interface TelegramWebApp {
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?: {
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;
}
function webApp(): TelegramWebApp | undefined {
@@ -53,6 +83,72 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0;
}
// 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
@@ -173,6 +269,15 @@ 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
@@ -195,13 +300,15 @@ export function telegramContentSafeAreaTop(): number {
}
/**
* telegramSafeAreaTop returns the device safe-area top inset (px) the notch / status bar
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
* outside Telegram or on older clients.
* 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 telegramSafeAreaTop(): number {
return webApp()?.safeAreaInset?.top ?? 0;
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 };
}
/**
@@ -212,6 +319,78 @@ 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.
*/
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 ?? '')));
}
/** Haptic is the set of feedbacks the app triggers. */
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
@@ -224,66 +403,6 @@ export function telegramHaptic(kind: Haptic): void {
else h.impactOccurred?.(kind);
}
/**
* isMobilePlatform reports whether the Mini App runs on a Telegram mobile client iOS or
* Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
* Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
* mobile-only chrome such as the close guard and immersive fullscreen.
*/
function isMobilePlatform(): boolean {
const p = webApp()?.platform;
return p === 'ios' || p === 'android' || p === 'android_x';
}
/**
* telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
* but only on mobile clients mirroring how Telegram's own Mini Apps go immersive on phones
* while staying a standard window on desktop (where the bot's full-size setting already fills
* the window). A no-op outside Telegram, on desktop, or on clients predating the method.
*/
export function telegramRequestFullscreen(): void {
if (isMobilePlatform()) webApp()?.requestFullscreen?.();
}
/**
* 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. The
* guard is only armed on mobile clients: on desktop, closing a window is deliberate and
* Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
* auto-save), so the confirmation is skipped there.
*/
export function telegramClosingConfirmation(on: boolean): void {
const w = webApp();
if (on) {
if (isMobilePlatform()) 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.
@@ -302,6 +421,160 @@ export function onTelegramPath(): boolean {
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
+207 -44
View File
@@ -1,12 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
let friends = $state<AccountRef[]>([]);
@@ -16,6 +17,11 @@
let robotBlocks = $state<RobotBlockEntry[]>([]);
let code = $state<FriendCode | null>(null);
let redeemInput = $state('');
// The friend row whose kebab actions are slid open, like the lobby list.
let revealedId = $state<string | null>(null);
// Pending confirmation targets: the friend account awaiting a block / unfriend confirm.
let blockTarget = $state<AccountRef | null>(null);
let unfriendTarget = $state<AccountRef | null>(null);
async function load() {
try {
@@ -52,6 +58,57 @@
const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(id));
// toggleReveal slides one friend row open (closing any other), exposing its
// block / unfriend icon actions; tapping the same kebab again closes it.
function toggleReveal(id: string): void {
revealedId = revealedId === id ? null : id;
}
// confirmBlock / confirmUnfriend run the pending action once its modal is
// accepted, then clear the target and the revealed row.
function confirmBlock(target = blockTarget): void {
blockTarget = null;
revealedId = null;
if (target) void blockUser(target.accountId);
}
function confirmUnfriend(target = unfriendTarget): void {
unfriendTarget = null;
revealedId = null;
if (target) void remove(target.accountId);
}
// onBlockClick / onUnfriendClick: inside the Mini App (and online) confirm with Telegram's native
// dialog and act on accept; otherwise open the in-app confirm modal (the offline / web path).
async function onBlockClick(f: AccountRef): Promise<void> {
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
if (await telegramShowConfirm(`${t('friends.blockConfirm')}\n${f.displayName}`)) confirmBlock(f);
return;
}
blockTarget = f;
}
async function onUnfriendClick(f: AccountRef): Promise<void> {
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
if (await telegramShowConfirm(`${t('friends.unfriendConfirm')}\n${f.displayName}`)) confirmUnfriend(f);
return;
}
unfriendTarget = f;
}
// While a friend row is slid open, a tap anywhere outside its action buttons
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
$effect(() => {
if (revealedId === null) return;
function onDown(e: PointerEvent) {
const el = e.target as Element | null;
if (el?.closest('.acts') || el?.closest('.kebab')) return;
revealedId = null;
}
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
async function getCode() {
try {
code = await gateway.friendCodeIssue();
@@ -141,7 +198,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span>
{#if tg}
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if}
</div>
{:else}
@@ -152,30 +209,39 @@
{#if incoming.length}
<section>
<h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)}
<div class="item">
<span class="who">{r.displayName}</span>
<span class="acts">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span>
</div>
{/each}
<div class="list">
{#each incoming as r (r.accountId)}
<div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span>
<span class="btns">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span>
</div>
</div>
{/each}
</div>
</section>
{/if}
<section>
<h3>{t('friends.yours')}</h3>
{#if friends.length}
{#each friends as f (f.accountId)}
<div class="item">
<span class="who">{f.displayName}</span>
<span class="acts">
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div>
{/each}
<div class="list">
{#each friends as f (f.accountId)}
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
<div class="acts">
<button class="iconbtn" onclick={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
<button class="iconbtn" onclick={() => onUnfriendClick(f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
</div>
<div class="row">
<span class="who">{f.displayName}</span>
<button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button>
</div>
</div>
{/each}
</div>
{:else}
<p class="muted">{t('friends.none')}</p>
{/if}
@@ -184,20 +250,49 @@
{#if blocked.length || robotBlocks.length}
<section>
<h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)}
<div class="item">
<span class="who">{b.displayName}</span>
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
{#each robotBlocks as r (r.id)}
<div class="item">
<span class="who">{r.displayName}</span>
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</div>
{/each}
<div class="list">
{#each blocked as b (b.accountId)}
<div class="rowwrap">
<div class="row">
<span class="who">{b.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div>
{/each}
{#each robotBlocks as r (r.id)}
<div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if blockTarget}
<Modal title={t('friends.blockConfirm')} onclose={() => (blockTarget = null)}>
<p class="confirm-name">{blockTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={() => confirmBlock()} disabled={!connection.online}>{t('friends.block')}</button>
</div>
</Modal>
{/if}
{#if unfriendTarget}
<Modal title={t('friends.unfriendConfirm')} onclose={() => (unfriendTarget = null)}>
<p class="confirm-name">{unfriendTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={() => confirmUnfriend()} disabled={!connection.online}>{t('friends.unfriend')}</button>
</div>
</Modal>
{/if}
{/if}
</div>
@@ -279,28 +374,76 @@
padding: 4px 0;
text-align: left;
}
.item {
.list {
display: flex;
flex-direction: column;
}
/* One-line rows split by hairlines, mirroring the lobby list. */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* Block / unfriend icon actions sit behind the friend row, exposed when it slides left. */
.acts {
position: absolute;
inset: 0 0 0 auto;
display: flex;
align-items: stretch;
}
.iconbtn {
flex: 0 0 auto;
width: 48px;
border: none;
background: var(--bg-elev);
color: var(--text);
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
}
.iconbtn + .iconbtn {
border-left: 1px solid var(--border); /* the vertical divider between 🚫 and ✖️ */
}
.row {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-sm);
margin-bottom: 8px;
background: var(--bg);
transform: translateX(0);
transition: transform 0.18s ease;
}
.rowwrap.revealed .row {
transform: translateX(-96px); /* 2 × 48px icon buttons */
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 6px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.btns {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.who {
flex: 1;
min-width: 0;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn {
padding: 8px 12px;
border: 1px solid var(--accent);
@@ -315,7 +458,27 @@
color: var(--text);
border-radius: var(--radius-sm);
}
.ghost.danger {
color: var(--danger, #c0392b);
.confirm-name {
margin: 0 0 12px;
font-weight: 600;
overflow-wrap: anywhere; /* a long display name wraps instead of stretching the sheet */
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.confirm-row .danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
import { app, retryTelegramLaunch } from '../lib/app.svelte';
import { shareText } from '../lib/share';
import { t } from '../lib/i18n/index.svelte';
const diag = $derived(app.launchError);
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
// prose), so the developer reads the same report in any locale. Kept terse to fit one screenshot.
const report = $derived(
diag
? [
`sdk-load: ${diag.sdkLoad}`,
`sdk: ${diag.hasSDK ? 'yes' : 'no'} webapp: ${diag.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${diag.platform || '—'} tg-version: ${diag.version || '—'}`,
`initData: ${diag.initDataLen > 0 ? diag.initDataLen : 'empty'} tgdata-in-url: ${diag.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${diag.fieldsPresent.join(',') || '—'}`,
`missing: ${diag.fieldsMissing.join(',') || '—'}`,
`os: ${diag.osPlatform || '—'} mobile: ${diag.mobile || '—'}`,
`browser: ${diag.browser || '—'}`,
`ua: ${diag.userAgent || '—'}`,
].join('\n')
: '',
);
let retrying = $state(false);
async function retry(): Promise<void> {
if (retrying) return;
retrying = true;
try {
await retryTelegramLaunch();
} finally {
retrying = false;
}
}
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function share(): Promise<void> {
const r = await shareText(report, t('launch.errorTitle'));
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
if (r === 'copied') {
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => (copied = false), 1500);
}
}
</script>
{#if diag}
<div class="boot">
<div class="card">
<h1>{t('launch.errorTitle')}</h1>
<p class="msg">{t('launch.errorBody')}</p>
<pre class="diag">{report}</pre>
<div class="actions">
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
</div>
</div>
</div>
{/if}
<style>
.boot {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
background: var(--bg);
}
.card {
max-width: 32rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
text-align: center;
color: var(--text);
}
h1 {
margin: 0;
font-size: 1.1rem;
}
.msg {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.diag {
margin: 0;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.4;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.share,
.retry {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
.share {
background: var(--accent);
color: var(--accent-text);
}
.retry {
background: transparent;
color: var(--accent);
}
.retry:disabled {
opacity: 0.5;
}
</style>