Compare commits

..

34 Commits

Author SHA1 Message Date
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
47 changed files with 2950 additions and 299 deletions
+1
View File
@@ -267,6 +267,7 @@ jobs:
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_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 }} TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable. # The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} 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_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_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_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }} TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps: steps:
@@ -200,6 +201,7 @@ jobs:
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL' export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID' export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_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_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME' export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK' export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
+60
View File
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
return out, nil 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 // UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language, // returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id. // 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/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
) )
@@ -19,13 +20,16 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a // telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language; BrowserTZ (the client's detected // 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 { type telegramAuthRequest struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
Username string `json:"username"` Username string `json:"username"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"` LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"` BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"`
} }
// handleTelegramAuth provisions (or finds) the account bound to a Telegram // 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 // joined the chat before registering is granted on the spot (no chat_member
// event fires on registration). // event fires on registration).
s.publishChatAccessChange(acc.ID) 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) s.mintSession(c, acc)
} }
+2
View File
@@ -47,9 +47,11 @@ AWG_CONF= # required; AmneziaWG sidecar config (the
TELEGRAM_BOT_TOKEN= # required TELEGRAM_BOT_TOKEN= # required
TELEGRAM_GAME_CHANNEL_ID= TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating 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_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_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_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_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= 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 workflow manually (Gitea → Actions → prod-deploy → run from `master`, input
`confirm=deploy`). It builds + pushes the images to the registry, ships the `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, 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 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 → 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). deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
+11
View File
@@ -23,9 +23,15 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_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_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead). # Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
TELEGRAM_TEST_ENV: "false" TELEGRAM_TEST_ENV: "false"
@@ -46,8 +52,13 @@ services:
GOMAXPROCS: "1" GOMAXPROCS: "1"
volumes: volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# Support relay state (topic mapping, block list); survives redeploys.
- bot-state:/data
deploy: deploy:
resources: resources:
limits: limits:
cpus: "1.0" cpus: "1.0"
memory: 256M 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 # 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. # "Ban users" right; chat_member updates are delivered only to a chat admin.
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-} 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 # 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 # 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). # @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
@@ -325,6 +330,8 @@ services:
GOMAXPROCS: "1" GOMAXPROCS: "1"
volumes: volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro - ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# Support relay state (topic mapping, block list); survives redeploys.
- bot-state:/data
deploy: deploy:
resources: resources:
limits: limits:
@@ -496,3 +503,4 @@ volumes:
prometheus-data: prometheus-data:
tempo-data: tempo-data:
grafana-data: grafana-data:
bot-state:
+7 -1
View File
@@ -74,7 +74,13 @@ health_running() { # health_running <container>: running, not restarting, stable
roll() { # roll <service> <health-cmd...> roll() { # roll <service> <health-cmd...>
local svc="$1"; shift local svc="$1"; shift
echo ">>> rolling $svc -> $TAG" 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 failed health check"; return 1; }
echo "<<< $svc healthy" echo "<<< $svc healthy"
} }
+28 -3
View File
@@ -204,7 +204,7 @@ arrive from a platform rather than completing a mandatory registration).
> recipient's interface language (`preferred_language`), with no per-bot routing. New > 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 > 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 > `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 > `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and > `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile. > gained `variant_preferences` on Profile/UpdateProfile.
@@ -1040,8 +1040,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 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 **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 (`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 `/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
redirects to the root — the client-side guard); a stray hit on the gateway's `/` — 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 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, `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 `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
@@ -1199,3 +1200,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 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 `/users` console card. Roles are validated against a known set in Go, so adding one needs no
migration. 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.
+15 -2
View File
@@ -213,6 +213,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 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 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. 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 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 (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 recorded only against that game — the disguise holds, the shared robot is never globally
@@ -250,8 +253,9 @@ merge".
**Preferences (which variants you can be matched into).** A profile setting picks the game **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 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 yourself to be matched into; a **new account starts with Erudite only** — unless it was created
least one** selected. This list is exactly what **New Game** offers when you start a game 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 (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 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 invited friend may accept an invitation in **any** variant, and you can always open and play
@@ -269,6 +273,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 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. 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 ### History & statistics
Finished games are archived in a dictionary-independent form and exportable to 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 GCG; the export is offered **only once a game is finished**, and never for an
+15 -2
View File
@@ -218,6 +218,9 @@ _Вход сейчас только через провайдера, поэто
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там. есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
@@ -256,8 +259,9 @@ UTC; при создании аккаунта она подставляется
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте, **приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
@@ -275,6 +279,15 @@ UTC; при создании аккаунта она подставляется
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной. обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
канал для тех, кто пишет боту напрямую, а не через приложение.
### История и статистика ### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** и никогда — для в 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**: `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 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 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`) ## 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 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` / theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the `setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
native header **BackButton** drives back-navigation (the app's chevron is hidden in app's **own back chevron** (Header) drives back-navigation on every platform — the native
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile** Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API **HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window; bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop the native header (and its BackButton) and the Android system swipe-back then minimised the app,
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts so it stays windowed with Telegram's thin native header (close) above the app's own header; move
auto-save); **vertical swipes** (swipe-to-minimise) drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check 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` 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?" 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 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 // 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 // brand-new account's display name and language from the validated launch fields, its
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first // time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
// contact only). // validated launch deep-link startParam, its variant preferences (first contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) { func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
var out SessionResp var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{ map[string]string{
@@ -196,6 +196,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"username": username, "username": username,
"first_name": firstName, "first_name": firstName,
"browser_tz": browserTz, "browser_tz": browserTz,
"start_param": startParam,
}, &out) }, &out)
return out, err return out, err
} }
+11 -2
View File
@@ -9,6 +9,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"net/url"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector" "scrabble/gateway/internal/connector"
@@ -154,11 +155,19 @@ func DomainCode(err error) (string, bool) {
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler { func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) 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 { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Fatal("auth.telegram not registered") 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 { if err != nil {
t.Fatalf("handler: %v", err) 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"] != "Иван" { 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) 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) { 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/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 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) -------------------------------------------------------- # --- validator (home) --------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS validator FROM gcr.io/distroless/static-debian12:nonroot AS validator
COPY --from=build /out/validator /usr/local/bin/validator COPY --from=build /out/validator /usr/local/bin/validator
@@ -32,4 +37,5 @@ ENTRYPOINT ["/usr/local/bin/validator"]
# --- bot (remote) ------------------------------------------------------------ # --- bot (remote) ------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS bot FROM gcr.io/distroless/static-debian12:nonroot AS bot
COPY --from=build /out/bot /usr/local/bin/bot COPY --from=build /out/bot /usr/local/bin/bot
COPY --from=build --chown=65532:65532 /out/data /data
ENTRYPOINT ["/usr/local/bin/bot"] 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 `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 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. `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 - **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 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 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 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 `?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 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`, - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits. 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_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_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_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_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_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_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_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_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway | | `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
+19
View File
@@ -10,6 +10,7 @@ import (
"context" "context"
"log" "log"
"os/signal" "os/signal"
"path/filepath"
"sync" "sync"
"syscall" "syscall"
"time" "time"
@@ -23,6 +24,7 @@ import (
"scrabble/platform/telegram/internal/botlink" "scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config" "scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/promobot" "scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
) )
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. // 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)) 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{ b, err := bot.New(bot.Config{
Token: cfg.Token, Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL, APIBaseURL: cfg.APIBaseURL,
@@ -73,6 +88,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
SendRatePerSecond: cfg.SendRatePerSecond, SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID, ChatID: cfg.ChatID,
GameChannelID: cfg.GameChannelID, GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
}, logger) }, logger)
if err != nil { if err != nil {
return err return err
@@ -114,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
TestEnv: cfg.TestEnv, TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername, BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL, BotLinkURL: cfg.BotLinkURL,
StartParam: cfg.PromoStartParam,
SendRatePerSecond: cfg.SendRatePerSecond, SendRatePerSecond: cfg.SendRatePerSecond,
}, logger) }, logger)
if err != nil { 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("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv), zap.Bool("test_env", cfg.TestEnv),
zap.Bool("chat_gating", cfg.ChatID != 0), zap.Bool("chat_gating", cfg.ChatID != 0),
zap.Bool("support_relay", cfg.SupportChatID != 0),
zap.Bool("promo_bot", promo != nil)) zap.Bool("promo_bot", promo != nil))
var wg sync.WaitGroup var wg sync.WaitGroup
+61 -7
View File
@@ -15,6 +15,8 @@ import (
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"scrabble/platform/telegram/internal/support"
) )
// Config configures the bot wrapper. // Config configures the bot wrapper.
@@ -39,6 +41,14 @@ type Config struct {
// GameChannelID is the game channel whose public @username the /start welcome links // 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. // to (resolved from this id via getChat at startup); 0 omits that follow link.
GameChannelID int64 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 // EligibilityResolver answers whether the Telegram user identified by externalID
@@ -66,11 +76,21 @@ type Bot struct {
channelUsername string channelUsername string
chatUsername string chatUsername string
// botID is the bot's own Telegram user id (resolved at startup); it skips the // 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 botID int64
// eligibility resolves a joining user's chat write eligibility; nil leaves a // eligibility resolves a joining user's chat write eligibility; nil leaves a
// joiner muted (fail-closed) until it is wired. // joiner muted (fail-closed) until it is wired.
eligibility EligibilityResolver 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 // 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 { if log == nil {
log = zap.NewNop() 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 { if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) 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.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), 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 { if cfg.ChatID != 0 {
// chat_member updates are off by default; subscribe explicitly (alongside allowed := tgbot.AllowedUpdates{
// 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{
models.AllowedUpdateMessage, models.AllowedUpdateMessage,
models.AllowedUpdateMyChatMember, models.AllowedUpdateMyChatMember,
models.AllowedUpdateChatMember, models.AllowedUpdateChatMember,
})) }
if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
} }
if cfg.TestEnv { if cfg.TestEnv {
// Route to the Bot API test environment (.../bot<token>/test/METHOD). // 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 { if t.chatID != 0 {
t.logChatAdminStatus(ctx) t.logChatAdminStatus(ctx)
} }
if t.supportEnabled() {
t.initSupport(ctx)
}
t.resolveWelcomeHandles(ctx) t.resolveWelcomeHandles(ctx)
t.api.Start(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) t.handleChatMember(ctx, update.ChatMember)
return 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) 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 // (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. // there with the "Ban users" right, and "chat_member" in its allowed updates.
ChatID int64 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 // 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 // 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 // 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 // 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. // promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string 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 // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
// the base of every launch button (TELEGRAM_MINIAPP_URL, required). // the base of every launch button (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string MiniAppURL string
@@ -103,6 +119,10 @@ const (
defaultValidatorGRPCAddr = ":9091" defaultValidatorGRPCAddr = ":9091"
defaultBotReconnectDelay = 2 * time.Second defaultBotReconnectDelay = 2 * time.Second
defaultSendRatePerSecond = 25 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. // LoadValidator reads the validator configuration from the environment.
@@ -135,6 +155,8 @@ func LoadBot() (BotConfig, error) {
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"), PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"), BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), 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"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{ BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"), 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 { if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
return BotConfig{}, err 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 { if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
return BotConfig{}, err 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 // TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
// fails validation (the validator path). // fails validation (the validator path).
func TestLoadRejectsUnsupportedExporter(t *testing.T) { func TestLoadRejectsUnsupportedExporter(t *testing.T) {
@@ -9,7 +9,9 @@
package promobot package promobot
import ( import (
"cmp"
"context" "context"
"html"
"net/url" "net/url"
"strings" "strings"
@@ -33,6 +35,11 @@ type Config struct {
// BotLinkURL is the main bot's Mini App direct link; the button appends // BotLinkURL is the main bot's Mini App direct link; the button appends
// ?startapp=<payload> to it. // ?startapp=<payload> to it.
BotLinkURL string 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 // SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
// disables the limiter. The burst equals the per-second rate. // disables the limiter. The burst equals the per-second rate.
SendRatePerSecond int SendRatePerSecond int
@@ -43,6 +50,7 @@ type Bot struct {
api *tgbot.Bot api *tgbot.Bot
username string username string
linkURL string linkURL string
startParam string
log *zap.Logger log *zap.Logger
limiter *rate.Limiter limiter *rate.Limiter
} }
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil { if log == nil {
log = zap.NewNop() 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 { if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) 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 // 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) { func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil { if update.Message == nil {
return return
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.From != nil { if update.Message.From != nil {
lang = update.Message.From.LanguageCode 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{ if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID, ChatID: update.Message.Chat.ID,
Text: text, Text: text,
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)), ParseMode: models.ParseModeHTML,
ReplyMarkup: t.launchMarkup(button, param),
}); err != nil { }); err != nil {
t.log.Warn("promo: reply to start failed", zap.Error(err)) 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)) return strings.TrimSpace(strings.TrimPrefix(text, cmd))
} }
// promoText returns the localized message body and button label, naming the main bot // promoText returns the localized message body and button label. The body names the main
// (Russian for a "ru" language code, English otherwise). // bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
func promoText(lang, username string) (text, button string) { // 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") { 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) { func TestPromoTextLocalization(t *testing.T) {
en, enBtn := promoText("en", "ScrabbleBot") const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") { // 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) t.Errorf("en text = %q", en)
} }
if enBtn != "🤩 I want to play!" { if enBtn != "🤩 I want to play!" {
t.Errorf("en button = %q", enBtn) t.Errorf("en button = %q", enBtn)
} }
ru, ruBtn := promoText("ru-RU", "ScrabbleBot") ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") { if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
t.Errorf("ru text = %q", ru) t.Errorf("ru text = %q", ru)
} }
if ruBtn != "🤩 Хочу играть!" { if ruBtn != "🤩 Хочу играть!" {
t.Errorf("ru button = %q", ruBtn) t.Errorf("ru button = %q", ruBtn)
} }
// An unknown language falls back to English. // An unknown language falls back to English, still with the linked mention.
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") { if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
t.Errorf("fallback text = %q, want English", got) 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" { if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID) t.Errorf("chat_id = %q, want 42", api.chatID)
} }
if !strings.Contains(api.text, "@ScrabbleBot") { if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
t.Errorf("text = %q, want the @mention", api.text) t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
} }
if strings.Contains(api.replyMarkup, "web_app") { if strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup) 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'; import { test as base } from '@playwright/test';
// All e2e specs run hermetically against the mock transport. Neutralise the real // 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 // telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the
// on telegram.org — it is unreachable from the CI runner, and a render-blocking // suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise
// <script> to it would hang every page load. Specs that exercise the Telegram launch // the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the
// inject their own window.Telegram via addInitScript before navigating. // dynamic load short-circuits on the already-present SDK.
export const test = base.extend({ export const test = base.extend({
page: async ({ page }, use) => { 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: '' }), route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
); );
await use(page); 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(); 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 }) => { test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await expect(page.getByText('Invitations')).toBeVisible(); 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); 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/'); await page.goto('/telegram/');
// The guard sends a non-Telegram visitor back to the root, where the normal // The entry no longer bounces a visitor without Telegram sign-in data to the marketing landing;
// (guest / email) login is shown. // it shows a compact diagnostic screen (Share + Retry) that helps pinpoint why initData was
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); // absent — notably the Android empty-initData failure.
await expect(page).not.toHaveURL(/\/telegram\//); 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"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside <!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
Telegram (initData is empty), so it loads on every entry. --> render-blocking <script> to telegram.org hangs the whole page on a network that blocks
<script src="https://telegram.org/js/telegram-web-app.js"></script> 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 <!-- 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). --> to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta <meta
+13 -16
View File
@@ -2,13 +2,13 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { cubicOut } from 'svelte/easing'; import { cubicOut } from 'svelte/easing';
import { app, bootstrap } from './lib/app.svelte'; 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 { t } from './lib/i18n/index.svelte';
import { insideTelegram, telegramBackButton } from './lib/telegram';
import Toast from './components/Toast.svelte'; import Toast from './components/Toast.svelte';
import Splash from './components/Splash.svelte'; import Splash from './components/Splash.svelte';
import StaleInviteModal from './components/StaleInviteModal.svelte'; import StaleInviteModal from './components/StaleInviteModal.svelte';
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte'; import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
import DebugPanel from './components/DebugPanel.svelte';
import Login from './screens/Login.svelte'; import Login from './screens/Login.svelte';
import Lobby from './screens/Lobby.svelte'; import Lobby from './screens/Lobby.svelte';
import NewGame from './screens/NewGame.svelte'; import NewGame from './screens/NewGame.svelte';
@@ -19,6 +19,7 @@
import Feedback from './screens/Feedback.svelte'; import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte'; import Blocked from './screens/Blocked.svelte';
import BootError from './screens/BootError.svelte'; import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => { onMount(() => {
void bootstrap(); void bootstrap();
@@ -29,19 +30,6 @@
// another screen is not covered. // another screen is not covered.
const routeIsLobby = $derived(router.route.name === 'lobby'); 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 // 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 // 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 // screen out to the right and reveals the lobby (back). Transitions are local, so
@@ -84,6 +72,11 @@
{#if !routeIsLobby} {#if !routeIsLobby}
<div class="splash">{t('common.loading')}</div> <div class="splash">{t('common.loading')}</div>
{/if} {/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} {:else if app.bootError}
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy): <!-- 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. --> show the retry screen instead of falling back to the web login. -->
@@ -128,10 +121,14 @@
<StaleInviteModal /> <StaleInviteModal />
<WelcomeRedeemModal /> <WelcomeRedeemModal />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash /> <Splash />
{/if} {/if}
{#if app.debugOpen}
<DebugPanel />
{/if}
<style> <style>
.splash { .splash {
height: 100%; height: 100%;
+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"> <script lang="ts">
import { navigate } from '../lib/router.svelte'; import { navigate } from '../lib/router.svelte';
import { insideTelegram } from '../lib/telegram';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { t } from '../lib/i18n/index.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 Spinner from './Spinner.svelte';
import AdBanner from './AdBanner.svelte'; import AdBanner from './AdBanner.svelte';
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props(); 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 always shows its own back chevron when there is a back target — on every platform, in
// the app's own chevron is hidden to avoid two back affordances. // and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
const showBack = $derived(!!back && !insideTelegram()); // 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> </script>
<header class="nav" class:grow> <header class="nav" class:grow>
@@ -24,7 +37,9 @@
<span class="spacer"></span> <span class="spacer"></span>
{/if} {/if}
{#if connection.online} {#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} {:else}
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1> <h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
{/if} {/if}
@@ -66,7 +81,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap); gap: var(--gap);
padding: 10px var(--pad); padding: 5px var(--pad);
} }
h1 { h1 {
font-size: 1.05rem; font-size: 1.05rem;
@@ -124,15 +139,15 @@
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the 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 (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 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 plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
native controls stays constant if the user scales up the font (the title then grows Telegram's native controls stays constant if the user scales up the font (the title then
downward and the bar with it). (Owner-tunable: the 10px.) */ grows downward and the bar with it). (Owner-tunable: the 8px.) */
min-height: var(--tg-content-top); min-height: var(--tg-content-top);
box-sizing: border-box; box-sizing: border-box;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-top: calc(var(--tg-safe-top) + 16px); padding-top: calc(var(--tg-safe-top) + 8px);
padding-bottom: 6px; padding-bottom: 3px;
} }
:global(html.tg-fullscreen) .spacer { :global(html.tg-fullscreen) .spacer {
display: none; display: none;
+1 -4
View File
@@ -24,7 +24,7 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram'; import { telegramHaptic } from '../lib/telegram';
import { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -228,8 +228,6 @@
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack); placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
} }
onMount(() => { 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 // Render instantly from the cache (a game opened before), then refresh in the
// background. A cold open shows the loading state until load() resolves. // background. A cold open shows the loading state until load() resolves.
const cached = getCachedGame(id); const cached = getCachedGame(id);
@@ -579,7 +577,6 @@
clearTimeout(draftSaveTimer); clearTimeout(draftSaveTimer);
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {}); void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
} }
telegramClosingConfirmation(false);
}); });
function onCell(row: number, col: number) { function onCell(row: number, col: number) {
+87 -20
View File
@@ -12,7 +12,11 @@ import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { import {
insideTelegram, insideTelegram,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath, onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
telegramColorScheme, telegramColorScheme,
telegramContentSafeAreaTop, telegramContentSafeAreaTop,
telegramSafeAreaTop, telegramSafeAreaTop,
@@ -21,7 +25,6 @@ import {
telegramLaunch, telegramLaunch,
type TelegramLaunch, type TelegramLaunch,
telegramOnEvent, telegramOnEvent,
telegramRequestFullscreen,
telegramSetChrome, telegramSetChrome,
} from './telegram'; } from './telegram';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
@@ -47,6 +50,14 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen * 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. */ * instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean; 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 /** 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. */ * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean; lobbyReady: boolean;
@@ -96,6 +107,8 @@ export const app = $state<{
}>({ }>({
ready: false, ready: false,
bootError: false, bootError: false,
launchError: null,
debugOpen: false,
lobbyReady: false, lobbyReady: false,
splashDone: false, splashDone: false,
streamAlive: false, streamAlive: false,
@@ -187,6 +200,16 @@ export function dismissWelcomeRedeem(): void {
app.welcomeRedeem = false; 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 * 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 * list, a game's state, or a move result): unread is any unread entry, message whether one of them
@@ -530,6 +553,31 @@ function syncViewportHeight(): void {
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
} }
/**
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
* bootstrap and a manual launch retry call it.
*/
function applyTelegramChrome(launch: TelegramLaunch): void {
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();
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> { export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
@@ -553,33 +601,30 @@ export async function bootstrap(): Promise<void> {
window.visualViewport.addEventListener('scroll', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewportHeight);
} }
// Telegram Mini App launch: apply the platform theme, authenticate via initData, // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
// and route any deep-link start parameter. On the dedicated /telegram/ entry path // longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common
// outside Telegram (no initData), refuse to render and send the visitor to the // where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app
// site root. // 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 (onTelegramPath() && !insideTelegram()) {
if (typeof location !== 'undefined') location.replace('/'); app.launchError = collectTelegramDiag();
app.ready = true;
return; return;
} }
if (insideTelegram()) { if (insideTelegram()) {
const launch = telegramLaunch(); const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme); applyTelegramChrome(launch);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly // Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
// 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();
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', 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();
await bootTelegram(launch); await bootTelegram(launch);
app.ready = true; app.ready = true;
return; return;
@@ -646,6 +691,28 @@ export async function retryTelegramBoot(): Promise<void> {
app.ready = true; 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 * 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 * specific game, the friends screen with a friend-code redemption, or the lobby
+9 -1
View File
@@ -14,6 +14,11 @@ export const en = {
'boot.errorTitle': "Couldn't load the game", 'boot.errorTitle': "Couldn't load the game",
'boot.errorBody': 'Please try again in a moment.', '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.back': 'Back',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.ok': 'OK', 'common.ok': 'OK',
@@ -227,6 +232,9 @@ export const en = {
'friends.decline': 'Decline', 'friends.decline': 'Decline',
'friends.unfriend': 'Remove', 'friends.unfriend': 'Remove',
'friends.block': 'Block', 'friends.block': 'Block',
'friends.actions': 'Actions',
'friends.blockConfirm': 'Block this player?',
'friends.unfriendConfirm': 'Remove from friends?',
'friends.add': 'Add a friend', 'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends', 'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player', 'friends.blockFromGame': 'Block player',
@@ -239,7 +247,7 @@ export const en = {
'friends.redeem': 'Add', 'friends.redeem': 'Add',
'friends.copy': 'Copy', 'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.', 'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram', 'friends.shareTelegram': 'Share',
'friends.inviteText': "Let's play Scrabble!", 'friends.inviteText': "Let's play Scrabble!",
'friends.linkCopied': 'Link copied.', 'friends.linkCopied': 'Link copied.',
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", '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.errorTitle': 'Не удалось загрузить игру',
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
'common.back': 'Назад', 'common.back': 'Назад',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.ok': 'ОК', 'common.ok': 'ОК',
@@ -228,6 +233,9 @@ export const ru: Record<MessageKey, string> = {
'friends.decline': 'Отклонить', 'friends.decline': 'Отклонить',
'friends.unfriend': 'Удалить', 'friends.unfriend': 'Удалить',
'friends.block': 'Заблокировать', 'friends.block': 'Заблокировать',
'friends.actions': 'Действия',
'friends.blockConfirm': 'Заблокировать?',
'friends.unfriendConfirm': 'Удалить из друзей?',
'friends.add': 'Добавить друга', 'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья', 'friends.addFromGame': 'В друзья',
'friends.blockFromGame': 'Заблокировать', 'friends.blockFromGame': 'Заблокировать',
@@ -240,7 +248,7 @@ export const ru: Record<MessageKey, string> = {
'friends.redeem': 'Добавить', 'friends.redeem': 'Добавить',
'friends.copy': 'Копировать', 'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.', 'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram', 'friends.shareTelegram': 'Поделиться',
'friends.inviteText': 'Давай играть в Эрудит!', 'friends.inviteText': 'Давай играть в Эрудит!',
'friends.linkCopied': 'Ссылка скопирована.', 'friends.linkCopied': 'Ссылка скопирована.',
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
+40 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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'; import type { GcgExport } from './model';
const file = {} as File; const file = {} as File;
@@ -60,3 +60,42 @@ describe('shareOrDownloadGcg', () => {
expect(anchor.click).toHaveBeenCalledOnce(); 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(); a.remove();
URL.revokeObjectURL(url); 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';
}
}
+106 -65
View File
@@ -1,11 +1,12 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
collectTelegramDiag,
insideTelegram, insideTelegram,
loadTelegramSDK,
telegramSdkOutcome,
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramClosingConfirmation,
telegramLaunch, telegramLaunch,
telegramOpenExternalLink, telegramOpenExternalLink,
telegramRequestFullscreen,
} from './telegram'; } from './telegram';
function stubWebApp(initData: string, startParam?: string) { function stubWebApp(initData: string, startParam?: string) {
@@ -45,69 +46,6 @@ describe('telegram launch detection', () => {
}); });
}); });
// 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('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();
}
});
});
describe('telegramOpenExternalLink', () => { describe('telegramOpenExternalLink', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
@@ -155,3 +93,106 @@ describe('routeExternalLinkInTelegram', () => {
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false); 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');
});
});
+230 -66
View File
@@ -1,6 +1,7 @@
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in // Telegram Mini App SDK access. The official telegram-web-app.js (loaded dynamically with a
// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses: // timeout by loadTelegramSDK — not a render-blocking <script> in index.html, so a network that
// launch detection, initData (for auth.telegram), the deep-link start parameter, // 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. // theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
import type { TelegramThemeParams } from './theme'; import type { TelegramThemeParams } from './theme';
@@ -9,14 +10,18 @@ interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
platform?: string; platform?: string;
version?: string;
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean; isFullscreen?: boolean;
isExpanded?: boolean;
viewportHeight?: number;
viewportStableHeight?: number;
exitFullscreen?: () => void;
safeAreaInset?: { top: number; bottom: number; left: number; right: number }; safeAreaInset?: { top: number; bottom: number; left: number; right: number };
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number }; contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
ready?: () => void; ready?: () => void;
expand?: () => void; expand?: () => void;
requestFullscreen?: () => void;
openTelegramLink?: (url: string) => void; openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void; openLink?: (url: string) => void;
onEvent?: (event: string, handler: () => void) => void; onEvent?: (event: string, handler: () => void) => void;
@@ -24,14 +29,13 @@ interface TelegramWebApp {
setBackgroundColor?: (color: string) => void; setBackgroundColor?: (color: string) => void;
setBottomBarColor?: (color: string) => void; setBottomBarColor?: (color: string) => void;
disableVerticalSwipes?: () => void; disableVerticalSwipes?: () => void;
enableClosingConfirmation?: () => void;
disableClosingConfirmation?: () => void;
HapticFeedback?: { HapticFeedback?: {
impactOccurred?: (style: string) => void; impactOccurred?: (style: string) => void;
notificationOccurred?: (type: string) => void; notificationOccurred?: (type: string) => void;
selectionChanged?: () => void; selectionChanged?: () => void;
}; };
BackButton?: { BackButton?: {
isVisible?: boolean;
show?: () => void; show?: () => void;
hide?: () => void; hide?: () => void;
onClick?: (cb: () => void) => void; onClick?: (cb: () => void) => void;
@@ -53,6 +57,72 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0; 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 * 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 * it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
@@ -224,66 +294,6 @@ export function telegramHaptic(kind: Haptic): void {
else h.impactOccurred?.(kind); 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 * startParamFromURL reads a startapp parameter from the page URL — a bot web_app
* launch button carries the deep-link there rather than in initDataUnsafe. * launch button carries the deep-link there rather than in initDataUnsafe.
@@ -302,6 +312,160 @@ export function onTelegramPath(): boolean {
return location.pathname.startsWith('/telegram/'); 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) --- // --- 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 // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
+169 -22
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
@@ -16,6 +17,11 @@
let robotBlocks = $state<RobotBlockEntry[]>([]); let robotBlocks = $state<RobotBlockEntry[]>([]);
let code = $state<FriendCode | null>(null); let code = $state<FriendCode | null>(null);
let redeemInput = $state(''); 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() { async function load() {
try { try {
@@ -52,6 +58,41 @@
const blockUser = (id: string) => act(() => gateway.block(id)); const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(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(): void {
const target = blockTarget;
blockTarget = null;
revealedId = null;
if (target) void blockUser(target.accountId);
}
function confirmUnfriend(): void {
const target = unfriendTarget;
unfriendTarget = null;
revealedId = null;
if (target) void remove(target.accountId);
}
// 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() { async function getCode() {
try { try {
code = await gateway.friendCodeIssue(); code = await gateway.friendCodeIssue();
@@ -141,7 +182,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
{#if tg} {#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} {/if}
</div> </div>
{:else} {:else}
@@ -152,30 +193,39 @@
{#if incoming.length} {#if incoming.length}
<section> <section>
<h3>{t('friends.incoming')}</h3> <h3>{t('friends.incoming')}</h3>
<div class="list">
{#each incoming as r (r.accountId)} {#each incoming as r (r.accountId)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="acts"> <span class="btns">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button> <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> <button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span> </span>
</div> </div>
</div>
{/each} {/each}
</div>
</section> </section>
{/if} {/if}
<section> <section>
<h3>{t('friends.yours')}</h3> <h3>{t('friends.yours')}</h3>
{#if friends.length} {#if friends.length}
<div class="list">
{#each friends as f (f.accountId)} {#each friends as f (f.accountId)}
<div class="item"> <div class="rowwrap" class:revealed={revealedId === f.accountId}>
<div class="acts">
<button class="iconbtn" onclick={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
<button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
</div>
<div class="row">
<span class="who">{f.displayName}</span> <span class="who">{f.displayName}</span>
<span class="acts"> <button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button>
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button> </div>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div> </div>
{/each} {/each}
</div>
{:else} {:else}
<p class="muted">{t('friends.none')}</p> <p class="muted">{t('friends.none')}</p>
{/if} {/if}
@@ -184,20 +234,49 @@
{#if blocked.length || robotBlocks.length} {#if blocked.length || robotBlocks.length}
<section> <section>
<h3>{t('friends.blockedList')}</h3> <h3>{t('friends.blockedList')}</h3>
<div class="list">
{#each blocked as b (b.accountId)} {#each blocked as b (b.accountId)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{b.displayName}</span> <span class="who">{b.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div> </div>
{/each} {/each}
{#each robotBlocks as r (r.id)} {#each robotBlocks as r (r.id)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div> </div>
{/each} {/each}
</div>
</section> </section>
{/if} {/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} {/if}
</div> </div>
@@ -279,28 +358,76 @@
padding: 4px 0; padding: 4px 0;
text-align: left; 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; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border); background: var(--bg);
background: var(--surface); transform: translateX(0);
border-radius: var(--radius-sm); transition: transform 0.18s ease;
margin-bottom: 8px; }
.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 { .who {
flex: 1;
min-width: 0;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn { .btn {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--accent); border: 1px solid var(--accent);
@@ -315,7 +442,27 @@
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.ghost.danger { .confirm-name {
color: var(--danger, #c0392b); 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> </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>