Compare commits

...

23 Commits

Author SHA1 Message Date
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
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
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 deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
41 changed files with 999 additions and 154 deletions
+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
} }
+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))
+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)
} }
+23 -6
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
} }
+3 -1
View File
@@ -150,7 +150,9 @@ Re-run `ansible/` after a host resize — it is idempotent.
workflow manually (Gitea → Actions → prod-deploy → run from `master`, input workflow manually (Gitea → Actions → prod-deploy → run from `master`, input
`confirm=deploy`). It builds + pushes the images to the registry, ships the `confirm=deploy`). It builds + pushes the images to the registry, ships the
compose/config/certs/env over SSH, deploys the main host with `prod-deploy.sh` (rolling, compose/config/certs/env over SSH, deploys the main host with `prod-deploy.sh` (rolling,
health-gated, **auto-rollback to the previous tag**), then the bot host, then probes the health-gated, **auto-rollback to the previous tag**; caddy is force-recreated on its roll so
a bind-mounted `Caddyfile` change applies — its image is pinned and admin is off, so neither a
new tag nor a hot reload would pick it up), then the bot host, then probes the
public site. After `master` is green this workflow is the **only** thing that touches public site. After `master` is green this workflow is the **only** thing that touches
prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main → prod — nothing auto-deploys there. It runs four visible jobs: **build → deploy-main →
deploy-bot → verify** (the per-service rolling shows in the deploy-main log). deploy-bot → verify** (the per-service rolling shows in the deploy-main log).
+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 {
+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"
} }
+16 -4
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
@@ -1089,7 +1098,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.
+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.
+5 -1
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 &
+6 -2
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, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
+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.
+11 -4
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
+14 -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;
+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()
} }
+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)
}
})
}
+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();
@@ -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);
} }
} }
@@ -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);
} }
} }
+17 -18
View File
@@ -68,7 +68,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
@@ -109,7 +108,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: {},
@@ -451,18 +449,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.
// //
// But the banner and out-of-app push routing ARE resolved from preferred_language, so an // The banner and out-of-app push are resolved server-side from preferred_language, so it
// explicit device choice the account has not recorded yet (picked while a guest, or // must track whatever language the UI actually shows — the explicit choice AND the system
// differing from the Telegram system-language seed) would otherwise leave them in the wrong // guess. Reconcile it to the active locale on every adopt, not only after an explicit
// language until the next Settings change. Reconcile the account to the saved local choice // Settings choice: a user who never opened Settings would otherwise be stuck on the
// here; persistLanguageToServer no-ops for guests and when already equal. // creation-time seed — e.g. an English banner under a Russian UI. This keeps every
if (app.localeLocked) void persistLanguageToServer(app.locale); // 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);
} }
@@ -483,9 +483,10 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
return; return;
} }
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
// A guest who chose a language and then linked in place now has a durable account: push the // A guest who linked in place now has a durable account: push the active interface language
// saved choice so the banner + push routing follow it (see adoptSession). // so the banner + push routing follow it (see adoptSession — reconciled regardless of an
if (app.localeLocked) void persistLanguageToServer(app.locale); // explicit Settings choice).
void persistLanguageToServer(app.locale);
} }
/** /**
@@ -538,7 +539,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');
@@ -754,7 +754,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);
+23
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,6 +76,26 @@ 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(
+9 -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));
} }
+3
View File
@@ -227,6 +227,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',
+3
View File
@@ -228,6 +228,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': 'Заблокировать',
+3 -3
View File
@@ -63,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)));
+168 -21
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>
<div class="list">
{#each incoming as r (r.accountId)} {#each incoming as r (r.accountId)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="acts"> <span class="btns">
<button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button> <button class="btn" onclick={() => respond(r.accountId, true)} disabled={!connection.online}>{t('friends.accept')}</button>
<button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button> <button class="ghost" onclick={() => respond(r.accountId, false)} disabled={!connection.online}>{t('friends.decline')}</button>
</span> </span>
</div> </div>
</div>
{/each} {/each}
</div>
</section> </section>
{/if} {/if}
<section> <section>
<h3>{t('friends.yours')}</h3> <h3>{t('friends.yours')}</h3>
{#if friends.length} {#if friends.length}
<div class="list">
{#each friends as f (f.accountId)} {#each friends as f (f.accountId)}
<div class="item"> <div class="rowwrap" class:revealed={revealedId === f.accountId}>
<div class="acts">
<button class="iconbtn" onclick={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
<button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
</div>
<div class="row">
<span class="who">{f.displayName}</span> <span class="who">{f.displayName}</span>
<span class="acts"> <button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button>
<button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button> </div>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div> </div>
{/each} {/each}
</div>
{:else} {:else}
<p class="muted">{t('friends.none')}</p> <p class="muted">{t('friends.none')}</p>
{/if} {/if}
@@ -184,20 +234,49 @@
{#if blocked.length || robotBlocks.length} {#if blocked.length || robotBlocks.length}
<section> <section>
<h3>{t('friends.blockedList')}</h3> <h3>{t('friends.blockedList')}</h3>
<div class="list">
{#each blocked as b (b.accountId)} {#each blocked as b (b.accountId)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{b.displayName}</span> <span class="who">{b.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(b.accountId)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div> </div>
{/each} {/each}
{#each robotBlocks as r (r.id)} {#each robotBlocks as r (r.id)}
<div class="item"> <div class="rowwrap">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="btns">
<button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button> <button class="ghost" onclick={() => unblock(r.id)} disabled={!connection.online}>{t('friends.unblock')}</button>
</span>
</div>
</div> </div>
{/each} {/each}
</div>
</section> </section>
{/if} {/if}
{#if blockTarget}
<Modal title={t('friends.blockConfirm')} onclose={() => (blockTarget = null)}>
<p class="confirm-name">{blockTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmBlock} disabled={!connection.online}>{t('friends.block')}</button>
</div>
</Modal>
{/if}
{#if unfriendTarget}
<Modal title={t('friends.unfriendConfirm')} onclose={() => (unfriendTarget = null)}>
<p class="confirm-name">{unfriendTarget.displayName}</p>
<div class="confirm-row">
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
<button class="danger" onclick={confirmUnfriend} disabled={!connection.online}>{t('friends.unfriend')}</button>
</div>
</Modal>
{/if}
{/if} {/if}
</div> </div>
@@ -279,28 +358,76 @@
padding: 4px 0; padding: 4px 0;
text-align: left; text-align: left;
} }
.item { .list {
display: flex;
flex-direction: column;
}
/* One-line rows split by hairlines, mirroring the lobby list. */
.rowwrap {
position: relative;
overflow: hidden;
}
.rowwrap + .rowwrap {
border-top: 1px solid var(--border);
}
/* Block / unfriend icon actions sit behind the friend row, exposed when it slides left. */
.acts {
position: absolute;
inset: 0 0 0 auto;
display: flex;
align-items: stretch;
}
.iconbtn {
flex: 0 0 auto;
width: 48px;
border: none;
background: var(--bg-elev);
color: var(--text);
font-size: 1.1rem;
display: flex;
align-items: center;
justify-content: center;
}
.iconbtn + .iconbtn {
border-left: 1px solid var(--border); /* the vertical divider between 🚫 and ✖️ */
}
.row {
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid var(--border); background: var(--bg);
background: var(--surface); transform: translateX(0);
border-radius: var(--radius-sm); transition: transform 0.18s ease;
margin-bottom: 8px; }
.rowwrap.revealed .row {
transform: translateX(-96px); /* 2 × 48px icon buttons */
}
.kebab {
flex: 0 0 auto;
width: 30px;
padding: 6px 0;
border: none;
background: none;
color: var(--text-muted);
font-size: 1.4rem;
line-height: 1;
}
.btns {
display: flex;
gap: 8px;
flex: 0 0 auto;
} }
.who { .who {
flex: 1;
min-width: 0;
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.acts {
display: flex;
gap: 8px;
flex: 0 0 auto;
}
.btn { .btn {
padding: 8px 12px; padding: 8px 12px;
border: 1px solid var(--accent); border: 1px solid var(--accent);
@@ -315,7 +442,27 @@
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.ghost.danger { .confirm-name {
color: var(--danger, #c0392b); margin: 0 0 12px;
font-weight: 600;
overflow-wrap: anywhere; /* a long display name wraps instead of stretching the sheet */
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.confirm-row .danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
} }
</style> </style>