Compare commits

..

91 Commits

Author SHA1 Message Date
Ilia Denisov 5689f7f6a3 feat: on-device move preview (local eval) with network fallback
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 58s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Score and validate a tentative move on-device instead of a per-arrangement network
round trip. The dawg reader and the validate/score/direction slice of the
scrabble-solver engine are ported to TypeScript (ui/src/lib/dict), pinned
byte-for-byte to the Go engine by a `conformance` CI job (full-dictionary reader
parity plus a battery of plays across every variant and both cross-word rules,
including the inferred orientation). The server stays authoritative — submit_play
re-validates — so the local result is an advisory accelerator only.

- backend: Registry.DictBytes + an authed GET /api/v1/user/dict/{variant}/{version}
  (immutable) streaming the pinned per-game dawg; caddy routes /dict to the gateway.
- gateway: a session-gated /dict edge route proxying it; fetchDict on the transport.
- client: the dictionary loads on game open (low priority so it never starves the
  game on a slow link; aborted at a 5s cap or when leaving the game), is cached in
  IndexedDB (best-effort, self-healing on a rejected blob) and reused across
  sessions; a warm-up overlay covers a cold load, then the network preview is the
  fallback; a bad-connection breaker stops warming after repeated misses; the move
  preview cancels its in-flight request when the tiles change.
- parity generators backend/cmd/{dictgen,validategen} + gated Vitest suites, run in
  CI against the release dictionaries. A hidden debug readout lists the cached
  dictionaries + breaker state, and its reset clears the cache.
