Compare commits

..

40 Commits

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

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

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

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

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

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

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

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

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

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

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

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

Emit Alt-Svc: clear site-wide at the contour caddy so clients actively drop
any cached alternative and stay on h2/h1. This caddy terminates TLS in prod
(the fix target); in the test contour it serves plain :80 and the host caddy
re-stamps its own Alt-Svc, so the live test fix lives in the host caddy. Add
docs/EDGE_HTTP3.md (symptom, diagnosis method, verify, and option B -- serving
h3 for real -- if it recurs) and link it from ARCHITECTURE.md.
2026-06-22 21:20:31 +02:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00: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
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer be1627936f Merge pull request 'chore(deploy): pin dictionary seed to v1.3.0' (#110) from feature/pin-dict-v130 into development
CI / changes (push) Successful in 2s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 54s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 53s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m19s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-06-22 13:06:26 +00:00
Ilia Denisov 1ba52dd0b4 refactor(deploy): make DICT_VERSION a required build arg (single-sourced)
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 9s
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 1m28s
Drop the literal version default from the build files (backend Dockerfile both
stages, loadtest Dockerfile, the compose build-arg) so the release tag is not
duplicated as a stale-prone default a newcomer can't tell from the real source.

DICT_VERSION is now required: compose uses ${DICT_VERSION:?…} and the Dockerfiles
have no ARG default, so a missing value fails loudly instead of baking a stale tag.
The tag lives only in its genuine sources — ci.yaml env (CI tests), the Gitea
TEST_/PROD_DICT_VERSION variables (deploy seed) and deploy/.env.example (local).
Adds a "Bumping the dictionary version" section to deploy/README and fixes the bare
docker-build examples (CLAUDE.md, README.md, loadtest/README) to pass --build-arg.
2026-06-22 15:03:07 +02:00
Ilia Denisov bb0e3e17e5 chore(deploy): pin dictionary seed to v1.3.0
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m22s
Bump DICT_VERSION v1.2.1 -> v1.3.0 across the seed surface: .env.example, the
compose build-arg default, both backend Dockerfile stages, the loadtest
Dockerfile, the CI dawg-download version, and the deploy/backend docs.

v1.3.0 drops the abbreviation class from the Russian word list (scrabble-dictionary
#6). This only seeds a FRESH volume; a live contour/prod volume is unaffected (the
.seed_version marker wins — seed-drift guard) and moves to v1.3.0 through the admin
console (ARCHITECTURE §5). Per-contour deploy still overrides via TEST_/PROD_DICT_VERSION.
2026-06-22 14:45:52 +02:00
developer 1ef2bde395 Merge pull request 'feat(ui): Erudit blank tiles carry the star (✻) mark' (#109) from feature/erudit-blank-star into development
CI / changes (push) Successful in 1s
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 / deploy (push) Successful in 1m18s
2026-06-22 11:06:22 +00:00
Ilia Denisov 62f66735a3 fix(ui): nudge the rack blank star up a pixel
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 1m19s
By eye the centred star still read a hair low; subtract 1px from its top
offset (top: calc(0.5% - 1px)).
2026-06-22 12:55:06 +02:00
Ilia Denisov 81680a1d5e fix(ui): raise the rack blank star to centre on the letters
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
Top-aligning it still read a touch low; move the empty-blank star up
(top 8% -> 0.5%) so its ink centres against the rack letters' block,
matching the board tile's centred mark. Size unchanged.
2026-06-22 12:22:48 +02:00
Ilia Denisov a393561d79 fix(ui): align the Erudit blank star with neighbouring tiles
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
Rack: the empty-blank star is now top-anchored level with the letters and a
touch larger (was centred, sitting low). Board and Stats best-move tiles: the
placed-blank star's ink is centred on the value digits' line (was slightly
high). CSS-only nudges; pixel offsets measured against the rendered glyphs.
2026-06-22 12:11:35 +02:00
Ilia Denisov e3e4cedc77 feat(ui): Erudit blank tiles carry the star (✻) mark
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 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m17s
The Erudit variant's blank is the "звёздочка", so render it with a star.
An empty rack blank (and its drag ghost) shows ✻ centred; a placed blank
keeps its designated letter and carries ✻ where the (absent) point value
sits — on the board and in the Stats best-move tiles. The Scrabble variants
are unchanged. Gated by usesStarBlank() in lib/variants.ts.
2026-06-22 11:52:00 +02:00
83 changed files with 2086 additions and 260 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ on:
# unit/integration jobs inherit it. The deploy job overrides it per contour with # 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. # vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env: env:
DICT_VERSION: v1.2.1 DICT_VERSION: v1.3.0
jobs: jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when # changes detects which areas a PR/push touched, so the test jobs can skip when
+1 -1
View File
@@ -144,7 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
pnpm start # UI mock mode: lobby -> game, no backend pnpm start # UI mock mode: lobby -> game, no backend
docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
docker compose -f deploy/docker-compose.yml config # validate the full contour docker compose -f deploy/docker-compose.yml config # validate the full contour
+1 -1
View File
@@ -89,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
services build from multi-stage distroless `*/Dockerfile`. services build from multi-stage distroless `*/Dockerfile`.
```sh ```sh
docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env) docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
``` ```
+6 -4
View File
@@ -7,12 +7,14 @@
# (GOPRIVATE), so the build stage needs git and network. # (GOPRIVATE), so the build stage needs git and network.
# #
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all # Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
# in the Docker context: # in the Docker context. DICT_VERSION has no default — the caller supplies the
# docker build -f backend/Dockerfile -t scrabble-backend . # scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md
# "Bumping the dictionary version"):
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
# --- dictionary artifact ----------------------------------------------------- # --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.2.1 ARG DICT_VERSION
RUN apk add --no-cache curl tar RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \ RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \ && curl -fsSL -o /tmp/dawg.tar.gz \
@@ -42,7 +44,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One # Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version # DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag. # label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION=v1.2.1 ARG DICT_VERSION
COPY --from=build /out/backend /usr/local/bin/backend COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console # mounted at /opt/dawg inherits this ownership on first use, so the admin console
+1 -1
View File
@@ -228,7 +228,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
```sh ```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg): # DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \ BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \ GOPRIVATE='gitea.iliadenisov.ru/*' \
+50 -14
View File
@@ -119,6 +119,16 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return s.provision(ctx, kind, externalID, provisionSeed{}) 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 // ProvisionRobot provisions (or finds) the durable account backing a robot pool
// member: a KindRobot identity carrying displayName, with chat blocked but friend // 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 // requests NOT blocked — a request to a robot is accepted as pending and, since the
@@ -160,7 +170,7 @@ func (s *Store) ProvisionRobot(ctx context.Context, externalID, displayName stri
// is never overwritten. The created flag lets the auth handler re-evaluate moderated- // 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 // chat write access on first registration — the path of a user who joined the chat
// before registering, whom no chat_member event covers. // 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 // 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 // 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. // that one call, which the idempotent chat-access re-evaluation tolerates.
@@ -169,7 +179,9 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
if err != nil && !created { if err != nil && !created {
return Account{}, false, err 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 return acc, created, err
} }
@@ -197,12 +209,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 // provisionSeed carries the optional create-time profile seed for a brand-new
// account (Telegram first contact). Empty fields fall back to the accounts table // account (first contact). Empty fields fall back to the accounts table defaults,
// defaults, so an unknown language keeps the 'en' default and an empty name keeps // so an unknown language keeps the 'en' default, an empty name keeps the ” default
// the ” default. // and an empty time zone keeps the 'UTC' default.
type provisionSeed struct { type provisionSeed struct {
preferredLanguage string preferredLanguage string
displayName 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 // telegramSeed derives the create-time seed from Telegram launch fields: a
@@ -368,16 +392,22 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
var created Account var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error { err = withTx(ctx, s.db, func(tx *sql.Tx) error {
// Seed the new row's display name and language (Telegram first contact); an // Seed the new row's display name, language and time zone (first contact); an
// empty seed reproduces the table defaults ('' and 'en') the other callers // empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers
// relied on, so their behaviour is unchanged. // 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 lang := seed.preferredLanguage
if lang == "" { if lang == "" {
lang = "en" lang = "en"
} }
tz := seed.timeZone
if tz == "" {
tz = "UTC"
}
insertAccount := table.Accounts. insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone).
VALUES(accountID, seed.displayName, lang). VALUES(accountID, seed.displayName, lang, tz).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
@@ -416,15 +446,21 @@ const guestDisplayName = "Guest"
// ProvisionGuest creates a fresh ephemeral guest account: a durable row carrying // 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 // 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 // foreign-key the accounts table) while being excluded from statistics, friends
// and history. Guests are not reused — each bootstrap mints a new account. // and history. Guests are not reused — each bootstrap mints a new account. browserTZ
func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) { // (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() accountID, err := uuid.NewV7()
if err != nil { if err != nil {
return Account{}, fmt.Errorf("account: new guest id: %w", err) return Account{}, fmt.Errorf("account: new guest id: %w", err)
} }
tz := seedZone(browserTZ)
if tz == "" {
tz = "UTC"
}
stmt := table.Accounts. stmt := table.Accounts.
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest, table.Accounts.TimeZone).
VALUES(accountID, guestDisplayName, true). VALUES(accountID, guestDisplayName, true, tz).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts 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, // the unauthenticated email-login entry point and, unlike RequestCode,
// does not refuse an already-confirmed email — that is the ordinary returning-user // 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 // 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. // the login. On first contact browserTZ (the client's detected "±HH:MM" UTC offset)
func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) { // 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) addr, err := normalizeEmail(email)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr) acc, err := s.store.ProvisionEmail(ctx, addr, browserTZ)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
@@ -7,8 +7,9 @@
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li> <li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li> <li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</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>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> <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}} {{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul> </ul>
+13 -1
View File
@@ -554,5 +554,17 @@ type FeedbackDetailView struct {
ReplyBody string ReplyBody string
RepliedAt string RepliedAt string
CreatedAt 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
} }
+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 // validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded // attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform. // 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) acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil { if err != nil {
return err 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 attachmentName = "" // a name without bytes carries no attachment
} }
ch := normalizeChannel(channel) ch := normalizeChannel(channel)
// Snapshot the sender's interface language at submit time (acc is already loaded // Snapshot the sender's interface language, the client app version and the client's
// for the guest check) so the operator later sees the state as it was. // detected UTC offset at submit time (acc is already loaded for the guest check) so the
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP)) // 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 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 // 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 // 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 // channel are stored as given. lang (interface language), version (client app build) and
// taken now, so the operator later sees the state at submit time. created_at defaults // browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator
// to now() in the database. // 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) { 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() id, err := uuid.NewV7()
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) 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, if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages `INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip) (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)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil { 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 uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
} }
return id, nil return id, nil
@@ -228,7 +228,15 @@ type AdminMessage struct {
Body string Body string
Channel string Channel string
// Lang is the sender's interface language, snapshotted at submit time. // 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 SenderIP string
HasAttachment bool HasAttachment bool
AttachmentName string AttachmentName string
@@ -343,7 +351,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage var m AdminMessage
var repliedAt sql.NullTime var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, 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, ''), 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), (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 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` WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan( err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, &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.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+59 -11
View File
@@ -110,15 +110,15 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
} }
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact // TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
// seeds the new account's language and display name from the launch fields, // seeds the new account's language, display name and time zone from the launch
// defaults the in-app-only flag on, and never overwrites an existing account on a // fields / detected offset, defaults the in-app-only flag on, and never overwrites
// later login (language seeding). // an existing account on a later login (language and zone seeding).
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString() 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 { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -131,12 +131,15 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if acc.DisplayName != "Иван" { if acc.DisplayName != "Иван" {
t.Errorf("DisplayName = %q, want Иван", 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 { if !acc.NotificationsInAppOnly {
t.Error("NotificationsInAppOnly should default to true") t.Error("NotificationsInAppOnly should default to true")
} }
// A later login with different fields returns the same account, unchanged. // 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 { if err != nil {
t.Fatalf("re-provision telegram: %v", err) t.Fatalf("re-provision telegram: %v", err)
} }
@@ -146,8 +149,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
if again.ID != acc.ID { if again.ID != acc.ID {
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
} }
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" { if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" || again.TimeZone != "+03:00" {
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName) 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 +204,7 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
// language CHECK. // language CHECK.
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
ctx := context.Background() 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 { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -172,7 +220,7 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
func TestHighRateFlagRoundTrip(t *testing.T) { func TestHighRateFlagRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) 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 { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -228,7 +276,7 @@ func TestIdentityExternalID(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) store := account.NewStore(testDB)
ext := "tg-" + uuid.NewString() 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 { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -253,7 +301,7 @@ func TestIdentityExternalID(t *testing.T) {
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
ctx := context.Background() ctx := context.Background()
store := account.NewStore(testDB) 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 { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
+1 -1
View File
@@ -222,7 +222,7 @@ func TestConsoleGameDetailRobotSchedule(t *testing.T) {
func TestConsoleThrottledViewAndFlagClear(t *testing.T) { func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
ctx := context.Background() ctx := context.Background()
accounts := account.NewStore(testDB) 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 { if err != nil {
t.Fatalf("provision: %v", err) 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}) srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})
ext := "tg-" + uuid.NewString() ext := "tg-" + uuid.NewString()
acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter") acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+5 -2
View File
@@ -206,7 +206,7 @@ func TestEmailLoginFlow(t *testing.T) {
svc := account.NewEmailService(account.NewStore(testDB), mailer) svc := account.NewEmailService(account.NewStore(testDB), mailer)
email := "login-" + uuid.NewString() + "@example.com" email := "login-" + uuid.NewString() + "@example.com"
accountID, err := svc.RequestLoginCode(ctx, email) accountID, err := svc.RequestLoginCode(ctx, email, "+02:00")
if err != nil { if err != nil {
t.Fatalf("request login code: %v", err) t.Fatalf("request login code: %v", err)
} }
@@ -225,12 +225,15 @@ func TestEmailLoginFlow(t *testing.T) {
if acc.IsGuest { if acc.IsGuest {
t.Error("an email account must be durable, not a guest") 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) { if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login") t.Error("the email identity must be confirmed after login")
} }
// A second login for the same email is the returning user: same account. // 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) t.Fatalf("second request: %v", err)
} }
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) 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) { func TestFeedbackGuestRejected(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
guest := provisionGuest(t) 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) t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
} }
} }
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) 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) t.Fatalf("submit: %v", err)
} }
// Anti-spam gate: a second message is refused while the first is unreviewed. // 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) t.Fatalf("second submit err = %v, want ErrPendingReview", err)
} }
if st, err := svc.State(ctx, acc); err != nil { 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 if m.Body != "please fix the board" { // trimmed
t.Fatalf("body = %q, want trimmed", m.Body) 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) t.Fatalf("admin message = %+v", m)
} }
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" { 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) acc := provisionAccount(t)
// msg1, replied → the player can send again and currently sees the reply. // 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) t.Fatalf("submit msg1: %v", err)
} }
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil { 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 // 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. // 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) t.Fatalf("submit msg2: %v", err)
} }
st, err := svc.State(ctx, acc) st, err := svc.State(ctx, acc)
@@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) {
t.Fatalf("set language: %v", err) t.Fatalf("set language: %v", err)
} }
// A message snapshots the sender's interface language at submit time. // 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) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
@@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil { if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("grant role: %v", err) 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) t.Fatalf("banned submit err = %v, want ErrBanned", err)
} }
if st, err := svc.State(ctx, acc); err != nil { 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 { if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("revoke role: %v", err) 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) t.Fatalf("submit after unban: %v", err)
} }
} }
@@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
acc := provisionAccount(t) // fresh account so the pending gate never fires first 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) t.Fatalf("submit err = %v, want %v", err, tt.want)
} }
}) })
@@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) 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) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
@@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) 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) t.Fatalf("submit: %v", err)
} }
if err := svc.DeleteAllByAccount(ctx, acc); err != nil { 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 { if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err) 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) 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. // provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID { func provisionGuest(t *testing.T) uuid.UUID {
t.Helper() t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background()) acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts, 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 { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+2 -2
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB) st := account.NewStore(testDB)
uniq := uuid.NewString() uniq := uuid.NewString()
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman") human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "")
if err != nil { if err != nil {
t.Fatalf("provision human: %v", err) t.Fatalf("provision human: %v", err)
} }
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("provision robot: %v", err) t.Fatalf("provision robot: %v", err)
} }
guest, err := st.ProvisionGuest(ctx) guest, err := st.ProvisionGuest(ctx, "")
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) 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;
@@ -1198,6 +1198,17 @@ func fmtTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04") 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. // fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string { func fmtTimePtr(t *time.Time) string {
if t == nil { if t == nil {
@@ -77,6 +77,17 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
s.consoleError(c, err) s.consoleError(c, err)
return 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{ view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, 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), HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody, Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt), 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 { if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned view.Banned = banned
+24 -7
View File
@@ -18,12 +18,14 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a // telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only). // brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
type telegramAuthRequest struct { type telegramAuthRequest struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
Username string `json:"username"` Username string `json:"username"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"` LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"`
} }
// handleTelegramAuth provisions (or finds) the account bound to a Telegram // handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -35,7 +37,7 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
abortBadRequest(c, "external_id is required") abortBadRequest(c, "external_id is required")
return 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 { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
@@ -97,9 +99,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) { 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 { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
@@ -107,9 +121,12 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.mintSession(c, acc) 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 { 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 // handleEmailRequest issues a login confirm-code to the email. It always reports
@@ -121,7 +138,7 @@ func (s *Server) handleEmailRequest(c *gin.Context) {
abortBadRequest(c, "email is required") abortBadRequest(c, "email is required")
return 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) s.abortErr(c, err)
return return
} }
+7 -1
View File
@@ -16,6 +16,12 @@ type feedbackSubmitRequest struct {
Attachment string `json:"attachment"` Attachment string `json:"attachment"`
AttachmentName string `json:"attachment_name"` AttachmentName string `json:"attachment_name"`
Channel string `json:"channel"` 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. // feedbackReplyDTO is the operator's reply shown back to the player.
@@ -61,7 +67,7 @@ func (s *Server) handleFeedbackSubmit(c *gin.Context) {
} }
attachment = data 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) s.abortErr(c, err)
return return
} }
+1 -1
View File
@@ -16,7 +16,7 @@ POSTGRES_PASSWORD=change-me # required
# the active version lives in the DB. On a live volume a changed value is ignored (the # the active version lives in the DB. On a live volume a changed value is ignored (the
# recorded .seed_version marker wins — the seed-drift guard); change a running # recorded .seed_version marker wins — the seed-drift guard); change a running
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). # contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
DICT_VERSION=v1.2.1 DICT_VERSION=v1.3.0
# --- Logging ---------------------------------------------------------------- # --- Logging ----------------------------------------------------------------
LOG_LEVEL=info LOG_LEVEL=info
+26 -2
View File
@@ -80,7 +80,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. | | `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | | `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -117,6 +117,28 @@ collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
intentionally **unset** — caddy owns `/_gm` in the contour. intentionally **unset** — caddy owns `/_gm` in the contour.
## Bumping the dictionary version
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
a build-time input with **no default** in the images, so it is set in exactly two places to
move the whole stack — change both to a new release:
1. **CI tests**`.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
download that dawg).
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
`DICT_VERSION`).
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
default — a missing value fails loudly instead of baking a stale tag.
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
## Production rollout ## Production rollout
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only, Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
@@ -128,7 +150,9 @@ Re-run `ansible/` after a host resize — it is idempotent.
workflow manually (Gitea → Actions → prod-deploy → run from `master`, input workflow manually (Gitea → Actions → prod-deploy → run from `master`, input
`confirm=deploy`). It builds + pushes the images to the registry, ships the `confirm=deploy`). It builds + pushes the images to the registry, ships the
compose/config/certs/env over SSH, deploys the main host with `prod-deploy.sh` (rolling, compose/config/certs/env over SSH, deploys the main host with `prod-deploy.sh` (rolling,
health-gated, **auto-rollback to the previous tag**), then the bot host, then probes the health-gated, **auto-rollback to the previous tag**; caddy is force-recreated on its roll so
a bind-mounted `Caddyfile` change applies — its image is pinned and admin is off, so neither a
new tag nor a hot reload would pick it up), then the bot host, then probes the
public site. After `master` is green this workflow is the **only** thing that touches public site. After `master` is green this workflow is the **only** thing that touches
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main → prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log). deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
+12
View File
@@ -21,6 +21,18 @@
} }
{$CADDY_SITE_ADDRESS::80} { {$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. # Operator surfaces under /_gm: a single shared Basic-Auth, then route.
@gm path /_gm /_gm/* @gm path /_gm /_gm/*
handle @gm { handle @gm {
+5 -3
View File
@@ -68,9 +68,11 @@ services:
context: .. context: ..
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
args: args:
# Seed dictionary for a FRESH volume; the per-contour value comes from the # Seed dictionary for a FRESH volume; required (no default) so the release tag is
# deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below. # set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION)
DICT_VERSION: ${DICT_VERSION:-v1.2.1} # or .env for local builds. See the volume note below + deploy/README.md "Bumping the
# dictionary version".
DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env}
# Build version stamped into the binary (git tag; see pkg/version). # Build version stamped into the binary (git tag; see pkg/version).
VERSION: ${APP_VERSION:-dev} VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
+15 -1
View File
@@ -36,7 +36,21 @@
"type": "stat", "type": "stat",
"title": "Database size", "title": "Database size",
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 }, "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" }, "datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(pg_database_size_bytes{datname=\"scrabble\"})" }] "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...> roll() { # roll <service> <health-cmd...>
local svc="$1"; shift local svc="$1"; shift
echo ">>> rolling $svc -> $TAG" echo ">>> rolling $svc -> $TAG"
dc up -d --no-build --no-deps "$svc" || return 1 # caddy's image is pinned (caddy:2-alpine, no $TAG) and its Caddyfile is bind-mounted, so a
# config-only change leaves the compose definition unchanged: `up -d` treats the container as
# current and does not recreate it, and admin is off so there is no hot reload — the new
# Caddyfile would never load. Force a recreate for caddy so config changes always apply; every
# other service already recreates on its new $TAG image.
local recreate=(); [ "$svc" = caddy ] && recreate=(--force-recreate)
dc up -d --no-build --no-deps "${recreate[@]}" "$svc" || return 1
"$@" || { echo "!!! $svc failed health check"; return 1; } "$@" || { echo "!!! $svc failed health check"; return 1; }
echo "<<< $svc healthy" echo "<<< $svc healthy"
} }
+29 -11
View File
@@ -158,7 +158,12 @@ arrive from a platform rather than completing a mandatory registration).
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in 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 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 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 - **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 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 (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
@@ -640,7 +645,7 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
**floats games with any unread entry to the top** of the your-turn and opponent-turn **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 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. latency is recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email - **Profile**: `preferred_language` (en/ru; tracks the interface language — §4), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window**, the (confirm-code binding, see §4), **timezone**, the daily **away window**, the
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New **variant preferences** (`variant_preferences`, the matchable-variant set that gates New
Game — §3, defaulting to Erudit only, at least one enforced) and the Game — §3, defaulting to Erudit only, at least one enforced) and the
@@ -649,7 +654,11 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a 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` 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 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. accounts and merge are covered in §4.
## 9. Persistence ## 9. Persistence
@@ -1031,8 +1040,9 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
Single public origin, path-routed. The Vite build has two entries: a lightweight Single public origin, path-routed. The Vite build has two entries: a lightweight
**landing page** and the game **SPA**. The gateway **embeds** the SPA build **landing page** and the game **SPA**. The gateway **embeds** the SPA build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at (`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path `/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
redirects to the root — the client-side guard); a stray hit on the gateway's `/` — no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away); a stray hit on the gateway's `/`
308-redirects to `/app/`. The **landing** ships in its own static container: the 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
@@ -1089,7 +1099,10 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
the **main host** runs the full stack (`docker-compose.yml` + `docker-compose.prod.yml`), 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 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 — 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_*`, 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 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. no inbound port, and login is unaffected if that host or the link is down.
@@ -1149,9 +1162,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts 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 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 `feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting
**channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain **channel** (telegram/ios/android/web, client-reported and validated), the **client app version**
is `internal/feedback` (store + service), modelled on the admin chat-moderation surface. (`__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 **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 gate is server-side. Because the operator must act before the next message, this is itself the
@@ -1159,8 +1174,11 @@ rate limit — there is no separate per-user feedback limiter.
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an **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 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 / (user content rendered as auto-escaped `html/template` text; it shows the channel, interface
delete / delete-all actions — each marks the message read; merely opening the detail does not. 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: 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 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 inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
+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.
+8 -2
View File
@@ -213,6 +213,9 @@ block **overrides but does not delete** an existing friendship (so you may block
they keep seeing you as one); active games are never interrupted — you can finish them, with they keep seeing you as one); active games are never interrupted — you can finish them, with
the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card the blocked opponent's chat composer hidden (only the log remains). Blocking from a game card
mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only. mirrors the block in **Settings → Friends**; **unblock** and **unfriend** live there only.
On Settings → Friends each friend is a one-line row whose right-hand kebab (⋮) slides open
**block 🚫** and **remove ✖️** icon actions, and each action is gated by a confirmation
that names the friend (*Block this player?* / *Remove from friends?*).
Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game Blocking an **auto-match opponent who is secretly a robot** behaves the same in that game
(struck name, hidden composer) and lists the blocked opponent under the name you saw, but is (struck name, hidden composer) and lists the blocked opponent under the name you saw, but is
recorded only against that game — the disguise holds, the shared robot is never globally recorded only against that game — the disguise holds, the shared robot is never globally
@@ -241,7 +244,8 @@ also clears the moment its recipient **takes their move**.
Edit the display name (letters joined by a single space / "." / "_" separator, with an 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 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 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 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 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 & an email or Telegram and merging accounts are covered under "Accounts, linking &
@@ -346,7 +350,9 @@ over-grant cannot be reversed there.
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered 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 **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 (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, 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 **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 stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
+8 -3
View File
@@ -218,6 +218,9 @@ _Вход сейчас только через провайдера, поэто
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там. есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
@@ -248,8 +251,9 @@ _Вход сейчас только через провайдера, поэто
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не «_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы
переходом через полночь) и переключателей блокировок. Форма профиля редактируется игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
@@ -356,7 +360,8 @@ high-rate флага. С карточки пользователя операт
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя, каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка **версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера
на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
+11 -2
View File
@@ -8,7 +8,13 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**: `prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root. runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App
launch that delivered none, as seen on some Android clients) the app renders a compact,
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
never load it.
## Layout shell (`components/Screen.svelte`) ## Layout shell (`components/Screen.svelte`)
@@ -109,7 +115,10 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
## Tiles & board ## Tiles & board
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the - **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
value), the point value bottom-right; blanks show no value. value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing - **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
the board's width** inside a fixed-size viewport (a real layout change → native scroll the board's width** inside a fixed-size viewport (a real layout change → native scroll
that works consistently across browsers; no `transform`, which broke scrolling that works consistently across browsers; no `transform`, which broke scrolling
+15 -8
View File
@@ -184,8 +184,10 @@ type ChatResp struct {
} }
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a // TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
// brand-new account's display name and language from the validated launch fields. // brand-new account's display name and language from the validated launch fields and
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) { // its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
// contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
var out SessionResp var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{ map[string]string{
@@ -193,6 +195,7 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"language_code": languageCode, "language_code": languageCode,
"username": username, "username": username,
"first_name": firstName, "first_name": firstName,
"browser_tz": browserTz,
}, &out) }, &out)
return out, err return out, err
} }
@@ -243,17 +246,21 @@ func (c *Client) ChatAccessByUser(ctx context.Context, userID string) (ChatAcces
return out, err return out, err
} }
// GuestAuth provisions a guest account and mints a session. // GuestAuth provisions a guest account and mints a session, seeding its time zone
func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) { // from browserTz (the client's detected "±HH:MM" UTC offset).
func (c *Client) GuestAuth(ctx context.Context, browserTz string) (SessionResp, error) {
var out SessionResp 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 return out, err
} }
// EmailRequest asks the backend to mail a login code. // EmailRequest asks the backend to mail a login code, provisioning the account on
func (c *Client) EmailRequest(ctx context.Context, email string) error { // 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", "", "", 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. // EmailLogin verifies a login code and mints a session.
@@ -27,12 +27,14 @@ type FeedbackUnreadResp struct {
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded // FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For. // 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{ payload := map[string]string{
"body": body, "body": body,
"attachment": "", "attachment": "",
"attachment_name": attachmentName, "attachment_name": attachmentName,
"channel": channel, "channel": channel,
"version": version,
"browser_tz": browserTz,
} }
if len(attachment) > 0 { if len(attachment) > 0 {
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment) payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
+12 -5
View File
@@ -158,7 +158,7 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
if err != nil { if err != nil {
return nil, err return nil, err
} }
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName) sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -167,8 +167,15 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
} }
func authGuestHandler(backend *backendclient.Client) Handler { func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, _ Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
sess, err := backend.GuestAuth(ctx) // 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 { if err != nil {
return nil, err return nil, err
} }
@@ -179,7 +186,7 @@ func authGuestHandler(backend *backendclient.Client) Handler {
func authEmailRequestHandler(backend *backendclient.Client) Handler { func authEmailRequestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailRequestRequest(req.Payload, 0) 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 nil, err
} }
return encodeAck(true), nil return encodeAck(true), nil
@@ -499,7 +506,7 @@ func hideGameHandler(backend *backendclient.Client) Handler {
func feedbackSubmitHandler(backend *backendclient.Client) Handler { func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0) 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 nil, err
} }
return encodeAck(true), nil return encodeAck(true), nil
+2 -1
View File
@@ -12,7 +12,8 @@
# --- dictionary artifact ----------------------------------------------------- # --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg FROM alpine:3.20 AS dawg
ARG DICT_VERSION=v1.2.1 # Required, no default: the build caller supplies the scrabble-dictionary release tag.
ARG DICT_VERSION
RUN apk add --no-cache curl tar RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \ RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \ && curl -fsSL -o /tmp/dawg.tar.gz \
+2 -2
View File
@@ -35,8 +35,8 @@ The harness reaches Postgres and the gateway directly, so run it as a one-shot
container on the contour's docker network (this bypasses the host→gateway hairpin): container on the contour's docker network (this bypasses the host→gateway hairpin):
```sh ```sh
# from the repo root # from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag)
docker build -f loadtest/Dockerfile -t scrabble-loadtest . docker build --build-arg DICT_VERSION=v1.3.0 -f loadtest/Dockerfile -t scrabble-loadtest .
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \ docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \ -e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
+16 -5
View File
@@ -99,24 +99,33 @@ table MoveRecord {
// --- auth (unauthenticated) --- // --- auth (unauthenticated) ---
// TelegramLoginRequest carries the platform launch data; the gateway validates // 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 { table TelegramLoginRequest {
init_data:string; init_data:string;
browser_tz:string;
} }
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional // 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 { table GuestLoginRequest {
locale:string; 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 { table EmailRequestRequest {
email:string; email:string;
browser_tz:string;
} }
// EmailLoginRequest logs in (or provisions) the account owning email, verifying // EmailLoginRequest logs in to the account owning email (provisioned at the
// the confirm-code. // request step), verifying the confirm-code.
table EmailLoginRequest { table EmailLoginRequest {
email:string; email:string;
code:string; code:string;
@@ -383,6 +392,8 @@ table FeedbackSubmitRequest {
attachment:[ubyte]; attachment:[ubyte];
attachment_name:string; attachment_name:string;
channel:string; channel:string;
version:string;
browser_tz:string;
} }
// FeedbackReply is the operator's answer shown back to the player. // 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 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) { func EmailRequestRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) { func EmailRequestRequestAddEmail(builder *flatbuffers.Builder, email flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(email), 0) 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 { func EmailRequestRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+23 -1
View File
@@ -99,8 +99,24 @@ func (rcv *FeedbackSubmitRequest) Channel() []byte {
return nil 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) { func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(4) builder.StartObject(6)
} }
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0) 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) { func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0) 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 { func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *GuestLoginRequest) Locale() []byte {
return nil 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) { func GuestLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) { func GuestLoginRequestAddLocale(builder *flatbuffers.Builder, locale flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(locale), 0) 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 { func GuestLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+12 -1
View File
@@ -49,12 +49,23 @@ func (rcv *TelegramLoginRequest) InitData() []byte {
return nil 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) { func TelegramLoginRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1) builder.StartObject(2)
} }
func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) { func TelegramLoginRequestAddInitData(builder *flatbuffers.Builder, initData flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(initData), 0) 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 { func TelegramLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+11 -4
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 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 — 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). 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 - **Bot chat.** `/start <payload>` (and the chat menu button) reply with a localized
launch button; a deep-link payload routes the launch to a game / invitation / friend welcome and a Mini App launch button; a deep-link payload routes the launch to a game /
code. This is **self-contained** the bot never calls back into the game, so `/start` invitation / friend code. The welcome is **Russian or English** by the sender's reported
onboarding works even when the game is down. 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 - **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 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 human setting) and the bot only **restricts** — Telegram intersects the chat default with
+1
View File
@@ -72,6 +72,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
MiniAppURL: cfg.MiniAppURL, MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond, SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID, ChatID: cfg.ChatID,
GameChannelID: cfg.GameChannelID,
}, logger) }, logger)
if err != nil { if err != nil {
return err return err
+57 -4
View File
@@ -33,8 +33,12 @@ type Config struct {
SendRatePerSecond int SendRatePerSecond int
// ChatID is the moderated discussion chat the bot gates write access in; 0 // 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 // 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 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
} }
// EligibilityResolver answers whether the Telegram user identified by externalID // EligibilityResolver answers whether the Telegram user identified by externalID
@@ -54,6 +58,13 @@ type Bot struct {
limiter *rate.Limiter limiter *rate.Limiter
// chatID is the moderated discussion chat (0 disables gating). // chatID is the moderated discussion chat (0 disables gating).
chatID int64 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 // 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.
botID int64 botID int64
@@ -69,7 +80,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil { if log == nil {
log = zap.NewNop() log = zap.NewNop()
} }
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID} t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID}
if cfg.SendRatePerSecond > 0 { if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
} }
@@ -123,9 +134,43 @@ func (t *Bot) Run(ctx context.Context) {
if t.chatID != 0 { if t.chatID != 0 {
t.logChatAdminStatus(ctx) t.logChatAdminStatus(ctx)
} }
t.resolveWelcomeHandles(ctx)
t.api.Start(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 // logChatAdminStatus checks, at startup, whether the bot can actually gate the
// moderated chat — it must be an administrator there with the restrict-members // moderated chat — it must be an administrator there with the restrict-members
// ("Ban users") right, or Telegram delivers no chat_member updates and restricts // ("Ban users") right, or Telegram delivers no chat_member updates and restricts
@@ -198,11 +243,19 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.Chat.Type != models.ChatTypePrivate { if update.Message.Chat.Type != models.ChatTypePrivate {
return 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) startParam := startPayload(update.Message.Text)
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID, ChatID: update.Message.Chat.ID,
Text: "Tap to open Scrabble.", Text: text,
ReplyMarkup: t.launchMarkup("Open Scrabble", startParam), ReplyMarkup: t.launchMarkup(button, startParam),
}); err != nil { }); err != nil {
t.log.Warn("reply to start failed", zap.Error(err)) t.log.Warn("reply to start failed", zap.Error(err))
} }
+54 -1
View File
@@ -29,6 +29,10 @@ func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
f.text = r.FormValue("text") f.text = r.FormValue("text")
f.replyMarkup = r.FormValue("reply_markup") f.replyMarkup = r.FormValue("reply_markup")
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) 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: default:
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
} }
@@ -105,7 +109,7 @@ func TestTestEnvironmentRoutesGetMe(t *testing.T) {
} }
func TestHandleStartRepliesPrivateOnly(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{} api := &fakeBotAPI{}
b := newTestBot(t, api) b := newTestBot(t, api)
b.handleStart(context.Background(), b.api, &models.Update{Message: &models.Message{ 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") { 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) 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) { t.Run("group ignored", func(t *testing.T) {
api := &fakeBotAPI{} 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) { func TestStartPayload(t *testing.T) {
cases := map[string]string{ cases := map[string]string{
"/start g123": "g123", "/start g123": "g123",
+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)
}
})
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
// All e2e specs run hermetically against the mock transport. Neutralise the real // All e2e specs run hermetically against the mock transport. Neutralise the real
// telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks // telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the
// on telegram.org — it is unreachable from the CI runner, and a render-blocking // suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise
// <script> to it would hang every page load. Specs that exercise the Telegram launch // the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the
// inject their own window.Telegram via addInitScript before navigating. // dynamic load short-circuits on the already-present SDK.
export const test = base.extend({ export const test = base.extend({
page: async ({ page }, use) => { page: async ({ page }, use) => {
await page.route('**/telegram-web-app.js', (route) => await page.route('**/telegram-web-app.js*', (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }), route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
); );
await use(page); await use(page);
+54
View File
@@ -44,6 +44,60 @@ test('friends: issue a code, accept an incoming request, redeem a code', async (
await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible(); await expect(page.locator('.who', { hasText: 'Friend 111111' })).toBeVisible();
}); });
test('friends: the row kebab reveals block/remove and an outside tap closes it', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
// A friend row slides open on its kebab (like the lobby), exposing two icon actions.
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await expect(kaya).toHaveClass(/revealed/);
await expect(kaya.locator('.acts').getByRole('button', { name: 'Block' })).toBeVisible();
await expect(kaya.locator('.acts').getByRole('button', { name: 'Remove' })).toBeVisible();
// A tap anywhere outside the action buttons collapses the row again.
await page.getByRole('heading', { name: 'Your friends' }).click();
await expect(kaya).not.toHaveClass(/revealed/);
});
test('friends: blocking from the list confirms (naming the friend) and moves them to Blocked', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await kaya.locator('.acts').getByRole('button', { name: 'Block' }).click();
// The confirmation keeps a generic title and names the friend in the body.
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Block this player?')).toBeVisible();
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
await dialog.getByRole('button', { name: 'Block' }).click();
// The block applied: Kaya leaves the friends list and shows under Blocked players.
await expect(page.getByText('No friends yet.')).toBeVisible();
const blocked = page.locator('.rowwrap', { hasText: 'Kaya' });
await expect(blocked.getByRole('button', { name: 'Unblock' })).toBeVisible();
});
test('friends: removing from the list confirms (naming the friend) and drops them', async ({ page }) => {
await loginLobby(page);
await openFriends(page);
const kaya = page.locator('.rowwrap', { hasText: 'Kaya' });
await kaya.locator('.kebab').click();
await kaya.locator('.acts').getByRole('button', { name: 'Remove' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog.getByText('Remove from friends?')).toBeVisible();
await expect(dialog.locator('.confirm-name')).toHaveText('Kaya');
await dialog.getByRole('button', { name: 'Remove' }).click();
// Unfriending just drops the friendship — Kaya is gone and not blocked.
await expect(page.getByText('No friends yet.')).toBeVisible();
await expect(page.locator('.rowwrap', { hasText: 'Kaya' })).toHaveCount(0);
});
test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => { test('invitations: the lobby shows an invitation and accepting clears it', async ({ page }) => {
await loginLobby(page); await loginLobby(page);
await expect(page.getByText('Invitations')).toBeVisible(); await expect(page.getByText('Invitations')).toBeVisible();
+24 -5
View File
@@ -107,11 +107,30 @@ test('inside Telegram, a failed launch shows the retry screen, not the web login
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
}); });
test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => { test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a redirect', async ({
page,
}) => {
await page.goto('/telegram/'); await page.goto('/telegram/');
// The guard sends a non-Telegram visitor back to the root, where the normal // The entry no longer bounces a visitor without Telegram sign-in data to the marketing landing;
// (guest / email) login is shown. // it shows a compact diagnostic screen (Share + Retry) that helps pinpoint why initData was
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible(); // absent — notably the Android empty-initData failure.
await expect(page).not.toHaveURL(/\/telegram\//); await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
// It stays on /telegram/ (no redirect) and never shows the web (guest) login.
await expect(page).toHaveURL(/\/telegram\//);
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
// fetch must not strand the page — the diagnostic screen still renders, reporting no SDK. (This
// route overrides the fixture's empty-body fulfill; the later registration wins.)
await page.route('**/telegram-web-app.js*', (route) => route.abort());
await page.goto('/telegram/');
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// The diagnostic names the load outcome: a failed fetch reads as sdk-load: error.
await expect(page.getByText('sdk-load: error')).toBeVisible();
}); });
+5 -3
View File
@@ -2,9 +2,11 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside <!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a
Telegram (initData is empty), so it loads on every entry. --> render-blocking <script> to telegram.org hangs the whole page on a network that blocks
<script src="https://telegram.org/js/telegram-web-app.js"></script> telegram.org (common where Telegram itself reaches users only over a proxy), stranding even
the launch-diagnostic screen. The app loads it dynamically, with a timeout, only on a
Telegram entry — see lib/telegram.ts loadTelegramSDK and lib/app.svelte.ts bootstrap. -->
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch <!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). --> to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta <meta
+7 -1
View File
@@ -19,6 +19,7 @@
import Feedback from './screens/Feedback.svelte'; import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte'; import Blocked from './screens/Blocked.svelte';
import BootError from './screens/BootError.svelte'; import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => { onMount(() => {
void bootstrap(); void bootstrap();
@@ -84,6 +85,11 @@
{#if !routeIsLobby} {#if !routeIsLobby}
<div class="splash">{t('common.loading')}</div> <div class="splash">{t('common.loading')}</div>
{/if} {/if}
{:else if app.launchError}
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
of bouncing to the marketing landing (also the probe for the empty-initData failure on
some Android clients). -->
<TelegramLaunchError />
{:else if app.bootError} {:else if app.bootError}
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy): <!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
show the retry screen instead of falling back to the web login. --> show the retry screen instead of falling back to the web login. -->
@@ -128,7 +134,7 @@
<StaleInviteModal /> <StaleInviteModal />
<WelcomeRedeemModal /> <WelcomeRedeemModal />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError}
<Splash /> <Splash />
{/if} {/if}
+18 -6
View File
@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile // A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile // look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
// shows its letter but no value, exactly as on the board. Letters are upper-cased for // shows its letter but no value, exactly as on the board; in Erudit it also carries the
// display. The tile values ride on each tile, so this renders without the variant's // blank's star (✻) in the value corner. Letters are upper-cased for display. The tile
// alphabet table (which the statistics screen has not cached). // values ride on each tile, so this needs only the variant id (for the star) — not the
import type { BestMoveTile } from '../lib/model'; // variant's alphabet table, which the statistics screen has not cached.
import type { BestMoveTile, Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let { word }: { word: BestMoveTile[] } = $props(); let { word, variant }: { word: BestMoveTile[]; variant: Variant } = $props();
const label = $derived(word.map((t) => t.letter).join('').toUpperCase()); const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
</script> </script>
@@ -15,7 +17,11 @@
{#each word as tile, i (i)} {#each word as tile, i (i)}
<span class="tile" class:blank={tile.blank} aria-hidden="true"> <span class="tile" class:blank={tile.blank} aria-hidden="true">
<span class="letter">{tile.letter.toUpperCase()}</span> <span class="letter">{tile.letter.toUpperCase()}</span>
{#if !tile.blank}<span class="val">{tile.value}</span>{/if} {#if !tile.blank}
<span class="val">{tile.value}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
</span> </span>
{/each} {/each}
</span> </span>
@@ -50,4 +56,10 @@
font-size: 7px; font-size: 7px;
font-weight: 600; font-weight: 600;
} }
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink kept on the value digits' line (mirrors the board tile). */
.blankmark {
font-size: 8px;
bottom: 0;
}
</style> </style>
+12 -1
View File
@@ -4,6 +4,7 @@
import type { Premium } from '../lib/premiums'; import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet'; import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels'; import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog'; import type { Locale } from '../lib/i18n/catalog';
@@ -254,7 +255,11 @@
> >
{#if letter} {#if letter}
<span class="letter">{letter}</span> <span class="letter">{letter}</span>
{#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if} {#if !blank}
<span class="val">{valueForLetter(variant, letter)}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
{:else if r === centre.row && c === centre.col} {:else if r === centre.row && c === centre.col}
<span class="star"></span> <span class="star"></span>
{:else if bl?.kind === 'single'} {:else if bl?.kind === 'single'}
@@ -410,6 +415,12 @@
font-size: 2.4cqw; font-size: 2.4cqw;
font-weight: 600; font-weight: 600;
} }
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: 2.8cqw;
bottom: 0;
}
.star { .star {
position: absolute; position: absolute;
inset: 0; inset: 0;
+2 -2
View File
@@ -17,7 +17,7 @@
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey } from '../lib/variants'; import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints'; import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share'; import { shareOrDownloadGcg } from '../lib/share';
@@ -1293,7 +1293,7 @@
{#if drag} {#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px"> <div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? '' : drag.letter}</span> <span>{drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}</span>
</div> </div>
{/if} {/if}
+18 -2
View File
@@ -3,6 +3,7 @@
import { BLANK } from '../lib/placement'; import { BLANK } from '../lib/placement';
import { valueForLetter } from '../lib/alphabet'; import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let { let {
slots, slots,
@@ -66,8 +67,12 @@
animate:hop={shuffling} animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)} onpointerdown={(e) => ondown(e, slot.index)}
> >
<span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span> {#if slot.letter === BLANK}
{#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if} {#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if}
{:else}
<span class="letter">{slot.letter}</span>
<span class="val">{valueForLetter(variant, slot.letter)}</span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
@@ -133,4 +138,15 @@
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
} }
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
.star {
position: absolute;
top: calc(0.5% - 1px);
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
}
</style> </style>
@@ -27,22 +27,34 @@ email(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
browserTz():string|null
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
browserTz(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startEmailRequestRequest(builder:flatbuffers.Builder) { static startEmailRequestRequest(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) { static addEmail(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, emailOffset, 0); builder.addFieldOffset(0, emailOffset, 0);
} }
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, browserTzOffset, 0);
}
static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endEmailRequestRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset { static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder); EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset); EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
return EmailRequestRequest.endEmailRequestRequest(builder); return EmailRequestRequest.endEmailRequestRequest(builder);
} }
} }
@@ -56,8 +56,22 @@ channel(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
version():string|null
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
version(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
browserTz():string|null
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
browserTz(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) { static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
builder.startObject(4); builder.startObject(6);
} }
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) { static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
@@ -88,17 +102,27 @@ static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset)
builder.addFieldOffset(3, channelOffset, 0); builder.addFieldOffset(3, channelOffset, 0);
} }
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, versionOffset, 0);
}
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, browserTzOffset, 0);
}
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset { static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder); FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
FeedbackSubmitRequest.addBody(builder, bodyOffset); FeedbackSubmitRequest.addBody(builder, bodyOffset);
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset); FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset); FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
FeedbackSubmitRequest.addChannel(builder, channelOffset); FeedbackSubmitRequest.addChannel(builder, channelOffset);
FeedbackSubmitRequest.addVersion(builder, versionOffset);
FeedbackSubmitRequest.addBrowserTz(builder, browserTzOffset);
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder); return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
} }
} }
@@ -27,22 +27,34 @@ locale(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
browserTz():string|null
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
browserTz(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startGuestLoginRequest(builder:flatbuffers.Builder) { static startGuestLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) { static addLocale(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, localeOffset, 0); builder.addFieldOffset(0, localeOffset, 0);
} }
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, browserTzOffset, 0);
}
static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endGuestLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset):flatbuffers.Offset { static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
GuestLoginRequest.startGuestLoginRequest(builder); GuestLoginRequest.startGuestLoginRequest(builder);
GuestLoginRequest.addLocale(builder, localeOffset); GuestLoginRequest.addLocale(builder, localeOffset);
GuestLoginRequest.addBrowserTz(builder, browserTzOffset);
return GuestLoginRequest.endGuestLoginRequest(builder); return GuestLoginRequest.endGuestLoginRequest(builder);
} }
} }
@@ -27,22 +27,34 @@ initData(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
browserTz():string|null
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
browserTz(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startTelegramLoginRequest(builder:flatbuffers.Builder) { static startTelegramLoginRequest(builder:flatbuffers.Builder) {
builder.startObject(1); builder.startObject(2);
} }
static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) { static addInitData(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, initDataOffset, 0); builder.addFieldOffset(0, initDataOffset, 0);
} }
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, browserTzOffset, 0);
}
static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endTelegramLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset):flatbuffers.Offset { static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset {
TelegramLoginRequest.startTelegramLoginRequest(builder); TelegramLoginRequest.startTelegramLoginRequest(builder);
TelegramLoginRequest.addInitData(builder, initDataOffset); TelegramLoginRequest.addInitData(builder, initDataOffset);
TelegramLoginRequest.addBrowserTz(builder, browserTzOffset);
return TelegramLoginRequest.endTelegramLoginRequest(builder); return TelegramLoginRequest.endTelegramLoginRequest(builder);
} }
} }
+97 -29
View File
@@ -8,10 +8,15 @@ import { gateway } from './gateway';
import { GatewayError } from './client'; import { GatewayError } from './client';
import { navigate, router } from './router.svelte'; import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { import {
insideTelegram, insideTelegram,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath, onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
telegramColorScheme, telegramColorScheme,
telegramContentSafeAreaTop, telegramContentSafeAreaTop,
telegramSafeAreaTop, telegramSafeAreaTop,
@@ -46,6 +51,11 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen * backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */ * instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean; bootError: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
launchError: TelegramDiag | null;
/** Whether the lobby's first cold load has settled (success or error). The loading splash /** Whether the lobby's first cold load has settled (success or error). The loading splash
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean; lobbyReady: boolean;
@@ -67,7 +77,6 @@ export const app = $state<{
locale: Locale; locale: Locale;
reduceMotion: boolean; reduceMotion: boolean;
boardLabels: BoardLabelMode; boardLabels: BoardLabelMode;
localeLocked: boolean;
/** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */ /** Pending incoming friend requests, for the lobby ⚙️ badge and the Settings Friends tab. */
notifications: number; notifications: number;
/** Per-game flag: the player has at least one unread chat entry (message or nudge) in that /** Per-game flag: the player has at least one unread chat entry (message or nudge) in that
@@ -96,6 +105,7 @@ export const app = $state<{
}>({ }>({
ready: false, ready: false,
bootError: false, bootError: false,
launchError: null,
lobbyReady: false, lobbyReady: false,
splashDone: false, splashDone: false,
streamAlive: false, streamAlive: false,
@@ -108,7 +118,6 @@ export const app = $state<{
locale: 'en', locale: 'en',
reduceMotion: false, reduceMotion: false,
boardLabels: 'beginner', boardLabels: 'beginner',
localeLocked: false,
notifications: 0, notifications: 0,
chatUnread: {}, chatUnread: {},
messageUnread: {}, messageUnread: {},
@@ -450,11 +459,20 @@ async function adoptSession(s: Session): Promise<void> {
await saveSession(s); await saveSession(s);
try { try {
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
// The live interface language follows the device — the explicit local choice (locked, saved // The live interface language follows the device — the explicit local choice (saved in
// in prefs) or the system guess made at bootstrap — and is no longer overridden from the // prefs) or the system guess made at bootstrap — and is no longer overridden from the
// account here. preferred_language stays the user's saved choice (written from Settings, // account here: the Telegram bot a user signs in through must not dictate the UI, so a
// and used for out-of-app push routing), but the Telegram bot a user signs in through must // ru-bot launch on an English system stays English.
// not dictate the UI: a ru-bot launch on an English system stays English. //
// The banner and out-of-app push are resolved server-side from preferred_language, so it
// must track whatever language the UI actually shows — the explicit choice AND the system
// guess. Reconcile it to the active locale on every adopt, not only after an explicit
// Settings choice: a user who never opened Settings would otherwise be stuck on the
// creation-time seed — e.g. an English banner under a Russian UI. This keeps every
// server-rendered, language-dependent surface (banner, out-of-app push) aligned with the
// interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when
// already equal), so there is no write in the steady state.
void persistLanguageToServer(app.locale);
} catch (err) { } catch (err) {
handleError(err); handleError(err);
} }
@@ -475,6 +493,10 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
return; return;
} }
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
// A guest who linked in place now has a durable account: push the active interface language
// so the banner + push routing follow it (see adoptSession — reconciled regardless of an
// explicit Settings choice).
void persistLanguageToServer(app.locale);
} }
/** /**
@@ -518,6 +540,35 @@ function syncViewportHeight(): void {
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
} }
/**
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
* bootstrap and a manual launch retry call it.
*/
function applyTelegramChrome(launch: TelegramLaunch): void {
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
// back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
// drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's
// full-size window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
}
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
* the launch-error screen. A network that blocks telegram.org makes the script hang rather than
* fail fast (a connection refusal resolves immediately via the script's error event), so this only
* bounds a true hang; it is generous enough not to misfire on a slow but working network. */
const TELEGRAM_SDK_TIMEOUT_MS = 10000;
export async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
@@ -527,7 +578,6 @@ export async function bootstrap(): Promise<void> {
applyReduceMotion(app.reduceMotion); applyReduceMotion(app.reduceMotion);
if (prefs.locale) { if (prefs.locale) {
app.locale = prefs.locale; app.locale = prefs.locale;
app.localeLocked = true;
setLocale(prefs.locale); setLocale(prefs.locale);
} else { } else {
const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en'); const guess = localeFrom(typeof navigator !== 'undefined' ? navigator.language : 'en');
@@ -542,33 +592,30 @@ export async function bootstrap(): Promise<void> {
window.visualViewport.addEventListener('scroll', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewportHeight);
} }
// Telegram Mini App launch: apply the platform theme, authenticate via initData, // Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no
// and route any deep-link start parameter. On the dedicated /telegram/ entry path // longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common
// outside Telegram (no initData), refuse to render and send the visitor to the // where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app
// site root. // or the diagnostic screen below. Skipped on a plain web / native entry, which never needs it.
if (onTelegramPath() || hasLaunchFragment()) {
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
// clients), render the compact launch-error screen with a diagnostic snapshot the user can share
// with the developer, instead of bouncing the visitor to the marketing landing.
if (onTelegramPath() && !insideTelegram()) { if (onTelegramPath() && !insideTelegram()) {
if (typeof location !== 'undefined') location.replace('/'); app.launchError = collectTelegramDiag();
app.ready = true;
return; return;
} }
if (insideTelegram()) { if (insideTelegram()) {
const launch = telegramLaunch(); const launch = telegramLaunch();
if (launch.theme) applyTelegramTheme(launch.theme); applyTelegramChrome(launch);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly // Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
// fighting tile drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
// window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
await bootTelegram(launch); await bootTelegram(launch);
app.ready = true; app.ready = true;
return; return;
@@ -635,6 +682,28 @@ export async function retryTelegramBoot(): Promise<void> {
app.ready = true; app.ready = true;
} }
/**
* retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If
* sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs
* the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the
* current state. It never reloads — telegram-web-app.js consumes the launch fragment on first load,
* so a reload could discard the very data we are waiting for.
*/
export async function retryTelegramLaunch(): Promise<void> {
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
if (!insideTelegram()) {
app.launchError = collectTelegramDiag();
return;
}
app.launchError = null;
app.ready = false;
const launch = telegramLaunch();
applyTelegramChrome(launch);
await bootTelegram(launch);
app.ready = true;
}
/** /**
* routeStartParam navigates a Telegram deep-link start parameter to its target: a * routeStartParam navigates a Telegram deep-link start parameter to its target: a
* specific game, the friends screen with a friend-code redemption, or the lobby * specific game, the friends screen with a friend-code redemption, or the lobby
@@ -743,7 +812,6 @@ export function setTheme(theme: ThemePref): void {
export function setLocalePref(locale: Locale): void { export function setLocalePref(locale: Locale): void {
app.locale = locale; app.locale = locale;
app.localeLocked = true;
setLocale(locale); setLocale(locale);
persistPrefs(); persistPrefs();
void persistLanguageToServer(locale); void persistLanguageToServer(locale);
@@ -756,7 +824,7 @@ export function setLocalePref(locale: Locale): void {
*/ */
async function persistLanguageToServer(locale: Locale): Promise<void> { async function persistLanguageToServer(locale: Locale): Promise<void> {
const p = app.profile; const p = app.profile;
if (!p || p.isGuest || p.preferredLanguage === locale) return; if (!p || !languageNeedsServerSync(p, locale)) return;
try { try {
app.profile = await gateway.profileUpdate({ app.profile = await gateway.profileUpdate({
displayName: p.displayName, displayName: p.displayName,
+29 -2
View File
@@ -19,13 +19,16 @@ import {
decodeStateView, decodeStateView,
decodeStats, decodeStats,
encodeCheckWord, encodeCheckWord,
encodeEmailRequest,
encodeFeedbackSubmit, encodeFeedbackSubmit,
encodeDraftSave, encodeDraftSave,
encodeEnqueue, encodeEnqueue,
encodeExchange, encodeExchange,
encodeGuestLogin,
encodeStateRequest, encodeStateRequest,
encodeSubmitPlay, encodeSubmitPlay,
encodeTarget, encodeTarget,
encodeTelegramLogin,
encodeUpdateProfile, encodeUpdateProfile,
} from './codec'; } from './codec';
@@ -73,21 +76,45 @@ describe('codec', () => {
}); });
}); });
it('carries the detected browser zone on every account-creating auth request', () => {
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')),
);
expect(tg.initData()).toBe('init-data-blob');
expect(tg.browserTz()).toBe('+03:00');
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
new ByteBuffer(encodeGuestLogin('ru', '-05:30')),
);
expect(guest.locale()).toBe('ru');
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
});
it('round-trips a feedback submit and decodes state + unread', () => { it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]); const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')), new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios', 'v1.2.3', '+03:00')),
); );
expect(req.body()).toBe('please fix'); expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png'); expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios'); expect(req.channel()).toBe('ios');
expect(req.version()).toBe('v1.2.3');
expect(req.browserTz()).toBe('+03:00');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]); expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty. // No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')), new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web', 'dev', '+00:00')),
); );
expect(req2.body()).toBe('hi'); expect(req2.body()).toBe('hi');
expect(req2.version()).toBe('dev');
expect(req2.browserTz()).toBe('+00:00');
expect(req2.attachmentLength()).toBe(0); expect(req2.attachmentLength()).toBe(0);
// State carrying a reply. // State carrying a reply.
+15 -3
View File
@@ -179,27 +179,33 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
return finish(b, fb.ChatPostRequest.endChatPostRequest(b)); return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
} }
export function encodeTelegramLogin(initData: string): Uint8Array { export function encodeTelegramLogin(initData: string, browserTz: string): Uint8Array {
const b = new Builder(512); const b = new Builder(512);
const d = b.createString(initData); const d = b.createString(initData);
const tz = b.createString(browserTz);
fb.TelegramLoginRequest.startTelegramLoginRequest(b); fb.TelegramLoginRequest.startTelegramLoginRequest(b);
fb.TelegramLoginRequest.addInitData(b, d); fb.TelegramLoginRequest.addInitData(b, d);
fb.TelegramLoginRequest.addBrowserTz(b, tz);
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b)); return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
} }
export function encodeGuestLogin(locale: string): Uint8Array { export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const l = b.createString(locale); const l = b.createString(locale);
const tz = b.createString(browserTz);
fb.GuestLoginRequest.startGuestLoginRequest(b); fb.GuestLoginRequest.startGuestLoginRequest(b);
fb.GuestLoginRequest.addLocale(b, l); fb.GuestLoginRequest.addLocale(b, l);
fb.GuestLoginRequest.addBrowserTz(b, tz);
return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b)); return finish(b, fb.GuestLoginRequest.endGuestLoginRequest(b));
} }
export function encodeEmailRequest(email: string): Uint8Array { export function encodeEmailRequest(email: string, browserTz: string): Uint8Array {
const b = new Builder(128); const b = new Builder(128);
const e = b.createString(email); const e = b.createString(email);
const tz = b.createString(browserTz);
fb.EmailRequestRequest.startEmailRequestRequest(b); fb.EmailRequestRequest.startEmailRequestRequest(b);
fb.EmailRequestRequest.addEmail(b, e); fb.EmailRequestRequest.addEmail(b, e);
fb.EmailRequestRequest.addBrowserTz(b, tz);
return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b)); return finish(b, fb.EmailRequestRequest.endEmailRequestRequest(b));
} }
@@ -489,17 +495,23 @@ export function encodeFeedbackSubmit(
attachment: Uint8Array | null, attachment: Uint8Array | null,
attachmentName: string, attachmentName: string,
channel: string, channel: string,
version: string,
browserTz: string,
): Uint8Array { ): Uint8Array {
const b = new Builder(256); const b = new Builder(256);
const bodyOff = b.createString(body); const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0; const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName); const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel); const chOff = b.createString(channel);
const verOff = b.createString(version);
const tzOff = b.createString(browserTz);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b); fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff); fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff); if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff); fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff); fb.FeedbackSubmitRequest.addChannel(b, chOff);
fb.FeedbackSubmitRequest.addVersion(b, verOff);
fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b)); return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
} }
+8
View File
@@ -14,6 +14,11 @@ export const en = {
'boot.errorTitle': "Couldn't load the game", 'boot.errorTitle': "Couldn't load the game",
'boot.errorBody': 'Please try again in a moment.', 'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
'common.back': 'Back', 'common.back': 'Back',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.ok': 'OK', 'common.ok': 'OK',
@@ -227,6 +232,9 @@ export const en = {
'friends.decline': 'Decline', 'friends.decline': 'Decline',
'friends.unfriend': 'Remove', 'friends.unfriend': 'Remove',
'friends.block': 'Block', 'friends.block': 'Block',
'friends.actions': 'Actions',
'friends.blockConfirm': 'Block this player?',
'friends.unfriendConfirm': 'Remove from friends?',
'friends.add': 'Add a friend', 'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends', 'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player', 'friends.blockFromGame': 'Block player',
+8
View File
@@ -15,6 +15,11 @@ export const ru: Record<MessageKey, string> = {
'boot.errorTitle': 'Не удалось загрузить игру', 'boot.errorTitle': 'Не удалось загрузить игру',
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
'common.back': 'Назад', 'common.back': 'Назад',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.ok': 'ОК', 'common.ok': 'ОК',
@@ -228,6 +233,9 @@ export const ru: Record<MessageKey, string> = {
'friends.decline': 'Отклонить', 'friends.decline': 'Отклонить',
'friends.unfriend': 'Удалить', 'friends.unfriend': 'Удалить',
'friends.block': 'Заблокировать', 'friends.block': 'Заблокировать',
'friends.actions': 'Действия',
'friends.blockConfirm': 'Заблокировать?',
'friends.unfriendConfirm': 'Удалить из друзей?',
'friends.add': 'Добавить друга', 'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья', 'friends.addFromGame': 'В друзья',
'friends.blockFromGame': 'Заблокировать', 'friends.blockFromGame': 'Заблокировать',
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { languageNeedsServerSync } from './language';
import type { Profile } from './model';
// The reconciler only reads isGuest + preferredLanguage; a partial cast keeps the fixture small.
const profile = (over: Partial<Profile>): Profile => ({ isGuest: false, preferredLanguage: 'en', ...over }) as Profile;
describe('languageNeedsServerSync', () => {
it('is false without a profile', () => {
expect(languageNeedsServerSync(null, 'ru')).toBe(false);
expect(languageNeedsServerSync(undefined, 'ru')).toBe(false);
});
it('is false for a guest — guests keep only the client preference', () => {
expect(languageNeedsServerSync(profile({ isGuest: true, preferredLanguage: 'en' }), 'ru')).toBe(false);
});
it('is false when the account already matches the locale', () => {
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'ru')).toBe(false);
});
it('is true for a real account whose stored language differs (banner + push follow it)', () => {
expect(languageNeedsServerSync(profile({ preferredLanguage: 'en' }), 'ru')).toBe(true);
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'en')).toBe(true);
});
});
+22
View File
@@ -0,0 +1,22 @@
// Interface-language reconciliation. Kept out of app.svelte.ts (a runes module that the
// node-env Vitest layer cannot import) so the decision is unit-testable.
import type { Locale } from './i18n/catalog';
import type { Profile } from './model';
/**
* languageNeedsServerSync reports whether the durable account's `preferred_language` should be
* rewritten to the chosen interface `locale`. It is true only for a real (non-guest) account
* whose stored language differs from the locale; guests keep only the client-side preference,
* and an already-matching account is a no-op.
*
* The UI language follows the device (the local choice / system guess), but the advertising
* banner and out-of-app push routing are resolved server-side from `preferred_language`. A saved
* device choice the account has not yet recorded — picked while a guest, or differing from the
* Telegram system-language seed — would otherwise leave the banner and pushes in the wrong
* language until the next Settings change. Both the Settings control and the on-load reconciler
* gate their write on this.
*/
export function languageNeedsServerSync(profile: Profile | null | undefined, locale: Locale): boolean {
return !!profile && !profile.isGuest && profile.preferredLanguage !== locale;
}
+40 -1
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery, shareOrDownloadGcg } from './share'; import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share';
import type { GcgExport } from './model'; import type { GcgExport } from './model';
const file = {} as File; const file = {} as File;
@@ -60,3 +60,42 @@ describe('shareOrDownloadGcg', () => {
expect(anchor.click).toHaveBeenCalledOnce(); expect(anchor.click).toHaveBeenCalledOnce();
}); });
}); });
describe('pickTextShare', () => {
it('shares when Web Share is available', () => {
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
});
it('shares when share exists without canShare (text needs no file capability check)', () => {
expect(pickTextShare({ share: async () => {} })).toBe('share');
});
it('copies when there is no Web Share (desktop)', () => {
expect(pickTextShare(undefined)).toBe('copy');
expect(pickTextShare({} as never)).toBe('copy');
});
});
describe('shareText', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses the OS share sheet when available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'title', text: 'diag' });
});
it('copies to the clipboard when Web Share is absent (desktop)', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
expect(await shareText('diag', 'title')).toBe('copied');
expect(writeText).toHaveBeenCalledWith('diag');
});
it('reports failure without a fallback when the share is cancelled', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('failed');
});
});
+39
View File
@@ -51,3 +51,42 @@ function downloadFile(content: string, filename: string): void {
a.remove(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**
* pickTextShare decides how to deliver a plain-text payload: through the OS share sheet (Web Share,
* available on mobile including the Telegram Mini App) or, on a desktop browser without it, a
* clipboard copy. Pure, so it is unit-tested with a mock navigator.
*/
export function pickTextShare(nav: TextShareNav | undefined): 'share' | 'copy' {
if (nav && typeof nav.share === 'function' && (typeof nav.canShare !== 'function' || nav.canShare({ text: 'x' }))) {
return 'share';
}
return 'copy';
}
/**
* shareText delivers text through the OS share sheet where supported, else copies it to the
* clipboard. It reports the path taken — 'shared', 'copied', or 'failed' (a cancelled share or an
* unavailable clipboard) — so the caller can confirm a silent copy to the user. Like
* shareOrDownloadGcg it never strands the webview: a cancelled share simply does nothing.
*/
export async function shareText(text: string, title: string): Promise<'shared' | 'copied' | 'failed'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (!nav) return 'failed';
if (pickTextShare(nav) === 'share') {
try {
await nav.share({ title, text });
return 'shared';
} catch {
return 'failed';
}
}
try {
await nav.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
+106
View File
@@ -1,6 +1,9 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
collectTelegramDiag,
insideTelegram, insideTelegram,
loadTelegramSDK,
telegramSdkOutcome,
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramClosingConfirmation, telegramClosingConfirmation,
telegramLaunch, telegramLaunch,
@@ -155,3 +158,106 @@ describe('routeExternalLinkInTelegram', () => {
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false); expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false);
}); });
}); });
describe('collectTelegramDiag', () => {
afterEach(() => vi.unstubAllGlobals());
it('reports a missing SDK outside Telegram', () => {
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(false);
expect(d.hasWebApp).toBe(false);
expect(d.initDataLen).toBe(0);
expect(d.fieldsPresent).toEqual([]);
expect(d.fieldsMissing).toEqual(['user', 'auth_date', 'hash', 'signature']);
});
it('reads field NAMES (never values) from a non-empty initData', () => {
stubWebApp('query_id=abc&user=%7B%7D&auth_date=1&hash=deadbeef');
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.hasWebApp).toBe(true);
expect(d.initDataLen).toBeGreaterThan(0);
expect(d.fieldsPresent).toEqual(['query_id', 'user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
it('recovers field names from the URL fragment when the SDK left initData empty', () => {
// Telegram passed launch data in the fragment, but WebApp.initData is empty (the Android
// failure this screen diagnoses): the names come from the raw fragment instead, and
// hashHadTgData flags that the data did arrive in the URL.
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '', platform: 'android' } } });
vi.stubGlobal('location', {
hash: '#tgWebAppData=user%3D%257B%257D%26auth_date%3D1%26hash%3Ddeadbeef&tgWebAppVersion=7.0',
pathname: '/telegram/',
});
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.platform).toBe('android');
expect(d.initDataLen).toBe(0);
expect(d.hashHadTgData).toBe(true);
expect(d.fieldsPresent).toEqual(['user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
});
describe('loadTelegramSDK', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('resolves true and records "present" when the SDK is already there', async () => {
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '' } } });
vi.stubGlobal('document', { createElement: vi.fn(), head: { appendChild: vi.fn() } });
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('present');
});
it('records "loaded" when the script defines the WebApp', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: {
appendChild: () => {
(window as unknown as { Telegram: unknown }).Telegram = { WebApp: { initData: '' } };
(script.onload as () => void)();
},
},
});
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('loaded');
});
it('records "no-webapp" when the script loads but defines nothing', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onload as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('no-webapp');
});
it('records "error" when the script fails to load (telegram.org unreachable)', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onerror as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('error');
});
it('records "timeout" when the script neither loads nor fails in time', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', {});
vi.stubGlobal('document', { createElement: () => ({}), head: { appendChild: vi.fn() } });
const p = loadTelegramSDK(10000);
await vi.advanceTimersByTimeAsync(10000);
await expect(p).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('timeout');
});
});
+197 -3
View File
@@ -1,6 +1,7 @@
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in // Telegram Mini App SDK access. The official telegram-web-app.js (loaded dynamically with a
// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses: // timeout by loadTelegramSDK — not a render-blocking <script> in index.html, so a network that
// launch detection, initData (for auth.telegram), the deep-link start parameter, // blocks telegram.org cannot hang the page) exposes window.Telegram.WebApp; this wraps the subset
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram. // theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
import type { TelegramThemeParams } from './theme'; import type { TelegramThemeParams } from './theme';
@@ -9,6 +10,7 @@ interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
platform?: string; platform?: string;
version?: string;
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean; isFullscreen?: boolean;
@@ -53,6 +55,72 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0; return !!w && typeof w.initData === 'string' && w.initData.length > 0;
} }
// The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the
// version the official Mini Apps page currently recommends so newer client features (fullscreen,
// safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs.
const sdkScriptSrc = 'https://telegram.org/js/telegram-web-app.js?62';
/**
* TelegramSdkOutcome records how the dynamic telegram-web-app.js load resolved, surfaced on the
* launch-error screen to tell the failure modes apart — notably 'error' / 'timeout', which mean the
* network could not reach telegram.org (the script blocked or hung):
*
* not-attempted — loadTelegramSDK was never called (a plain web / native entry)
* present — window.Telegram.WebApp was already there (a cached load or native injection)
* loaded — the script loaded and defined window.Telegram.WebApp
* no-webapp — the script loaded (HTTP 200) but did not define window.Telegram.WebApp
* error — the script failed to load (telegram.org unreachable / blocked, a fast failure)
* timeout — the script neither loaded nor failed within the timeout (a blocked, hanging fetch)
*/
export type TelegramSdkOutcome = 'not-attempted' | 'present' | 'loaded' | 'no-webapp' | 'error' | 'timeout';
let sdkLoadOutcome: TelegramSdkOutcome = 'not-attempted';
/** telegramSdkOutcome returns how the last loadTelegramSDK attempt resolved (see TelegramSdkOutcome). */
export function telegramSdkOutcome(): TelegramSdkOutcome {
return sdkLoadOutcome;
}
/**
* loadTelegramSDK injects the official telegram-web-app.js and resolves true once
* window.Telegram.WebApp is available, or false if the script errors or does not load within
* timeoutMs. It is loaded dynamically — not a render-blocking <script> in index.html — so a network
* that blocks telegram.org (common where Telegram itself reaches users only over a proxy) cannot
* hang the page and strand the app or the launch-error screen. Resolves true immediately when the
* SDK is already present (a cached load, or a future native injection); a fast connection failure
* resolves via the error event without waiting out the timeout, so the timeout only bounds a true
* hang.
*/
export function loadTelegramSDK(timeoutMs: number): Promise<boolean> {
if (typeof document === 'undefined') return Promise.resolve(false);
if (webApp()) {
sdkLoadOutcome = 'present';
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
let done = false;
const finish = (outcome: TelegramSdkOutcome): void => {
if (done) return;
done = true;
sdkLoadOutcome = outcome;
resolve(outcome === 'loaded');
};
const timer = setTimeout(() => finish(webApp() ? 'loaded' : 'timeout'), timeoutMs);
const s = document.createElement('script');
s.src = sdkScriptSrc;
s.async = true;
s.onload = () => {
clearTimeout(timer);
finish(webApp() ? 'loaded' : 'no-webapp');
};
s.onerror = () => {
clearTimeout(timer);
finish('error');
};
document.head.appendChild(s);
});
}
/** /**
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to * telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false * it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
@@ -302,6 +370,132 @@ export function onTelegramPath(): boolean {
return location.pathname.startsWith('/telegram/'); return location.pathname.startsWith('/telegram/');
} }
/** hasLaunchFragment reports whether the URL fragment carries Telegram launch params (tgWebApp…),
* the form Telegram appends when opening a Mini App — so the SDK is loaded for a Mini App opened
* at the site root too, not only the /telegram/ path. */
export function hasLaunchFragment(): boolean {
if (typeof location === 'undefined') return false;
return location.hash.includes('tgWebApp');
}
// --- Launch diagnostics (the /telegram/ entry without sign-in data) ---
/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal
* the launch-error screen reports. Only field names are ever inspected, never their values. */
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
interface uaBrand {
brand: string;
version: string;
}
interface uaDataValue {
platform?: string;
mobile?: boolean;
brands?: uaBrand[];
}
/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android Telegram
* webview), or undefined where it is unavailable (iOS / Safari / Firefox). */
function uaData(): uaDataValue | undefined {
if (typeof navigator === 'undefined') return undefined;
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
}
/** launchFragmentData returns the raw tgWebAppData carried in the URL fragment (the form Telegram
* appends on launch), or '' when absent. With no SDK present a non-empty value means Telegram
* delivered the data but telegram-web-app.js never ran to parse it. */
function launchFragmentData(): string {
if (typeof location === 'undefined') return '';
const frag = location.hash.replace(/^#/, '');
if (!frag) return '';
try {
return new URLSearchParams(frag).get('tgWebAppData') ?? '';
} catch {
return '';
}
}
/** initDataFieldNames parses a Telegram initData (or raw fragment data) query string and returns
* only its field NAMES — never the values, since the hash / signature are auth material. */
function initDataFieldNames(raw: string): string[] {
if (!raw) return [];
try {
return [...new URLSearchParams(raw).keys()];
} catch {
return [];
}
}
/**
* TelegramDiag is a privacy-safe snapshot of why a Mini App launch lacked sign-in data, taken at
* the moment of failure and rendered on the launch-error screen so a stuck user can share it with
* the developer. It carries no secret values — only presence flags, client / OS identification and
* the field NAMES of the launch data, never the signed initData itself, and never an IP.
*/
export interface TelegramDiag {
/** Whether window.Telegram (the telegram-web-app.js script) is present at all. */
hasSDK: boolean;
/** Whether window.Telegram.WebApp is present. */
hasWebApp: boolean;
/** How the dynamic telegram-web-app.js load resolved (see TelegramSdkOutcome) — 'error' /
* 'timeout' mean telegram.org was unreachable, the prime suspect for an empty launch. */
sdkLoad: TelegramSdkOutcome;
/** Telegram's own platform string (ios | android | android_x | tdesktop | web | …), or ''. */
platform: string;
/** The Bot API version the client reports, or ''. */
version: string;
/** The length of WebApp.initData — 0 is the failure this screen reports. */
initDataLen: number;
/** Whether the URL fragment still carried tgWebAppData at launch; true with hasSDK false means
* Telegram delivered the data but the SDK script did not load to parse it. */
hashHadTgData: boolean;
/** The field names present in the launch data (from initData, or the raw fragment when the SDK
* left initData empty); values are never included. */
fieldsPresent: string[];
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
fieldsMissing: string[];
/** The OS / platform per User-Agent Client Hints (else navigator.platform), e.g. 'Android', ''. */
osPlatform: string;
/** Whether the client reports itself mobile per Client Hints: 'yes' | 'no' | '' (unknown). */
mobile: string;
/** The browser brands + major versions per Client Hints (Chromium only), or ''. */
browser: string;
/** The full User-Agent string — the catch-all that also carries the OS version and webview build. */
userAgent: string;
}
/**
* collectTelegramDiag captures a TelegramDiag snapshot of the current launch state. Call it at the
* point a /telegram/ launch is found to lack sign-in data, so the snapshot reflects that moment —
* sign-in data arriving late would otherwise mask the failure.
*/
export function collectTelegramDiag(): TelegramDiag {
const w = webApp();
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
const initData = w?.initData ?? '';
const fragData = initData ? '' : launchFragmentData();
const present = initDataFieldNames(initData || fragData);
const ua = uaData();
const navPlatform =
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
return {
hasSDK: sdk,
hasWebApp: !!w,
sdkLoad: sdkLoadOutcome,
platform: w?.platform ?? '',
version: w?.version ?? '',
initDataLen: initData.length,
hashHadTgData: fragData.length > 0,
fieldsPresent: present,
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
osPlatform: ua?.platform ?? navPlatform,
mobile: ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no',
browser: (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', '),
userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent,
};
}
// --- Login Widget (web sign-in for account linking) --- // --- Login Widget (web sign-in for account linking) ---
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
+8 -4
View File
@@ -10,6 +10,7 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb'; import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client'; import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec'; import * as codec from './codec';
import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
@@ -62,13 +63,13 @@ export function createTransport(baseUrl: string): GatewayClient {
}, },
async authTelegram(initData) { async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData))); return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
}, },
async authGuest(locale) { async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
}, },
async authEmailRequest(email) { async authEmailRequest(email) {
await exec('auth.email.request', codec.encodeEmailRequest(email)); await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset()));
}, },
async authEmailLogin(email, code) { async authEmailLogin(email, code) {
return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code))); return codec.decodeSession(await exec('auth.email.login', codec.encodeEmailLogin(email, code)));
@@ -147,7 +148,10 @@ export function createTransport(baseUrl: string): GatewayClient {
await exec('chat.read', codec.encodeGameAction(id)); await exec('chat.read', codec.encodeGameAction(id));
}, },
async feedbackSubmit(body, attachment, attachmentName, channel) { async feedbackSubmit(body, attachment, attachmentName, channel) {
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel)); // The app build (Vite define) and the device's detected UTC offset ride with the report
// so the operator sees which version it came from and the local time it was filed; the
// caller need not pass them.
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel, __APP_VERSION__, browserOffset()));
}, },
async feedbackGet() { async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty())); return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
+15
View File
@@ -5,6 +5,8 @@ import {
availableVariants, availableVariants,
supportsMultipleWordsToggle, supportsMultipleWordsToggle,
multipleWordsForRequest, multipleWordsForRequest,
usesStarBlank,
BLANK_STAR,
} from './variants'; } from './variants';
describe('ALL_VARIANTS', () => { describe('ALL_VARIANTS', () => {
@@ -53,3 +55,16 @@ describe('multipleWordsForRequest', () => {
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true); expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
}); });
}); });
describe('usesStarBlank', () => {
it('marks the blank with a star for Erudit only', () => {
expect(usesStarBlank('erudit_ru')).toBe(true);
expect(usesStarBlank('scrabble_ru')).toBe(false);
expect(usesStarBlank('scrabble_en')).toBe(false);
});
it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => {
expect(BLANK_STAR).toBe('✻');
expect(BLANK_STAR.codePointAt(0)).toBe(0x273b);
});
});
+14
View File
@@ -48,6 +48,20 @@ export const VARIANT_FLAG: Record<Variant, string> = {
// ru -> Russian + Эрудит. // ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' }; export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the
// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY
// TEARDROP-SPOKED ASTERISK.
export const BLANK_STAR = '✻';
// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only
// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it
// in the value corner (the corner is free — a blank has no point value). The Scrabble
// variants leave the blank unmarked (an empty rack face; a placed blank shown by its
// designated letter alone).
export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru';
}
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set // availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// they enabled in Settings). An empty or absent set is ungated (returns every variant) // they enabled in Settings). An empty or absent set is ungated (returns every variant)
// — a safety fallback; a real profile always carries at least one preference. // — a safety fallback; a real profile always carries at least one preference.
+189 -42
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
@@ -16,6 +17,11 @@
let robotBlocks = $state<RobotBlockEntry[]>([]); let robotBlocks = $state<RobotBlockEntry[]>([]);
let code = $state<FriendCode | null>(null); let code = $state<FriendCode | null>(null);
let redeemInput = $state(''); let redeemInput = $state('');
// The friend row whose kebab actions are slid open, like the lobby list.
let revealedId = $state<string | null>(null);
// Pending confirmation targets: the friend account awaiting a block / unfriend confirm.
let blockTarget = $state<AccountRef | null>(null);
let unfriendTarget = $state<AccountRef | null>(null);
async function load() { async function load() {
try { try {
@@ -52,6 +58,41 @@
const blockUser = (id: string) => act(() => gateway.block(id)); const blockUser = (id: string) => act(() => gateway.block(id));
const unblock = (id: string) => act(() => gateway.unblock(id)); const unblock = (id: string) => act(() => gateway.unblock(id));
// toggleReveal slides one friend row open (closing any other), exposing its
// block / unfriend icon actions; tapping the same kebab again closes it.
function toggleReveal(id: string): void {
revealedId = revealedId === id ? null : id;
}
// confirmBlock / confirmUnfriend run the pending action once its modal is
// accepted, then clear the target and the revealed row.
function confirmBlock(): void {
const target = blockTarget;
blockTarget = null;
revealedId = null;
if (target) void blockUser(target.accountId);
}
function confirmUnfriend(): void {
const target = unfriendTarget;
unfriendTarget = null;
revealedId = null;
if (target) void remove(target.accountId);
}
// While a friend row is slid open, a tap anywhere outside its action buttons
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
$effect(() => {
if (revealedId === null) return;
function onDown(e: PointerEvent) {
const el = e.target as Element | null;
if (el?.closest('.acts') || el?.closest('.kebab')) return;
revealedId = null;
}
window.addEventListener('pointerdown', onDown, true);
return () => window.removeEventListener('pointerdown', onDown, true);
});
async function getCode() { async function getCode() {
try { try {
code = await gateway.friendCodeIssue(); code = await gateway.friendCodeIssue();
@@ -152,30 +193,39 @@
{#if incoming.length} {#if incoming.length}
<section> <section>
<h3>{t('friends.incoming')}</h3> <h3>{t('friends.incoming')}</h3>
{#each incoming as r (r.accountId)} <div class="list">
<div class="item"> {#each incoming as r (r.accountId)}
<span class="who">{r.displayName}</span> <div class="rowwrap">
<span class="acts"> <div class="row">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button> <span class="who">{r.displayName}</span>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button> <span class="btns">
</span> <button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
</div> <button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
{/each} </span>
</div>
</div>
{/each}
</div>
</section> </section>
{/if} {/if}
<section> <section>
<h3>{t('friends.yours')}</h3> <h3>{t('friends.yours')}</h3>
{#if friends.length} {#if friends.length}
{#each friends as f (f.accountId)} <div class="list">
<div class="item"> {#each friends as f (f.accountId)}
<span class="who">{f.displayName}</span> <div class="rowwrap" class:revealed={revealedId === f.accountId}>
<span class="acts"> <div class="acts">
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button> <button class="iconbtn" onclick={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button> <button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
</span> </div>
</div> <div class="row">
{/each} <span class="who">{f.displayName}</span>
<button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button>
</div>
</div>
{/each}
</div>
{:else} {:else}
<p class="muted">{t('friends.none')}</p> <p class="muted">{t('friends.none')}</p>
{/if} {/if}
@@ -184,20 +234,49 @@
{#if blocked.length || robotBlocks.length} {#if blocked.length || robotBlocks.length}
<section> <section>
<h3>{t('friends.blockedList')}</h3> <h3>{t('friends.blockedList')}</h3>
{#each blocked as b (b.accountId)} <div class="list">
<div class="item"> {#each blocked as b (b.accountId)}
<span class="who">{b.displayName}</span> <div class="rowwrap">
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button> <div class="row">
</div> <span class="who">{b.displayName}</span>
{/each} <span class="btns">
{#each robotBlocks as r (r.id)} <button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
<div class="item"> </span>
<span class="who">{r.displayName}</span> </div>
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button> </div>
</div> {/each}
{/each} {#each robotBlocks as r (r.id)}
<div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div>
{/each}
</div>
</section> </section>
{/if} {/if}
{#if blockTarget}
<Modal title={t('friends.blockConfirm')} onclose={() => (blockTarget = null)}>
<p class="confirm-name">{blockTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmBlock} disabled={!connection.online}>{t('friends.block')}</button>
</div>
</Modal>
{/if}
{#if unfriendTarget}
<Modal title={t('friends.unfriendConfirm')} onclose={() => (unfriendTarget = null)}>
<p class="confirm-name">{unfriendTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmUnfriend} disabled={!connection.online}>{t('friends.unfriend')}</button>
</div>
</Modal>
{/if}
{/if} {/if}
</div> </div>
@@ -279,28 +358,76 @@
padding: 4px 0; padding: 4px 0;
text-align: left; text-align: left;
} }
.item { .list {
display: flex;
flex-direction: column;
}
/* One-line rows split by hairlines, mirroring the lobby list. */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* Block / unfriend icon actions sit behind the friend row, exposed when it slides left. */
.acts {
position: absolute;
inset: 0 0 0 auto;
display: flex;
align-items: stretch;
}
.iconbtn {
flex: 0 0 auto;
width: 48px;
border: none;
background: var(--bg-elev);
color: var(--text);
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
}
.iconbtn + .iconbtn {
border-left: 1px solid var(--border); /* the vertical divider between 🚫 and ✖️ */
}
.row {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border); background: var(--bg);
background: var(--surface); transform: translateX(0);
border-radius: var(--radius-sm); transition: transform 0.18s ease;
margin-bottom: 8px; }
.rowwrap.revealed .row {
transform: translateX(-96px); /* 2 × 48px icon buttons */
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 6px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.btns {
display: flex;
gap: 8px;
flex: 0 0 auto;
} }
.who { .who {
flex: 1;
min-width: 0;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn { .btn {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--accent); border: 1px solid var(--accent);
@@ -315,7 +442,27 @@
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.ghost.danger { .confirm-name {
color: var(--danger, #c0392b); margin: 0 0 12px;
font-weight: 600;
overflow-wrap: anywhere; /* a long display name wraps instead of stretching the sheet */
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.confirm-row .danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
} }
</style> </style>
+1 -1
View File
@@ -73,7 +73,7 @@
{#each bestMoves as bm (bm.variant)} {#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span> <span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="score">{bm.score}</span> <span class="score">{bm.score}</span>
<span class="wordcell"><WordTiles word={bm.word} /></span> <span class="wordcell"><WordTiles word={bm.word} variant={bm.variant} /></span>
{/each} {/each}
</div> </div>
</div> </div>
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
import { app, retryTelegramLaunch } from '../lib/app.svelte';
import { shareText } from '../lib/share';
import { t } from '../lib/i18n/index.svelte';
const diag = $derived(app.launchError);
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
// prose), so the developer reads the same report in any locale. Kept terse to fit one screenshot.
const report = $derived(
diag
? [
`sdk-load: ${diag.sdkLoad}`,
`sdk: ${diag.hasSDK ? 'yes' : 'no'} webapp: ${diag.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${diag.platform || '—'} tg-version: ${diag.version || '—'}`,
`initData: ${diag.initDataLen > 0 ? diag.initDataLen : 'empty'} tgdata-in-url: ${diag.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${diag.fieldsPresent.join(',') || '—'}`,
`missing: ${diag.fieldsMissing.join(',') || '—'}`,
`os: ${diag.osPlatform || '—'} mobile: ${diag.mobile || '—'}`,
`browser: ${diag.browser || '—'}`,
`ua: ${diag.userAgent || '—'}`,
].join('\n')
: '',
);
let retrying = $state(false);
async function retry(): Promise<void> {
if (retrying) return;
retrying = true;
try {
await retryTelegramLaunch();
} finally {
retrying = false;
}
}
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function share(): Promise<void> {
const r = await shareText(report, t('launch.errorTitle'));
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
if (r === 'copied') {
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => (copied = false), 1500);
}
}
</script>
{#if diag}
<div class="boot">
<div class="card">
<h1>{t('launch.errorTitle')}</h1>
<p class="msg">{t('launch.errorBody')}</p>
<pre class="diag">{report}</pre>
<div class="actions">
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
</div>
</div>
</div>
{/if}
<style>
.boot {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
background: var(--bg);
}
.card {
max-width: 32rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
text-align: center;
color: var(--text);
}
h1 {
margin: 0;
font-size: 1.1rem;
}
.msg {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.diag {
margin: 0;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.4;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.share,
.retry {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
.share {
background: var(--accent);
color: var(--accent-text);
}
.retry {
background: transparent;
color: var(--accent);
}
.retry:disabled {
opacity: 0.5;
}
</style>