- docs: ARCHITECTURE §5, TESTING, UI_DESIGN, FUNCTIONAL (+ru).
2026-07-01 22:58:40 +02:00
developer f0399e1bbc Merge pull request 'build(ui): drop sourcemaps from prod bundle' (#153) from feature/ui-drop-prod-sourcemaps into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
2026-07-01 15:00:44 +00:00
Ilia Denisov e6c5198caa build(ui): drop sourcemaps from prod bundle
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The production UI build shipped `.map` files with full `sourcesContent`, and
the gateway/landing images serve `dist/` verbatim, so anyone could fetch
`/app/assets/main-*.js.map` (same assets under `/vk/`, `/telegram/`) and
reconstruct the entire TypeScript/Svelte source at the edge.

Gate `build.sourcemap` off for `mode === 'production'` (the Docker image build).
Dev and the `mock` e2e build (`vite build --mode mock`) keep maps for debugging.
Document the posture in ARCHITECTURE.md §12.
2026-07-01 16:57:05 +02:00
developer 84cc56198e Merge pull request 'feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings)' (#152) from feature/vk-status-bar-chrome into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m17s
2026-07-01 12:39:11 +00:00
Ilia Denisov 5f9b4a7a38 feat(ui): paint VK status bar to the app theme (VKWebAppSetViewSettings)
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 58s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m13s
Parity with the Telegram chrome painting: on a VK Mini App launch and on
every theme change, set VK's status-bar appearance (and, on Android, the
action/navigation bar colours) from the app's live theme tokens, so the VK
chrome matches the UI instead of clashing.

syncVKChrome mirrors syncTelegramChrome; the status-bar appearance is
derived from the --bg token's luminance (appearanceForBg, unit-tested).
Wired into the VK onScheme handler (fires on launch + on theme change) and
setTheme. Docs: UI_DESIGN VK integration.
2026-07-01 13:54:14 +02:00
developer 4458f0e545 Merge pull request 'fix(ui): copy GCG to clipboard on Android in-app WebViews' (#151) from feature/gcg-export-android-fix into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m13s
2026-07-01 11:23:59 +00:00
developer 41f26da9a7 Merge pull request 'fix(game): clear nudges on game completion' (#148) from feature/nudge-clear-on-completion 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 / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m15s
2026-07-01 11:23:39 +00:00
developer cf1d773c18 Merge pull request 'fix(ui): mobile WebView polish — tap-flash, VK haptics, VK swipe-back' (#149) from feature/mobile-webview-polish into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m14s
2026-07-01 11:23:29 +00:00
Ilia Denisov 88b6761e28 fix(ui): copy GCG to clipboard on Android in-app WebViews
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m16s
On-device diagnostics from Android Telegram and VK confirmed both expose
no navigator.share AND no navigator.canShare, so the export fell to a Blob
<a download> that those WebViews silently ignore — nothing happened.

pickGcgDelivery is now a 3-way decision: Web Share where available (iOS),
a clipboard copy in an Android in-app WebView (Telegram/VK: no share, dead
download), else a desktop Blob download. shareOrDownloadGcg reports the
outcome so the game shows a "GCG copied" toast; the copy is VKWebAppCopyText
inside VK (which also covers the desktop VK iframe, where navigator.clipboard
is blocked) and navigator.clipboard otherwise.

Unit tests cover the 3-way choice and the copy/failed outcomes; new i18n key
game.gcgCopied (en+ru); docs ARCHITECTURE/FUNCTIONAL(+ru)/UI_DESIGN/TESTING.
2026-07-01 13:17:00 +02:00
Ilia Denisov f4dbb545e0 fix(ui): suppress tap-flash directly on the header title and back
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
The global -webkit-tap-highlight-color on #app was not enough on Android
in-app WebViews (Telegram, VK): they only suppress the selection-like tap
flash when -webkit-user-select / -webkit-tap-highlight-color /
-webkit-touch-callout sit DIRECTLY on the tapped element, not inherited.
Set them on the two tappable header controls — the title (a 10-tap debug
target) and the back chevron.
2026-07-01 13:06:11 +02:00
Ilia Denisov 71c3411276 fix(ui): mobile in-app WebView polish (tap-flash, VK haptics, swipe-back)
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m13s
- Tap-highlight: add -webkit-tap-highlight-color: transparent on #app.
  Android in-app WebViews (Telegram, VK) flashed a momentary selection-like
  box on tappable nodes on tap — seen on the header title and the back
  chevron; user-select (already none) does not govern it. Inherited, so
  this clears it app-wide.

- VK haptics: mirror the Telegram haptic set on VK via VK Bridge taptic
  (impact / notification / selection), routed through a new shared
  lib/haptics.ts dispatcher. VK users previously got no haptics; the game
  and error call sites now fire haptic() instead of telegramHaptic().

- VK swipe-back: disable VK's horizontal swipe-back at launch
  (VKWebAppSetSwipeSettings history:false) so it does not fight the app's
  own edge-swipe-back and on-board tile drag — parity with Telegram's
  disabled vertical swipes; the app owns navigation via its back chevron.

Docs: UI_DESIGN (no-select / tap-highlight, VK integration).
2026-06-30 23:00:19 +02:00
Ilia Denisov f9acea1d9a fix(game): clear nudges on game completion
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Nudge badges lingered in the lobby on games that ended by turn-timeout,
resignation or forfeit: those paths commit the finish directly, bypassing
the move path's per-mover NudgeClearer, so the awaited seat's nudge was
never marked read. A finished game's nudges are stale, so clear them all.

Add a wired NudgeExpirer (social.ExpireNudges) called from the shared
commit finish block — covering every completion path — and from the
voidGame recovery path. It clears every seat's nudge bits for the game
and leaves chat messages unread; unlike ClearNudges it records no
publish-to-read latency, since a completion is an expiry, not a read.

An integration test reproduces the timeout case (nudge cleared, chat
kept). Docs: ARCHITECTURE §9.1, FUNCTIONAL (+_ru), backend/README.
2026-06-30 22:51:54 +02:00
developer adf7c55695 Merge pull request 'feat(ui): first-run onboarding coachmarks' (#147) from feature/onboarding-coachmarks into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m21s
2026-06-30 20:09:33 +00:00
Ilia Denisov 6636d7c309 feat(ui): first-run onboarding coachmarks
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
A one-time coachmark overlay walks a new player through the lobby and their
first game board: a light dimmed layer draws one tail-pointed hint bubble at a
time, advancing on a tap anywhere and removing itself for good after the last
hint. Two independent series (lobby: settings/stats/new game; game:
header/pass-exchange/hints/shuffle/rack), gated by a per-device persisted flag
and marked done only after the last hint, so an interrupted run replays from
the start. A deep-link into Settings -> Friends still triggers the lobby series
on the first trip back to the lobby.

Targets carry a data-coach attribute, so one positioning engine anchors the
bubble in both portrait and landscape, re-measuring each frame until the
geometry settles (route slide, hidden-banner reflow, fonts). The promo banner
hides while the overlay is up (app.coachActive); a hidden DebugPanel "Reset
visited" control replays the walk-through. Off by default in the mock build so
the Playwright smoke is unaffected; ?coach forces it on for the dedicated e2e.

Pure geometry (step lists, nextVisibleStep, placeBubble) in lib/coachmark.ts
(unit-tested); Coachmark.svelte renders. Docs: FUNCTIONAL(+ru) onboarding
story, UI_DESIGN coachmark section.
2026-06-30 21:48:56 +02:00
developer 90f2427fa7 Merge pull request 'feat(assets): VK loading-screen Lottie — bouncing Erudit tile' (#146) from feature/vk-loader-lottie into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m28s
2026-06-30 16:37:26 +00:00
Ilia Denisov d0f860a33e feat(assets): VK loading-screen Lottie — bouncing Erudit tile
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A 96x96 transparent Lottie for the VK app loading screen: the «Э» tile
(score 8) drops in under gravity, lands with a soft cushion squash
(left/right edges bulge convex, corners follow via a chordal spline),
springs back and loops (~1.1s). A warm glint is caught on the bounce.

Pure 2D shapes (ddd:0) for player compatibility; ~11KB (<24KB limit).
Glyphs are real LiberationSans outlines (Arial-metric, matching the
game's font stack). Includes a reproducible, dependency-light build
pipeline (build/generate.js + extract.js + glyphs.json) and a README.
The preview GIF's green-baize background is preview-only; the asset
itself is transparent.
2026-06-30 18:29:51 +02:00
developer d1ceccf033 Merge pull request 'docs: sync FUNCTIONAL/UI_DESIGN/.claude with the shipped VK + landscape behaviour (#140-#142 follow-up)' (#145) from fix/vk-docs-deeplink-and-pan into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-30 06:07:37 +00:00
Ilia Denisov 373aa2aa6d docs: sync VK theme-follow + home-bar safe-area into FUNCTIONAL/UI_DESIGN/.claude
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
The #142 polish shipped three VK behaviours the docs never picked up (they were
written for the #140 auth MVP):

- the "auto" theme follows the VK client light/dark (VKWebAppUpdateConfig), not
  the VK webview's prefers-color-scheme;
- the layout clears the VK mobile home bar via CSS env() max'd with the VK bridge
  insets (VKWebAppUpdateInsets) — the bridge value is needed on Android, where the
  VK webview exposes no env() inset (.claude said env() handled it alone);
- share/copy route through the bridge (VKWebAppShare/VKWebAppCopyText).

FUNCTIONAL (+_ru) VK paragraph gains theme+home-bar parity with the Telegram one;
UI_DESIGN gets a VK-integration note beside the Telegram one.
2026-06-30 08:05:38 +02:00
Ilia Denisov 4a6fe318be docs: correct VK invite-link reality + note desktop board drag-to-pan
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Two doc edits that did not land alongside their code:

- FUNCTIONAL (+_ru) claimed a friend invitation is shareable as a Telegram
  *or VK* deep link "that opens it directly". On VK that is false: vkShareLink
  emits a plain vk.com/app<id> link (VK forwards no payload to the Mini App),
  so the recipient enters the copied code by hand (#142, commit d76f1f4 pivoted
  off the earlier #f<code> plan; the doc kept the old wording).
- UI_DESIGN omitted the desktop / landscape-iframe mouse drag-to-pan of the
  zoomed board added in #141 (touch scrolls the viewport natively; a mouse
  cannot, so an explicit drag-to-pan handler moves it).
2026-06-30 07:51:11 +02:00
developer 00441e3657 Merge pull request 'chore(ci): bump dictionary test-suite seed to v1.3.1' (#143) from chore/dict-seed-v1.3.1 into development
CI / changes (push) Successful in 2s
CI / integration (push) Successful in 14s
CI / deploy (push) Successful in 1m16s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 16s
CI / unit (push) Successful in 9s
CI / ui (push) Successful in 55s
CI / gate (push) Successful in 0s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-30 05:29:44 +00:00
developer 3b7c7c077e chore(ci): bump dictionary test-suite seed to v1.3.1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
The scrabble-dictionary release v1.3.1 drops the obscene lexicon (mat).
Bump the centralised CI seed so unit/integration jobs validate against the
same dictionary the prod deploy now pins (vars.PROD_DICT_VERSION=v1.3.1).
2026-06-30 05:27:42 +00:00
developer e900d592f8 Merge pull request 'feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area' (#142) from feature/vk-bridge-integration into development
CI / changes (push) Successful in 2s
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 1m25s
2026-06-29 21:04:53 +00:00
Ilia Denisov d76f1f4026 fix(vk): friend-code link is the plain vk.com/app link (VK strips iframe query payload)
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 1m33s
The contour diagnostic confirmed VK forwards only the signed vk_* params to the iframe — a
custom payload on the app link is dropped (the '#' eaten by the vk.com SPA, a '?' query
stripped), so the friend-code cannot ride the link. The VK share link is now just
vk.com/app<id>; the recipient enters the copied code by hand (VKWebAppCopyText works). The
vkStartParam reader + bootVK routing stay as a no-op, ready for a post-moderation channel.
Removes the temporary deep-link diagnostic.
2026-06-29 22:58:43 +02:00
Ilia Denisov 0ea9764a0d chore(vk): temporary deep-link diagnostic (remove after contour catch)
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 1m26s
2026-06-29 22:41:08 +02:00
Ilia Denisov 6a5ce12fab fix(vk): friend-code link as ?hash, Android safe-area via bridge insets, landscape home-bar colour
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 55s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Contour review of the VK Bridge group:

- #6 invite link: VK's documented '#' direct-link payload is eaten by the vk.com SPA before it
  reaches the app, so the friend-code link now carries the payload as a query param
  (vk.com/app<id>?hash=f<code>); the recipient already reads the `hash` query param (vkStartParam).
  (Whether VK forwards the '?' through to the iframe is being confirmed on the contour.)
- #8 Android: the VK mobile webview does not surface the home-bar inset via CSS env() (config
  insets are iOS-only), so subscribe to the bridge insets (VKWebAppUpdateConfig + VKWebAppUpdateInsets)
  and set --tg-safe-* to max(env(), the VK value).
- #8 landscape colour: the home-indicator strip was the (grey) page background because the two-pane
  landscape game has no bottom bar. The left-panel controls bar now paints its own chrome into the
  inset (Screen gains a selfInset flag that drops the shell's detached padding strip), and the
  game-land runs flush to the edge.

Verified: svelte-check, 347 unit, build, bundle-gate; the landscape safe-area painting reproduced in
the mock (controls bar + board reach the edge, strip takes the bar colour). The VK-Bridge / VK launch
behaviours (Android insets, the ?hash forward) need the live contour.
2026-06-29 22:15:43 +02:00
Ilia Denisov da17c18895 feat(vk): native share/copy, auto theme, friend-code deep link, home-bar safe area
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 1m25s
Group B of the VK integration — the contour-verified follow-up to the launch+auth MVP:

- Share/copy inside the VK iframe go through VK Bridge: the friend-code invite shares via
  VKWebAppShare and copies via VKWebAppCopyText, since navigator.share is absent in the desktop
  iframe and navigator.clipboard is blocked there.
- The invite link is a VK Mini App direct link (vk.com/app<id>#f<code>) on VK instead of the
  Telegram link; the app id comes from vk_app_id in the launch params (no build arg needed). The
  recipient's launch routes the deep link from VK's `hash` launch query parameter.
- The app's "auto" theme follows the VK client's light/dark appearance (VKWebAppUpdateConfig),
  which the VK mobile webview's prefers-color-scheme does not track.
- The safe-area CSS vars default to env(safe-area-inset-*), so the VK mobile layout clears the
  home bar (and Capacitor/PWA too); Telegram still overrides them from its SDK.

vk.ts adds vkAppId/vkStartParam/vkShare/vkCopyText/vkOnScheme. Verified: svelte-check, 347 unit
(+ vkAppId/vkStartParam/vkShareLink), build, bundle-gate. The VK-Bridge behaviours need the live
contour (not reproducible headless).
2026-06-29 21:27:36 +02:00
developer 303348ed39 Merge pull request 'fix(ui): landscape board zoom/pan + rack tile rendering + mobile block label' (#141) from fix/landscape-board-ui into development
CI / changes (push) Successful in 2s
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 1m31s
2026-06-29 19:11:05 +00:00
Ilia Denisov a06dfe00fc fix(ui): pin the landscape confirm button into the freed tile slot
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 1m24s
Follow-up to the contour review: with the rack now exactly seven fixed-size slots in the
narrow landscape panel, the 56px confirm button (sized for the roomy portrait rack) was
wider than the single slot a staged tile frees, so it overlapped the now-rightmost tile.
Match the button width to one landscape tile slot, so it sits inside the freed slot with
its right edge level with the rack's right edge — the mirror of the first tile's left edge.
2026-06-29 20:54:44 +02:00
Ilia Denisov a88678c5c6 fix(ui): landscape zoom two-step (sync both axes) + fixed rack tile size
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 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 2m35s
Follow-up to the contour review of the landscape fixes:

- Zoom still positioned "in two steps": a wide viewport overflows vertically as soon as
  the square board grows past its height, but horizontally only once it grows past the
  (much wider) width — so the browser pins scrollLeft to 0 while the vertical axis
  already pans (no time/ease tween fixes it; the horizontal scroll range physically is
  not there yet). Found by instrumenting the scroll trajectory. Now drive both axes by
  one progress = how far the board has grown past the viewport width (when a horizontal
  pan first becomes possible): until then the board just zooms centred, past it both
  axes pan together in one diagonal motion. Also disable scroll-anchoring so the browser
  stops fighting the programmatic scroll mid-transition. Re-verified: both axes now
  start and move together.
- Rack tiles resized when a tile was placed: landscape used flex-grow, so removing a
  tile regrew the rest. Give them a fixed size (1/7 of the fixed-width rack), like the
  portrait rack, so placing a tile leaves the rest put.
2026-06-29 20:26:01 +02:00
Ilia Denisov 500a01cf97 fix(ui): landscape board zoom/pan + rack tile rendering, shorter mobile block label
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 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
Bugs surfaced while testing in VK but present on every platform (verified across
browsers, not VK-specific):

- Rack tiles: the letter used a fixed rem size, so in landscape — where tiles shrink
  below 46px in the narrow left panel — it overflowed and dropped into the bottom-left
  corner. Size it relative to the rack (cqw, like the board's labels; the tile's own
  container-query size resolves unreliably under flex + aspect-ratio).
- Landscape board zoom positioned "in two steps": the per-frame clamp-to-max reached
  the wide axis before the tall one. Interpolate both scroll axes together by time over
  the grow/shrink transition instead (settles on zoom-out too).
- The zoomed board could not be panned with a mouse (touch scrolls the overflow:auto
  viewport natively; a mouse cannot drag-scroll a div). Add a drag-to-pan handler, active
  only while zoomed, off pending tiles, past a small movement threshold, swallowing the
  trailing click so it does not also act on a cell.
- Shorten the in-game block-confirm label ru "Блокируем?" -> "В бан?" (the long form
  overflowed the seat score chip on mobile).
2026-06-27 13:46:15 +02:00
developer d9ede77e2f Merge pull request 'feat(vk): embed the game as a VK Mini App' (#140) from feature/vk-embedding into development
CI / changes (push) Successful in 3s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 57s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m30s
2026-06-27 11:23:44 +00:00
Ilia Denisov 65c194264c feat(vk): embed the game as a VK Mini App
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 1m0s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Mirror the Telegram Mini App wrapper for VK: the SPA loads at a new /vk/
entry, authenticates from VK's signed launch parameters, and provisions a
'vk' platform identity — the minimum to run the game in VK test mode.

- Gateway verifies the launch signature in-process (internal/vkauth:
  HMAC-SHA256 over the sorted vk_* params under GATEWAY_VK_APP_SECRET,
  base64url) — a pure offline check, no side-service. New auth.vk op
  (gated on the secret), backendclient.VKAuth, /vk/ SPA mount.
- Backend: KindVK + ProvisionVK/vkSeed, /sessions/vk handler, identity
  kind widened to include 'vk' (migration 00005, expand-contract).
- UI: src/lib/vk.ts (VK Bridge, lazy-imported), bootVK + the /vk/ boot
  dispatch, encodeVKLogin + authVK across transport/client/mock. VK omits
  the name from the signed params, so the client reads it via
  VKWebAppGetUserInfo as an unsigned display seed.
- Deploy: /vk in the edge Caddyfile, GATEWAY_VK_APP_SECRET wired through
  compose + .env.example + CI (TEST_) + prod-deploy (PROD_).
- Admin console: surface the VK user id (link to the VK profile) next to
  the Telegram id on the user card.
- Docs: ARCHITECTURE §12/§13, FUNCTIONAL (+ _ru), gateway README; VK
  integration reference under .claude/.

Signature algorithm verified against dev.vk.com plus independent Node/Python
references and a %2C edge-case vector.
2026-06-27 11:37:31 +02:00
developer 13c22734ee Merge pull request 'chore: track the deploy-check skill in the repo' (#139) from chore/deploy-check-skill into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m26s
2026-06-24 13:07:22 +00:00
Ilia Denisov 7dabcd1317 chore: track the deploy-check skill in the repo
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m23s
The deploy-check skill is repo-specific: it encodes this project's hard-won
pre-deploy runtime constraints (distroless nonroot, edge Alt-Svc/HTTP3, caddy
recreate, DICT_VERSION boot, expand-contract migrations, the Telegram permission
model) and points at deploy/README.md, docs/ARCHITECTURE.md, docs/EDGE_HTTP3.md and
the agent memory files. It belongs with the deploy logic it guards, not in the global
config, so it travels with the repo and stays versioned alongside it.
2026-06-24 14:39:00 +02:00
developer b03e012011 Merge pull request 'feat(telegram): native dialogs (experimental, stacked on #136)' (#137) from feature/telegram-native-dialogs into development
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
CI / changes (push) Successful in 2s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / unit (pull_request) Successful in 10s
2026-06-24 11:41:28 +00:00
developer 2495446a47 Merge pull request 'feat(telegram): Mini App embedding enhancements' (#136) from feature/telegram-embedding into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
2026-06-24 11:41:15 +00:00
Ilia Denisov 35705f7d1e fix(telegram): defer deep-link notices until the loading cover clears
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m28s
The stale-invite / welcome-on-redeem notices are raised during boot, so the native
popup fired over the loading splash. Gate both the native popup and the in-app Modal
on the current route's loading cover being gone — the tile splash on the lobby
(splashDone), the plain loading screen elsewhere (app.ready) — so the notice appears
with the settled screen on every build (Telegram and native/web alike).
2026-06-24 13:25:14 +02:00
Ilia Denisov 4f0cc81dfb fix(telegram): evaluate native-dialog availability at fire time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m27s
The deep-link info modals (stale invite, welcome-on-redeem) captured
insideTelegram() && telegramDialogsAvailable() in a const at component init, but they
mount in App before bootstrap loads the SDK, so the value was always false and they
fell back to the in-app Modal even inside Telegram. Evaluate it at fire time (in the
effect and the {#if} guard), as the destructive confirms already do at click time.
2026-06-24 13:16:11 +02:00
Ilia Denisov 2ba7cc3086 feat(telegram): native dialogs for confirms and deep-link notices
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Inside the Mini App, route the destructive confirmations (resign, block, unfriend)
through Telegram's native showConfirm, and present the deep-link info modals (stale
invite, welcome-on-redeem) as a native showPopup whose button opens the bot chat.
Outside Telegram, or on a client predating the dialogs, the existing in-app Modal is
used unchanged; offline also keeps the modal so the action retains its disabled state.

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

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

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

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

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

Subscribe to the themeChanged WebApp event and re-apply the theme live. Extract
the launch-time token + colour-scheme + chrome application into syncTelegramTheme
(reading live themeParams when no launch snapshot is passed) and call it from both
applyTelegramChrome (launch) and the new event handler. Add a telegramThemeParams
live getter (+ unit test). Drop the stale 'immersive fullscreen' note from
applyTelegramChrome — the app deliberately does not request fullscreen.
2026-06-24 09:20:41 +02:00
developer 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 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 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 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 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 18db62e19d Merge pull request 'feat(ui): friends list as lobby-style rows with kebab + confirm modals' (#123) from feature/friends-list-kebab-confirm into development
CI / ui (push) Successful in 56s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / deploy (push) Successful in 1m18s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 22:28:25 +00:00
Ilia Denisov d4e34efa80 test(ui): e2e for the friends-list kebab, confirm modals and outside-tap
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Cover the reworked Settings -> Friends interactions in the mock e2e
(Chromium + WebKit): the row kebab slides open the block/remove icons and an
outside tap collapses it; blocking confirms (naming the friend) and moves them
to Blocked; removing confirms and drops the friendship.
2026-06-23 00:24:33 +02:00
Ilia Denisov 12ff6dad86 feat(ui): close the friends kebab on an outside tap
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m30s
A slid-open friend row now collapses when the user taps anywhere outside its
action buttons (taps on a kebab are skipped so its own toggle still drives the
open/close). Uses the same capture-phase window pointerdown idiom as Screen,
active only while a row is revealed.
2026-06-23 00:20:09 +02:00
Ilia Denisov 5a80696fe8 feat(ui): friends list as lobby-style rows with kebab + confirm modals
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m31s
Settings -> Friends previously rendered each friend as a bordered card with
two always-visible text buttons (Remove / Block) that fired immediately. Rework
the whole screen to the lobby's visual language: one-line rows split by
hairline separators across all three sections (friends, incoming requests,
blocked).

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

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

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

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

Emit Alt-Svc: clear site-wide at the contour caddy so clients actively drop
any cached alternative and stay on h2/h1. This caddy terminates TLS in prod
(the fix target); in the test contour it serves plain :80 and the host caddy
re-stamps its own Alt-Svc, so the live test fix lives in the host caddy. Add
docs/EDGE_HTTP3.md (symptom, diagnosis method, verify, and option B -- serving
h3 for real -- if it recurs) and link it from ARCHITECTURE.md.
2026-06-22 21:20:31 +02:00
developer 9d1ca213d6 Merge pull request 'fix(i18n): banner/push follow the interface language even without a Settings change' (#118) from feature/banner-language-followup into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m16s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 18:17:03 +00:00
developer 1f78bb274b Merge pull request 'feat(telegram): localized /start welcome with channel & chat follow links' (#117) from feature/bot-welcome-localized into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 16s
CI / ui (push) Has been skipped
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / gate (pull_request) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / deploy (pull_request) Has been skipped
2026-06-22 18:16:52 +00:00
Ilia Denisov 81b716569f fix(i18n): reconcile preferred_language to the interface locale on every adopt
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
A user who never changed the language in Settings kept their account at the
creation-time preferred_language seed (e.g. en from the Telegram launch language_code)
even after switching the device to another language: the UI followed the device (ru) but
the ad banner and out-of-app push — both resolved server-side from preferred_language —
stayed en. The on-adopt reconcile was gated on an explicit local choice (localeLocked),
so a system-guess locale was never pushed through.

Reconcile preferred_language to the active interface locale (app.locale) on every session
adopt and link, regardless of how the locale was chosen; persistLanguageToServer already
self-gates (a no-op for guests and when already equal), so there is no steady-state write.
The banner and push are the only server-rendered language surfaces and both read
preferred_language, so this keeps the whole interface consistent — not just the banner.
Drop the now-dead localeLocked flag (the reconcile guards were its only readers; the saved
prefs.locale still restores the UI choice per device).

Trade-off: preferred_language now follows the most-recently-opened device, so an explicit
choice on one device can be overwritten by a system guess on another (the "explicit" mark
is local, per-device); making it globally sticky would need a DB flag.

Docs: ARCHITECTURE §4 + the profile field.
2026-06-22 20:09:41 +02:00
Ilia Denisov aa330b726e feat(telegram): localized /start welcome with channel & chat follow links
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The main bot answered /start with a single English line ("Tap to open Scrabble.").
Localize it: Russian or English by the sender's reported Telegram language
(Message.from.language_code, which the Bot API carries on the message itself — there is
no separate user-update event — English fallback), with the longer welcome copy and a
localized launch button ("Открыть «Эрудит»" / "Open “Erudite”").

The welcome links the game channel and the discussion chat by their public @username,
resolved once at startup from the configured TELEGRAM_GAME_CHANNEL_ID / TELEGRAM_CHAT_ID
via getChat and cached. A handle that is unset, private, or unreadable degrades to a
generic noun ("the channel" / "our chat") rather than a dangling "@", so the paragraph
always reads cleanly (the bot's info screen still lists the real links). Adds
GameChannelID to bot.Config (wired from the existing config) for the channel handle.

Tests: startText localization + handle embedding + per-slot generic fallback; handleStart
language selection; resolveWelcomeHandles. README updated.
2026-06-22 19:39:00 +02:00
developer d5369a0188 Merge pull request 'feat(account): seed the time zone from the client's detected offset at creation' (#116) from feature/account-seed-timezone into development
CI / changes (push) Successful in 2s
CI / changes (pull_request) Successful in 1s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 15s
CI / ui (push) Successful in 54s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m18s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 17:07:53 +00:00
developer 170a6ae9ef Merge pull request 'feat(feedback): capture app version + browser zone; Filed time in three zones' (#115) from feature/feedback-version into development
CI / changes (push) Successful in 1s
CI / changes (pull_request) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 55s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 53s
CI / gate (push) Successful in 0s
CI / gate (pull_request) Successful in 0s
CI / deploy (push) Successful in 1m21s
CI / deploy (pull_request) Has been skipped
2026-06-22 17:07:21 +00:00
Ilia Denisov ef2c2d1eb9 feat(account): seed the time zone from the client's detected offset at creation
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m18s
A new account's time_zone defaulted to 'UTC' until the player saved a profile, so the
robot's sleep window and the turn-timeout away-window sweeper — both anchored to the
account zone via account.ResolveZone — ran on UTC for every fresh player, skewing
robot-game timing until a manual Settings save. Seed the zone at creation instead, from
the client's detected "±HH:MM" offset.

- Carry browser_tz on the three account-creating auth requests (TelegramLoginRequest,
  GuestLoginRequest, EmailRequestRequest — the email account is provisioned at the
  code-request step, not at login) through the fbs envelope (+ Go/TS codegen), the
  gateway transcode + backend client, and the backend auth handlers into
  ProvisionTelegram / ProvisionGuest / ProvisionEmail.
- create() now writes time_zone explicitly: the validated detected offset, or 'UTC'
  (equal to the column default) when absent or malformed — deterministic, never guessed.
  The column is already NOT NULL DEFAULT 'UTC', so no migration is needed and existing
  accounts keep 'UTC'. An existing account is never overwritten on re-login.
- A detected zero offset is stored as "+00:00" (the zone is known and equals UTC),
  distinct from the "UTC" default that means "unknown" — which the feedback console's
  three-zone Filed display already reflects.
- Guard the guest handler against an empty payload (the bootstrap historically carried
  none) so it degrades to no-seed rather than panicking in GetRootAs*.
- Tests: zone seeding across Telegram/guest/email plus the "+00:00"/malformed/empty
  cases and the not-overwrite rule; codec round-trip for the three auth encoders.
  ARCHITECTURE + FUNCTIONAL(+ru) updated.
2026-06-22 18:43:24 +02:00
Ilia Denisov 004aca4e97 feat(feedback): capture the browser UTC offset; Filed time in three zones
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 1m20s
The account time zone defaults to UTC until a player saves a profile, so a
report's Filed time could only render in UTC even for a player clearly in
another zone. Capture the client's detected "±HH:MM" offset (browser_tz) with
each submission and show the Filed time in three zones in the operator console
— UTC, the browser offset detected at submit, and the sender's saved profile
zone — each shown "N/A" when not known, so the operator can tell what is
certainly known from what is merely defaulted.

- Thread browser_tz through the fbs envelope (+ Go/TS codegen), the gateway
  transcode + backend client, and the backend feedback service/store; add the
  column via migration 00004 (additive, image-rollback-safe).
- Fix fmtTimeIn to resolve "±HH:MM" offsets via account.ResolveZone;
  time.LoadLocation alone silently fell back to UTC for offset zones, which is
  the second reason a "+02:00" sender showed only UTC.
- Update ARCHITECTURE/FUNCTIONAL(+ru) docs and the feedback integration test.
2026-06-22 18:05:39 +02:00
Ilia Denisov b78ce42922 feat(feedback): capture the app version; show version + local Filed time
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m19s
Each feedback submission now carries the client app version (__APP_VERSION__),
snapshotted like the interface language: FlatBuffers FeedbackSubmitRequest gains
a version field → gateway transcode → backend, persisted in a new nullable
feedback_messages.app_version column (migration 00003, additive so an image
rollback stays DB-safe). The operator console detail shows the app version and
renders the Filed time in UTC plus the sender's time zone (fmtTimeIn).

Touches: fbs schema + regenerated Go/TS codegen, codec + transport (the client
attaches its build), gateway transcode + backendclient, feedback store/service,
admin view + template, docs (ARCHITECTURE §15, FUNCTIONAL + _ru). Verified:
feedback integration tests (migration + version round-trip), codec round-trip,
check/unit/build green.
2026-06-22 17:02:05 +02:00
developer 08c2c5f660 Merge pull request 'fix(i18n): banner/push language follows the device's saved choice' (#113) from feature/banner-language-sync into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / deploy (push) Successful in 1m19s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 17s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 14:26:38 +00:00
developer 06cc4c1edc Merge pull request 'feat(ads): seed the house banner with the curated tip set' (#112) from feature/ad-tips-default-banner into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 9s
CI / integration (push) Successful in 17s
CI / ui (push) Has been skipped
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m20s
2026-06-22 14:26:29 +00:00
Ilia Denisov 90f0424de2 fix(i18n): reconcile preferred_language with the device's saved language
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 1m18s
The UI language follows the device (the local choice / system guess) and is
deliberately not overridden from the account, but the advertising banner and
out-of-app push routing are resolved server-side from preferred_language. A
saved device choice the account had not recorded — picked while a guest, or
differing from the Telegram system-language seed — left the banner (and pushes)
in the wrong language until a Settings change rewrote preferred_language.

On profile load (adoptSession and the in-place link path) push the saved local
choice to the account when it differs (new pure helper languageNeedsServerSync;
no-op for guests and when already equal), so the banner and pushes match the
visible UI from the first open. Unit-tested.
2026-06-22 16:16:33 +02:00
Ilia Denisov c36543e54e feat(ads): seed the house banner with the curated tip set
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) Has been skipped
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
Migration 00002 replaces the default (house) campaign's single seed tip with
the 47 curated, language-agnostic Scrabble tips — one bilingual ad_messages row
each (body_en + body_ru), which the client round-robins per ARCHITECTURE §ads.

Data-only: the ad_messages schema is unchanged, so a backend image rollback
stays DB-safe. Down restores the original single seed tip. Verified up/down
against a throwaway Postgres (47 rows; apostrophes escaped).
2026-06-22 15:44:49 +02:00
166 changed files with 10383 additions and 629 deletions
+89
View File
@@ -0,0 +1,89 @@
---
name: deploy-check
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
---
# Pre-deploy runtime-constraint check
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
edge config). The worst frictions in this repo were never logic bugs — they were
**environment mismatches that crashed a live env and forced a redesign**. Run this
list against the diff first; turn crash-and-redesign into a single pass.
This checklist is a prompt, **not** the source of truth. The canonical detail
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
agent memory files referenced below — read them when an item is in play, and add a
new class here when a new incident teaches one.
## How to run it
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
2. For every risk class below that the diff touches, perform the **Check** and
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
not touch — say which you skipped and why.
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
passed with a dead backend (it only checked static landing+gateway). Verify the
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
## Risk classes
### 1. Container user — distroless nonroot UID 65532
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
boot with "permission denied" — service images run UID 65532.
- **Check:** any new/changed mounted secret, key, or config file must be readable by
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
### 2. Caddy header pipeline ordering
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
empty); it passed CI and only showed up live.
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
ordering for every affected route, and test the tripwire/route on the live
contour, not just CI.
### 3. Edge Alt-Svc / HTTP3
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
QUIC before falling back to h2 — Mini App "hangs on load".
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
`docs/EDGE_HTTP3.md`)
### 4. Prod caddy config recreate
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
stayed inert until a manual `docker restart`.
- **Check:** a config-only edge change must `--force-recreate` caddy in
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
(memory: `prod-deploy-caddy-config-recreate`)
### 5. DICT_VERSION / dictionary boot
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
goes live via the admin console upload, not a redeploy. (memory:
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
### 6. Migrations — expand-contract + rollback safety
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
ahead of rolled-back code).
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
`contour-schema-change-wipe`)
### 7. Telegram permission model
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
denies, not default-deny; inverting it broke access.
- **Check:** any change to the Telegram permission / relay logic preserves the
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
## Output
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
file:line and the fix. If every touched class passes, say so plainly and name the
post-deploy live check to run (not just "CI green").
+175
View File
@@ -0,0 +1,175 @@
# VK Mini App / VK Games — integration reference
Captured research + our implementation map, so a future session does not need to re-fetch
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
reference repos cited at the end and verified against our own Go implementation).
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
entry — the single-origin, path-routed model.
## 1. Embedding model
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
cert required), appending the signed launch parameters as the **URL query string**.
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
clickjacking note in §Security.)
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
## 2. Launch parameters (URL query)
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
| Param | Meaning |
| --- | --- |
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
| `vk_app_id` | our registered app id |
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
| `vk_are_notifications_enabled` | 0/1 |
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
| `vk_ts` | unix seconds when VK generated the params |
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
| `sign` | **the signature** (see §3) — NOT part of the signed set |
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
2. Sort by key (alphabetical).
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
reference serialization for the constrained launch-param charset).
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
(protected / secure key, a.k.a. client_secret) from the app settings.
5. **base64url, no padding** (`+``-`, `/``_`, strip `=`).
6. Constant-time compare against `sign`.
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
freshness — the minted server session is the short-lived credential; a replay only
re-authenticates the same `vk_user_id`.
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
incl. the `%2C` comma case for `vk_access_token_settings`).
## 4. VK Bridge (client SDK)
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
- `VKWebAppInit`**required**: tells VK the Mini App loaded (dismisses VK's loading cover).
- `VKWebAppGetUserInfo``{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
verification — read `window.location.search` instead, which carries `sign`).
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
in the desktop VK iframe). **Used.**
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
blocked. **Used** as the copy-code / copy-link path.
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used.
- `VKWebAppUpdateInsets` (+ `VKWebAppUpdateConfig`) — device safe-area insets; the app **max'es** them
with CSS `env(safe-area-inset-*)` (viewport-fit=cover) so the bottom home bar is cleared. The bridge
value is needed on Android, where the VK webview exposes no `env()` inset. **Used.**
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
it **lazily** so the pure URL helpers stay node-test-importable.
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
channel (e.g. an invite API) ever delivers a payload.
## 5. Test mode (to verify before moderation)
1. App already registered (we have the App ID).
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
- Category = **Игра** (Game).
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
- Add own VK id to **testers**; open in test mode.
3. Test mode = visible only to admins/testers, no payments processed.
## 6. Auth / identity (our model)
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
- No VK access token / VK API call needed for the launch+login MVP.
## 7. Payments / monetization
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
dictionary game, flag if moderation asks.
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
- Moderation reviews after submission (commonly ~2472h); rejects on violence/hate/sexual/illegal
content or IP infringement — none apply.
## 9. Platforms
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
(not reproducible in Playwright — like the iOS gesture caveats).
## 10. Our implementation map (what to touch for VK)
- **Wire**: `pkg/fbs/scrabble.fbs``VKLoginRequest{ params, browser_tz, display_name }`
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
(registered via `WithVKAuth(secret)` option; `DomainCode``invalid_vk_params`),
`internal/backendclient` `VKAuth``POST /api/v1/internal/sessions/vk`,
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
(`PROD_…` secret, deploy-main).
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
## Sources
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
+79 -3
View File
@@ -31,7 +31,7 @@ on:
# unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env:
DICT_VERSION: v1.3.0
DICT_VERSION: v1.3.1
jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when
@@ -207,11 +207,66 @@ jobs:
run: pnpm run test:e2e
timeout-minutes: 5
# conformance proves the client's local move preview (the ported dawg reader +
# validator, ui/src/lib/dict) byte-for-byte against the authoritative Go engine:
# a Go step generates golden parity vectors from the release dictionaries, then the
# gated Vitest suite replays them. It spans both toolchains, so it runs whenever the
# Go engine side or the UI side changed.
conformance:
needs: changes
if: ${{ needs.changes.outputs.go == 'true' || needs.changes.outputs.ui == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
GOPRIVATE: gitea.iliadenisov.ru/*
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Fetch dictionary DAWGs
run: |
mkdir -p "${GITHUB_WORKSPACE}/dawg"
curl -fsSL -o /tmp/dawg.tar.gz "https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/${DICT_VERSION}/scrabble-dawg-${DICT_VERSION}.tar.gz"
tar xzf /tmp/dawg.tar.gz -C "${GITHUB_WORKSPACE}/dawg"
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.work
cache: true
- name: Generate golden parity vectors
run: |
go run ./backend/cmd/dictgen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/dictgold
go run ./backend/cmd/validategen -dawg-dir "${GITHUB_WORKSPACE}/dawg" -out /tmp/validgold
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install pnpm
run: npm install -g pnpm@11.0.9
- name: Install deps
working-directory: ui
run: pnpm install --frozen-lockfile
- name: Local-eval conformance (reader + validator vs the Go engine)
working-directory: ui
env:
DICT_DAWG_DIR: ${{ github.workspace }}/dawg
DICT_GOLD_DIR: /tmp/dictgold
DICT_VALID_DIR: /tmp/validgold
run: pnpm exec vitest run src/lib/dict/
# gate is the single branch-protection required check. It always runs and passes
# only when each upstream job succeeded or was skipped (a path-filtered no-op),
# failing the merge if any actually failed or was cancelled.
gate:
needs: [unit, integration, ui]
needs: [unit, integration, ui, conformance]
if: always()
runs-on: ubuntu-latest
defaults:
@@ -221,7 +276,7 @@ jobs:
- name: Aggregate required checks
run: |
fail=
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}"; do
for r in "unit:${{ needs.unit.result }}" "integration:${{ needs.integration.result }}" "ui:${{ needs.ui.result }}" "conformance:${{ needs.conformance.result }}"; do
name="${r%%:*}"; res="${r#*:}"
echo "$name = $res"
case "$res" in
@@ -261,12 +316,16 @@ jobs:
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
# The promo button reuses the UI's Mini App link variable.
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
@@ -336,6 +395,23 @@ jobs:
docker logs --tail 50 scrabble-backend || true
exit 1
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
# The client fetches each game's dictionary blob at {edge}/dict/{variant}/{version}
# for the local move preview. If caddy does not route /dict to the gateway the request
# falls to the static landing and the client silently gets a non-dawg blob. Probed
# unauthenticated it must be the gateway's 401 (the route reaches the gateway), never a
# 404/200 from the landing catch-all.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/dict/scrabble_en/v1 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -q " 401"; then
echo "ok: /dict reaches the gateway (401 unauthenticated)"
else
echo "FAIL: /dict did not reach the gateway (expected 401) — caddy route missing?"
exit 1
fi
- name: Probe the Telegram validator and bot liveness
run: |
set -u
+4
View File
@@ -82,6 +82,7 @@ jobs:
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
@@ -136,6 +137,7 @@ jobs:
export APP_VERSION='$TAG'
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
export GATEWAY_ABUSE_BAN_ENABLED='true'
EOF
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
@@ -179,6 +181,7 @@ jobs:
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
steps:
@@ -200,6 +203,7 @@ jobs:
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
+115
View File
@@ -0,0 +1,115 @@
# Erudit — VK loading-screen logo (Lottie)
Animated logo for the **VK app loading screen** (shown before the SPA assets load).
The Erudit «Э» tile (score `8`) **drops in under gravity, lands with a soft squash —
its left/right edges bulging into a cushion — then springs back up**, looping. A light
"glint" is caught at the moment of the bounce.
| File | Purpose |
|------|---------|
| `erudit-loader.json` | The Lottie to upload to VK. |
| `erudit-loader-preview.gif` | Looping preview. Rendered on a green "baize" background **only in the preview** — the real asset has a **transparent** background. |
| `build/` | The reproducible build pipeline (see *Regenerate*). |
**VK requirements met:** 96×96 px · vector Lottie JSON · ≤ 24 KB (~11 KB) · seamless
~1.1 s loop · transparent background.
Design reference: a photo of the physical wooden Erudit tile.
---
## How it works
A single flat layer holds the tile — a rounded-rect **face path**, the two glyphs, and
a thin border — and everything is driven by that layer's transform plus a few colour /
shape tracks. The anchor sits on the tile's **bottom edge**, so the squash happens
against the floor.
### Bounce (gravity)
`position.y` of the bottom edge goes apex → floor → apex with **no dwell** (the apex is
an instantaneous turn-around). The fall uses a strong **ease-in** (accelerate) and the
rise a strong **ease-out** (decelerate), so it reads as real gravity. While falling fast
the tile slightly **stretches** (tall+thin); that is the squash-and-stretch setup.
### Soft cushion (squash)
On contact the layer **squashes** (scaleY↓, scaleX↑) about the bottom anchor, with a
touch of skew. Crucially the squash/cushion is **decoupled from the fall speed** — it
eases in and out *slowly* (`ES`), so the landing feels soft even though the fall is
fast. The squash is deliberately gentle (≈ 86 % / 112 %).
### The cushion shape (the hard part)
The face is **not** a plain rounded rect — it is a path whose left/right contour bows
out into a convex cushion on impact:
- the rest rounded-rect outline is sampled (fine corner arcs + edge mids), and on impact
every point is pushed outward by a smooth **barrel** profile `f(y) = b·(1 (y/HH)²)`
(max at the middle, fading to zero at the top/bottom);
- so the **whole side — corners included — bows out as one piece**, never just the
straight middle;
- bezier handles are computed as a **chordal spline** (handle length ∝ the adjacent
chord), which stays smooth across the uneven corner/edge point spacing. This is what
keeps the corner↔cushion junction kink-free — both at rest and fully bulged — which a
naïve uniform Catmull-Rom (or moving discrete points with fixed tangents) does not.
### Glint
At the bounce the face flashes a little lighter (`FACE_GLINT`) and the glyphs warm
toward brown-red (`BLACK_LIT`), then settle back. A cheap, warm "catch the light".
### Border & background
The tile carries a thin static **border** a touch darker than the face (`BORDER`) so it
reads on any background. The **green baize background is added only when rendering the
preview GIF** — the Lottie itself is transparent.
### Player compatibility
Only plain 2-D shapes (`ddd:0`) — no 3-D layers, expressions or effects — so every
Lottie player (lottie-web on the web, rlottie on native) renders it identically.
---
## Glyphs
`Э` (U+042D) and `8` are **real outlines from LiberationSans-Regular** — metric-
compatible with Arial, matching the game's `--font: system-ui … Arial` stack
(see `ui/src/app.css`). `build/extract.js` pulls the contours into `build/glyphs.json`
as Lottie cubic paths; a hair of same-colour stroke adds a touch of weight.
---
## Regenerate
Requirements: Node ≥ 18. `extract.js` additionally needs `opentype.js`
(`npm i opentype.js`); **`generate.js` has no dependencies**.
```sh
cd assets/vk
# 1. (optional) re-extract the glyphs — only if you change the font:
# default font: /usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf
node build/extract.js [/path/to/font.ttf] # -> build/glyphs.json
# 2. build the animation (reads build/glyphs.json):
node build/generate.js erudit-loader.json # -> erudit-loader.json
# 3. (optional) live preview — needs lottie-web (npm i lottie-web):
node build/build-preview.js erudit-loader.json preview.html
# then open preview.html in a browser.
```
The preview GIF is assembled by rendering the Lottie frame-by-frame (lottie-web canvas,
filled with the green baize) and stitching with `ffmpeg`; the live HTML preview is the
quickest way to eyeball changes.
## Tunables (top of `build/generate.js`)
| Symbol | Meaning |
|--------|---------|
| `OP`, `FR` | loop length (frames) / fps — overall speed |
| `T_HIT / T_PEAK / T_LIFT / T_REC` | beats: contact / squash peak / lift-off / cushion recovered |
| `EI / EO / ES` | easings: fast fall / fast rise / slow soft cushion |
| `APEX`, `FLOOR` | bottom-edge screen-y at the top of the bounce / at rest |
| `HW`, `HH`, `CR` | tile half-width / half-height / corner radius |
| `scl` squash values | the squash amount (scaleX↑ / scaleY↓) |
| `bulge` `b` | cushion depth (how far the sides bow out) |
| `FACE / FACE_GLINT` | wood face / glint flash |
| `BORDER` | tile rim colour |
| `BLACK / BLACK_LIT` | glyph / glyph-at-glint (brown-red) |
| preview bg `#3C7858` | the green-baize colour used **only** in the GIF |
+31
View File
@@ -0,0 +1,31 @@
'use strict';
const fs = require('fs');
const data = fs.readFileSync(process.argv[2] || 'erudit.json', 'utf8');
const lottiePath = __dirname + '/node_modules/lottie-web/build/player/lottie.min.js';
const html = `<!doctype html><html><head><meta charset="utf8"><style>
body{margin:0;background:#2b2b2b;font-family:sans-serif;color:#ccc}
.row{display:flex;gap:18px;padding:18px;align-items:flex-end;flex-wrap:wrap}
.cell{text-align:center}
.chk{background-image:linear-gradient(45deg,#8a8a8a 25%,transparent 25%),linear-gradient(-45deg,#8a8a8a 25%,transparent 25%),linear-gradient(45deg,transparent 75%,#8a8a8a 75%),linear-gradient(-45deg,transparent 75%,#8a8a8a 75%);background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;background-color:#b5b5b5}
.s96{width:96px;height:96px}.big{width:336px;height:336px}small{font-size:11px}
</style></head><body><div class="row" id="row"></div>
<script src="./lottie.min.js"></script>
<script>
const animationData=${data};window.AD=animationData;
const row=document.getElementById('row');window.anims=[];
function make(cls,frame,label){
const cell=document.createElement('div');cell.className='cell';
const box=document.createElement('div');box.className='chk '+cls;cell.appendChild(box);
cell.appendChild(document.createElement('br'));
const cap=document.createElement('small');cap.textContent=label;cell.appendChild(cap);
row.appendChild(cell);
const a=lottie.loadAnimation({container:box,renderer:'svg',loop:false,autoplay:false,animationData:JSON.parse(JSON.stringify(animationData))});
a.addEventListener('DOMLoaded',()=>a.goToAndStop(frame,true));
window.anims.push(a);
}
[0,22,45,68].forEach(f=>make('s96',f,'f'+f));
make('big',22,'f22 x3.5');
window.__ready=true;
</script></body></html>`;
fs.writeFileSync(process.argv[3] || 'preview.html', html);
console.log('wrote', process.argv[3] || 'preview.html');
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Extract the Cyrillic "Э" (U+042D) and the digit "8" outlines from a grotesque
// font (LiberationSans = Arial-metric, matching the game's system-ui/Arial stack)
// and emit Lottie cubic-bezier contours, centred at the origin, y-down.
const opentype = require('opentype.js');
const fs = require('fs');
const FONT = process.argv[2] || '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf';
const b = fs.readFileSync(FONT);
const font = opentype.parse(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength));
const FS = 1000; // em scale
function glyphContours(ch) {
const p = font.charToGlyph(ch).getPath(0, 0, FS); // baseline at y=0, y-down
const contours = [];
let cur = null, prev = null;
for (const c of p.commands) {
if (c.type === 'M') {
if (cur) contours.push(cur);
cur = [{ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] }];
prev = { x: c.x, y: c.y };
} else if (c.type === 'L') {
cur.push({ v: [c.x, c.y], i: [c.x, c.y], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'C') {
cur[cur.length - 1].o = [c.x1, c.y1];
cur.push({ v: [c.x, c.y], i: [c.x2, c.y2], o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Q') {
const c1 = [prev.x + 2 / 3 * (c.x1 - prev.x), prev.y + 2 / 3 * (c.y1 - prev.y)];
const c2 = [c.x + 2 / 3 * (c.x1 - c.x), c.y + 2 / 3 * (c.y1 - c.y)];
cur[cur.length - 1].o = c1;
cur.push({ v: [c.x, c.y], i: c2, o: [c.x, c.y] });
prev = { x: c.x, y: c.y };
} else if (c.type === 'Z') {
if (cur && cur.length > 1) {
const last = cur[cur.length - 1], first = cur[0];
if (Math.hypot(last.v[0] - first.v[0], last.v[1] - first.v[1]) < 1e-3) {
first.i = last.i; // fold the duplicate closing point into the first
cur.pop();
}
}
if (cur) { contours.push(cur); cur = null; }
}
}
if (cur) contours.push(cur);
let minx = Infinity, miny = Infinity, maxx = -Infinity, maxy = -Infinity;
const out = contours.map(ct => {
const v = [], i = [], o = [];
ct.forEach(pt => {
v.push(pt.v);
i.push([pt.i[0] - pt.v[0], pt.i[1] - pt.v[1]]);
o.push([pt.o[0] - pt.v[0], pt.o[1] - pt.v[1]]);
minx = Math.min(minx, pt.v[0]); maxx = Math.max(maxx, pt.v[0]);
miny = Math.min(miny, pt.v[1]); maxy = Math.max(maxy, pt.v[1]);
});
return { i, o, v, c: true };
});
return { contours: out, bbox: { x: minx, y: miny, w: maxx - minx, h: maxy - miny } };
}
const E = glyphContours('Э');
const D8 = glyphContours('8');
fs.writeFileSync('glyphs.json', JSON.stringify({ em: FS, E, D8 }));
console.log('Э bbox', E.bbox, 'contours', E.contours.map(c => c.v.length));
console.log('8 bbox', D8.bbox, 'contours', D8.contours.map(c => c.v.length));
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// VK preloader Lottie: a flat wooden Erudit tile ("Э" + score "8") that drops in
// under gravity, squashes on impact (convex cushion bulging out the left/right edges
// + a touch of skew), and springs back up — looping. A light "glint" is caught at the
// moment of the bounce. Pure 2D shapes (ddd:0). Glyphs are real LiberationSans
// outlines (Arial-metric, = the game's font stack); see extract.js -> glyphs.json.
const fs = require('fs');
const G = JSON.parse(fs.readFileSync(__dirname + '/glyphs.json', 'utf8'));
// ---- canvas / timing -------------------------------------------------------
const W = 96, H = 96, cx = 48;
const FR = 30, OP = 34; // ~1.13 s loop (dynamic, 25% faster)
// keyframe beats (frames): fast fall -> soft squash -> lift -> fast rise -> apex (no dwell)
const T_HIT = 13, T_PEAK = 18, T_LIFT = 19, T_REC = 28;
// ---- geometry --------------------------------------------------------------
const HW = 26, HH = 26, CR = 6; // tile half-width / half-height / corner radius
const FLOOR = 90, APEX = 60; // bottom-centre screen-y at rest / at the top of the bounce
// ---- palette ---------------------------------------------------------------
const col = h => [parseInt(h.slice(1,3),16)/255, parseInt(h.slice(3,5),16)/255, parseInt(h.slice(5,7),16)/255];
const FACE = col('#D9B978'); // wood face (flat)
const FACE_GLINT = col('#E7CD95'); // face flash caught on impact (gentle, not blinding)
const BORDER = col('#B49559'); // tile rim: a touch darker than the face, reads on any bg
const BLACK = col('#1A1A1A');
const BLACK_LIT = col('#421A0B'); // glyph warms to brown-red on the glint
const lerp = (a, b, t) => a.map((v, i) => v + (b[i] - v) * t);
// ---- property / easing helpers ---------------------------------------------
const still = v => ({ a: 0, k: v });
const EI = { o: { x: [0.82], y: [0] }, i: { x: [1], y: [1] } }; // strong accelerate (gravity fall)
const EO = { o: { x: [0], y: [0] }, i: { x: [0.18], y: [1] } }; // strong decelerate (rise to apex)
const ES = { o: { x: [0.42], y: [0] }, i: { x: [0.58], y: [1] } }; // soft/slow (the cushion)
function anim(samples) { // samples: {t, v, e?} ; e = easing toward the NEXT key
return { a: 1, k: samples.map((s, idx) => {
const kf = { t: s.t, s: s.v };
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
return kf;
}) };
}
const animColor = s => anim(s.map(x => ({ t: x.t, v: [...x.v, 1], e: x.e })));
// ---- shape helpers ---------------------------------------------------------
const tr = () => ({ ty: 'tr', p: still([0,0]), a: still([0,0]), s: still([100,100]), r: still(0), o: still(100) });
const grp = (items, nm) => ({ ty: 'gr', it: [...items, tr()], nm });
const fill = c => ({ ty: 'fl', c, o: still(100), r: 1, bm: 0 });
const stroke = (c,w) => ({ ty: 'st', c: still(c), o: still(100), w: still(w), lc: 2, lj: 2, ml: 4, bm: 0 });
function glyph(gd, hpx, px, py, bold, colour, nm) { // font glyph -> filled+stroked group
const sc = hpx / gd.bbox.h;
const bcx = gd.bbox.x + gd.bbox.w / 2, bcy = gd.bbox.y + gd.bbox.h / 2;
const shapes = gd.contours.map(ct => ({
ty: 'sh', d: 1, ks: still({
i: ct.i.map(p => [p[0]*sc, p[1]*sc]), o: ct.o.map(p => [p[0]*sc, p[1]*sc]),
v: ct.v.map(p => [(p[0]-bcx)*sc + px, (p[1]-bcy)*sc + py]), c: true,
}),
}));
return grp([...shapes, fill(colour), stroke(BLACK, bold)], nm);
}
// Sample the rest rounded-rect outline (clockwise): fine corner arcs + one mid point
// per straight edge, so a smooth interpolant reproduces it cleanly.
function arc(ccx, ccy, a0, a1, n) {
const out = [];
for (let i = 0; i < n; i++) { const a = (a0 + (a1 - a0) * i / (n - 1)) * Math.PI / 180; out.push([ccx + CR*Math.cos(a), ccy + CR*Math.sin(a)]); }
return out;
}
const REST = (() => {
const NC = 7, yi = HH - CR, xi = HW - CR;
const C = [ arc(xi,-yi,-90,0,NC), arc(xi,yi,0,90,NC), arc(-xi,yi,90,180,NC), arc(-xi,-yi,180,270,NC) ];
const pts = [];
for (let k = 0; k < 4; k++) {
pts.push(...C[k]);
const a = C[k][NC-1], b = C[(k+1)%4][0];
pts.push([(a[0]+b[0])/2, (a[1]+b[1])/2]); // straight-edge mid point (keeps the edge straight)
}
return pts;
})();
// The whole left/right contour (corners + side) bows out by one smooth barrel profile;
// Catmull-Rom tangents (from neighbours) make every junction C1-smooth -> no kink, the
// corners follow the cushion and the cushion melts into the corners, bulged or not.
function facePath(b) {
const f = y => b * (1 - (y/HH) * (y/HH)); // 0 at top/bottom, max at the middle
const V = REST.map(([x, y]) => [x + Math.sign(x) * f(y), y]); // push the sides out, top/bottom stay
const n = V.length, I = [], O = [];
for (let k = 0; k < n; k++) {
const p0 = V[(k-1+n)%n], p1 = V[k], p2 = V[(k+1)%n];
let dx = p2[0]-p0[0], dy = p2[1]-p0[1]; // tangent direction (neighbours)
const dl = Math.hypot(dx, dy) || 1; dx /= dl; dy /= dl;
const ln = Math.hypot(p2[0]-p1[0], p2[1]-p1[1]) / 3; // handles ~ 1/3 of the adjacent chord
const lp = Math.hypot(p1[0]-p0[0], p1[1]-p0[1]) / 3; // -> chordal spline, no overshoot/facets
O.push([dx*ln, dy*ln]); I.push([-dx*lp, -dy*lp]);
}
return { i: I, o: O, v: V, c: true };
}
const facePathKeys = samples => ({ a: 1, k: samples.map((s, idx) => {
const kf = { t: s.t, s: [facePath(s.b)] };
if (idx < samples.length - 1) { const e = s.e || ES; kf.o = e.o; kf.i = e.i; }
return kf;
}) });
// ---- animation tracks ------------------------------------------------------
// bottom-centre screen-y (the tile rests/squashes on its bottom edge)
const posY = anim([
{ t: 0, v: [cx, APEX, 0], e: EI }, // apex -> immediately falls (no dwell)
{ t: T_HIT, v: [cx, FLOOR, 0], e: ES }, // FAST fall (accelerate) -> floor
{ t: T_LIFT, v: [cx, FLOOR, 0], e: EO }, // sit on the floor while the cushion absorbs
{ t: OP, v: [cx, APEX, 0] }, // FAST rise (decelerate) -> apex = loop start
]);
// scale about the bottom anchor: stretch while falling, gentle/slow cushion squash on impact
const scl = anim([
{ t: 0, v: [100, 100, 100], e: ES },
{ t: T_HIT-5, v: [95, 106, 100], e: ES }, // stretch while falling fast
{ t: T_HIT, v: [104, 95, 100], e: ES }, // touch down
{ t: T_PEAK, v: [112, 86, 100], e: ES }, // soft squash peak (gentler, less plче)
{ t: T_REC, v: [100, 100, 100], e: ES }, // slow cushion release -> soft landing
{ t: OP, v: [100, 100, 100] },
]);
// a touch of skew through the squash (organic deform), back to 0
const skw = anim([
{ t: T_HIT, v: [0], e: ES }, { t: T_PEAK, v: [4], e: ES }, { t: T_REC, v: [0] }, { t: OP, v: [0] },
]);
// left/right cushion bulge: 0 -> gentle peak -> 0 (never inward)
const bulge = facePathKeys([
{ t: T_HIT, b: 0, e: ES }, { t: T_PEAK, b: 4, e: ES }, { t: T_REC, b: 0 }, { t: OP, b: 0 },
]);
// glint: face + glyph catch the light at the bounce
const faceCol = animColor([
{ t: T_HIT-2, v: FACE, e: ES }, { t: T_PEAK, v: FACE_GLINT, e: ES }, { t: T_REC, v: FACE }, { t: OP, v: FACE },
]);
const glyphCol = animColor([
{ t: T_HIT-2, v: BLACK, e: ES }, { t: T_PEAK, v: BLACK_LIT, e: ES }, { t: T_REC, v: BLACK }, { t: OP, v: BLACK },
]);
// ---- layer (face + glyphs share the bounce/squash transform) ---------------
const faceShape = grp([ { ty: 'sh', d: 1, ks: bulge }, fill(faceCol), stroke(BORDER, 1.8) ], 'face');
const glyphE = glyph(G.E, 32, 0, -1, 1.0, glyphCol, 'E'); // centred Э
const glyph8 = glyph(G.D8, 8.5, HW-6.5, HH-7.5, 0.5, glyphCol, 'score'); // 8 in the corner
const tile = {
ddd: 0, ind: 1, ty: 4, nm: 'tile', sr: 1,
ks: { o: still(100), r: still(0), p: posY, a: still([0, HH, 0]), s: scl, sk: skw, sa: still(0) },
ao: 0, shapes: [ glyph8, glyphE, faceShape ], ip: 0, op: OP, st: 0, bm: 0,
};
const root = {
v: '5.7.4', fr: FR, ip: 0, op: OP, w: W, h: H, nm: 'erudit-vk-loader', ddd: 0,
assets: [], layers: [ tile ], markers: [],
};
const out = process.argv[2] || 'erudit.json';
const round = (k, v) => (typeof v === 'number' ? Math.round(v * 100) / 100 : v);
fs.writeFileSync(out, JSON.stringify(root, round));
console.log('wrote', out, fs.statSync(out).size, 'bytes');
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -43,8 +43,10 @@ per-user blocks, and per-game chat with nudges folded in as a message kind; chat
messages are length-capped, content-filtered (no links/emails/phone numbers,
including obfuscated forms) and stored with the sender's IP. Each message carries an
`unread_seats` read bitmask (a set bit per recipient seat still to read it); `MarkRead`
clears a reader's bit when they open the move history or chat, and a wired `NudgeClearer`
clears a nudge when its recipient moves — both record the publish-to-read latency.
clears a reader's bit when they open the move history or chat, a wired `NudgeClearer`
clears a nudge when its recipient moves, and a wired `NudgeExpirer` clears **all** of a game's
nudges when it finishes (any completion path) — the first two record the publish-to-read latency,
the completion expiry does not (it is not a read); chat messages stay unread on completion.
A friend request (or block) aimed at a **disguised pooled robot** is recorded per game+seat
in `robot_friend_requests` / `robot_blocks`, never against the shared robot account; a
background reaper drops a robot friend request once its game has been finished for **7 days**.
+3 -1
View File
@@ -180,8 +180,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
socialSvc := social.NewService(social.NewStore(db), accounts, games)
socialSvc.SetNotifier(hub)
socialSvc.SetMetrics(tel.MeterProvider().Meter("scrabble/backend/social"))
// A nudge the recipient answered by moving is marked read on the move path.
// A nudge the recipient answered by moving is marked read on the move path; every nudge in a
// game is marked read when the game finishes (a stale badge), on any completion path.
games.SetNudgeClearer(socialSvc.ClearNudges)
games.SetNudgeExpirer(socialSvc.ExpireNudges)
// Reap per-game disguised-robot friend requests once their game is long finished
// (the robot ignores them; the row only pins the in-game "request sent" state).
robotReqReaper := social.NewRobotFriendRequestReaper(socialSvc, logger)
+193
View File
@@ -0,0 +1,193 @@
// Command dictgen dumps golden parity vectors from the committed dawg
// dictionaries so the TypeScript dawg reader can be checked byte-for-byte
// against the authoritative Go dafsa reader.
//
// For each *.dawg file it writes, into the output directory:
//
// - <name>.words.bin — every stored word as alphabet-index bytes, in index
// order, framed as [1-byte length][length index bytes]. The word at stream
// position k has IndexOfB == k.
// - <name>.neg.bin — negative lookups (sequences whose IndexOfB is -1), same
// framing, to exercise the not-found path at varying depths.
// - <name>.meta.json — NumAdded/NumNodes/NumEdges plus the alphabet size, for
// a header-parse sanity cross-check on the TS side.
//
// It is a development tool (not built into any service), analogous to
// cmd/jetgen. Run it from the repository root:
//
// go run ./backend/cmd/dictgen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bufio"
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"sort"
"strings"
dawg "github.com/iliadenisov/dafsa"
)
// meta is the per-dictionary sanity payload cross-checked by the TS reader.
type meta struct {
NumAdded int `json:"numAdded"`
NumNodes int `json:"numNodes"`
NumEdges int `json:"numEdges"`
Alphabet int `json:"alphabet"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the golden files (required)")
negCount := flag.Int("neg", 20000, "number of negative lookups to emit per dictionary")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
files, err := filepath.Glob(filepath.Join(*dawgDir, "*.dawg"))
if err != nil {
fail("glob: %v", err)
}
sort.Strings(files)
if len(files) == 0 {
fail("no .dawg files in %s", *dawgDir)
}
for _, f := range files {
if err := process(f, *outDir, *negCount); err != nil {
fail("%s: %v", filepath.Base(f), err)
}
}
}
// process emits the golden files for a single dawg dictionary.
func process(path, outDir string, negCount int) error {
name := strings.TrimSuffix(filepath.Base(path), ".dawg")
data, err := os.ReadFile(path)
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
// Stream every stored word in index order; keep a decimated sample and the
// maximum alphabet index for negative generation.
wf, err := os.Create(filepath.Join(outDir, name+".words.bin"))
if err != nil {
return err
}
bw := bufio.NewWriter(wf)
var (
count int
maxIx byte
sample [][]byte
)
finder.EnumerateB(func(index int, word []byte, final bool) int {
if !final {
return 0 // Continue
}
if index != count {
panic(fmt.Sprintf("%s: enumerate index gap: got %d want %d", name, index, count))
}
writeWord(bw, word)
for _, b := range word {
if b > maxIx {
maxIx = b
}
}
if count%4 == 0 && len(sample) < 60000 {
sample = append(sample, append([]byte(nil), word...))
}
count++
return 0 // Continue
})
if err := bw.Flush(); err != nil {
return err
}
if err := wf.Close(); err != nil {
return err
}
if count != finder.NumAdded() {
return fmt.Errorf("word count %d != NumAdded %d", count, finder.NumAdded())
}
alphabet := int(maxIx) + 1
// Negatives: mutate sampled real words and keep the ones the reader rejects.
nf, err := os.Create(filepath.Join(outDir, name+".neg.bin"))
if err != nil {
return err
}
nbw := bufio.NewWriter(nf)
rng := rand.New(rand.NewSource(1))
neg := 0
for neg < negCount && len(sample) > 0 {
base := sample[rng.Intn(len(sample))]
cand := append([]byte(nil), base...)
switch rng.Intn(3) {
case 0: // extend by one index
cand = append(cand, byte(rng.Intn(alphabet)))
case 1: // flip one index
if len(cand) > 0 {
cand[rng.Intn(len(cand))] = byte(rng.Intn(alphabet))
}
case 2: // drop the tail and flip the new last index
if len(cand) > 1 {
cand = cand[:len(cand)-1]
cand[len(cand)-1] = byte(rng.Intn(alphabet))
}
}
if finder.IndexOfB(cand) == -1 {
writeWord(nbw, cand)
neg++
}
}
if err := nbw.Flush(); err != nil {
return err
}
if err := nf.Close(); err != nil {
return err
}
m := meta{NumAdded: finder.NumAdded(), NumNodes: finder.NumNodes(), NumEdges: finder.NumEdges(), Alphabet: alphabet}
mb, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, name+".meta.json"), mb, 0o644); err != nil {
return err
}
fmt.Printf("%-12s words=%d negatives=%d alphabet=%d nodes=%d edges=%d\n",
name, count, neg, alphabet, finder.NumNodes(), finder.NumEdges())
return nil
}
// writeWord frames one index-byte word as [length][bytes].
func writeWord(w *bufio.Writer, word []byte) {
if len(word) > 255 {
panic(fmt.Sprintf("word too long to frame: %d", len(word)))
}
w.WriteByte(byte(len(word)))
w.Write(word)
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "dictgen: "+format+"\n", args...)
os.Exit(1)
}
+486
View File
@@ -0,0 +1,486 @@
// Command validategen produces golden conformance fixtures for the TypeScript
// move validator (ui/src/lib/dict/validate.ts). For each variant it self-plays
// greedy games with the authoritative scrabble-solver engine to build realistic
// board positions, then records a battery of candidate plays — the engine's own
// top move, letter-mutated variants, random scatters and (on the empty board) an
// off-centre translation — each paired with the ground-truth result of
// ValidatePlayOpts (legal, score, the words formed). The TS conformance test
// replays these and must agree exactly.
//
// It is a development tool (not built into any service), analogous to
// cmd/dictgen. Run it from the repository root:
//
// go run ./backend/cmd/validategen -dawg-dir ../scrabble-solver/dawg -out <dir>
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"math/rand"
"os"
"path/filepath"
"gitea.iliadenisov.ru/developer/scrabble-solver/board"
"gitea.iliadenisov.ru/developer/scrabble-solver/rack"
"gitea.iliadenisov.ru/developer/scrabble-solver/rules"
"gitea.iliadenisov.ru/developer/scrabble-solver/scrabble"
"gitea.iliadenisov.ru/developer/scrabble-solver/selfplay"
dawg "github.com/iliadenisov/dafsa"
)
// blankTile marks a blank tile in a drawn hand (matches selfplay).
const blankTile byte = 0xff
// variantSpec pairs a variant label with its ruleset and dawg file.
type variantSpec struct {
name string
rules *rules.Ruleset
dawg string
}
// cell is an occupied board square or a placement (alphabet-index letter).
type cell struct {
R, C, Letter int
Blank bool
}
// word mirrors scrabble.Word in index space.
type word struct {
Row, Col, Dir int
Letters []int
Blanks []bool
Score int
}
// fixture is one candidate play with the engine's ground-truth verdict.
type fixture struct {
Board int `json:"board"` // index into the boards list
Dir int `json:"dir"`
IgnoreCrossWords bool `json:"ignoreCrossWords"`
Tiles []cell `json:"tiles"`
Legal bool `json:"legal"`
Score int `json:"score"`
Bonus int `json:"bonus"`
Main *word `json:"main,omitempty"`
Cross []word `json:"cross,omitempty"`
}
// alphaEntry mirrors one row of the per-variant alphabet table the server sends the
// client (index, concrete letter as the ruleset emits it, tile value), so the adapter
// cross-test can drive the letter-space client path exactly as production does.
type alphaEntry struct {
Index int `json:"index"`
Letter string `json:"letter"`
Value int `json:"value"`
}
// variantFile is the whole conformance payload for one variant.
type variantFile struct {
Variant string `json:"variant"`
Rows int `json:"rows"`
Cols int `json:"cols"`
Center int `json:"center"`
RackSize int `json:"rackSize"`
Bingo int `json:"bingo"`
Values []int `json:"values"`
Premiums []int `json:"premiums"` // row-major rules.Premium codes
Alphabet []alphaEntry `json:"alphabet"`
Boards [][]cell `json:"boards"`
Fixtures []fixture `json:"fixtures"`
}
func main() {
dawgDir := flag.String("dawg-dir", "../scrabble-solver/dawg", "directory holding the .dawg files")
outDir := flag.String("out", "", "output directory for the fixture files (required)")
games := flag.Int("games", 6, "self-play games per (variant, rule)")
plies := flag.Int("plies", 40, "maximum plies captured per game")
flag.Parse()
if *outDir == "" {
fail("-out is required")
}
if err := os.MkdirAll(*outDir, 0o755); err != nil {
fail("mkdir out: %v", err)
}
specs := []variantSpec{
{"scrabble_en", rules.English(), "en_sowpods.dawg"},
{"scrabble_ru", rules.RussianScrabble(), "ru_scrabble.dawg"},
{"erudit_ru", rules.Erudit(), "ru_erudit.dawg"},
}
for _, sp := range specs {
if err := generate(sp, *dawgDir, *outDir, *games, *plies); err != nil {
fail("%s: %v", sp.name, err)
}
}
}
func generate(sp variantSpec, dawgDir, outDir string, games, plies int) error {
data, err := os.ReadFile(filepath.Join(dawgDir, sp.dawg))
if err != nil {
return err
}
finder, err := dawg.Read(bytes.NewReader(data), 0)
if err != nil {
return fmt.Errorf("read dawg: %w", err)
}
defer finder.Close()
rs := sp.rules
solver := scrabble.NewSolver(rs, finder)
out := variantFile{
Variant: sp.name, Rows: rs.Rows, Cols: rs.Cols, Center: rs.Center,
RackSize: rs.RackSize, Bingo: rs.Bingo, Values: rs.Values,
Premiums: premiumCodes(rs), Alphabet: alphabetOf(rs),
}
// Capture under both the standard rule and the single-word rule, building the
// board with the same rule so positions are reachable under it.
for _, ignore := range []bool{false, true} {
opts := scrabble.PlayOptions{IgnoreCrossWords: ignore}
for g := range games {
seed := int64(g*1000) + boolseed(ignore) + variantSeed(sp.name)
playAndCapture(&out, rs, solver, opts, seed, plies)
}
}
b, err := json.Marshal(&out)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(outDir, sp.name+".fixtures.json"), b, 0o644); err != nil {
return err
}
fmt.Printf("%-12s boards=%d fixtures=%d\n", sp.name, len(out.Boards), len(out.Fixtures))
return nil
}
// playAndCapture greedily self-plays one game, recording candidate plays against
// each board position along the way.
func playAndCapture(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, seed int64, plies int) {
rng := rand.New(rand.NewSource(seed))
bag := selfplay.NewBag(rs, seed)
b := board.New(rs.Rows, rs.Cols)
hands := [2][]byte{bag.Draw(rs.RackSize), bag.Draw(rs.RackSize)}
passes := 0
for turn := range plies {
p := turn % 2
rk := rackOf(hands[p], rs.Size())
moves := solver.GenerateMovesOpts(b, rk, scrabble.Both, opts)
if len(moves) == 0 {
if passes++; passes >= 4 {
break
}
continue
}
passes = 0
top := moves[0]
boardIdx := len(out.Boards)
out.Boards = append(out.Boards, boardCells(b))
captureCandidates(out, rs, solver, opts, b, boardIdx, top, rng)
scrabble.Apply(b, top)
hands[p] = removeUsed(hands[p], top)
if need := rs.RackSize - len(hands[p]); need > 0 {
hands[p] = append(hands[p], bag.Draw(need)...)
}
if len(hands[p]) == 0 && bag.Len() == 0 {
break
}
}
}
// captureCandidates records the engine's top move plus derived candidates for one
// board, each with its ValidatePlayOpts verdict.
func captureCandidates(out *variantFile, rs *rules.Ruleset, solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, top scrabble.Move, rng *rand.Rand) {
size := rs.Size()
record := func(tiles []scrabble.Placement) {
if len(tiles) == 0 {
return
}
out.Fixtures = append(out.Fixtures, makeFixture(solver, opts, b, boardIdx, tiles))
}
// The engine's own top move (legal).
record(top.Tiles)
// Letter-mutated variants: usually reject on the dictionary, occasionally form
// a different legal word.
for range 3 {
mut := clonePlacements(top.Tiles)
i := rng.Intn(len(mut))
mut[i].Letter = byte((int(mut[i].Letter) + 1 + rng.Intn(size-1)) % size)
record(mut)
}
// Random scatters: exercise geometry, dictionary and connectivity paths.
for range 3 {
record(randomScatter(b, size, 2+rng.Intn(4), rng))
}
// Single tiles abutting the board exercise the direction inference — a single
// tile is ambiguous, its orientation resolved from which axis it extends.
for range 3 {
if t, ok := randomAdjacentSingle(b, size, rng); ok {
record([]scrabble.Placement{t})
}
}
// On the empty board, an off-centre translation of the first move exercises the
// first-move centre rule.
if b.IsEmpty() {
shifted := clonePlacements(top.Tiles)
ok := true
for i := range shifted {
shifted[i].Row++
shifted[i].Col++
if !b.InBounds(shifted[i].Row, shifted[i].Col) {
ok = false
break
}
}
if ok {
record(shifted)
}
}
}
// makeFixture validates a candidate against board b and serializes it with its
// ground truth. Word breakdown is recorded only for legal plays (the TS test
// checks words only then); an illegal play records legal=false alone.
func makeFixture(solver *scrabble.Solver, opts scrabble.PlayOptions, b *board.Board, boardIdx int, tiles []scrabble.Placement) fixture {
// Infer the orientation exactly as the backend evaluate does (dir-less), so the
// fixture matches the real eval path and pins the client's ported inference.
dir := playDirectionMirror(solver, b, tiles, opts)
fx := fixture{
Board: boardIdx,
Dir: int(dir),
IgnoreCrossWords: opts.IgnoreCrossWords,
Tiles: placementCells(tiles),
}
m, err := solver.ValidatePlayOpts(b, dir, tiles, opts)
if err == nil {
fx.Legal = true
fx.Score = m.Score
fx.Bonus = m.Bonus
fx.Main = toWord(m.Main)
for _, cw := range m.Cross {
fx.Cross = append(fx.Cross, *toWord(cw))
}
}
return fx
}
func placementCells(ts []scrabble.Placement) []cell {
cs := make([]cell, len(ts))
for i, t := range ts {
cs[i] = cell{R: t.Row, C: t.Col, Letter: int(t.Letter), Blank: t.Blank}
}
return cs
}
func toWord(w scrabble.Word) *word {
letters := make([]int, len(w.Letters))
for i, l := range w.Letters {
letters[i] = int(l)
}
return &word{
Row: w.Row, Col: w.Col, Dir: int(w.Dir),
Letters: letters, Blanks: append([]bool(nil), w.Blanks...), Score: w.Score,
}
}
func alphabetOf(rs *rules.Ruleset) []alphaEntry {
n := rs.Alphabet.Size()
out := make([]alphaEntry, n)
for i := range n {
ch, _ := rs.Alphabet.Character(byte(i))
out[i] = alphaEntry{Index: i, Letter: ch, Value: rs.Values[i]}
}
return out
}
func premiumCodes(rs *rules.Ruleset) []int {
codes := make([]int, rs.Rows*rs.Cols)
for i := range codes {
codes[i] = int(rs.PremiumAt(i))
}
return codes
}
func boardCells(b *board.Board) []cell {
var cs []cell
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
v := b.At(r, c)
cs = append(cs, cell{R: r, C: c, Letter: int(v&0x3f) - 1, Blank: v&0x80 != 0})
}
}
}
return cs
}
func clonePlacements(ts []scrabble.Placement) []scrabble.Placement {
return append([]scrabble.Placement(nil), ts...)
}
// randomScatter picks n distinct empty in-bounds squares with random letters.
func randomScatter(b *board.Board, size, n int, rng *rand.Rand) []scrabble.Placement {
seen := map[[2]int]bool{}
var ts []scrabble.Placement
for tries := 0; tries < n*20 && len(ts) < n; tries++ {
r := rng.Intn(b.Rows())
c := rng.Intn(b.Cols())
if seen[[2]int{r, c}] || b.Filled(r, c) {
continue
}
seen[[2]int{r, c}] = true
ts = append(ts, scrabble.Placement{Row: r, Col: c, Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0})
}
return ts
}
// randomAdjacentSingle picks a random empty in-bounds square abutting at least one
// filled square, with a random letter — a single-tile play whose orientation the
// inference must resolve. It returns ok=false on an empty board.
func randomAdjacentSingle(b *board.Board, size int, rng *rand.Rand) (scrabble.Placement, bool) {
var cands [][2]int
for r := 0; r < b.Rows(); r++ {
for c := 0; c < b.Cols(); c++ {
if b.Filled(r, c) {
continue
}
if b.Filled(r-1, c) || b.Filled(r+1, c) || b.Filled(r, c-1) || b.Filled(r, c+1) {
cands = append(cands, [2]int{r, c})
}
}
}
if len(cands) == 0 {
return scrabble.Placement{}, false
}
rc := cands[rng.Intn(len(cands))]
return scrabble.Placement{Row: rc[0], Col: rc[1], Letter: byte(rng.Intn(size)), Blank: rng.Intn(10) == 0}, true
}
// playDirectionMirror mirrors engine (*Game).playDirection: the geometric
// resolution, except a single tile under the single-word rule tries both
// orientations through the solver and keeps the higher-scoring legal one (H wins
// ties). It reproduces the orientation the backend evaluate infers.
func playDirectionMirror(solver *scrabble.Solver, b *board.Board, placements []scrabble.Placement, opts scrabble.PlayOptions) scrabble.Direction {
geo := resolveDirectionMirror(b, placements)
if len(placements) != 1 || !opts.IgnoreCrossWords {
return geo
}
best, found, bestScore := geo, false, 0
for _, dir := range [...]scrabble.Direction{scrabble.Horizontal, scrabble.Vertical} {
m, err := solver.ValidatePlayOpts(b, dir, placements, opts)
if err != nil {
continue
}
if !found || m.Score > bestScore {
best, found, bestScore = dir, true, m.Score
}
}
return best
}
// resolveDirectionMirror mirrors engine.resolveDirection.
func resolveDirectionMirror(b *board.Board, placements []scrabble.Placement) scrabble.Direction {
if len(placements) >= 2 {
row := placements[0].Row
for _, p := range placements[1:] {
if p.Row != row {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
if len(placements) == 1 {
p := placements[0]
h := runLengthMirror(b, p.Row, p.Col, scrabble.Horizontal)
v := runLengthMirror(b, p.Row, p.Col, scrabble.Vertical)
if v >= 2 && v > h {
return scrabble.Vertical
}
if h >= 2 {
return scrabble.Horizontal
}
if v >= 2 {
return scrabble.Vertical
}
}
return scrabble.Horizontal
}
// runLengthMirror mirrors engine.runLength.
func runLengthMirror(b *board.Board, row, col int, dir scrabble.Direction) int {
dr, dc := 0, 1
if dir == scrabble.Vertical {
dr, dc = 1, 0
}
n := 1
for r, c := row-dr, col-dc; b.Filled(r, c); r, c = r-dr, c-dc {
n++
}
for r, c := row+dr, col+dc; b.Filled(r, c); r, c = r+dr, c+dc {
n++
}
return n
}
// rackOf builds a generation rack from a hand of tiles (reimplemented from the
// unexported selfplay helper).
func rackOf(tiles []byte, size int) rack.Rack {
r := rack.New(size)
for _, t := range tiles {
if t == blankTile {
r.AddBlank()
} else {
r.Add(t)
}
}
return r
}
// removeUsed returns the hand with the tiles consumed by m removed.
func removeUsed(tiles []byte, m scrabble.Move) []byte {
out := append([]byte(nil), tiles...)
for _, p := range m.Tiles {
want := p.Letter
if p.Blank {
want = blankTile
}
for i, t := range out {
if t == want {
out = append(out[:i], out[i+1:]...)
break
}
}
}
return out
}
func boolseed(b bool) int64 {
if b {
return 500000
}
return 0
}
func variantSeed(name string) int64 {
var s int64
for _, r := range name {
s = s*131 + int64(r)
}
return s
}
func fail(format string, args ...any) {
fmt.Fprintf(os.Stderr, "validategen: "+format+"\n", args...)
os.Exit(1)
}
+96 -19
View File
@@ -22,12 +22,13 @@ import (
"scrabble/backend/internal/postgres/jet/backend/table"
)
// Identity kinds recognised by the backend. Email is modelled as an identity
// alongside platform identities; its confirmed flag is driven by the email
// confirm-code flow. Robot is a synthetic kind: each pooled
// robot opponent is a durable account bound to one robot identity.
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
// each pooled robot opponent is a durable account bound to one robot identity.
const (
KindTelegram = "telegram"
KindVK = "vk"
KindEmail = "email"
KindRobot = "robot"
)
@@ -119,6 +120,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return s.provision(ctx, kind, externalID, provisionSeed{})
}
// ProvisionEmail returns the account owning the email identity externalID, creating
// it (unconfirmed) on first contact with browserTZ — the client's detected "±HH:MM"
// UTC offset — seeded into its time zone. Like ProvisionByIdentity it is race-safe
// and leaves an existing account untouched, so a returning user's saved zone is never
// overwritten. The email account is created here (the code-request step), not at the
// later login, so this is where its zone is seeded.
func (s *Store) ProvisionEmail(ctx context.Context, externalID, browserTZ string) (Account, error) {
return s.provision(ctx, KindEmail, externalID, provisionSeed{timeZone: seedZone(browserTZ)})
}
// ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend
// requests NOT blocked — a request to a robot is accepted as pending and, since the
@@ -160,7 +171,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
// is never overwritten. The created flag lets the auth handler re-evaluate moderated-
// chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers.
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, bool, error) {
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName, browserTZ string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact. A race with a concurrent create only over- or under-reports created for
// that one call, which the idempotent chat-access re-evaluation tolerates.
@@ -169,7 +180,31 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
if err != nil && !created {
return Account{}, false, err
}
acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
seed := telegramSeed(languageCode, username, firstName)
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindTelegram, externalID, seed)
return acc, created, err
}
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
// whether this call created it (first contact). On first contact only, it seeds the new
// account's preferred language from the VK languageCode (vk_language, when it maps to a
// supported language) and its display name sanitized from displayName — the name read
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
// falling back to a generated placeholder when it yields no letters; an already-existing
// account is returned unchanged, so a later profile edit is never overwritten.
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
// Pre-check whether the identity already exists so the caller can act on first
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
// that one call.
_, err := s.findByIdentity(ctx, KindVK, externalID)
created := errors.Is(err, ErrNotFound)
if err != nil && !created {
return Account{}, false, err
}
seed := vkSeed(languageCode, displayName)
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindVK, externalID, seed)
return acc, created, err
}
@@ -197,12 +232,24 @@ func (s *Store) provision(ctx context.Context, kind, externalID string, seed pro
}
// provisionSeed carries the optional create-time profile seed for a brand-new
// account (Telegram first contact). Empty fields fall back to the accounts table
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
// the ” default.
// account (first contact). Empty fields fall back to the accounts table defaults,
// so an unknown language keeps the 'en' default, an empty name keeps the ” default
// and an empty time zone keeps the 'UTC' default.
type provisionSeed struct {
preferredLanguage string
displayName string
timeZone string
}
// seedZone returns browserTZ when it is a well-formed zone to persist at account
// creation (a "±HH:MM" offset or a loadable IANA name), else "" so the new account
// falls back to the accounts table's 'UTC' default. The client reports the device's
// detected offset deterministically; a bad value is dropped rather than guessed at.
func seedZone(browserTZ string) string {
if validZone(browserTZ) {
return browserTZ
}
return ""
}
// telegramSeed derives the create-time seed from Telegram launch fields: a
@@ -234,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
return seed
}
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
// language from languageCode (vk_language, normally a 2-letter code) and a display name
// from displayName (sanitized to the editable format), falling back to a generated
// placeholder in the seeded language when the name yields no usable letters. Unlike
// telegramSeed there is no @username fallback — VK provides only the name.
func vkSeed(languageCode, displayName string) provisionSeed {
var seed provisionSeed
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
seed.preferredLanguage = lang
}
name := sanitizeDisplayName(displayName)
if name == "" {
name = placeholderDisplayName(seed.preferredLanguage)
}
seed.displayName = name
return seed
}
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns).
@@ -368,16 +433,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Seed the new row's display name and language (Telegram first contact); an
// empty seed reproduces the table defaults ('' and 'en') the other callers
// relied on, so their behaviour is unchanged.
// Seed the new row's display name, language and time zone (first contact); an
// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
// relied on, so their behaviour is unchanged. time_zone is written explicitly (the
// detected offset, or 'UTC' equal to the column default) so a seeded zone lands at
// creation while an unseeded one stays UTC.
lang := seed.preferredLanguage
if lang == "" {
lang = "en"
}
tz := seed.timeZone
if tz == "" {
tz = "UTC"
}
insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
VALUES(accountID, seed.displayName, lang).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
VALUES(accountID, seed.displayName, lang, tz).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
@@ -391,7 +462,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
table.Identities.Kind,
table.Identities.ExternalID,
table.Identities.Confirmed,
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
return err
}
@@ -416,15 +487,21 @@ const guestDisplayName = "Guest"
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying
// no identity, flagged is_guest, so it can hold a session and a game seat (both
// foreign-key the accounts table) while being excluded from statistics, friends
// and history. Guests are not reused — each bootstrap mints a new account.
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
// and history. Guests are not reused — each bootstrap mints a new account. browserTZ
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling
// back to the 'UTC' default when empty or malformed.
func (s *Store) ProvisionGuest(ctx context.Context, browserTZ string) (Account, error) {
accountID, err := uuid.NewV7()
if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err)
}
tz := seedZone(browserTZ)
if tz == "" {
tz = "UTC"
}
stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest).
VALUES(accountID, guestDisplayName, true).
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
+5 -3
View File
@@ -131,13 +131,15 @@ func (s *EmailService) ConfirmCode(ctx context.Context, accountID uuid.UUID, ema
// the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user
// login. The code is mailed to the address, so only its real owner can complete
// the login. It returns the target account id for the subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
// the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
// seeds the new account's time zone. It returns the target account id for the
// subsequent LoginWithCode.
func (s *EmailService) RequestLoginCode(ctx context.Context, email, browserTZ string) (uuid.UUID, error) {
addr, err := normalizeEmail(email)
if err != nil {
return uuid.UUID{}, err
}
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
if err != nil {
return uuid.UUID{}, err
}
+60
View File
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
return out, nil
}
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
// canonical variant labels joined by "-". It is deliberately distinct from the routing
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
// router falls through to the lobby for it.
const variantSeedPrefix = "v"
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
// returns nil for any payload that is not a variant-seed link or that fails validation
// against the known variants, so a malformed, empty or unrelated start-param simply
// leaves the account on its default preferences rather than failing the login.
func SeedVariantsFromStartParam(startParam string) []string {
if !strings.HasPrefix(startParam, variantSeedPrefix) {
return nil
}
body := strings.TrimPrefix(startParam, variantSeedPrefix)
if body == "" {
return nil
}
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
if err != nil {
return nil
}
return prefs
}
// SetVariantPreferences overwrites only the variant-preference set of the account,
// cleaning it to a deduplicated, canonically ordered subset of the known variants
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
// without disturbing its other profile fields.
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
clean, err := validateVariantPreferences(prefs)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE(
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
).SET(
// clean is validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
// UpdateProfile.
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns)
var row model.Accounts
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Account{}, ErrNotFound
}
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
}
return modelToAccount(row), nil
}
// UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id.
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
}
}
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
// seed: supported-language detection from vk_language (bare and region-tagged) and the
// display name sanitized from the client-supplied name. Unlike Telegram there is no
// @username fallback — VK provides only the name.
func TestVKSeed(t *testing.T) {
cases := map[string]struct {
languageCode, displayName string
wantLang, wantName string
}{
"ru bare": {"ru", "Иван", "ru", "Иван"},
"en region-tagged": {"en-US", "John", "en", "John"},
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
"unknown language": {"uk", "Тарас", "", "Тарас"},
"empty language": {"", "Neo", "", "Neo"},
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := vkSeed(tc.languageCode, tc.displayName)
if got.preferredLanguage != tc.wantLang {
t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang)
}
if got.displayName != tc.wantName {
t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName)
}
})
}
}
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
func TestVKSeedPlaceholder(t *testing.T) {
cases := map[string]struct {
languageCode, displayName string
wantRe string
}{
"en empty": {"en", "", `^Player-\d{5}$`},
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
"default en": {"uk", "", `^Player-\d{5}$`},
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
got := vkSeed(tc.languageCode, tc.displayName).displayName
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
}
})
}
}
@@ -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)
}
})
}
}
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
@@ -7,8 +7,9 @@
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>App version</b> {{if .Version}}<code>{{.Version}}</code>{{else}}<span class="note">unknown</span>{{end}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>Filed</b> {{.CreatedAt}} UTC &middot; browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}<span class="note">N/A</span>{{end}} &middot; user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}<span class="note">N/A</span>{{end}}</li>
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul>
@@ -142,6 +142,11 @@
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
</section>
{{end}}
{{if .VKID}}
<section class="panel"><h2>VK</h2>
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
</section>
{{end}}
<section class="panel"><h2>Games</h2>
<table class="list">
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
+23 -7
View File
@@ -156,13 +156,17 @@ type UserDetailView struct {
HintBalance int
// HintGrantMax is the per-grant cap the operator's "add hints" form enforces (it mirrors the
// server's maxHintGrant), passed through so the policy value lives in one place.
HintGrantMax int
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
Games []GameRow
HintGrantMax int
CreatedAt string
HasStats bool
Stats StatsRow
Identities []IdentityRow
Games []GameRow
// TelegramID and VKID are the account's platform external ids (empty when absent).
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
// user id with a link to the VK profile (there is no VK messaging to drive).
TelegramID string
VKID string
ConnectorEnabled bool
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
// time (min/mean/max), empty when the account has no timed move.
@@ -554,5 +558,17 @@ type FeedbackDetailView struct {
ReplyBody string
RepliedAt string
CreatedAt string
Banned bool
// Version is the client app build the report was sent from (empty for rows that predate it).
Version string
// The Filed time is shown in three zones so the operator can tell what is certainly known from
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
// default — the template then shows "N/A" so the missing datum is explicit.
CreatedAtBrowser string
BrowserTZ string
CreatedAtUser string
UserTZ string
Banned bool
}
+29 -3
View File
@@ -21,11 +21,13 @@ var dictFiles = map[Variant]string{
VariantErudit: "ru_erudit.dawg",
}
// entry is one resident dictionary: the loaded finder and the solver built over
// it. The finder is retained so Close can release it.
// entry is one resident dictionary: the loaded finder, the solver built over it
// and the file it was loaded from. The finder is retained so Close can release
// it; path is retained so the raw bytes can be re-read for the client download.
type entry struct {
finder dawg.Finder
solver *scrabble.Solver
path string
}
// Registry holds the dictionaries resident in memory, addressed by variant and
@@ -130,7 +132,7 @@ func (r *Registry) Load(v Variant, version, dir string) error {
if old, ok := r.entries[v][version]; ok {
_ = old.finder.Close()
}
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder)}
r.entries[v][version] = entry{finder: finder, solver: scrabble.NewSolver(rs, finder), path: path}
r.latest[v] = version
return nil
}
@@ -202,6 +204,30 @@ func (r *Registry) Versions(v Variant) []string {
return versions
}
// DictBytes returns the raw serialized DAWG for the (variant, version) pair,
// re-read from the file it was loaded from — the same immutable bytes the solver
// holds. It backs the client-side dictionary download for the local move
// preview. It returns ErrUnknownVariant or ErrUnknownVersion when that dictionary
// is not resident, and wraps any read error. The file is read outside the lock.
func (r *Registry) DictBytes(v Variant, version string) ([]byte, error) {
r.mu.RLock()
versions, ok := r.entries[v]
if !ok {
r.mu.RUnlock()
return nil, fmt.Errorf("%w: %s", ErrUnknownVariant, v)
}
e, ok := versions[version]
r.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("%w: %s/%s", ErrUnknownVersion, v, version)
}
data, err := os.ReadFile(e.path)
if err != nil {
return nil, fmt.Errorf("engine: read %s/%s dictionary bytes from %s: %w", v, version, e.path, err)
}
return data, nil
}
// Lookup reports whether word is present in the (variant, version) dictionary,
// backing the unlimited word-check tool. It returns ErrUnknownVariant or
// ErrUnknownVersion when that dictionary is not resident, and an error when word
+5 -4
View File
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
// validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil {
return err
@@ -112,9 +112,10 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded
// for the guest check) so the operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
// Snapshot the sender's interface language, the client app version and the client's
// detected UTC offset at submit time (acc is already loaded for the guest check) so the
// operator later sees the state as it was.
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, browserTZ, parseIP(senderIP))
return err
}
+18 -10
View File
@@ -34,10 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) is a snapshot
// taken now, so the operator later sees the state at submit time. created_at defaults
// to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
// channel are stored as given. lang (interface language), version (client app build) and
// browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator
// later sees the state at submit time. created_at defaults to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
}
if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
(message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
@@ -228,7 +228,15 @@ type AdminMessage struct {
Body string
Channel string
// Lang is the sender's interface language, snapshotted at submit time.
Lang string
Lang string
// Version is the client app build the report was sent from, snapshotted at submit time.
Version string
// BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
// filed time can be shown in the sender's browser-local zone even before they save a profile.
BrowserTZ string
// TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
// rendering CreatedAt in the sender's own configured time alongside UTC.
TimeZone string
SenderIP string
HasAttachment bool
AttachmentName string
@@ -343,7 +351,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage
var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''),
COALESCE(m.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone,
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -352,7 +360,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang,
&m.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
+48 -4
View File
@@ -54,8 +54,14 @@ type Service struct {
// have committed a move (a nudge answered by moving stops counting as unread). It is
// best-effort and kept as a func so the game package never imports the social package.
clearNudges func(ctx context.Context, gameID, accountID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
// expireNudges, when set, marks every pending nudge in a game read once the game
// finishes (the nudge badge is stale on a completed game). Unlike clearNudges it is
// keyed by game alone — it clears all seats' nudges, not one mover's — and runs on
// every completion path through commit. Best-effort; a func so the game package never
// imports the social package.
expireNudges func(ctx context.Context, gameID uuid.UUID) error
metrics *gameMetrics
log *zap.Logger
}
// NewService constructs a Service. store and accounts wrap the same pool;
@@ -107,6 +113,15 @@ func (svc *Service) SetNudgeClearer(fn func(ctx context.Context, gameID, account
svc.clearNudges = fn
}
// SetNudgeExpirer installs the hook that marks every pending nudge in a game read once the
// game finishes, on any completion path (a closing move, a resignation, a turn-timeout or a
// forfeit). It must be called during startup wiring; the default (nil) leaves a finished
// game's nudges to expire only when a recipient opens the move history or chat. The social
// package wires its ExpireNudges here. Chat messages are deliberately left unread.
func (svc *Service) SetNudgeExpirer(fn func(ctx context.Context, gameID uuid.UUID) error) {
svc.expireNudges = fn
}
// SetFirstMoveEntropy overrides the entropy source for the first-move draw
// (docs/ARCHITECTURE.md §6). It must be called during wiring or test setup before any
// game is created; the production default is crypto/rand and is never overridden.
@@ -699,6 +714,16 @@ func (svc *Service) commit(ctx context.Context, gameID uuid.UUID, g *engine.Game
}
if c.finished {
svc.cache.remove(gameID)
// A finished game's nudges are stale, so clear them all here — every completion path
// funnels through commit (a closing move, a resignation, a forfeit or a turn-timeout),
// and only the move path also clears the mover's nudge on its own. Best-effort like
// clearNudges: the finish has committed, so a cleanup failure is logged, not surfaced.
// ExpireNudges leaves chat messages unread.
if svc.expireNudges != nil {
if err := svc.expireNudges(ctx, gameID); err != nil {
svc.log.Warn("expire nudges on game finish", zap.Error(err))
}
}
}
post, err := svc.store.GetGame(ctx, gameID)
if err != nil {
@@ -1440,13 +1465,24 @@ func (svc *Service) voidGame(ctx context.Context, pre Game, g *engine.Game) erro
if err != nil {
return err
}
return svc.store.VoidGame(ctx, voidCommit{
if err := svc.store.VoidGame(ctx, voidCommit{
gameID: pre.ID,
endReason: g.Reason().String(),
scores: scores,
now: svc.clock(),
stats: buildStats(g, statSeats),
})
}); err != nil {
return err
}
// A voided game is finished (as a draw) but bypasses commit, so clear its now-stale nudges
// here too. Best-effort, like the commit path: the void has persisted, so a cleanup failure
// is logged, not surfaced.
if svc.expireNudges != nil {
if err := svc.expireNudges(ctx, pre.ID); err != nil {
svc.log.Warn("expire nudges on voided game", zap.Error(err))
}
}
return nil
}
// replayMove re-applies one journalled move to g through the decoded engine API.
@@ -1613,6 +1649,14 @@ func (svc *Service) lookupWord(variant engine.Variant, version, word string) (bo
return present, nil
}
// DictBytes returns the raw serialized dictionary for the (variant, version) pair
// from the registry, backing the client-side dictionary download used by the
// local move preview. It surfaces engine.ErrUnknownVariant /
// engine.ErrUnknownVersion when that dictionary is not resident.
func (svc *Service) DictBytes(variant engine.Variant, version string) ([]byte, error) {
return svc.registry.DictBytes(variant, version)
}
// hintsRemaining is a player's remaining hint budget: the unspent per-game
// allowance plus the profile wallet.
func hintsRemaining(allowance, used, wallet int) int {
+106 -11
View File
@@ -110,15 +110,15 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
}
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields,
// defaults the in-app-only flag on, and never overwrites an existing account on a
// later login (language seeding).
// seeds the new account's language, display name and time zone from the launch
// fields / detected offset, defaults the in-app-only flag on, and never overwrites
// an existing account on a later login (language and zone seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван", "+03:00")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -131,12 +131,15 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if acc.DisplayName != "Иван" {
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
}
if acc.TimeZone != "+03:00" {
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
}
if !acc.NotificationsInAppOnly {
t.Error("NotificationsInAppOnly should default to true")
}
// A later login with different fields returns the same account, unchanged.
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other", "+09:00")
if err != nil {
t.Fatalf("re-provision telegram: %v", err)
}
@@ -146,8 +149,100 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
}
}
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
// language, display name and time zone from the launch fields / detected offset, records
// the vk identity as confirmed (a platform identity), and never overwrites an existing
// account on a later launch. It also exercises the widened identities.kind CHECK — a
// 'vk' row must insert.
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "vk-" + uuid.NewString()
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
if err != nil {
t.Fatalf("provision vk: %v", err)
}
if !created {
t.Error("created = false on first contact, want true")
}
if acc.PreferredLanguage != "ru" {
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
}
if acc.DisplayName != "Иван Петров" {
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
}
if acc.TimeZone != "+03:00" {
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
}
// A VK identity is a platform identity: confirmed on insert.
if !identityConfirmed(t, account.KindVK, ext) {
t.Error("vk identity must be confirmed")
}
// A later launch with different fields returns the same account, unchanged.
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
if err != nil {
t.Fatalf("re-provision vk: %v", err)
}
if created {
t.Error("created = true on a repeat launch, want false")
}
if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
}
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
}
}
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
// missing or malformed offset falls back to the "UTC" column default rather than
// being guessed at.
func TestProvisionSeedsTimeZone(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
// A detected zero offset is written as "+00:00" — we record that the zone was
// detected (and equals UTC), distinct from the "UTC" default meaning "unknown".
utcDetected, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Zero", "+00:00")
if err != nil {
t.Fatalf("provision telegram +00:00: %v", err)
}
if utcDetected.TimeZone != "+00:00" {
t.Errorf("TimeZone = %q, want the seeded +00:00", utcDetected.TimeZone)
}
// A malformed offset is dropped: the account keeps the UTC default.
bad, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Bad", "not-a-zone")
if err != nil {
t.Fatalf("provision telegram bad tz: %v", err)
}
if bad.TimeZone != "UTC" {
t.Errorf("TimeZone = %q, want UTC fallback for a malformed offset", bad.TimeZone)
}
// A guest is seeded its detected offset; an empty one keeps the UTC default.
guest, err := store.ProvisionGuest(ctx, "-05:30")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
if guest.TimeZone != "-05:30" {
t.Errorf("guest TimeZone = %q, want the seeded -05:30", guest.TimeZone)
}
plainGuest, err := store.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision plain guest: %v", err)
}
if plainGuest.TimeZone != "UTC" {
t.Errorf("plain guest TimeZone = %q, want UTC default", plainGuest.TimeZone)
}
}
@@ -156,7 +251,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background()
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
acc, _, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -172,7 +267,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -228,7 +323,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString()
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
acc, _, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
@@ -253,7 +348,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
acc, _, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player", "")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
+1 -1
View File
@@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background()
accounts := account.NewStore(testDB)
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
+1 -1
View File
@@ -55,7 +55,7 @@ func TestChatAccessResolver(t *testing.T) {
srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter")
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
+59
View File
@@ -138,6 +138,65 @@ func TestNudgeClearedByMove(t *testing.T) {
}
}
// TestGameCompletionExpiresNudgesKeepsChat checks that finishing a game by turn-timeout marks every
// pending nudge in it read — the lobby's nudge badge is stale once the game is over — while leaving
// real chat messages unread. It reproduces the reported bug: the timeout path commits the finish
// directly, bypassing the move path's per-mover nudge clear, so without a completion-driven expiry
// the awaited seat's nudge lingered as a badge on the finished game.
func TestGameCompletionExpiresNudgesKeepsChat(t *testing.T) {
ctx := context.Background()
gameSvc := newGameService()
socialSvc := newSocialService()
gameSvc.SetNudgeExpirer(socialSvc.ExpireNudges)
seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
g, err := gameSvc.Create(ctx, game.CreateParams{
Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: time.Hour, Seed: openingSeed(t),
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Seat 1 nudges the to-move seat 0 (the awaited player), and seat 0 posts a real chat message,
// which seat 1 then holds unread. So before the timeout each side has exactly one unread entry:
// seat 0 a nudge, seat 1 a message — letting HasUnread isolate each kind.
if _, err := socialSvc.Nudge(ctx, g.ID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
if _, err := socialSvc.PostMessage(ctx, g.ID, seats[0], "good luck", ""); err != nil {
t.Fatalf("post message: %v", err)
}
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); !unread {
t.Fatal("seat 0 should hold the nudge unread before the timeout")
}
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
t.Fatal("seat 1 should hold the message unread before the timeout")
}
// Age the turn past its deadline and time seat 0 out; an empty away window keeps this
// deterministic regardless of the wall clock. The sweep finishes the game through the direct
// commit path, never the move path.
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
setAway(t, seats[0], "UTC", "00:00", "00:00")
if n, err := gameSvc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
t.Fatalf("sweep swept %d (err %v), want >= 1", n, err)
}
if status, reason := gameStatus(t, gameSvc, g.ID); status != game.StatusFinished || reason != "timeout" {
t.Fatalf("game not timed out: status %q reason %q", status, reason)
}
// The nudge is stale on a finished game and must be cleared; the chat message must survive.
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[0]); unread {
t.Error("the nudge should be expired once the game has finished")
}
if unread, _ := socialSvc.HasUnread(ctx, g.ID, seats[1]); !unread {
t.Error("a real chat message must stay unread after the game finishes")
}
if msg, _ := socialSvc.HasUnreadMessage(ctx, g.ID, seats[1]); !msg {
t.Error("the chat message must remain flagged as an unread message after completion")
}
}
// TestChatToRobotIsBornRead checks a text message to a disguised robot opponent (a pooled
// robot substituted into an ordinary, non-AI game) is born read: the robot never opens the
// chat, so the message must not linger unread (skewing the count and the read metric).
+5 -2
View File
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
svc := account.NewEmailService(account.NewStore(testDB), mailer)
email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email)
accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
if err != nil {
t.Fatalf("request login code: %v", err)
}
@@ -225,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) {
if acc.IsGuest {
t.Error("an email account must be durable, not a guest")
}
if acc.TimeZone != "+02:00" {
t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone)
}
if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login")
}
// A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email); err != nil {
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil {
t.Fatalf("second request: %v", err)
}
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
+13 -13
View File
@@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U
func TestFeedbackGuestRejected(t *testing.T) {
svc := newFeedbackService()
guest := provisionGuest(t)
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "+05:00", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
}
}
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "+03:00", "9.9.9.9"); err != nil {
t.Fatalf("submit: %v", err)
}
// Anti-spam gate: a second message is refused while the first is unreviewed.
if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrPendingReview) {
t.Fatalf("second submit err = %v, want ErrPendingReview", err)
}
if st, err := svc.State(ctx, acc); err != nil {
@@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
if m.Body != "please fix the board" { // trimmed
t.Fatalf("body = %q, want trimmed", m.Body)
}
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" || m.BrowserTZ != "+03:00" {
t.Fatalf("admin message = %+v", m)
}
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
@@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
acc := provisionAccount(t)
// msg1, replied → the player can send again and currently sees the reply.
if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit msg1: %v", err)
}
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
@@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
// Sending a new message immediately drops the previous reply (it now belongs to an
// older message), even though it is well within the one-week window.
if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit msg2: %v", err)
}
st, err := svc.State(ctx, acc)
@@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) {
t.Fatalf("set language: %v", err)
}
// A message snapshots the sender's interface language at submit time.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", "", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
@@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("grant role: %v", err)
}
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrBanned) {
t.Fatalf("banned submit err = %v, want ErrBanned", err)
}
if st, err := svc.State(ctx, acc); err != nil {
@@ -196,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("revoke role: %v", err)
}
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit after unban: %v", err)
}
}
@@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
acc := provisionAccount(t) // fresh account so the pending gate never fires first
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", "", ""); !errors.Is(err, tt.want) {
t.Fatalf("submit err = %v, want %v", err, tt.want)
}
})
@@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
@@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
svc := newFeedbackService()
acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit: %v", err)
}
if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
@@ -286,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
}
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", "", ""); err != nil {
t.Fatalf("submit after delete-all: %v", err)
}
}
+1 -1
View File
@@ -120,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID {
t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -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)
}
}
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts,
})
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "")
if err != nil {
t.Fatalf("provision: %v", err)
}
+2 -2
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB)
uniq := uuid.NewString()
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "")
if err != nil {
t.Fatalf("provision human: %v", err)
}
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil {
t.Fatalf("provision robot: %v", err)
}
guest, err := st.ProvisionGuest(ctx)
guest, err := st.ProvisionGuest(ctx, "")
if err != nil {
t.Fatalf("provision guest: %v", err)
}
@@ -0,0 +1,64 @@
-- Replace the default (house) ad campaign's single seed tip with the curated,
-- language-agnostic Scrabble tip set (one bilingual row per tip; the client picks the
-- column for the viewer's language). Data-only — the ad_messages schema is unchanged, so
-- a backend image rollback stays DB-safe. The default campaign is the fixed house id seeded
-- in 00001; ON DELETE CASCADE is irrelevant here (we only touch its messages).
-- +goose Up
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 0, 'Keep a balanced rack — a slight edge of consonants over vowels.', 'Держи на руках баланс — с лёгким перевесом согласных над гласными.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 1, 'Your "leave" (the tiles you keep) sets up your next turn — value it.', '«Остаток» (что оставляешь на руках) готовит следующий ход — цени его.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 2, 'Shed duplicate tiles — repeats clog your options.', 'Сбрасывай дубли фишек — повторы забивают возможности.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 3, 'A slightly consonant-heavy rack builds full-rack plays more easily.', 'Лёгкий перевес согласных проще складывается в выкладку всех фишек.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 4, 'Play several tiles per turn to keep your rack cycling.', 'Выкладывай по нескольку фишек за ход, чтобы рука обновлялась.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 5, 'Don''t hoard hard-to-place duplicates or a lone high-value tile.', 'Не копи труднопристраиваемые дубли или одинокую дорогую фишку.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 6, 'Using all your rack tiles in one move scores a large bonus — chase it.', 'Выкладка всех фишек с рук за ход даёт крупный бонус — стремись к ней.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 7, 'Learn common prefixes and suffixes — they extend words to use every tile.', 'Учи частые приставки и суффиксы — они растягивают слово на все фишки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 8, '"Fish": play few tiles to keep a near-complete rack when you''re ahead.', '«Рыбачь»: сыграй мало фишек, сохранив почти всю руку, когда ведёшь.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 9, 'Don''t hoard high-value tiles — play them in good time, not at the very end.', 'Не копи дорогие фишки — играй их вовремя, а не под самый конец.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 10, 'Don''t hold a high-value tile waiting for a rare partner — usually a loss.', 'Не держи дорогую фишку ради редкого партнёра — обычно это проигрыш.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 11, 'Land your priciest tile on a premium square for a big single score.', 'Сажай самую дорогую фишку на бонусную клетку ради крупных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 12, 'High-value tiles shine in parallel plays through short words.', 'Дорогие фишки сильны в параллельных выкладках через короткие слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 13, 'Stuck with an unplayable high-value tile late? Exchange it.', 'Завис с неиграбельной дорогой фишкой под конец? Обменяй её.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 14, 'The blanks are the most valuable tiles in the bag — guard them.', 'Пустышки — самые ценные фишки в мешке; береги их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 15, 'Save a blank for a full-rack play or a key premium square.', 'Береги пустышку для выкладки всех фишек или важной бонусной клетки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 16, 'Don''t spend a blank cheaply — hold it for a much bigger gain.', 'Не трать пустышку по мелочи — придержи ради куда большей выгоды.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 17, 'Put high-value tiles on letter-bonus or word-bonus squares.', 'Клади дорогие фишки на бонус буквы или слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 18, 'Stack bonuses — a letter bonus under a word bonus multiplies both.', 'Совмещай бонусы — бонус буквы под бонусом слова умножает оба.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 19, 'Parallel plays can earn nearly half your points — look for them.', 'Параллельные выкладки могут давать почти половину очков — ищи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 20, 'A hook adds one tile to an existing word to make a new one.', '«Крючок» — одна фишка к готовому слову, образующая новое.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 21, 'Hooks work at the front or the back of a word.', 'Крючки работают спереди и сзади слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 22, 'Short words are the keys to tight parallel plays — memorize them.', 'Короткие слова — ключ к плотным параллелям; выучи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 23, 'Your opening word crosses the centre — keep it compact, don''t open up.', 'Первое слово идёт через центр — держи компактным, не раскрывайся.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 24, 'It''s not only your score — limit your opponent''s options too.', 'Это не только твои очки — ограничивай и возможности соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 25, 'Denying a big reply often beats squeezing a few more points yourself.', 'Закрыть крупный ответ часто важнее, чем добрать пару своих очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 26, 'When ahead, keep the board tight and closed; avoid open lanes.', 'Ведёшь — держи доску плотной и закрытой, не открывай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 27, 'When behind, open the board up to create high-scoring chances.', 'Отстаёшь — раскрывай доску ради шансов на крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 28, 'Don''t leave a word-bonus square open right beside your word.', 'Не оставляй клетку бонуса слова открытой рядом со своим словом.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 29, 'Block a hot square even with a weak word to deny a big play.', 'Закрывай опасную клетку даже слабым словом, чтобы срубить крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 30, 'Know words that take no hooks — use them to seal off lines.', 'Знай слова, не берущие крючков — ими запирай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 31, 'Track the tiles played to judge what is still left in the bag.', 'Считай сыгранные фишки — так поймёшь, что осталось в мешке.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 32, 'Exchange when your rack is unbalanced or can only score low.', 'Меняй фишки, когда рука несбалансированна или тянет мало.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 33, 'A good exchange beats a bad play — a clean rack is worth a turn.', 'Хороший обмен лучше плохого хода — чистая рука стоит хода.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 34, 'Swap away a surplus of vowels or consonants to rebalance.', 'Сбрасывай в обмен избыток гласных или согласных, чтобы выровняться.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 35, 'Rare high-value tiles are gone once seen — note them as they appear.', 'Редкие дорогие фишки исчезают, едва мелькнув — отмечай их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 36, 'Once the bag is empty, deduce your opponent''s remaining tiles.', 'Когда мешок пуст, вычисли оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 37, 'Shed high-value tiles before the bag empties — don''t get stuck with them.', 'Сбрось дорогие фишки до опустения мешка — не зависай с ними.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 38, 'Unplayed tiles count against you at the end — try to go out first.', 'Несыгранные фишки минусуют очки в конце — старайся выйти первым.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 39, 'Going out first adds your opponent''s leftover tiles to your score.', 'Кто вышел первым, добирает очки за оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 40, 'Sometimes leaving one tile in the bag buys you an extra turn.', 'Иногда оставить одну фишку в мешке — это лишний ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 41, 'In the endgame, block the exact squares your opponent needs.', 'В эндшпиле блокируй именно те клетки, что нужны сопернику.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 42, 'Shuffle your rack to spot new patterns.', 'Перемешивай фишки на руках — так замечаешь новые сочетания.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 43, 'Separate prefix, suffix and middle tiles to anagram faster.', 'Разнеси приставку, суффикс и середину — анаграммы решаются быстрее.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 44, 'Value board position and future turns over raw points this turn.', 'Цени позицию и будущие ходы выше сиюминутных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 45, 'Early game build position; midgame maximize score; endgame defend.', 'В начале — позиция, в середине — очки, в конце — защита.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 46, 'Learn the short-word lists first — they pay off in every game.', 'Сначала учи списки коротких слов — окупаются в каждой партии.');
-- +goose Down
-- Restore the original single house tip seeded by the baseline.
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru)
VALUES ('00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
'Tip: a play using all 7 tiles earns a +50 bonus.',
'Совет: ход всеми 7 фишками приносит бонус +50 очков.');
@@ -0,0 +1,10 @@
-- Capture the client app version (the build a report was sent from) with each feedback
-- message, so the operator console can show which version a player was on. Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN app_version text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN app_version;
@@ -0,0 +1,11 @@
-- Capture the client's detected UTC offset ("±HH:MM") with each feedback message, so the
-- operator console can show the filed time in the sender's browser-local zone even before that
-- player has ever saved a profile (the account zone defaults to UTC until then). Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN browser_tz text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN browser_tz;
@@ -0,0 +1,13 @@
-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check
-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set
-- is backward-compatible, so a backend image rollback stays DB-safe — the older code
-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the
-- generated go-jet model is not regenerated.
-- +goose Up
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text])));
-- +goose Down
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text])));
+4
View File
@@ -27,6 +27,7 @@ func (s *Server) registerRoutes() {
if s.sessions != nil && s.accounts != nil {
in := s.internal
in.POST("/sessions/telegram", s.handleTelegramAuth)
in.POST("/sessions/vk", s.handleVKAuth)
in.POST("/sessions/guest", s.handleGuestAuth)
in.POST("/sessions/email/request", s.handleEmailRequest)
in.POST("/sessions/email/login", s.handleEmailLogin)
@@ -89,6 +90,9 @@ func (s *Server) registerRoutes() {
u.GET("/games/:id/draft", s.handleGetDraft)
u.PUT("/games/:id/draft", s.handleSaveDraft)
u.POST("/games/:id/hide", s.handleHideGame)
// Raw dictionary download for the client-side local move preview, keyed by
// the game's pinned (variant, version); immutable, so cached hard.
u.GET("/dict/:variant/:version", s.handleDictBytes)
}
if s.feedback != nil {
u.POST("/feedback", s.handleFeedbackSubmit)
@@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
view.TelegramID = tg
}
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
view.VKID = vk
}
if games, err := s.games.ListForAccount(ctx, id); err == nil {
for _, g := range games {
view.Games = append(view.Games, gameRow(g))
@@ -1198,6 +1201,17 @@ func fmtTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04")
}
// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
func fmtTimeIn(t time.Time, tz string) string {
if t.IsZero() {
return ""
}
return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string {
if t == nil {
@@ -77,6 +77,17 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
s.consoleError(c, err)
return
}
// Filed time in three zones so the operator can tell what is certainly known from what is
// merely defaulted: always UTC; the client's offset detected at submit (when the build
// reported one); and the sender's saved profile zone (when set beyond the UTC default). An
// empty rendered time makes the template show "N/A" for that line.
browserCreated, userCreated := "", ""
if m.BrowserTZ != "" {
browserCreated = fmtTimeIn(m.CreatedAt, m.BrowserTZ)
}
if m.TimeZone != "" && m.TimeZone != "UTC" {
userCreated = fmtTimeIn(m.CreatedAt, m.TimeZone)
}
view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
@@ -84,6 +95,9 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
Version: m.Version,
CreatedAtBrowser: browserCreated, BrowserTZ: m.BrowserTZ,
CreatedAtUser: userCreated, UserTZ: m.TimeZone,
}
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned
+69 -7
View File
@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
)
@@ -18,12 +19,17 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
// brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
// deep-link payload, which may seed the new account's variant preferences (first
// contact only).
type telegramAuthRequest struct {
ExternalID string `json:"external_id"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"`
}
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -35,7 +41,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
abortBadRequest(c, "external_id is required")
return
}
acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName, req.BrowserTZ)
if err != nil {
s.abortErr(c, err)
return
@@ -45,6 +51,47 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
// joined the chat before registering is granted on the spot (no chat_member
// event fires on registration).
s.publishChatAccessChange(acc.ID)
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
// English Scrabble alongside the default Erudit). Best-effort: an absent or
// malformed payload leaves the account on its defaults, and a write failure must
// not block the session mint.
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
s.log.Warn("telegram: seed variant preferences failed",
zap.String("account", acc.ID.String()), zap.Error(err))
}
}
}
s.mintSession(c, acc)
}
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
// params. LanguageCode (vk_language) and DisplayName (read client-side via
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
// offset) seeds its time zone. All seeds apply on first contact only.
type vkAuthRequest struct {
ExternalID string `json:"external_id"`
LanguageCode string `json:"language_code"`
DisplayName string `json:"display_name"`
BrowserTZ string `json:"browser_tz"`
}
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
// session for it, seeding a new account's language and display name from the supplied VK
// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation
// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the
// MVP carries no launch deep link.
func (s *Server) handleVKAuth(c *gin.Context) {
var req vkAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "external_id is required")
return
}
acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ)
if err != nil {
s.abortErr(c, err)
return
}
s.mintSession(c, acc)
}
@@ -97,9 +144,21 @@ func (s *Server) handlePushTarget(c *gin.Context) {
})
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
// guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ
// (the client's detected "±HH:MM" UTC offset) is written to the new guest account's
// time zone, so robot timing is anchored to the player's zone from the first game.
type guestAuthRequest struct {
BrowserTZ string `json:"browser_tz"`
}
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session,
// seeding its time zone from the optional detected browser offset.
func (s *Server) handleGuestAuth(c *gin.Context) {
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
// The body is optional: an absent or malformed one simply yields no time-zone seed
// (the account keeps the UTC default), so a bind error must not fail the bootstrap.
var req guestAuthRequest
_ = c.ShouldBindJSON(&req)
acc, err := s.accounts.ProvisionGuest(c.Request.Context(), req.BrowserTZ)
if err != nil {
s.abortErr(c, err)
return
@@ -107,9 +166,12 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.mintSession(c, acc)
}
// emailRequest is an email-login code request.
// emailRequest is an email-login code request. BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds the time zone of an account provisioned here on first
// contact (the email account is created at the request step, not at login).
type emailRequest struct {
Email string `json:"email"`
Email string `json:"email"`
BrowserTZ string `json:"browser_tz"`
}
// handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -121,7 +183,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required")
return
}
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email, req.BrowserTZ); err != nil {
s.abortErr(c, err)
return
}
+31
View File
@@ -0,0 +1,31 @@
package server
import (
"net/http"
"github.com/gin-gonic/gin"
"scrabble/backend/internal/engine"
)
// handleDictBytes streams the raw serialized dictionary for a (variant, version)
// pair so the client can validate and score moves locally — the local move
// preview — instead of a network round trip per tile arrangement. It is reached
// only through the gateway's session-gated /dict route (which resolves the
// X-User-ID this group requires), and served with a long immutable cache lifetime
// because a published dictionary version never changes. An unknown variant or a
// version that is not resident is a 404.
func (s *Server) handleDictBytes(c *gin.Context) {
variant, err := engine.ParseVariant(c.Param("variant"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "unknown variant"})
return
}
data, err := s.games.DictBytes(variant, c.Param("version"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "dictionary not found"})
return
}
c.Header("Cache-Control", "public, max-age=31536000, immutable")
c.Data(http.StatusOK, "application/octet-stream", data)
}
+7 -1
View File
@@ -16,6 +16,12 @@ type feedbackSubmitRequest struct {
Attachment string `json:"attachment"`
AttachmentName string `json:"attachment_name"`
Channel string `json:"channel"`
// Version is the client's app version (pkg/version / the SPA build), snapshotted so the
// operator sees which build a report came from.
Version string `json:"version"`
// BrowserTZ is the client's detected UTC offset ("±HH:MM") at submit, so the operator can
// see the filed time in the sender's local zone even before they save a profile.
BrowserTZ string `json:"browser_tz"`
}
// feedbackReplyDTO is the operator's reply shown back to the player.
@@ -61,7 +67,7 @@ func (s *Server) handleFeedbackSubmit(c *gin.Context) {
}
attachment = data
}
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, req.Version, req.BrowserTZ, clientIP(c)); err != nil {
s.abortErr(c, err)
return
}
+34
View File
@@ -55,6 +55,25 @@ func (svc *Service) ClearNudges(ctx context.Context, gameID, accountID uuid.UUID
return nil
}
// ExpireNudges marks every pending nudge in the game read, for when the game finishes: a nudge
// badge is stale once the game is over, so completion clears them all for every seat. Chat
// messages are left untouched (they stay unread). It satisfies game.NudgeExpirer and is wired
// into the completion path; failures are the caller's to log (the game has already finished).
// Unlike ClearNudges it records no publish-to-read latency — a completion is an expiry, not the
// recipient reading the nudge — so the latency metric is not skewed by games that end hours later.
func (svc *Service) ExpireNudges(ctx context.Context, gameID uuid.UUID) error {
ctx, span := svc.tracer.Start(ctx, "social.ExpireNudges",
trace.WithAttributes(attribute.String("game.id", gameID.String())))
defer span.End()
n, err := svc.store.expireNudges(ctx, gameID)
if err != nil {
span.RecordError(err)
return err
}
span.SetAttributes(attribute.Int64("expired.count", n))
return nil
}
// UnreadGames returns the set of games in which viewerID has at least one unread chat
// entry, for seeding the lobby's per-card unread badge in a single query.
func (svc *Service) UnreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
@@ -140,6 +159,21 @@ RETURNING m.created_at`
return out, rows.Err()
}
// expireNudges marks every still-unread nudge in the game read for all seats at once, used when
// the game finishes. It returns the number of nudge entries it cleared. Only 'nudge' rows are
// touched, so chat messages keep their unread bits; it returns no post times because a completion
// is an expiry, not a player reading, and must not feed the publish-to-read latency metric.
func (s *Store) expireNudges(ctx context.Context, gameID uuid.UUID) (int64, error) {
const q = `UPDATE backend.chat_messages
SET unread_seats = 0
WHERE game_id = $1 AND kind = 'nudge' AND unread_seats <> 0`
res, err := s.db.ExecContext(ctx, q, gameID)
if err != nil {
return 0, fmt.Errorf("social: expire nudges: %w", err)
}
return res.RowsAffected()
}
// unreadGames returns the games where viewerID has an unread entry, resolving their
// seat per game through the game_players join.
func (s *Store) unreadGames(ctx context.Context, viewerID uuid.UUID) (map[uuid.UUID]bool, error) {
+8
View File
@@ -47,9 +47,17 @@ AWG_CONF= # required; AmneziaWG sidecar config (the
TELEGRAM_BOT_TOKEN= # required
TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating
TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the support relay (topic per user); empty disables it
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
TELEGRAM_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
# --- VK Mini App ------------------------------------------------------------
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
GATEWAY_VK_APP_SECRET=
+3 -1
View File
@@ -150,7 +150,9 @@ Re-run `ansible/` after a host resize — it is idempotent.
workflow manually (Gitea → Actions → prod-deploy → run from `master`, input
`confirm=deploy`). It builds + pushes the images to the registry, ships the
compose/config/certs/env over SSH, deploys the main host with `prod-deploy.sh` (rolling,
health-gated, **auto-rollback to the previous tag**), then the bot host, then probes the
health-gated, **auto-rollback to the previous tag**; caddy is force-recreated on its roll so
a bind-mounted `Caddyfile` change applies — its image is pinned and admin is off, so neither a
new tag nor a hot reload would pick it up), then the bot host, then probes the
public site. After `master` is green this workflow is the **only** thing that touches
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
+14 -2
View File
@@ -1,6 +1,6 @@
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
# every operator surface under /_gm (the backend-rendered admin console and the
# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to
# Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to
# the gateway; the catch-all — notably the public landing at / — goes to the
# static landing container, so stray traffic never reaches the Go edge.
# Mirrors ../galaxy-game's /_gm model.
@@ -21,6 +21,18 @@
}
{$CADDY_SITE_ADDRESS::80} {
# HTTP/3 is advertised by default whenever this caddy terminates TLS (prod:
# CADDY_SITE_ADDRESS is the domain). But UDP/443 is never reachable — the prod
# compose maps only "443:443" (TCP) and ufw opens 443/tcp — so a client that cached
# the `Alt-Svc: h3` advert (sticky for ma=2592000s) stalls on the dead QUIC path
# before falling back to h2, which surfaced as the Telegram Mini App intermittently
# hanging on load. `Alt-Svc: clear` actively drops any cached alternative and pins
# clients to h2/h1; it is applied site-wide so every route is covered. In the test
# contour this caddy serves plain :80 (no h3 to advertise) and the host caddy
# re-stamps its own Alt-Svc, so the live test fix lives in the host caddy — here it
# is the prod fix. Background + alternatives (incl. serving h3 for real): docs/EDGE_HTTP3.md.
header Alt-Svc clear
# Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/*
handle @gm {
@@ -41,7 +53,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /scrabble.edge.v1.Gateway/*
handle @gateway {
reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot
+11
View File
@@ -23,9 +23,15 @@ services:
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# Support relay: a private forum supergroup the bot relays direct user messages
# into (one topic per user). 0/unset disables it; the bot needs admin there with
# the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
TELEGRAM_TEST_ENV: "false"
@@ -46,8 +52,13 @@ services:
GOMAXPROCS: "1"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# Support relay state (topic mapping, block list); survives redeploys.
- bot-state:/data
deploy:
resources:
limits:
cpus: "1.0"
memory: 256M
volumes:
bot-state:
+12
View File
@@ -147,6 +147,10 @@ services:
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
# Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
# the VK auth path (auth.vk is then unregistered).
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
# listeners stay on the internal network (the bot shares the VPN netns); in prod
@@ -292,6 +296,11 @@ services:
# only restricts (mutes the ineligible) — and the bot must be an admin there with the
# "Ban users" right; chat_member updates are delivered only to a chat admin.
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
# The private forum supergroup the bot relays direct user messages into (one topic
# per user) and reads operator replies from. Empty disables the support relay; when
# set the bot must be an admin there with the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data
# The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
@@ -325,6 +334,8 @@ services:
GOMAXPROCS: "1"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# Support relay state (topic mapping, block list); survives redeploys.
- bot-state:/data
deploy:
resources:
limits:
@@ -496,3 +507,4 @@ volumes:
prometheus-data:
tempo-data:
grafana-data:
bot-state:
+15 -1
View File
@@ -36,7 +36,21 @@
"type": "stat",
"title": "Database size",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"fieldConfig": {
"defaults": {
"unit": "bytes",
"color": { "mode": "thresholds" },
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 8589934592 },
{ "color": "red", "value": 17179869184 }
]
}
},
"overrides": []
},
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(pg_database_size_bytes{datname=\"scrabble\"})" }]
},
+7 -1
View File
@@ -74,7 +74,13 @@ health_running() { # health_running <container>: running, not restarting, stable
roll() { # roll <service> <health-cmd...>
local svc="$1"; shift
echo ">>> rolling $svc -> $TAG"
dc up -d --no-build --no-deps "$svc" || return 1
# caddy's image is pinned (caddy:2-alpine, no $TAG) and its Caddyfile is bind-mounted, so a
# config-only change leaves the compose definition unchanged: `up -d` treats the container as
# current and does not recreate it, and admin is off so there is no hot reload — the new
# Caddyfile would never load. Force a recreate for caddy so config changes always apply; every
# other service already recreates on its new $TAG image.
local recreate=(); [ "$svc" = caddy ] && recreate=(--force-recreate)
dc up -d --no-build --no-deps "${recreate[@]}" "$svc" || return 1
"$@" || { echo "!!! $svc failed health check"; return 1; }
echo "<<< $svc healthy"
}
+106 -25
View File
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
users, a weighted fair rotation — §10),
and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
Telegram's native **Settings** button into the in-app settings, and syncs the
device-independent display preferences (theme, reduce-motion, board labels — **not** the
interface language) across the user's Telegram devices via **CloudStorage**.
- **`platform/telegram`** — the Telegram side-service (module
`scrabble/platform/telegram`), split into two binaries that share the bot token
(**one bot**, one optional game channel, §3):
@@ -144,21 +149,34 @@ arrive from a platform rather than completing a mandatory registration).
- The gateway validates the originating credential **once** — Telegram `initData`
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified
**in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params
under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check,
as VK signing needs no API round-trip, so no side-service), an email-code login, or a guest
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4). The validator runs on the main host and never reaches the Bot
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
`vk_language`) and display name (§4; VK omits the name from the signed params, so the client
reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the
main host and never reaches the Bot API, so login does not depend on Telegram or the remote
bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's
`auth_date`), so freshness is not enforced — the minted session is the short-lived credential,
and a replay only re-authenticates the same `vk_user_id`.
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
game channel), split into a home **validator** and a remote **bot** that share the
token. `ValidateInitData` (the validator) validates `initData` against that single
token and returns only the Telegram user identity — there is no per-bot "service
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
user identity — there is no per-bot "service
language" and no supported-languages set on the wire. The bot's chat messages and
out-of-app push are
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
any bot-scoped language, and the friend-invite **share link** (and its caption) point at
that one bot. First Telegram contact seeds the new account's `preferred_language` from the
launch `language_code` (§4); the interface language is otherwise edited in Settings.
launch `language_code` (§4), but the **interface language follows the device** — the system
guess, or an explicit Settings choice saved locally — and the bot never dictates the UI.
`preferred_language` is then **reconciled to the active interface locale on every session
adopt** (not only on a Settings change; a no-op for guests and when already equal), so the
server-rendered language surfaces — this push and the ad banner — always match the UI rather
than stranding a user who never opened Settings on the creation-time seed.
- **Variant preferences (New Game gating).** Which variants a player may be matched into is a
per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
(`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
@@ -199,7 +217,7 @@ arrive from a platform rather than completing a mandatory registration).
> recipient's interface language (`preferred_language`), with no per-bot routing. New
> Game variant gating moved off the login language onto a per-user profile setting
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
> paths; an invited friend may still accept any variant). The per-bot env vars and
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile.
@@ -334,6 +352,17 @@ Key points:
- History is dictionary-independent (§9.1): the engine emits decoded
`MoveRecord`s and reconstructs the board from them with `engine.ReplayBoard`
(alphabet only, no dictionary).
- The **client mirrors this validation path for an instant move preview** (the
local eval, `ui/src/lib/dict`): the dawg reader and the
validate/score/direction slice are ported to TypeScript, byte-for-byte against
this engine and pinned by the `conformance` CI job. Composing a move is scored
on-device with no round trip. It is an **advisory accelerator only**
`submit_play` re-validates on the server, so a wrong or spoofed local result
cannot corrupt a game. The pinned per-game dictionary blob is fetched once
through a **session-gated `GET /dict/{variant}/{version}`** edge route
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
miss, storage eviction or a bad-connection breaker falls back to the network
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
## 6. Game rules
@@ -628,7 +657,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
robot instead clears when the robot answers by moving, as for a human. A seat's bit clears when that player **opens the move history or the chat**
(`POST /games/:id/chat/read`, which the client sends only when it holds unread, so a
history open is not a constant backend call), and a **nudge additionally clears when its
recipient answers by moving** (the move path calls a wired `NudgeClearer`). The mask is
recipient answers by moving** (the move path calls a wired `NudgeClearer`); and **every nudge in
a game is marked read when that game finishes** — on any completion path (a closing move, a
resignation, a forfeit or a turn-timeout, all funnelling through the shared `commit`), since the
nudge badge is stale once the game is over (a wired `NudgeExpirer`; chat messages stay unread and
this expiry records no read latency). The mask is
inverted so "anything unread" is a plain `unread_seats <> 0`, which the per-viewer
`unread_chat` game-view flag (seeding the lobby and in-game unread **dot**), the admin
unread filter and the unread gauge all use. A second per-viewer flag, **`unread_messages`**
@@ -638,9 +671,10 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
REST-seed-then-event-bump lifecycle (the live-event game-view leaves them false; a nudge
event raises only `unread_chat`, a message event raises both). The lobby additionally
**floats games with any unread entry to the top** of the your-turn and opponent-turn
sections (the finished section keeps its activity order). On each clear the publish-to-read
latency is recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
sections (the finished section keeps its activity order). On each player-driven clear the
publish-to-read latency is recorded the completion expiry records none (it is not a read);
the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
Game — §3, defaulting to Erudit only, at least one enforced) and the
@@ -649,7 +683,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
fixed `±HH:MM` **UTC offset** (or a legacy IANA name) resolved by `account.ResolveZone`
for the sweeper and the robot's sleep (a fixed offset trades DST for a simple
picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
picker), and is **seeded at account creation** from the client's detected offset — sent
on the Telegram / guest / email first-contact request — so the robot's sleep and the
away-window sweeper are anchored to the player's real zone from the first game rather
than the `UTC` default (an undetected or malformed offset keeps the default); the away
window is at most **12 h** (midnight-wrap aware). Linked platform
accounts and merge are covered in §4.
## 9. Persistence
@@ -749,7 +787,9 @@ pragmas, `8G`/`H8` coordinates, lower-case blanks, `.` pass-throughs, `-TILES`
exchanges), plus `#note` lines for resignations and timeouts, which the standard
does not cover. **GCG export is offered only on a finished game** (`game.ErrGameActive`
otherwise), so an in-progress journal is never leaked mid-play; the client
shares the `.gcg` file via the Web Share API where available, else downloads it.
shares the `.gcg` file via the Web Share API where available; an Android in-app WebView
(Telegram / VK) has no Web Share and silently ignores an `<a download>`, so there it copies the GCG
text to the clipboard instead (the payload is tiny), and a plain desktop browser downloads the file.
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
@@ -963,7 +1003,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
| Concern | Enforced by |
| --- | --- |
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
| Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
@@ -1026,14 +1066,23 @@ valid-code population). Brute-forcing a 6-digit friend code within these limits
accepted MVP risk with low blast radius (an unwanted friendship is removable/blockable);
a dedicated redeem sub-limit or a longer code is the hardening step if abuse appears.
**Client source exposure.** The production UI build ships **no sourcemaps**
(`vite.config.ts` gates `build.sourcemap` off when `mode === 'production'`), so the gateway
and landing images serve only minified JS and the full TypeScript/Svelte source is not
recoverable from a served `.map` at the edge. Dev and the `mock` e2e build (`vite build
--mode mock`) keep maps for debugging. This is surface reduction, not secrecy — the single
minified bundle still carries all platform code paths and is reversible with effort.
## 13. Deployment (informational)
Single public origin, path-routed. The Vite build has two entries: a lightweight
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
redirects to the root — the client-side guard); a stray hit on the gateway's `/`
308-redirects to `/app/`. The **landing** ships in its own static container: the
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
@@ -1042,7 +1091,7 @@ static file serving and never reaches the Go edge. Hash-named `/assets/*` are se
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/` — goes to the landing container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
@@ -1089,7 +1138,10 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
the **main host** runs the full stack (`docker-compose.yml` + `docker-compose.prod.yml`),
the **bot host** runs only the bot (`docker-compose.bot.yml`, no VPN — native Bot API
egress, telemetry off). There is no host caddy, so the contour caddy terminates TLS —
`CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. The gateway **publishes**
`CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. Caddy advertises HTTP/3 by default, but UDP/443 is not exposed (the
compose maps only TCP and ufw opens 443/tcp), so the edge emits `Alt-Svc: clear` to keep
clients on h2/h1 rather than stall on a dead QUIC path — see [`EDGE_HTTP3.md`](EDGE_HTTP3.md).
The gateway **publishes**
the bot-link `:9443`; the remote bot dials it over mTLS (certs from `PROD_BOTLINK_*`,
ServerName `gateway`, so TLS validation is independent of the public dial address), holds
no inbound port, and login is unaffected if that host or the link is down.
@@ -1149,9 +1201,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting
**channel** (telegram/ios/android/web, client-reported and validated), the **client app version**
(`__APP_VERSION__`, the build a report was sent from), the client's **detected UTC offset** at
submit (`browser_tz`, `±HH:MM`) and a snapshot of the sender's interface language are recorded. The domain is `internal/feedback` (store + service), modelled on the admin
chat-moderation surface.
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the
@@ -1159,8 +1213,11 @@ rate limit — there is no separate per-user feedback limiter.
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
(user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
delete / delete-all actions — each marks the message read; merely opening the detail does not.
(user content rendered as auto-escaped `html/template` text; it shows the channel, interface
language and app version, and the filed time in three zones — UTC, the browser offset detected at
submit, and the sender's saved profile zone, each `N/A` when not known), and the read /
reply / archive / delete / delete-all actions — each marks the message read; merely opening the
detail does not.
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
@@ -1182,3 +1239,27 @@ blocks **only** feedback submission (unlike a suspension, the whole-account bloc
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
migration.
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct
support channel for users who message it on Telegram. Any message other than `/start` is relayed
into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a
dedicated **forum topic** whose first message is an info card (the name is a tappable profile
mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
button; every message is then
copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over).
Any **administrator** of the support chat who writes in a user's topic has their message copied
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
user's incoming messages (the topic stays); Clear deletes the relayed messages, keeping the info
card; a topic the operators delete is reopened on the next message. State (user→topic map, block
list, relayed message ids) is a small JSON file on a writable volume — the bot host has no database
and cannot reach Postgres. The relay is **bot-local**: it touches neither the backend, the gateway,
nor `feedback_messages`. The bot must be an administrator in the forum group with the manage-topics
and delete-messages rights.
> **Decision (2026-06-23) — bot-local support relay over forum topics.** The direct Telegram
> support channel lives entirely in the bot (no backend, no bot-link command), because the bot host
> has no database. One forum **topic per user** (not a reply-to-header thread) gives native per-user
> separation and survives message deletion; the topic id, not a fragile header message, anchors the
> mapping. Operators are the support chat's administrators (no separate owner id); messages relay
> both ways with `copyMessage`. State persists as JSON on a dedicated volume.
+111
View File
@@ -0,0 +1,111 @@
# Edge HTTP/3 (`Alt-Svc`) policy
## TL;DR
The edge **advertises HTTP/3 but does not actually serve it** (UDP/443 is not exposed),
so we suppress the advert with `Alt-Svc: clear`. Advertising QUIC on `:443/udp` while
that port is unreachable makes clients — notably the Telegram Mini App webview — stall
on a dead QUIC connection before falling back to h2, which shows up as the app "hanging
on load".
## Symptom
Opening the Mini App intermittently hangs on load: from a barely-noticeable pause to
several seconds, sometimes a blank window that never finishes downloading `index.html`.
Intermittent, worse after the first successful visit, reproduced on both the test
contour and prod.
## Root cause
Caddy enables HTTP/3 by default on any TLS listener and emits
`Alt-Svc: h3=":443"; ma=2592000` — telling every client "reach me over QUIC/UDP 443"
and to cache that for 30 days. But UDP/443 is **never reachable end to end**:
- **Test contour**: the host caddy publishes only `:443/tcp` (`docker port caddy` shows
no `udp`); QUIC packets from the internet are dropped.
- **Prod**: `deploy/docker-compose.prod.yml` maps `"443:443"` (Docker = **TCP only**)
and `deploy/ansible/roles/main/tasks/main.yml` opens 443 `proto: tcp`. UDP/443 is
dropped at both the publish and the firewall.
Caddy *does* bind `udp/443` inside the container and h3 works container-to-container
(verified `http=3 code=200`), so the listener is healthy — it is simply not exposed.
A client that cached the advert tries QUIC first on later opens, gets no response, and
waits for the QUIC attempt to time out before falling back to TCP/h2. That wait is the
stall. The very first visit (no cached `Alt-Svc`) uses h2 and is fast.
The h2/TCP serving path itself is healthy: 30 fresh-TLS requests through the full path
(host caddy -> contour caddy -> gateway) measured TTFB ~9.5 ms, total ~9.8 ms, no tail;
`index.html` is ~1 KB.
## Fix in place (option A — suppress the advert)
Emit `Alt-Svc: clear`, which actively drops any cached alternative (better than merely
deleting the header, which leaves the sticky 30-day cache in place):
- **Prod / repo**: `deploy/caddy/Caddyfile` — a site-level `header Alt-Svc clear` (this
caddy terminates TLS in prod).
- **Test contour**: the host caddy terminates TLS, so the fix lives there (homelab
config, outside this repo): `header Alt-Svc clear` on the `scrabble.*` site. The
in-compose caddy serves plain `:80` in test and never advertises h3, so the repo
directive is a harmless no-op there (the host caddy re-stamps the header).
`header Alt-Svc clear` overrides Caddy's auto-advert (verified) and is site-scoped.
### Verify
The runner/prod host shell cannot reach the Docker bridge IPs directly, so probe from a
container on the relevant network, using `--resolve` to hit the TLS-terminating caddy by
its bridge IP (this also bypasses the public-IP NAT hairpin):
```sh
# <edge-ip> = the TLS-terminating caddy's IP on its network (docker inspect ... )
docker run --rm --network edge curlimages/curl:latest -sS -D - -o /dev/null \
--resolve <host>:443:<edge-ip> https://<host>/telegram/ | grep -iE '^HTTP|^alt-svc'
# expect: HTTP/2 200, and NO `alt-svc: h3=...` (the header is absent or `alt-svc: clear`)
```
## If it recurs — alternatives to try
So we do not re-derive the diagnosis from scratch:
1. **Re-confirm the advert is actually suppressed** with the verify command above. A
redeploy or a Caddy upgrade could regress it, or a client may still hold a cached
`h3` entry that has not yet been replaced by a `clear` (it needs one successful h2
response to receive the `clear`).
2. **Option B — serve HTTP/3 for real** instead of suppressing it. Worth it only if we
actually want QUIC (the benefit is marginal for a ~1 KB shell plus hash-immutable
cached assets, and it adds UDP/QUIC attack surface):
- Publish UDP: add `"443:443/udp"` next to the TCP map in
`deploy/docker-compose.prod.yml` (and publish udp/443 on the test host caddy too).
- Open the firewall: add a `443 proto: udp` rule in
`deploy/ansible/roles/main/tasks/main.yml`.
- Drop the `header Alt-Svc clear` so Caddy advertises h3 again.
- Verify with an h3 client from inside the network:
`docker run --rm --network edge ymuski/curl-http3 curl --http3-only ...` should
return `http=3 code=200`.
3. **Look past the edge** if the advert is suppressed and stalls persist. The h2 path is
fast server-side, so a remaining stall is most likely the client network / RTT / the
provider, not our stack. Re-run the timing loop (below) to confirm the server is
still <~10 ms TTFB before chasing the client side.
## How this was diagnosed (method, to repeat)
- The runner/prod host shell cannot reach the Docker bridge subnets, so all probing runs
from a throwaway container on the target network (`docker run --network <net>
curlimages/curl`), using `--resolve <host>:443:<edge-ip>` to bypass the public-IP NAT
hairpin and exercise the real TLS path.
- Compare a fresh-connection timing loop (worst case, full TLS each time) against a
keepalive batch to separate handshake cost from serving cost:
```sh
docker run --rm --network edge curlimages/curl:latest sh -c '
for i in $(seq 1 30); do
curl -sS -o /dev/null --resolve <host>:443:<edge-ip> \
-w "http=%{http_version} code=%{http_code} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
https://<host>/telegram/
done'
```
- `docker port <caddy>` shows whether `udp/443` is actually published; the response
`Alt-Svc` header shows what the edge advertises. The two disagreeing is the bug.
+57 -11
View File
@@ -22,18 +22,39 @@ costs nothing when the rack has no legal move. The word-check accepts only the
variant's alphabet, remembers answers within the session and rate-limits repeats.
A public **landing page** at the site root introduces the game, switches language and
theme, and links to the matching per-language Telegram channel; the game itself runs at
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
(it follows the system scheme, not the saved preference); its language choice is saved.
### First-run onboarding
The first time a player opens the app it walks them through the interface with a light
**coachmark overlay**: a dimmed layer draws one hint bubble at a time, each pointing — with a
tail — at a real control, and a **tap anywhere** advances to the next; after the last hint the
overlay is gone for good. Two independent series run. The **lobby** series points at the
⚙️ settings, ✏️ statistics and 🎲 new-game tabs. The **game** series, on the player's first game
board, points at the scoreboard header (move history / chat / word-check), the 🔄 pass-and-exchange,
🛟 hints and 🔀 shuffle controls, and the rack. A series counts as seen only **after its last hint**,
so leaving mid-way replays it from the start next time; it is remembered per device. Arriving at the
lobby is the lobby trigger, so a player who deep-links straight into Settings → Friends still gets the
lobby walk-through on their first trip back. Both series work the same in portrait and landscape (the
bubbles re-anchor to wherever the controls move) and the hint texts are localized (en/ru). The
advertising banner hides while the overlay is up and returns when it closes.
### Identity & sessions
A player arrives from a platform (Telegram first), via email login, or as an
ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
App** launch authenticates from the platform's signed `initData`, themes the UI to
the Telegram colours, and — on first contact — seeds the new account's interface
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
the device safe-area, and — on first contact — seeds the new account's interface
language from the Telegram client. If a launch cannot reach the backend (for example during a
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters
(verified by the gateway), and on first contact seeds the new account's interface language from
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
the name in the signed launch). While the theme preference is "auto" the app follows the VK
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
"couldn't load" screen applies inside VK.
Telegram runs a **single bot**: every player uses
the same bot, and all of its chat and out-of-app notifications are written in the
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
@@ -120,7 +141,8 @@ and any incidental perpendicular words are ignored and not scored — while on i
standard Scrabble. English games are always standard and show no such toggle. In auto-match
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (24) are
formed by inviting players from the friend list (an invitation, like a friend code,
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
is shareable as a Telegram deep link that opens it directly — on VK, which forwards no payload to the
Mini App, the link only opens the app and the recipient enters the copied code by hand): the inviter chooses the
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days.
@@ -141,7 +163,10 @@ tile that extends
an existing word (down a column or across a row) is accepted. A play is validated
against the game's dictionary at submit time and scored; an unlimited preview
reports the word(s) a tentative move would form and its score, or that it is not
legal, and the move is offered for submission only once it is confirmed legal. The dictionary check tool is
legal, and the move is offered for submission only once it is confirmed legal. The preview is
computed **on-device** for an instant response once the game's dictionary has loaded — a
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
local dictionary is unavailable. The dictionary check tool is
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
English) to look it up. Hints are governed per game —
@@ -213,6 +238,9 @@ block **overrides but does not delete** an existing friendship (so you may block
they keep seeing you as one); active games are never interrupted — you can finish them, with
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
On Settings → Friends each friend is a one-line row whose right-hand kebab (⋮) slides open
**block 🚫** and **remove ✖️** icon actions, and each action is gated by a confirmation
that names the friend (*Block this player?* / *Remove from friends?*).
Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game
(struck name, hidden composer) and lists the blocked opponent under the name you saw, but is
recorded only against that game — the disguise holds, the shared robot is never globally
@@ -235,22 +263,28 @@ entry — a message **or a nudge** from an opponent — raises a small **dot** n
in the **lobby** and on the game's **score bar**, **red when an unread message is waiting and a
softer amber when only nudges are**. Opening the **move history** counts as reading
the chat, even without entering it: the dot clears and the 💬 icon **fade-blinks twice**. A nudge
also clears the moment its recipient **takes their move**.
also clears the moment its recipient **takes their move**, and **all of a game's nudges clear once
the game finishes** (the badge is stale once the game is over) — but unread chat messages stay unread.
### Profile & settings
Edit the display name (letters joined by a single space / "." / "_" separator, with an
optional trailing "." or a trailing run of up to five digits, up to 32 characters and at most
5 special characters — the "." / "_" punctuation, spaces and digits aside), the timezone
(chosen as a UTC offset), the
(chosen as a UTC offset, and pre-filled from your device's detected offset when the account
is first created — so robot games are timed correctly before you ever open this form), the
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
block toggles. The profile form is edited inline (no separate edit mode). Linking
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge".
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your
Telegram devices.
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
least one** selected. This list is exactly what **New Game** offers when you start a game
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
**at least one** selected. This list is exactly what **New Game** offers when you start a game
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
invited friend may accept an invitation in **any** variant, and you can always open and play
@@ -268,12 +302,22 @@ marked read once the screen shows it and disappears a week later. A badge on the
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
barred from feedback (a role, not a full account block) sees the send control disabled.
### Telegram support chat
A user can also reach the operators straight from the Telegram bot: anything they send the bot
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
private support group, grouped into a per-user thread. The user sees no automatic reply; an
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
Feedback above — it serves people who write to the bot directly rather than through the app.
### History & statistics
Finished games are archived in a dictionary-independent form and exportable to
GCG; the export is offered **only once a game is finished**, and never for an
honest-AI practice game (a live game's export would leak the move journal; an AI
game is throwaway). The client shares the `.gcg` file where the platform supports
it, otherwise downloads it. Statistics (durable accounts only):
it; on an Android in-app client (Telegram / VK), which has neither Web Share nor a working file
download, it copies the GCG to the clipboard (with a confirming toast); otherwise it downloads the file. Statistics (durable accounts only):
wins, losses, draws, max points in a game, and max points for a single move (the
best play, which already includes every word it formed plus the all-tiles bonus). It
also shows the player's **move count** (their plays — passes and exchanges do not
@@ -346,7 +390,9 @@ over-grant cannot be reversed there.
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
**unread / read / archived** with per-user search, each shown with its sender, source, channel
(with the bot language — en/ru — for a Telegram message), the sender's interface
language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
language, the **app version** it was sent from, IP, the filed time (in three zones — UTC, the
browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) and
any attachment. The operator can mark a message read, **reply** to the player (delivered
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
+61 -14
View File
@@ -23,19 +23,42 @@ top-1 подсказку, безлимитную проверку слова с
и ограничивает частоту повторов.
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
системной настройки, а не из сохранённой), выбор языка сохраняется.
### Первый запуск: онбординг
При первом открытии приложения игрока один раз проводят по интерфейсу лёгким
**coachmark-оверлеем**: затемнённый слой показывает по одной подсказке-«облачку» за раз, каждое
«хвостиком» указывает на реальный элемент управления, а **тап в любом месте** переходит к
следующей; после последней подсказки оверлей убирается навсегда. Серий две. Серия **лобби**
указывает на вкладки ⚙️ настроек, ✏️ статистики и 🎲 новой игры. Серия **игры**, на первой партии
игрока, указывает на шапку со счётом (история ходов / чат / проверка слова), кнопки 🔄 пас-и-обмен,
🛟 подсказок и 🔀 перемешивания, и на стойку с фишками. Серия считается просмотренной только
**после последней подсказки**, поэтому выход на середине в следующий раз покажет её с начала;
запоминается она по устройству. Триггер серии лобби — попадание в лобби, так что игрок, пришедший
по deeplink сразу в Настройки → Друзья, всё равно получит проводку по лобби при первом возврате.
Обе серии одинаково работают в portrait и landscape (облачка переустанавливаются туда, куда
переезжают элементы), тексты подсказок локализованы (en/ru). Рекламный баннер скрывается, пока
оверлей показан, и возвращается по закрытию.
### Личность и сессии
Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
интерфейса нового аккаунта по
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
@@ -125,7 +148,8 @@ _Вход сейчас только через провайдера, поэто
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
кто выбрал то же правило. Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
можно отправить deep-link'ом в Telegram, который откроет его сразу — на VK, который не передаёт Mini App
никакой нагрузки, ссылка лишь открывает приложение, а скопированный код получатель вводит вручную): инициатор
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней.
@@ -147,7 +171,9 @@ _Вход сейчас только через провайдера, поэто
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Инструмент проверки слова безлимитный и
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
чтобы его посмотреть. Подсказки управляются настройками
@@ -218,6 +244,9 @@ _Вход сейчас только через провайдера, поэто
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
@@ -242,21 +271,28 @@ _Вход сейчас только через провайдера, поэто
**лобби** и на **строке счёта** партии, **красную при непрочитанном сообщении и мягкую жёлтую,
когда непрочитаны только nudge'и**. Открытие **истории ходов** считается прочтением чата, даже
без захода в него: точка гаснет, а значок 💬 **дважды мигает**. Nudge также гаснет в момент, когда
его получатель **делает ход**.
его получатель **делает ход**, и **все nudge'и партии гаснут по её завершении** (после конца
партии бейдж неактуален) — но непрочитанные сообщения чата остаются непрочитанными.
### Профиль и настройки
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
переходом через полночь) и переключателей блокировок. Форма профиля редактируется
UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
@@ -274,12 +310,22 @@ UTC), суточного окна отсутствия (away; сетка по 10
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
### Чат поддержки в Telegram
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
канал для тех, кто пишет боту напрямую, а не через приложение.
### История и статистика
Завершённые партии архивируются в независимом от словаря виде и экспортируются
в GCG; экспорт доступен **только после завершения партии** и никогда — для
тренировочной партии с ИИ (экспорт идущей партии раскрыл бы журнал ходов, а партия
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает,
иначе скачивает его. Статистика (только у постоянных аккаунтов):
с ИИ одноразовая). Клиент делится файлом `.gcg` там, где платформа это поддерживает;
в Android-приложении (Telegram / VK), где нет ни Web Share, ни рабочей загрузки файла, копирует GCG
в буфер обмена (с подтверждающим тостом); иначе скачивает файл. Статистика (только у постоянных аккаунтов):
победы, поражения, ничьи, макс. очков за партию и макс. очков за один ход (лучший
ход, уже включающий все образованные им слова и бонус за все фишки). Также
показываются **число ходов** игрока (его выкладки — пасы и обмены не считаются) и
@@ -356,7 +402,8 @@ high-rate флага. С карточки пользователя операт
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера
на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
+10 -1
View File
@@ -17,10 +17,19 @@ tests or touching CI.
- **UI** — Vitest (unit) + Playwright
(e2e), mirroring the chosen plain-Svelte + Vite toolchain. Vitest covers
the FlatBuffers codecs (friend list, invitation, stats), the win-rate
derivation and the GCG share/download choice, plus Playwright specs against the
derivation and the GCG share/copy/download choice, plus Playwright specs against the
mock for the friends screen (code issue/redeem, accept a request), the lobby
invitations section, the stats screen, profile editing, and the GCG export's
finished-only visibility.
- **Local-eval conformance** — the client's on-device move preview (the ported
dawg reader + validator, `ui/src/lib/dict`) is checked byte-for-byte against the
authoritative Go engine. `backend/cmd/dictgen` and `backend/cmd/validategen` emit
golden vectors from the release dictionaries — every stored word plus a battery of
plays across both cross-word rules and all variants, each with the engine's
legality, score, words and inferred direction. The gated Vitest suites
(`ui/src/lib/dict/*.parity.test.ts`, skipped without their `DICT_*` env) replay
them and must agree exactly; the `conformance` CI job runs the whole loop, so a
drift in the port fails the merge.
- **Engine** — correctness of scoring and move generation is owned
by `scrabble-solver`'s own GCG-backed tests. `backend/internal/engine` adds, on
top of the embedded solver: per-variant smoke tests (load all three committed
+86 -13
View File
@@ -8,7 +8,13 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App
launch that delivered none, as seen on some Android clients) the app renders a compact,
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
never load it.
## Layout shell (`components/Screen.svelte`)
@@ -43,6 +49,32 @@ fast load shows it for ~1.25 s. Each tile **drops in** with a brief scale + fade
dismisses as soon as the lobby is ready. The pure layout and timing live in `lib/splash.ts`
(unit-tested); `Splash.svelte` is the renderer.
## First-run onboarding coachmarks (`components/Coachmark.svelte`, `lib/coachmark.ts`)
A one-time **coachmark overlay** introduces the interface to a new player. Like the splash it is an
**App-level overlay**; it runs two independent series chosen by the route — **lobby** (⚙️ settings →
✏️ stats → 🎲 new game) and **game** (scoreboard header → 🔄 pass/exchange → 🛟 hints → 🔀 shuffle →
rack). It draws **one bubble at a time** over a light scrim (`rgba(0,0,0,0.32)`); the whole layer
captures pointer events, so a **tap anywhere advances** and the UI underneath stays inert. After the
last hint the series is recorded done (`session.ts` `onboarding` key) and the overlay removes itself;
a series is marked only after its **last** hint, so an interrupted player replays it from the start.
The lobby series gates on `app.splashDone`, the game series on its first target laying out; both defer
while a modal (`welcomeRedeem` / `staleInvite`) is open.
Each target carries a **`data-coach` attribute**, so the **same positioning engine anchors it in both
orientations** wherever the layout moves it (the tab bar to the landscape left panel, etc.). The
bubble points at the live `getBoundingClientRect()` of its target, **re-measuring each frame until the
geometry settles** (the route-change slide, the hidden-banner reflow, async fonts) and on resize /
rotation; a target absent in the current state is skipped. The bubble matches the in-game counter text
size (`0.85rem`, larger in landscape), wraps by word and never fills the width; its **tail** sits on
the edge facing the target, centred on it (clamped near a corner). The pure geometry (step lists,
`nextVisibleStep`, `placeBubble`) lives in `lib/coachmark.ts` (unit-tested); `Coachmark.svelte` is the
renderer. While the overlay is up the **advertising banner is hidden** (`app.coachActive`) so its
scroll does not run behind the scrim. The hidden DebugPanel offers a **Reset visited** control that
clears the flags so the walk-through replays on the next launch. In the **mock build** the overlay
does not auto-show (so it cannot block the Playwright smoke); `?coach` forces it on for the dedicated
e2e and the screenshots.
## Navigation
- **Back**: a thin, compact `<` drawn from two rotated CSS borders (`Header.svelte`
@@ -68,7 +100,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
square with an accent underline). A red count **badge** rides the icon's corner — on the
lobby ⚙️ tab and the hub's 🤝 Friends tab for pending incoming friend requests (invitations
keep their own lobby section), and on the Hint tab for the remaining count. No text
selection on nav / tab-bar / buttons (`user-select: none`).
selection on nav / tab-bar / buttons (`user-select: none`), and no tap-highlight flash on any
tappable element (`-webkit-tap-highlight-color: transparent` on `#app`) — Android in-app WebViews
otherwise draw a momentary selection-like box on a clickable node on tap (seen on the header title
and the back chevron).
- **Screen transitions** (`App.svelte`): navigation slides directionally — a
screen entered from the lobby flies in from the right; returning to the lobby reveals it
from the left (back). Transitions are local (so they do not play on first load) and
@@ -91,20 +126,38 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
native header **BackButton** drives back-navigation (the app's chevron is hidden in
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window;
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
auto-save); **vertical swipes** (swipe-to-minimise)
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
app's **own back chevron** (Header) drives back-navigation on every platform — the native
Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
**HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
the native header (and its BackButton) and the Android system swipe-back then minimised the app,
so it stays windowed with Telegram's thin native header (close) above the app's own header; move
drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
the board scroll; **external links** (the word-check
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
confirmation a plain `target=_blank` triggers; and a live stream dropped
by a background suspend reconnects silently on return — the connection banner is
suppressed while hidden and for a short grace after resume (visibilitychange +
pageshow/pagehide + Telegram `activated`/`deactivated`).
- **VK integration** (`lib/vk.ts`): inside the VK Mini App the **auto** theme follows the VK
client's light/dark (`VKWebAppUpdateConfig`), as the VK webview's `prefers-color-scheme` does not
track it; explicit light/dark prefs still win. The device safe-area comes from CSS
`env(safe-area-inset-*)` (the meta already sets `viewport-fit=cover`) **max'd** with the VK bridge
insets (`VKWebAppUpdateInsets`, needed on Android, where the VK webview exposes no `env()` inset),
so the layout clears the VK home bar. Share and copy route through the bridge (`VKWebAppShare` /
`VKWebAppCopyText`) because `navigator.share` / `clipboard` are absent in the desktop VK iframe.
Haptics fire the same set as Telegram via VK Bridge taptic (`VKWebAppTapticImpactOccurred` /
`…NotificationOccurred` / `…SelectionChanged`), through the shared `lib/haptics.ts` dispatcher; and
VK's **horizontal swipe-back** is disabled at launch (`VKWebAppSetSwipeSettings`) so it does not
fight the app's own edge-swipe-back and tile drag — parity with Telegram's disabled vertical
swipes, the app owning navigation through its back chevron. VK's **status bar** (and, on Android,
the action / navigation bars) is painted to the app theme via `VKWebAppSetViewSettings` on launch
and every theme change — parity with the Telegram chrome painting (`syncVKChrome` mirrors
`syncTelegramChrome`); the status-bar appearance follows the `--bg` token's luminance
(`appearanceForBg`).
## Tiles & board
@@ -129,7 +182,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
that once retired this gesture) — and it is suppressed on the zoomed board, where the
one-finger drag pans. History also opens on a **tap of the score bar** and closes on a tap or
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
opens on a **tap of the score bar** and closes on a tap or
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
hint's placement, not
the top-left.
@@ -235,7 +291,8 @@ the surroundings in the light theme and a touch lighter in the dark theme, mappe
linkified). It is **server-driven**: the campaigns and display timings ride the `profile.get`
response (`app.profile.banner`, present only for an eligible viewer — see ARCHITECTURE §10), so
`Header` renders the strip only when that block is present, and a `notify` `banner` event re-fetches
the profile to show or hide it in place.
the profile to show or hide it in place. It is also hidden while a first-run onboarding overlay is up
(`app.coachActive`), reappearing by this same condition once the overlay closes.
The rotation runs in a **persistent module engine** (`lib/bannerEngine`): the scheduler and timer
live outside the components, so navigating between screens (which remounts the view) **continues the
@@ -349,7 +406,9 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
Safari.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
(the word(s) and the move score, no running total); *Export GCG* (the 📤 in the history
header) shares or downloads the `.gcg` file and appears only once the game is finished — and
header) delivers the `.gcg` file — Web Share where available, a clipboard copy in an Android in-app
WebView (Telegram / VK, which has neither Web Share nor a working download), else a Blob download —
and appears only once the game is finished — and
never in an honest-AI game (throwaway practice). Confirming a resign reveals the full board:
it closes the history drawer (portrait) and zooms the board out.
- **Finished game**: the board keeps no last-word highlight and no zoom; the history header
@@ -368,6 +427,20 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
raises a badge on the lobby ⚙️ tab (combined with the friend-request count) and a "1" on the
Info tab.
## Dictionary warm-up overlay (`components/DictWarmup.svelte`)
Opening a game whose local move-preview dictionary is not yet cached shows a brief,
non-dismissable warm-up overlay while it downloads (a returning player's cached
dictionary loads from IndexedDB in a few ms, so the flash-guard suppresses it then).
A darker-than-onboarding scrim covers
the board; centred on it, a large emoji cycles through a fixed playful sequence — one
per 500 ms tick (300 ms still, then a 200 ms clockwise spin with the current glyph
fading out and the next fading in), looping — under a constant "Loading…" caption.
Reduce-motion drops the spin to a plain cross-fade. A ~120 ms flash-guard suppresses
the overlay for a disk-cached dictionary that loads in a few ms; a cold (network) load
shows it until the dictionary is ready or a 5 s cap elapses, after which the preview
falls back to the network. See docs/ARCHITECTURE.md §5 (the local eval).
## Caveat
Emoji are rendered by the platform's system emoji font, so their exact look varies across
+11 -3
View File
@@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
backend over REST/JSON, and bridges the backend's gRPC push stream to each
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
in the deployed contour the front caddy owns `/_gm` (see
@@ -29,7 +29,7 @@ internal/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c (+ the in-memory active_users gauge)
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
```
The FlatBuffers payloads and the backend push proto are the shared wire
@@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
is unset the bot channel (out-of-app push + admin relay) is disabled.
The message-type catalog: `auth.telegram`, `auth.guest`,
`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the
signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) —
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
(`auth.vk` is unregistered).
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
live events
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
+2 -1
View File
@@ -190,10 +190,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
}
registry := transcode.NewRegistry(backend, validator)
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
Backend: backend,
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
+40 -8
View File
@@ -184,8 +184,10 @@ type ChatResp struct {
}
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
// brand-new account's display name and language from the validated launch fields.
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
// brand-new account's display name and language from the validated launch fields, its
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
// validated launch deep-link startParam, its variant preferences (first contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{
@@ -193,6 +195,25 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"language_code": languageCode,
"username": username,
"first_name": firstName,
"browser_tz": browserTz,
"start_param": startParam,
}, &out)
return out, err
}
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
// account's preferred language from languageCode (the vk_language hint), its display
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
// name from the signed launch params) and its time zone from browserTz (the client's
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
map[string]string{
"external_id": externalID,
"language_code": languageCode,
"display_name": displayName,
"browser_tz": browserTz,
}, &out)
return out, err
}
@@ -243,17 +264,21 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
return out, err
}
// GuestAuth provisions a guest account and mints a session.
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
// GuestAuth provisions a guest account and mints a session, seeding its time zone
// from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out)
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "",
map[string]string{"browser_tz": browserTz}, &out)
return out, err
}
// EmailRequest asks the backend to mail a login code.
func (c *Client) EmailRequest(ctx context.Context, email string) error {
// EmailRequest asks the backend to mail a login code, provisioning the account on
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new
// account's time zone, since the email account is created here, not at login.
func (c *Client) EmailRequest(ctx context.Context, email, browserTz string) error {
return c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/email/request", "", "",
map[string]string{"email": email}, nil)
map[string]string{"email": email, "browser_tz": browserTz}, nil)
}
// EmailLogin verifies a login code and mints a session.
@@ -458,6 +483,13 @@ func (c *Client) Evaluate(ctx context.Context, userID, gameID string, tiles []Pl
return out, err
}
// DictBytes fetches the raw serialized dictionary for a (variant, version) pair,
// backing the client-side local move preview. It returns the blob and the
// backend's Cache-Control header, forwarded so the immutable blob is cached hard.
func (c *Client) DictBytes(ctx context.Context, userID, variant, version string) ([]byte, string, error) {
return c.getRaw(ctx, "/api/v1/user/dict/"+url.PathEscape(variant)+"/"+url.PathEscape(version), userID, "Cache-Control")
}
// CheckWord looks a word up in the game's pinned dictionary. The word is carried as
// repeated ?idx= alphabet indices; the backend echoes the decoded concrete word.
func (c *Client) CheckWord(ctx context.Context, userID, gameID string, word []int) (WordCheckResp, error) {
@@ -27,12 +27,14 @@ type FeedbackUnreadResp struct {
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, version, browserTz, clientIP string) error {
payload := map[string]string{
"body": body,
"attachment": "",
"attachment_name": attachmentName,
"channel": channel,
"version": version,
"browser_tz": browserTz,
}
if len(attachment) > 0 {
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
+28
View File
@@ -125,6 +125,34 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
return nil
}
// getRaw performs one REST GET, returning the raw (un-decoded) response body and
// the value of respHeader (e.g. Cache-Control). userID, when non-empty, is
// forwarded as X-User-ID. A non-2xx response is returned as an *APIError. Unlike
// do it does not JSON-decode, so it carries a binary payload such as a dictionary
// blob.
func (c *Client) getRaw(ctx context.Context, path, userID, respHeader string) ([]byte, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, "", fmt.Errorf("backendclient: new request: %w", err)
}
if userID != "" {
req.Header.Set("X-User-ID", userID)
}
resp, err := c.http.Do(req)
if err != nil {
return nil, "", fmt.Errorf("backendclient: GET %s: %w", path, err)
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("backendclient: read response: %w", err)
}
if resp.StatusCode >= http.StatusMultipleChoices {
return nil, "", parseAPIError(resp.StatusCode, data)
}
return data, resp.Header.Get(respHeader), nil
}
// parseAPIError extracts the backend's {error:{code,message}} envelope.
func parseAPIError(status int, data []byte) *APIError {
var env struct {
+5
View File
@@ -32,6 +32,10 @@ type Config struct {
// plaintext, internal). The gateway calls it to validate Mini App initData and
// Login Widget data. Empty disables the telegram auth path.
ValidatorAddr string
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
VKAppSecret string
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig
@@ -169,6 +173,7 @@ func Load() (Config, error) {
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
+86 -8
View File
@@ -68,6 +68,7 @@ const (
type Server struct {
registry *transcode.Registry
sessions *session.Cache
backend *backendclient.Client
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
@@ -91,7 +92,10 @@ type Server struct {
type Deps struct {
Registry *transcode.Registry
Sessions *session.Cache
Limiter *ratelimit.Limiter
// Backend is the REST client backing the session-gated /dict blob route; a nil
// value disables that route (it 404s).
Backend *backendclient.Client
Limiter *ratelimit.Limiter
// Tracker accumulates limiter rejections for the periodic report; nil
// selects a private tracker (rejections are then only counted, never
// reported).
@@ -142,6 +146,7 @@ func NewServer(d Deps) *Server {
return &Server{
registry: d.Registry,
sessions: d.Sessions,
backend: d.Backend,
limiter: limiter,
tracker: tracker,
banlist: banlist,
@@ -183,14 +188,18 @@ func (s *Server) HTTPHandler() http.Handler {
// does not serve the app shell at the operator path.
mux.Handle("/_gm/", http.NotFoundHandler())
}
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
// each mount falls back to the app shell (index.html) for the hash router. The
// public landing lives in its own static container behind the contour caddy,
// so the catch-all redirects a stray root hit to the app shell — which
// keeps a local no-caddy run usable.
// The client-side local move preview pulls each game's pinned dictionary blob
// through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler())
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
// priority, and each mount falls back to the app shell (index.html) for the hash
// router. The public landing lives in its own static container behind the contour
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
// local no-caddy run usable.
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
@@ -396,6 +405,75 @@ func (s *Server) limitAdmin(next http.Handler) http.Handler {
})
}
// dictBytesHandler serves the raw dictionary blob for a (variant, version) pair to
// a signed-in client (the local move preview), proxying the backend's authed
// endpoint. It is session-gated — not public — bounds the download with the
// user-class limiter, and forwards the backend's immutable Cache-Control so the
// browser caches the blob hard. Only GET is allowed; the path is
// /dict/{variant}/{version}.
func (s *Server) dictBytesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("user:"+ip, s.userPolicy) {
s.noteRateLimited(r.Context(), classUser, ip, "dict")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
uid, _, err := s.resolve(r.Context(), r.Header, ip)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
variant, version, ok := parseDictPath(r.URL.Path)
if !ok {
http.NotFound(w, r)
return
}
data, cacheControl, err := s.backend.DictBytes(r.Context(), uid, variant, version)
if err != nil {
var apiErr *backendclient.APIError
if errors.As(err, &apiErr) && apiErr.Status < http.StatusInternalServerError {
http.NotFound(w, r) // unknown variant or version
return
}
s.log.Warn("dict fetch failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
if cacheControl != "" {
w.Header().Set("Cache-Control", cacheControl)
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(data)
})
}
// parseDictPath splits /dict/{variant}/{version}; both segments must be present
// and non-empty, and the version must not contain a further slash.
func parseDictPath(p string) (variant, version string, ok bool) {
rest, found := strings.CutPrefix(p, "/dict/")
if !found {
return "", "", false
}
i := strings.IndexByte(rest, '/')
if i <= 0 || i == len(rest)-1 {
return "", "", false
}
variant, version = rest[:i], rest[i+1:]
if strings.Contains(version, "/") {
return "", "", false
}
return variant, version, true
}
// resolve extracts and resolves the Authorization bearer token to an account id
// and its guest flag, returning a Connect Unauthenticated error when it is missing
// or unknown.
+67 -8
View File
@@ -9,15 +9,18 @@ import (
"context"
"encoding/json"
"errors"
"net/url"
"scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector"
"scrabble/gateway/internal/vkauth"
fb "scrabble/pkg/fbs/scrabblefb"
)
// Message types in the vertical slice.
const (
MsgAuthTelegram = "auth.telegram"
MsgAuthVK = "auth.vk"
MsgAuthGuest = "auth.guest"
MsgAuthEmailReq = "auth.email.request"
MsgAuthEmailLogin = "auth.email.login"
@@ -88,8 +91,9 @@ type TelegramValidator interface {
// NewRegistry builds the slice's message-type catalog over the backend client.
// The Telegram auth op is registered only when a validator is supplied (the
// connector is configured); otherwise auth.telegram is simply unknown.
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
// (e.g. WithVKAuth) are applied last from opts.
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
r := &Registry{ops: make(map[string]Op)}
if tg != nil {
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
@@ -125,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend)
registerLinkOps(r, backend, tg)
for _, opt := range opts {
opt(r, backend)
}
return r
}
// Option configures an optional registry operation at construction. It is kept out of
// NewRegistry's positional signature so existing call sites stay unaffected.
type Option func(r *Registry, backend *backendclient.Client)
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
// the op is simply unknown wherever VK is not configured.
func WithVKAuth(secret string) Option {
return func(r *Registry, backend *backendclient.Client) {
if secret != "" {
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
}
}
}
// Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType]
@@ -145,6 +167,9 @@ func DomainCode(err error) (string, bool) {
if errors.Is(err, connector.ErrInvalidInitData) {
return "invalid_init_data", true
}
if errors.Is(err, vkauth.ErrInvalid) {
return "invalid_vk_params", true
}
if errors.Is(err, connector.ErrInvalidLoginWidget) {
return "invalid_login_widget", true
}
@@ -154,11 +179,38 @@ func DomainCode(err error) (string, bool) {
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
user, err := tg.ValidateInitData(ctx, string(in.InitData()))
initData := string(in.InitData())
user, err := tg.ValidateInitData(ctx, initData)
if err != nil {
return nil, err
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName)
// start_param rides inside the signed initData validated just above, so the launch
// deep-link payload can be trusted here without a separate wire field; the backend
// uses it to seed a brand-new account's variant preferences.
startParam := ""
if q, perr := url.ParseQuery(initData); perr == nil {
startParam = q.Get("start_param")
}
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
if err != nil {
return nil, err
}
return encodeSession(sess), nil
}
}
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
// VK omits the user's name from the signed params, so the client-supplied display_name
// rides the wire as a cosmetic seed for a brand-new account.
func authVKHandler(backend *backendclient.Client, secret string) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
user, err := vkauth.Verify(string(in.Params()), secret)
if err != nil {
return nil, err
}
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
if err != nil {
return nil, err
}
@@ -167,8 +219,15 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
}
func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, _ Request) ([]byte, error) {
sess, err := backend.GuestAuth(ctx)
return func(ctx context.Context, req Request) ([]byte, error) {
// The guest bootstrap historically carried no payload; the detected zone is
// optional, so an absent or empty one simply yields no time-zone seed (rather
// than panicking in GetRootAs* on a zero-length buffer).
var browserTz string
if len(req.Payload) > 0 {
browserTz = string(fb.GetRootAsGuestLoginRequest(req.Payload, 0).BrowserTz())
}
sess, err := backend.GuestAuth(ctx, browserTz)
if err != nil {
return nil, err
}
@@ -179,7 +238,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
func authEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0)
if err := backend.EmailRequest(ctx, string(in.Email())); err != nil {
if err := backend.EmailRequest(ctx, string(in.Email()), string(in.BrowserTz())); err != nil {
return nil, err
}
return encodeAck(true), nil
@@ -499,7 +558,7 @@ func hideGameHandler(backend *backendclient.Client) Handler {
func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil {
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), string(in.Version()), string(in.BrowserTz()), req.ClientIP); err != nil {
return nil, err
}
return encodeAck(true), nil
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Fatal("auth.telegram not registered")
}
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")})
if err != nil {
t.Fatalf("handler: %v", err)
}
@@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
}
// start_param is parsed out of the validated initData and forwarded so the backend can
// seed the new account's variant preferences from a promo deep link.
if gotBody["start_param"] != "verudit_ru-scrabble_en" {
t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"])
}
}
func TestTelegramAuthInvalidInitData(t *testing.T) {
@@ -0,0 +1,116 @@
package transcode_test
import (
"context"
"encoding/json"
"net/http"
"net/url"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/gateway/internal/transcode"
fb "scrabble/pkg/fbs/scrabblefb"
)
const (
vkTestSecret = "wv527nm6mvf3rgomfhd6"
// vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under
// vkTestSecret, computed independently (a Python reference) — so a green test
// exercises VK's real algorithm end to end, not a self-consistent fake.
vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
)
// vkParams is the canonical VK launch-parameter set (without the sign) that
// vkGoldenSign covers.
func vkParams() url.Values {
return url.Values{
"vk_access_token_settings": {""},
"vk_app_id": {"6736218"},
"vk_are_notifications_enabled": {"0"},
"vk_is_app_user": {"1"},
"vk_is_favorite": {"0"},
"vk_language": {"ru"},
"vk_platform": {"android"},
"vk_ref": {"other"},
"vk_ts": {"1546961916"},
"vk_user_id": {"494075"},
}
}
func vkLoginPayload(params, browserTz, displayName string) []byte {
b := flatbuffers.NewBuilder(0)
p := b.CreateString(params)
tz := b.CreateString(browserTz)
dn := b.CreateString(displayName)
fb.VKLoginRequestStart(b)
fb.VKLoginRequestAddParams(b, p)
fb.VKLoginRequestAddBrowserTz(b, tz)
fb.VKLoginRequestAddDisplayName(b, dn)
b.Finish(fb.VKLoginRequestEnd(b))
return b.FinishedBytes()
}
func TestVKAuthForwardsSeedFields(t *testing.T) {
var gotBody map[string]string
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/internal/sessions/vk" {
t.Errorf("unexpected path %q", r.URL.Path)
}
_ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
op, ok := reg.Lookup(transcode.MsgAuthVK)
if !ok {
t.Fatal("auth.vk not registered")
}
signed := vkParams()
signed.Set("sign", vkGoldenSign)
payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")})
if err != nil {
t.Fatalf("handler: %v", err)
}
sess := fb.GetRootAsSession(payload, 0)
if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" {
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
}
// The verified vk_user_id and vk_language plus the client-supplied display name are
// forwarded so the backend can seed a brand-new account.
if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" {
t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody)
}
}
// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params)
// and the backend is never called.
func TestVKAuthInvalidSign(t *testing.T) {
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {
t.Error("backend must not be called when the sign is invalid")
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
op, _ := reg.Lookup(transcode.MsgAuthVK)
tampered := vkParams()
tampered.Set("sign", "deadbeef")
_, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")})
if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" {
t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok)
}
}
// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk
// unregistered.
func TestVKAuthDisabledWithoutSecret(t *testing.T) {
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
defer cleanup()
reg := transcode.NewRegistry(backend, nil)
if _, ok := reg.Lookup(transcode.MsgAuthVK); ok {
t.Error("auth.vk should be unregistered without a VK app secret")
}
}
+72
View File
@@ -0,0 +1,72 @@
// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the
// launch query string with the app's protected ("secure") key; the gateway holds that
// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a
// side-service, because the check is a pure offline HMAC with no VK API round-trip
// (unlike the Telegram validator, which lives in a separate process to isolate the bot
// token). See docs/ARCHITECTURE.md §12.
package vkauth
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"net/url"
"strings"
)
// ErrInvalid is returned when launch params fail signature verification, are
// malformed, carry no signed vk_* parameters, or lack the vk_user_id.
var ErrInvalid = errors.New("vkauth: invalid vk launch params")
// Identity is the user extracted from verified VK launch params. ExternalID is the
// vk_user_id used as the identities external_id; Language is the vk_language hint that
// seeds a brand-new account's preferred language.
type Identity struct {
ExternalID string
Language string
}
// Verify checks the `sign` of a VK Mini App launch query string against secret and
// returns the launching user's identity. Per VK's documented algorithm the signature
// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a
// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for
// the constrained launch-parameter charset) — under the app secret, then base64url
// without padding; the comparison is constant-time.
//
// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness
// is deliberately not enforced here: the gateway mints its own short-lived session, and
// a replay only re-authenticates the same vk_user_id.
func Verify(params, secret string) (Identity, error) {
values, err := url.ParseQuery(params)
if err != nil {
return Identity{}, ErrInvalid
}
sign := values.Get("sign")
if sign == "" {
return Identity{}, ErrInvalid
}
// Only vk_*-prefixed parameters are signed; any other query parameter the client
// appended is outside the signature and must be excluded from the check.
signed := url.Values{}
for k, v := range values {
if strings.HasPrefix(k, "vk_") {
signed[k] = v
}
}
if len(signed) == 0 {
return Identity{}, ErrInvalid
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(signed.Encode()))
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 {
return Identity{}, ErrInvalid
}
externalID := values.Get("vk_user_id")
if externalID == "" {
return Identity{}, ErrInvalid
}
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
}
+111
View File
@@ -0,0 +1,111 @@
package vkauth_test
import (
"errors"
"net/url"
"testing"
"scrabble/gateway/internal/vkauth"
)
const (
testSecret = "wv527nm6mvf3rgomfhd6"
// goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without
// padding, computed independently from a Python reference over goldenParams — so a
// green test proves Verify matches VK's documented algorithm, not merely itself.
goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
)
// goldenParams is the canonical VK launch-parameter set the golden signature covers
// (a representative real launch, including the empty vk_access_token_settings).
func goldenParams() url.Values {
return url.Values{
"vk_access_token_settings": {""},
"vk_app_id": {"6736218"},
"vk_are_notifications_enabled": {"0"},
"vk_is_app_user": {"1"},
"vk_is_favorite": {"0"},
"vk_language": {"ru"},
"vk_platform": {"android"},
"vk_ref": {"other"},
"vk_ts": {"1546961916"},
"vk_user_id": {"494075"},
}
}
func signedParams() url.Values {
p := goldenParams()
p.Set("sign", goldenSign)
return p
}
func TestVerifyValid(t *testing.T) {
id, err := vkauth.Verify(signedParams().Encode(), testSecret)
if err != nil {
t.Fatalf("Verify: unexpected error %v", err)
}
if id.ExternalID != "494075" || id.Language != "ru" {
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
}
}
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's
// signer and our url.Values.Encode both escape to %2C. The golden sign was computed
// independently (Node crypto) over these params under testSecret — so a green test
// proves Go's encoding matches VK's for that character.
func TestVerifyCommaValue(t *testing.T) {
p := url.Values{
"vk_access_token_settings": {"email,phone"},
"vk_app_id": {"6736218"},
"vk_language": {"ru"},
"vk_platform": {"mobile_web"},
"vk_ts": {"1546961916"},
"vk_user_id": {"494075"},
"sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"},
}
id, err := vkauth.Verify(p.Encode(), testSecret)
if err != nil {
t.Fatalf("Verify: unexpected error %v", err)
}
if id.ExternalID != "494075" {
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
}
}
// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may
// append (e.g. a tracking tag) is outside the signed set and does not break the check.
func TestVerifyIgnoresForeignParams(t *testing.T) {
id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret)
if err != nil {
t.Fatalf("Verify: unexpected error %v", err)
}
if id.ExternalID != "494075" {
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
}
}
func TestVerifyRejects(t *testing.T) {
tests := []struct {
name string
raw string
secret string
}{
{name: "tampered param", secret: testSecret, raw: func() string {
p := signedParams()
p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches
return p.Encode()
}()},
{name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()},
{name: "missing sign", secret: testSecret, raw: goldenParams().Encode()},
{name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()},
{name: "malformed query", secret: testSecret, raw: "%zz=bad"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) {
t.Fatalf("Verify error = %v, want ErrInvalid", err)
}
})
}
}
+30 -5
View File
@@ -99,24 +99,47 @@ table MoveRecord {
// --- auth (unauthenticated) ---
// TelegramLoginRequest carries the platform launch data; the gateway validates
// its HMAC before forwarding the extracted identity to the backend.
// its HMAC before forwarding the extracted identity to the backend. browser_tz is
// the client's detected UTC offset ("±HH:MM"), seeded into a brand-new account's
// time zone so the robot's sleep window and the turn-timeout away window are
// anchored to the player's real zone from first contact (first contact only).
table TelegramLoginRequest {
init_data:string;
browser_tz:string;
}
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
// signed vk_* launch parameters plus the sign, which the gateway verifies in-process
// (HMAC-SHA256 over the sorted vk_* params under the app secret, base64url) before
// forwarding the extracted vk_user_id to the backend. display_name is the player's
// name read client-side via VKWebAppGetUserInfo — VK omits it from the signed params,
// so it is an untrusted, cosmetic seed for a brand-new account's display name.
// browser_tz is the client's detected UTC offset ("±HH:MM"), seeded into a brand-new
// account's time zone (see TelegramLoginRequest.browser_tz; first contact only).
table VKLoginRequest {
params:string;
browser_tz:string;
display_name:string;
}
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
// preferred-language hint.
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
// guest account's time zone (see TelegramLoginRequest.browser_tz).
table GuestLoginRequest {
locale:string;
browser_tz:string;
}
// EmailRequestRequest asks the backend to send a login confirm-code to email.
// EmailRequestRequest asks the backend to send a login confirm-code to email. It
// also provisions the account on first contact, so browser_tz (the detected UTC
// offset) is seeded into its time zone here, not at the later login step.
table EmailRequestRequest {
email:string;
browser_tz:string;
}
// EmailLoginRequest logs in (or provisions) the account owning email, verifying
// the confirm-code.
// EmailLoginRequest logs in to the account owning email (provisioned at the
// request step), verifying the confirm-code.
table EmailLoginRequest {
email:string;
code:string;
@@ -383,6 +406,8 @@ table FeedbackSubmitRequest {
attachment:[ubyte];
attachment_name:string;
channel:string;
version:string;
browser_tz:string;
}
// FeedbackReply is the operator's answer shown back to the player.
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *EmailRequestRequest) Email() []byte {
return nil
}
func (rcv *EmailRequestRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func EmailRequestRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0)
}
func EmailRequestRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+23 -1
View File
@@ -99,8 +99,24 @@ func (rcv *FeedbackSubmitRequest) Channel() []byte {
return nil
}
func (rcv *FeedbackSubmitRequest) Version() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackSubmitRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(4)
builder.StartObject(6)
}
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
@@ -117,6 +133,12 @@ func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attach
func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0)
}
func FeedbackSubmitRequestAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(version), 0)
}
func FeedbackSubmitRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(browserTz), 0)
}
func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *GuestLoginRequest) Locale() []byte {
return nil
}
func (rcv *GuestLoginRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func GuestLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0)
}
func GuestLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *TelegramLoginRequest) InitData() []byte {
return nil
}
func (rcv *TelegramLoginRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
builder.StartObject(2)
}
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0)
}
func TelegramLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+82
View File
@@ -0,0 +1,82 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type VKLoginRequest struct {
_tab flatbuffers.Table
}
func GetRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &VKLoginRequest{}
x.Init(buf, n+offset)
return x
}
func FinishVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &VKLoginRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *VKLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *VKLoginRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *VKLoginRequest) Params() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *VKLoginRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *VKLoginRequest) DisplayName() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func VKLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(3)
}
func VKLoginRequestAddParams(builder *flatbuffers.Builder, params flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(params), 0)
}
func VKLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
}
func VKLoginRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(displayName), 0)
}
func VKLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+6
View File
@@ -24,6 +24,11 @@ ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/validator ./platform/telegram/cmd/validator
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/bot ./platform/telegram/cmd/bot
# The bot's support relay writes its JSON state under /data. Stage an empty directory
# owned by the distroless nonroot UID so a fresh named volume mounted there inherits
# writable ownership (Docker copies the image dir's mode/owner into an empty volume).
RUN mkdir -p /out/data
# --- validator (home) --------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS validator
COPY --from=build /out/validator /usr/local/bin/validator
@@ -32,4 +37,5 @@ ENTRYPOINT ["/usr/local/bin/validator"]
# --- bot (remote) ------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS bot
COPY --from=build /out/bot /usr/local/bin/bot
COPY --from=build --chown=65532:65532 /out/data /data
ENTRYPOINT ["/usr/local/bin/bot"]
+34 -5
View File
@@ -38,10 +38,17 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
operator-chosen for broadcasts) with a Mini App launch button and sends it. It replies
with an `Ack` per command (`delivered` mirrors the former connector semantics —
false when the kind is not rendered out-of-app or the user never started the bot).
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App
launch button; a deep-link payload routes the launch to a game / invitation / friend
code. This is **self-contained** the bot never calls back into the game, so `/start`
onboarding works even when the game is down.
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a localized
welcome and a Mini App launch button; a deep-link payload routes the launch to a game /
invitation / friend code. The welcome is **Russian or English** by the sender's reported
Telegram language (`Message.from.language_code`, which the Bot API carries on the message
itself — no separate user-update event — English fallback) and links the game channel and
discussion chat by their public `@username`, **resolved once at startup** from
`TELEGRAM_GAME_CHANNEL_ID` / `TELEGRAM_CHAT_ID` via `getChat` (a chat that is unset,
private, or unreadable degrades that link to a generic noun — "the channel" / "our
chat" — rather than a dangling "@"). This is otherwise **self-contained**
— the bot never calls back into the game, so `/start` onboarding works even when the game
is down.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with
@@ -56,13 +63,32 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
name is a tappable profile mention via a `text_mention` entity — which also stops a name starting
with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle
and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content
— text, media, voice, files — carries over). Any **administrator** of the support chat who writes
in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
(the loop guard). **Block** drops a user's incoming messages (the topic stays); **Clear** deletes
the relayed messages, keeping the info card; a topic the operators delete is reopened on the next
message. State (user→topic map, block list, relayed ids) is a JSON file under
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
would sign the initData), which the main bot's validator rejects. It is fully
self-contained: no bot-link, no gateway, no game.
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
alongside the default Erudit). The message body also renders the bot's `@username` as that
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
the seeded Mini App rather than the bot profile.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits.
@@ -125,9 +151,12 @@ Bot (`cmd/bot`):
| `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert |
| `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
+20
View File
@@ -10,6 +10,7 @@ import (
"context"
"log"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
@@ -23,6 +24,7 @@ import (
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
@@ -65,6 +67,19 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
// The support relay keeps its per-user state in a JSON file on a writable volume
// (the bot host has no database). A corrupt file is logged and the relay starts
// empty rather than crash-looping the bot.
var supportStore *support.Store
if cfg.SupportChatID != 0 {
statePath := filepath.Join(cfg.SupportStateDir, "support.json")
supportStore, err = support.Open(statePath)
if err != nil {
logger.Warn("support: state load failed, starting empty", zap.String("path", statePath), zap.Error(err))
supportStore = support.New(statePath)
}
}
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
@@ -72,6 +87,9 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID,
GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
}, logger)
if err != nil {
return err
@@ -113,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL,
StartParam: cfg.PromoStartParam,
SendRatePerSecond: cfg.SendRatePerSecond,
}, logger)
if err != nil {
@@ -127,6 +146,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
zap.Bool("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv),
zap.Bool("chat_gating", cfg.ChatID != 0),
zap.Bool("support_relay", cfg.SupportChatID != 0),
zap.Bool("promo_bot", promo != nil))
var wg sync.WaitGroup
+117 -10
View File
@@ -15,6 +15,8 @@ import (
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/support"
)
// Config configures the bot wrapper.
@@ -33,8 +35,20 @@ type Config struct {
SendRatePerSecond int
// ChatID is the moderated discussion chat the bot gates write access in; 0
// disables chat gating (and the chat_member long-poll subscription). Gating needs
// the bot to be an administrator there with the restrict-members right.
// the bot to be an administrator there with the restrict-members right. Its public
// @username is also resolved at startup for the /start welcome's discussion link.
ChatID int64
// GameChannelID is the game channel whose public @username the /start welcome links
// to (resolved from this id via getChat at startup); 0 omits that follow link.
GameChannelID int64
// SupportChatID is the private forum supergroup the bot relays direct user messages
// into (one topic per user) and reads operator replies from; 0 disables the support
// relay. The bot must be an administrator there with the manage-topics and
// delete-messages rights.
SupportChatID int64
// SupportStore persists the support relay's state (topic mapping, block list,
// relayed message ids); required when SupportChatID is set, ignored otherwise.
SupportStore *support.Store
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -54,12 +68,29 @@ type Bot struct {
limiter *rate.Limiter
// chatID is the moderated discussion chat (0 disables gating).
chatID int64
// channelID is the game channel (0 omits its welcome follow link).
channelID int64
// channelUsername and chatUsername are the public @usernames (without the leading
// @) of the game channel and the discussion chat, resolved once at startup
// (resolveWelcomeHandles) for the /start welcome's follow links; "" when unresolved.
channelUsername string
chatUsername string
// botID is the bot's own Telegram user id (resolved at startup); it skips the
// chat_member updates the bot's own restrict actions generate — the grant loop guard.
// chat_member updates the bot's own restrict actions generate — the grant loop guard
// and the bot's own relayed copies in the support chat.
botID int64
// eligibility resolves a joining user's chat write eligibility; nil leaves a
// joiner muted (fail-closed) until it is wired.
eligibility EligibilityResolver
// supportChatID is the support relay's forum supergroup (0 disables the relay).
supportChatID int64
// support persists the support relay's per-user state; nil when the relay is off.
support *support.Store
// supportLocks serialises per-user topic creation so a burst of a new user's
// messages opens exactly one topic.
supportLocks *keyedMutex
// admins caches the support chat's administrator ids (who may reply and act).
admins *adminCache
}
// New builds the bot wrapper, registering the /start handler and a default handler
@@ -69,7 +100,14 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID}
t := &Bot{
miniAppURL: cfg.MiniAppURL,
log: log,
chatID: cfg.ChatID,
channelID: cfg.GameChannelID,
supportChatID: cfg.SupportChatID,
support: cfg.SupportStore,
}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
@@ -78,15 +116,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
tgbot.WithDefaultHandler(t.handleUpdate),
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
}
if t.supportEnabled() {
t.supportLocks = newKeyedMutex()
t.admins = &adminCache{}
// The info-card buttons are callback_query updates; route them to the support
// callback handler by their "sup:" data prefix.
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
}
// Allowed updates default to "all except chat_member". Specify an explicit set only
// when we need chat_member (moderated chat) — and then re-add callback_query (which
// the explicit set would otherwise drop) when the support relay needs it.
if cfg.ChatID != 0 {
// chat_member updates are off by default; subscribe explicitly (alongside
// messages) so the bot sees joins in the moderated chat. The bot must also be an
// administrator there for Telegram to deliver them.
opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{
allowed := tgbot.AllowedUpdates{
models.AllowedUpdateMessage,
models.AllowedUpdateMyChatMember,
models.AllowedUpdateChatMember,
}))
}
if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
}
if cfg.TestEnv {
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
@@ -123,9 +172,46 @@ func (t *Bot) Run(ctx context.Context) {
if t.chatID != 0 {
t.logChatAdminStatus(ctx)
}
if t.supportEnabled() {
t.initSupport(ctx)
}
t.resolveWelcomeHandles(ctx)
t.api.Start(ctx)
}
// resolveWelcomeHandles resolves, once at startup, the public @usernames of the game
// channel and the discussion chat from their configured ids (getChat), caching them for
// the /start welcome's follow links. It runs before the update loop, so the handles are
// set before any /start is handled; a chat that is unset, private (no public username)
// or unreadable simply leaves its handle empty and the welcome omits that follow link.
func (t *Bot) resolveWelcomeHandles(ctx context.Context) {
t.channelUsername = t.resolveUsername(ctx, t.channelID, "game channel")
t.chatUsername = t.resolveUsername(ctx, t.chatID, "discussion chat")
}
// resolveUsername returns the public @username (without the leading @) of the chat with
// the given id, or "" when id is 0, the chat has no public username, or getChat fails —
// logging the reason, since a missing handle silently drops a welcome follow link.
func (t *Bot) resolveUsername(ctx context.Context, id int64, label string) string {
if id == 0 {
return ""
}
chat, err := t.api.GetChat(ctx, &tgbot.GetChatParams{ChatID: id})
if err != nil {
t.log.Warn("welcome: getChat failed; follow link omitted",
zap.String("chat", label), zap.Int64("id", id), zap.Error(err))
return ""
}
if chat.Username == "" {
t.log.Warn("welcome: chat has no public @username; follow link omitted",
zap.String("chat", label), zap.Int64("id", id))
return ""
}
t.log.Info("welcome: resolved follow link",
zap.String("chat", label), zap.String("username", chat.Username))
return chat.Username
}
// logChatAdminStatus checks, at startup, whether the bot can actually gate the
// moderated chat — it must be an administrator there with the restrict-members
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts
@@ -198,11 +284,19 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.Chat.Type != models.ChatTypePrivate {
return
}
// The sender's Telegram language rides on the message itself (Message.from.language_code
// in the Bot API — there is no separate user-update event); fall back to English when it
// is absent.
lang := ""
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
text, button := startText(lang, t.channelUsername, t.chatUsername)
startParam := startPayload(update.Message.Text)
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: "Tap to open Scrabble.",
ReplyMarkup: t.launchMarkup("Open Scrabble", startParam),
Text: text,
ReplyMarkup: t.launchMarkup(button, startParam),
}); err != nil {
t.log.Warn("reply to start failed", zap.Error(err))
}
@@ -252,6 +346,19 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleChatMember(ctx, update.ChatMember)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply.
if t.supportEnabled() && update.Message != nil {
switch {
case update.Message.Chat.ID == t.supportChatID:
t.handleSupportGroupMessage(ctx, update.Message)
return
case update.Message.Chat.Type == models.ChatTypePrivate:
t.handleSupportUserMessage(ctx, update.Message)
return
}
}
t.handleStart(ctx, api, update)
}
+54 -1
View File
@@ -29,6 +29,10 @@ func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.text = r.FormValue("text")
f.replyMarkup = r.FormValue("reply_markup")
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
case strings.HasSuffix(r.URL.Path, "/getChat"):
// Echo the requested id into the username so a resolver test can tell the
// channel lookup from the chat lookup.
io.WriteString(w, `{"ok":true,"result":{"id":-100,"type":"channel","username":"u`+r.FormValue("chat_id")+`"}}`)
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
@@ -105,7 +109,7 @@ func TestTestEnvironmentRoutesGetMe(t *testing.T) {
}
func TestHandleStartRepliesPrivateOnly(t *testing.T) {
t.Run("private replies", func(t *testing.T) {
t.Run("private replies in english by default", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
@@ -114,6 +118,35 @@ func TestHandleStartRepliesPrivateOnly(t *testing.T) {
if api.chatID != "42" || !strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("private /start: chat=%q markup=%q, want a web_app reply", api.chatID, api.replyMarkup)
}
// No reported language -> English welcome + English button.
if !strings.Contains(api.text, "Hi!") {
t.Errorf("text = %q, want the English welcome", api.text)
}
if !strings.Contains(api.replyMarkup, "Open") {
t.Errorf("reply_markup = %q, want the English button", api.replyMarkup)
}
})
t.Run("uses the sender's reported language", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start",
From: &models.User{ID: 7, LanguageCode: "ru"},
}})
if !strings.Contains(api.text, "Привет!") {
t.Errorf("text = %q, want the Russian welcome for a ru sender", api.text)
}
})
t.Run("embeds resolved follow handles", func(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.channelUsername, b.chatUsername = "erudit", "erudite_chat"
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{
Chat: models.Chat{ID: 42, Type: models.ChatTypePrivate}, Text: "/start",
}})
if !strings.Contains(api.text, "@erudit") || !strings.Contains(api.text, "@erudite_chat") {
t.Errorf("text = %q, want the follow handles", api.text)
}
})
t.Run("group ignored", func(t *testing.T) {
api := &fakeBotAPI{}
@@ -127,6 +160,26 @@ func TestHandleStartRepliesPrivateOnly(t *testing.T) {
})
}
func TestResolveWelcomeHandles(t *testing.T) {
api := &fakeBotAPI{}
b := newTestBot(t, api)
b.channelID, b.chatID = 111, 222
b.resolveWelcomeHandles(context.Background())
// The fake echoes the requested id into the username, so each lookup is independent.
if b.channelUsername != "u111" {
t.Errorf("channelUsername = %q, want u111", b.channelUsername)
}
if b.chatUsername != "u222" {
t.Errorf("chatUsername = %q, want u222", b.chatUsername)
}
// An unset id resolves to no handle (and makes no getChat call).
b.channelID = 0
b.resolveWelcomeHandles(context.Background())
if b.channelUsername != "" {
t.Errorf("channelUsername = %q, want empty for id 0", b.channelUsername)
}
}
func TestStartPayload(t *testing.T) {
cases := map[string]string{
"/start g123": "g123",
+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")
}
}
+60
View File
@@ -0,0 +1,60 @@
package bot
import "strings"
// startText returns the localized /start welcome body and the launch-button label.
// Russian is used when lang (the IETF language tag the Telegram client reports on the
// message's sender) starts with "ru", English otherwise and when it is absent — so a
// user with no reported language still gets a sensible message. channel and chat are
// the resolved public @usernames (without the leading @) of the game channel and the
// discussion chat; when either is empty its follow link degrades to a generic noun
// (e.g. "the channel" / "our chat") rather than rendering a dangling "@", since the
// bot's own info screen still lists the real links.
func startText(lang, channel, chat string) (text, button string) {
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return ruWelcome(channel, chat), "Открыть «Эрудит»"
}
return enWelcome(channel, chat), "Open “Erudite”"
}
// ruWelcome builds the Russian welcome. A known handle is named as "@<username>"; an
// unresolved one degrades to a plain noun.
func ruWelcome(channel, chat string) string {
ch := "канал"
if channel != "" {
ch = "@" + channel
}
ct := "чате"
if chat != "" {
ct = "@" + chat
}
return strings.Join([]string{
"Привет! 👋",
"Здесь можно сражаться в «Эрудит» со случайными игроками или в компании друзей.",
"Подписывайтесь на " + ch + ", чтобы быть в курсе последних игровых событий и вовремя " +
"получать важные уведомления. Игроки могут обсуждать игру и просто общаться в нашем " +
ct + "! 💬",
"Ни слова больше.\nПервая партия сама себя не сыграет 😊",
}, "\n\n")
}
// enWelcome builds the English welcome (the fallback for any non-Russian or missing
// language). A known handle is named as "@<username>"; an unresolved one degrades to a
// plain noun.
func enWelcome(channel, chat string) string {
ch := "the channel"
if channel != "" {
ch = "@" + channel
}
ct := "group chat"
if chat != "" {
ct = "@" + chat
}
return strings.Join([]string{
"Hi! 👋",
"Play Scrabble against random players — or with a group of friends.",
"Follow " + ch + " to stay up to date with the latest game events and receive important " +
"notifications in time. Players can discuss the game and simply chat in our " + ct + "! 💬",
"Okay, no more talking.\nFirst game won't play itself 😊",
}, "\n\n")
}
@@ -0,0 +1,73 @@
package bot
import (
"strings"
"testing"
)
func TestStartTextLocalizesByLanguage(t *testing.T) {
cases := []struct {
name string
lang string
wantButton string
wantSubstr string // a phrase unique to the chosen language body
}{
{"russian", "ru", "Открыть «Эрудит»", "Привет!"},
{"russian region tag", "ru-RU", "Открыть «Эрудит»", "Первая партия"},
{"english", "en", "Open “Erudite”", "Hi!"},
{"other language falls back to english", "de", "Open “Erudite”", "Hi!"},
{"absent language falls back to english", "", "Open “Erudite”", "no more talking"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text, button := startText(tc.lang, "erudit", "erudite_chat")
if button != tc.wantButton {
t.Errorf("button = %q, want %q", button, tc.wantButton)
}
if !strings.Contains(text, tc.wantSubstr) {
t.Errorf("text %q does not contain %q", text, tc.wantSubstr)
}
})
}
}
func TestStartTextEmbedsFollowHandles(t *testing.T) {
for _, lang := range []string{"ru", "en"} {
text, _ := startText(lang, "erudit", "erudite_chat")
if !strings.Contains(text, "@erudit") || !strings.Contains(text, "@erudite_chat") {
t.Errorf("lang %q: follow paragraph missing the handles: %q", lang, text)
}
}
}
func TestStartTextFallsBackToGenericWhenHandleMissing(t *testing.T) {
// An unresolved handle degrades to a generic noun rather than a dangling "@" — and
// only that slot degrades; a resolved sibling still shows its "@username".
t.Run("both missing leaves no @", func(t *testing.T) {
for _, lang := range []string{"ru", "en"} {
text, _ := startText(lang, "", "")
if strings.Contains(text, "@") {
t.Errorf("lang %q: text shows a dangling @: %q", lang, text)
}
}
// The generic nouns are present in each language.
ru, _ := startText("ru", "", "")
if !strings.Contains(ru, "на канал") || !strings.Contains(ru, "в нашем чате") {
t.Errorf("russian generic fallback missing: %q", ru)
}
en, _ := startText("en", "", "")
if !strings.Contains(en, "Follow the channel") || !strings.Contains(en, "in our group chat") {
t.Errorf("english generic fallback missing: %q", en)
}
})
t.Run("only the missing slot degrades", func(t *testing.T) {
// Channel resolved, chat missing: the channel keeps its @handle, the chat is generic.
en, _ := startText("en", "erudit", "")
if !strings.Contains(en, "@erudit") || strings.Contains(en, "@erudite") {
t.Errorf("channel handle not shown / chat handle leaked: %q", en)
}
if !strings.Contains(en, "in our group chat") {
t.Errorf("chat slot did not degrade to a generic noun: %q", en)
}
})
}
@@ -42,6 +42,16 @@ type BotConfig struct {
// (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin
// there with the "Ban users" right, and "chat_member" in its allowed updates.
ChatID int64
// SupportChatID is the chat id of the private forum supergroup the bot relays
// direct user messages into — one forum topic per user — and reads operator
// replies from (TELEGRAM_SUPPORT_CHAT_ID, optional; 0 disables the support relay).
// The bot must be an administrator there with the manage-topics and
// delete-messages rights. Distinct from ChatID (the moderated discussion chat).
SupportChatID int64
// SupportStateDir is the directory holding the support relay's JSON state file
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
// container user (UID 65532) and backed by a persistent volume.
SupportStateDir string
// PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
@@ -55,6 +65,12 @@ type BotConfig struct {
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string
// PromoStartParam is the promo button's launch payload, appended as
// ?startapp=<PromoStartParam> (TELEGRAM_PROMO_START_PARAM, default
// "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to
// seed a brand-new user's variant preferences; its labels must match the backend's
// known variants. Empty falls back to forwarding the user's own /start payload.
PromoStartParam string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
@@ -103,6 +119,10 @@ const (
defaultValidatorGRPCAddr = ":9091"
defaultBotReconnectDelay = 2 * time.Second
defaultSendRatePerSecond = 25
// defaultPromoStartParam is the promo button's default launch payload: a variant-seed
// deep link the backend decodes to add English Scrabble to a brand-new user's variant
// preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM.
defaultPromoStartParam = "verudit_ru-scrabble_en"
)
// LoadValidator reads the validator configuration from the environment.
@@ -135,6 +155,8 @@ func LoadBot() (BotConfig, error) {
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
@@ -152,6 +174,9 @@ func LoadBot() (BotConfig, error) {
if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SupportChatID, err = envInt64("TELEGRAM_SUPPORT_CHAT_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
return BotConfig{}, err
}
@@ -151,6 +151,48 @@ func TestLoadBotChatAndPromo(t *testing.T) {
})
}
// TestLoadBotSupportRelay verifies the support-relay chat id parses, the state
// directory defaults to /data, and the relay is disabled (chat id 0) by default.
func TestLoadBotSupportRelay(t *testing.T) {
t.Run("parsed", func(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "-1001234567890")
t.Setenv("TELEGRAM_SUPPORT_STATE_DIR", "/srv/state")
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
}
if c.SupportChatID != -1001234567890 {
t.Errorf("SupportChatID = %d, want -1001234567890", c.SupportChatID)
}
if c.SupportStateDir != "/srv/state" {
t.Errorf("SupportStateDir = %q, want /srv/state", c.SupportStateDir)
}
})
t.Run("defaults", func(t *testing.T) {
setBotRequired(t)
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
}
if c.SupportChatID != 0 {
t.Errorf("SupportChatID = %d, want 0 (relay disabled)", c.SupportChatID)
}
if c.SupportStateDir != "/data" {
t.Errorf("SupportStateDir = %q, want /data", c.SupportStateDir)
}
})
t.Run("malformed chat id rejected", func(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "not-a-number")
if _, err := LoadBot(); err == nil {
t.Fatal("LoadBot: expected an error for a malformed support chat id, got nil")
}
})
}
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
// fails validation (the validator path).
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
@@ -18,7 +18,8 @@ import (
)
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
// the hash, is malformed, or is older than the freshness window.
// the hash, is malformed, is older than the freshness window, or identifies a bot
// user (is_bot), which is denied.
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
// defaultMaxAge bounds how old a validated initData payload may be.
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
}
var u struct {
ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
return User{}, ErrInvalidInitData
}
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
// never carries it, so reject defensively rather than provision an account for a bot.
if u.IsBot {
return User{}, ErrInvalidInitData
}
return User{
ExternalID: strconv.FormatInt(u.ID, 10),
Username: u.Username,
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
}
})
}
func TestValidateBotUser(t *testing.T) {
t.Run("is_bot true is denied", func(t *testing.T) {
initData := signInitData(testToken, map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
})
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
t.Errorf("err = %v, want ErrInvalidInitData", err)
}
})
t.Run("is_bot false is allowed", func(t *testing.T) {
initData := signInitData(testToken, map[string]string{
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
})
u, err := NewHMACValidator(testToken).Validate(initData)
if err != nil {
t.Fatalf("validate: %v", err)
}
if u.ExternalID != "42" || u.FirstName != "Thomas" {
t.Errorf("user = %+v, want {42 Thomas}", u)
}
})
}
+32 -14
View File
@@ -9,7 +9,9 @@
package promobot
import (
"cmp"
"context"
"html"
"net/url"
"strings"
@@ -33,6 +35,11 @@ type Config struct {
// BotLinkURL is the main bot's Mini App direct link; the button appends
// ?startapp=<payload> to it.
BotLinkURL string
// StartParam is the campaign payload appended as ?startapp=<StartParam> to the launch
// button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend
// decodes to seed a brand-new user's variant preferences. Empty falls back to
// forwarding the user's own /start payload.
StartParam string
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
// disables the limiter. The burst equals the per-second rate.
SendRatePerSecond int
@@ -40,11 +47,12 @@ type Config struct {
// Bot is the promo bot wrapper around a Telegram Bot API client.
type Bot struct {
api *tgbot.Bot
username string
linkURL string
log *zap.Logger
limiter *rate.Limiter
api *tgbot.Bot
username string
linkURL string
startParam string
log *zap.Logger
limiter *rate.Limiter
}
// New builds the promo bot, registering a /start (and default) handler that replies
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil {
log = zap.NewNop()
}
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
@@ -87,7 +95,8 @@ func (t *Bot) Run(ctx context.Context) {
}
// handleStart replies to any message (typically /start) with the localized promo text
// and a button that opens the main bot's Mini App, forwarding any /start payload.
// and a button that opens the main bot's Mini App at the configured campaign payload
// (falling back to forwarding any /start payload the user arrived with).
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil {
return
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.From != nil {
lang = update.Message.From.LanguageCode
}
text, button := promoText(lang, t.username)
// The configured campaign payload (a variant-seed deep link) takes precedence; absent
// one, fall back to forwarding any /start payload the user arrived with. The same
// payload backs both the inline button and the @username link in the body.
param := cmp.Or(t.startParam, startPayload(update.Message.Text))
text, button := promoText(lang, t.username, t.launchURL(param))
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID,
Text: text,
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
ParseMode: models.ParseModeHTML,
ReplyMarkup: t.launchMarkup(button, param),
}); err != nil {
t.log.Warn("promo: reply to start failed", zap.Error(err))
}
@@ -159,11 +173,15 @@ func startPayload(text string) string {
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
}
// promoText returns the localized message body and button label, naming the main bot
// (Russian for a "ru" language code, English otherwise).
func promoText(lang, username string) (text, button string) {
// promoText returns the localized message body and button label. The body names the main
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
// target as the button), so tapping the mention opens the seeded Mini App rather than the
// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the
// static sentences need no escaping. Russian for a "ru" language code, English otherwise.
func promoText(lang, username, launchURL string) (text, button string) {
mention := `<a href="` + html.EscapeString(launchURL) + `">@` + html.EscapeString(username) + `</a>`
if strings.HasPrefix(strings.ToLower(lang), "ru") {
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
}
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!"
}
@@ -13,25 +13,30 @@ import (
)
func TestPromoTextLocalization(t *testing.T) {
en, enBtn := promoText("en", "ScrabbleBot")
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
// The @username is rendered as a clickable deep link (the same target as the button),
// not a plain mention, so tapping it opens the seeded Mini App.
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
en, enBtn := promoText("en", "ScrabbleBot", url)
if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") {
t.Errorf("en text = %q", en)
}
if enBtn != "🤩 I want to play!" {
t.Errorf("en button = %q", enBtn)
}
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
t.Errorf("ru text = %q", ru)
}
if ruBtn != "🤩 Хочу играть!" {
t.Errorf("ru button = %q", ruBtn)
}
// An unknown language falls back to English.
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
t.Errorf("fallback text = %q, want English", got)
// An unknown language falls back to English, still with the linked mention.
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
t.Errorf("fallback text = %q, want English with a linked mention", got)
}
}
@@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) {
if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID)
}
if !strings.Contains(api.text, "@ScrabbleBot") {
t.Errorf("text = %q, want the @mention", api.text)
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
}
if strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
+227
View File
@@ -0,0 +1,227 @@
// Package support persists the Telegram bot's support-relay state. For each user
// who direct-messages the bot it records the dedicated forum topic the bot opened
// in the operators' support chat, whether the user is blocked, and the ids of the
// messages relayed into that topic — so an operator's "clear" can delete them while
// keeping the topic's info card. The state is a small JSON file on a writable
// volume: the bot host has no database.
//
// The store is concurrency-safe. The go-telegram/bot library dispatches each update
// in its own goroutine, so every exported method locks. Mutators update only their
// own fields (SetTopic never touches Blocked, SetBlocked never touches the relayed
// ids) so a topic recreate cannot clobber a concurrent block toggle.
package support
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// User is one user's support-relay state.
type User struct {
// UserID is the user's Telegram user id (the private chat id the bot relays to).
UserID int64 `json:"user_id"`
// TopicID is the forum topic's message_thread_id in the support chat; 0 means no
// topic has been created yet.
TopicID int `json:"topic_id"`
// HeaderMsgID is the info-card message that opens the topic and carries the
// block/clear buttons; it is never deleted by a clear.
HeaderMsgID int `json:"header_msg_id"`
// Blocked reports whether the bot drops this user's incoming messages.
Blocked bool `json:"blocked"`
// FirstName, LastName and Username snapshot the user's Telegram profile at the
// time the topic was created or last refreshed (for the topic name and info card).
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Username string `json:"username,omitempty"`
// RelayedMsgIDs are the ids of the messages posted into the topic after the header
// (the user's relayed messages and the operators' replies); a clear deletes them.
RelayedMsgIDs []int `json:"relayed_msg_ids,omitempty"`
// CreatedAt is when the topic was first opened for this user.
CreatedAt time.Time `json:"created_at"`
}
// Store is the concurrency-safe, JSON-backed support-relay state. The zero value is
// not usable; build one with Open or New.
type Store struct {
mu sync.Mutex
path string
users map[int64]*User
byTopic map[int]int64 // TopicID -> UserID, for resolving operator replies
}
// file is the on-disk shape: a flat list of users. A JSON object keyed by user id
// would force the int64 keys through strings; a list keeps the ids typed.
type file struct {
Users []*User `json:"users"`
}
// New returns an empty store that persists to path, creating the parent directory
// when missing. Use it as the fallback when Open reports a corrupt file.
func New(path string) *Store {
_ = os.MkdirAll(filepath.Dir(path), 0o755)
return &Store{path: path, users: map[int64]*User{}, byTopic: map[int]int64{}}
}
// Open loads the store from path. A missing file yields an empty store and no
// error; an unreadable or malformed file returns an error so the caller can decide
// whether to start empty.
func Open(path string) (*Store, error) {
s := New(path)
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return s, nil
}
return nil, fmt.Errorf("support: read state %s: %w", path, err)
}
var f file
if err := json.Unmarshal(b, &f); err != nil {
return nil, fmt.Errorf("support: parse state %s: %w", path, err)
}
for _, u := range f.Users {
if u == nil || u.UserID == 0 {
continue
}
s.users[u.UserID] = u
if u.TopicID != 0 {
s.byTopic[u.TopicID] = u.UserID
}
}
return s, nil
}
// Get returns a detached copy of the user's state and whether it exists.
func (s *Store) Get(userID int64) (User, bool) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
return User{}, false
}
cp := *u
cp.RelayedMsgIDs = append([]int(nil), u.RelayedMsgIDs...)
return cp, true
}
// ByTopic returns the user id owning the given forum topic, and whether one is known.
func (s *Store) ByTopic(topicID int) (int64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
uid, ok := s.byTopic[topicID]
return uid, ok
}
// Blocked reports whether the user's incoming messages are dropped.
func (s *Store) Blocked(userID int64) bool {
s.mu.Lock()
defer s.mu.Unlock()
if u, ok := s.users[userID]; ok {
return u.Blocked
}
return false
}
// SetTopic records the forum topic and info-card message opened for the user and
// refreshes the profile snapshot, creating the record on first contact. It rebuilds
// the topic index when the topic changes (a recreate) and preserves the block state
// and the relayed-message ids. It persists the result.
func (s *Store) SetTopic(userID int64, topicID, headerMsgID int, firstName, lastName, username string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
s.users[userID] = u
} else if u.TopicID != 0 && u.TopicID != topicID {
delete(s.byTopic, u.TopicID)
}
u.TopicID = topicID
u.HeaderMsgID = headerMsgID
u.FirstName, u.LastName, u.Username = firstName, lastName, username
if topicID != 0 {
s.byTopic[topicID] = userID
}
return s.save()
}
// SetBlocked sets the user's blocked flag, creating a minimal record when none
// exists, and persists the result.
func (s *Store) SetBlocked(userID int64, blocked bool) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
s.users[userID] = u
}
u.Blocked = blocked
return s.save()
}
// AppendMsg records a message posted into the user's topic (for a later clear) and
// persists the result. It is a no-op when the user has no record yet.
func (s *Store) AppendMsg(userID int64, msgID int) error {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok {
return nil
}
u.RelayedMsgIDs = append(u.RelayedMsgIDs, msgID)
return s.save()
}
// ClearMsgs empties and returns the user's relayed-message ids (the info card is not
// among them) and persists the result, so the caller can delete them from the chat.
func (s *Store) ClearMsgs(userID int64) ([]int, error) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.users[userID]
if !ok || len(u.RelayedMsgIDs) == 0 {
return nil, nil
}
ids := u.RelayedMsgIDs
u.RelayedMsgIDs = nil
if err := s.save(); err != nil {
// Roll back so the ids are not lost if the write fails.
u.RelayedMsgIDs = ids
return nil, err
}
return ids, nil
}
// save writes the state atomically (temp file in the same directory, then rename).
// The caller must hold s.mu.
func (s *Store) save() error {
f := file{Users: make([]*User, 0, len(s.users))}
for _, u := range s.users {
f.Users = append(f.Users, u)
}
sort.Slice(f.Users, func(i, j int) bool { return f.Users[i].UserID < f.Users[j].UserID })
b, err := json.MarshalIndent(f, "", " ")
if err != nil {
return fmt.Errorf("support: marshal state: %w", err)
}
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".support-*.json")
if err != nil {
return fmt.Errorf("support: create temp state: %w", err)
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds
if _, err := tmp.Write(b); err != nil {
_ = tmp.Close()
return fmt.Errorf("support: write temp state: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("support: close temp state: %w", err)
}
if err := os.Rename(tmpName, s.path); err != nil {
return fmt.Errorf("support: replace state %s: %w", s.path, err)
}
return nil
}
@@ -0,0 +1,178 @@
package support
import (
"os"
"path/filepath"
"sync"
"testing"
)
// statePath returns a path inside a fresh temp directory for a store file.
func statePath(t *testing.T) string {
t.Helper()
return filepath.Join(t.TempDir(), "support.json")
}
func TestOpenMissingReturnsEmpty(t *testing.T) {
s, err := Open(statePath(t))
if err != nil {
t.Fatalf("Open missing: %v", err)
}
if _, ok := s.Get(42); ok {
t.Error("Get on an empty store returned a record")
}
}
func TestOpenCorruptReturnsError(t *testing.T) {
path := statePath(t)
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatalf("write corrupt: %v", err)
}
if _, err := Open(path); err == nil {
t.Fatal("Open on a corrupt file: expected an error, got nil")
}
}
func TestSetTopicPersistsAndReloads(t *testing.T) {
path := statePath(t)
s := New(path)
if err := s.SetTopic(7, 100, 101, "Ann", "Lee", "annlee"); err != nil {
t.Fatalf("SetTopic: %v", err)
}
// In-memory view.
got, ok := s.Get(7)
if !ok || got.TopicID != 100 || got.HeaderMsgID != 101 || got.Username != "annlee" {
t.Fatalf("Get = %+v ok=%v, want topic 100 / header 101 / annlee", got, ok)
}
if got.CreatedAt.IsZero() {
t.Error("CreatedAt not set on first contact")
}
if uid, ok := s.ByTopic(100); !ok || uid != 7 {
t.Errorf("ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
}
// Reloaded from disk.
reload, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, ok = reload.Get(7)
if !ok || got.TopicID != 100 || got.FirstName != "Ann" {
t.Fatalf("reloaded Get = %+v ok=%v, want topic 100 / Ann", got, ok)
}
if uid, ok := reload.ByTopic(100); !ok || uid != 7 {
t.Errorf("reloaded ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
}
}
func TestSetTopicRecreateReindexes(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.SetTopic(7, 200, 201, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic recreate: %v", err)
}
if _, ok := s.ByTopic(100); ok {
t.Error("ByTopic(100) still resolves after recreate; the stale topic was not dropped")
}
if uid, ok := s.ByTopic(200); !ok || uid != 7 {
t.Errorf("ByTopic(200) = %d ok=%v, want 7/true", uid, ok)
}
}
// TestBlockSurvivesTopicRecreate is the field-isolation guarantee: a topic recreate
// (SetTopic) must not clear a block set concurrently from the buttons.
func TestBlockSurvivesTopicRecreate(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", "annlee"); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.SetBlocked(7, true); err != nil {
t.Fatalf("SetBlocked: %v", err)
}
if err := s.SetTopic(7, 200, 201, "Ann", "", "annlee"); err != nil {
t.Fatalf("SetTopic recreate: %v", err)
}
if !s.Blocked(7) {
t.Error("block was cleared by a topic recreate")
}
}
func TestAppendAndClearMsgs(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
for _, id := range []int{500, 501, 502} {
if err := s.AppendMsg(7, id); err != nil {
t.Fatalf("AppendMsg %d: %v", id, err)
}
}
got, _ := s.Get(7)
if len(got.RelayedMsgIDs) != 3 {
t.Fatalf("RelayedMsgIDs = %v, want 3 ids", got.RelayedMsgIDs)
}
ids, err := s.ClearMsgs(7)
if err != nil {
t.Fatalf("ClearMsgs: %v", err)
}
if len(ids) != 3 || ids[0] != 500 || ids[2] != 502 {
t.Errorf("ClearMsgs = %v, want [500 501 502]", ids)
}
got, _ = s.Get(7)
if len(got.RelayedMsgIDs) != 0 {
t.Errorf("RelayedMsgIDs after clear = %v, want empty", got.RelayedMsgIDs)
}
if got.HeaderMsgID != 101 || got.TopicID != 100 {
t.Errorf("clear disturbed the topic/header: %+v", got)
}
// A second clear is a no-op.
if ids, err := s.ClearMsgs(7); err != nil || ids != nil {
t.Errorf("second ClearMsgs = %v, %v, want nil, nil", ids, err)
}
}
func TestGetReturnsDetachedCopy(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
if err := s.AppendMsg(7, 500); err != nil {
t.Fatalf("AppendMsg: %v", err)
}
got, _ := s.Get(7)
got.RelayedMsgIDs[0] = 999 // mutate the copy
again, _ := s.Get(7)
if again.RelayedMsgIDs[0] != 500 {
t.Error("Get returned a slice that aliases the store's state")
}
}
// TestConcurrentMutationsSafe exercises the mutex under parallel writers; run with
// -race to catch data races.
func TestConcurrentMutationsSafe(t *testing.T) {
s := New(statePath(t))
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
t.Fatalf("SetTopic: %v", err)
}
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(n int) {
defer wg.Done()
_ = s.AppendMsg(7, 1000+n)
_ = s.SetBlocked(7, n%2 == 0)
_, _ = s.Get(7)
_, _ = s.ByTopic(100)
}(i)
}
wg.Wait()
got, ok := s.Get(7)
if !ok || len(got.RelayedMsgIDs) != 20 {
t.Errorf("after concurrent appends: %d ids, want 20", len(got.RelayedMsgIDs))
}
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { test as base } from '@playwright/test';
// All e2e specs run hermetically against the mock transport. Neutralise the real
// telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks
// on telegram.org — it is unreachable from the CI runner, and a render-blocking
// <script> to it would hang every page load. Specs that exercise the Telegram launch
// inject their own window.Telegram via addInitScript before navigating.
// telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the
// suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise
// the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the
// dynamic load short-circuits on the already-present SDK.
export const test = base.extend({
page: async ({ page }, use) => {
await page.route('**/telegram-web-app.js', (route) =>
await page.route('**/telegram-web-app.js*', (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
);
await use(page);
+48
View File
@@ -0,0 +1,48 @@
import { expect, test, type Page } from './fixtures';
// The first-run onboarding coachmarks. `?coach` forces the overlay on in the mock build (where it
// is otherwise off so it cannot eat the smoke's taps) and bypasses the persisted "seen" flag, so
// the series replay deterministically here. A tap anywhere on the overlay advances; after the last
// hint the overlay removes itself.
// Tap the overlay near a corner (clear of the bubble) to advance to the next hint.
async function advance(page: Page): Promise<void> {
await page.locator('[data-coach-overlay]').click({ position: { x: 5, y: 5 } });
}
// Drive both series end to end: guest login -> lobby coachmarks (3) -> open the seeded game ->
// game coachmarks (5) -> gone. Shared by the portrait and landscape projects/cases.
async function runOnboarding(page: Page): Promise<void> {
const overlay = page.locator('[data-coach-overlay]');
await page.goto('/?coach=1');
await page.getByRole('button', { name: /guest/i }).click();
// Lobby series: settings -> stats -> new game.
await expect(overlay).toBeVisible({ timeout: 10000 });
await advance(page);
await advance(page);
await advance(page);
await expect(overlay).toBeHidden();
// The lobby is interactive again: open the seeded active game.
await page.getByRole('button', { name: /Ann/ }).click();
await expect(page.locator('[data-cell]').first()).toBeVisible();
// Game series: header -> pass/exchange -> hints -> shuffle -> rack.
await expect(overlay).toBeVisible({ timeout: 10000 });
for (let i = 0; i < 5; i++) await advance(page);
await expect(overlay).toBeHidden();
}
test('onboarding walks the lobby then the first game (portrait)', async ({ page }) => {
await runOnboarding(page);
});
test.describe('landscape', () => {
test.use({ viewport: { width: 1100, height: 640 } });
test('onboarding anchors and advances in the wide layout', async ({ page }) => {
await runOnboarding(page);
});
});
+54
View File
@@ -44,6 +44,60 @@ test('friends: issue a code, accept an incoming request, redeem a code', async (
await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible();
});
test('friends: the row kebab reveals block/remove and an outside tap closes it', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
// A friend row slides open on its kebab (like the lobby), exposing two icon actions.
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await expect(kaya).toHaveClass(/revealed/);
await expect(kaya.locator('.acts').getByRole('button', { name: 'Block' })).toBeVisible();
await expect(kaya.locator('.acts').getByRole('button', { name: 'Remove' })).toBeVisible();
// A tap anywhere outside the action buttons collapses the row again.
await page.getByRole('heading', { name: 'Your friends' }).click();
await expect(kaya).not.toHaveClass(/revealed/);
});
test('friends: blocking from the list confirms (naming the friend) and moves them to Blocked', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await kaya.locator('.acts').getByRole('button', { name: 'Block' }).click();
// The confirmation keeps a generic title and names the friend in the body.
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Block this player?')).toBeVisible();
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
await dialog.getByRole('button', { name: 'Block' }).click();
// The block applied: Kaya leaves the friends list and shows under Blocked players.
await expect(page.getByText('No friends yet.')).toBeVisible();
const blocked = page.locator('.rowwrap', { hasText: 'Kaya' });
await expect(blocked.getByRole('button', { name: 'Unblock' })).toBeVisible();
});
test('friends: removing from the list confirms (naming the friend) and drops them', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await kaya.locator('.acts').getByRole('button', { name: 'Remove' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Remove from friends?')).toBeVisible();
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
await dialog.getByRole('button', { name: 'Remove' }).click();
// Unfriending just drops the friendship — Kaya is gone and not blocked.
await expect(page.getByText('No friends yet.')).toBeVisible();
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0);
});
test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
await loginLobby(page);
await expect(page.getByText('Invitations')).toBeVisible();

Some files were not shown because too many files have changed in this diff Show More