Compare commits

..

3 Commits

83 changed files with 260 additions and 2086 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ on:
# unit/integration jobs inherit it. The deploy job overrides it per contour with # unit/integration jobs inherit it. The deploy job overrides it per contour with
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md. # vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
env: env:
DICT_VERSION: v1.3.0 DICT_VERSION: v1.2.1
jobs: jobs:
# changes detects which areas a PR/push touched, so the test jobs can skip when # changes detects which areas a PR/push touched, so the test jobs can skip when
+1 -1
View File
@@ -144,7 +144,7 @@ go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI
pnpm start # UI mock mode: lobby -> game, no backend pnpm start # UI mock mode: lobby -> game, no backend
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required (no default); gateway embeds the SPA docker build -f backend/Dockerfile -t scrabble-backend . # images; gateway embeds the SPA
docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway . docker build -f gateway/Dockerfile --target gateway -t scrabble-gateway .
docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing docker build -f gateway/Dockerfile --target landing -t scrabble-landing . # static landing
docker compose -f deploy/docker-compose.yml config # validate the full contour docker compose -f deploy/docker-compose.yml config # validate the full contour
+1 -1
View File
@@ -89,7 +89,7 @@ observability stack (OTel Collector → Prometheus + Tempo → Grafana) + a fron
services build from multi-stage distroless `*/Dockerfile`. services build from multi-stage distroless `*/Dockerfile`.
```sh ```sh
docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend . # DICT_VERSION required; pulls that DAWG release artifact docker build -f backend/Dockerfile -t scrabble-backend . # pulls the DAWG release artifact
docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI docker build -f gateway/Dockerfile -t scrabble-gateway . # node stage builds + embeds the UI
docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env) docker compose -f deploy/docker-compose.yml config # validate (needs the TEST_/PROD_ env)
``` ```
+4 -6
View File
@@ -7,14 +7,12 @@
# (GOPRIVATE), so the build stage needs git and network. # (GOPRIVATE), so the build stage needs git and network.
# #
# Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all # Build from the repository root so go.work, go.work.sum, pkg/ and backend/ are all
# in the Docker context. DICT_VERSION has no default — the caller supplies the # in the Docker context:
# scrabble-dictionary release tag (compose/CI pass it; see deploy/README.md # docker build -f backend/Dockerfile -t scrabble-backend .
# "Bumping the dictionary version"):
# docker build --build-arg DICT_VERSION=v1.3.0 -f backend/Dockerfile -t scrabble-backend .
# --- dictionary artifact ----------------------------------------------------- # --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg FROM alpine:3.20 AS dawg
ARG DICT_VERSION ARG DICT_VERSION=v1.2.1
RUN apk add --no-cache curl tar RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \ RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \ && curl -fsSL -o /tmp/dawg.tar.gz \
@@ -44,7 +42,7 @@ FROM gcr.io/distroless/static-debian12:nonroot
# Re-declare the build arg in this stage so it labels the seed dictionary. One # Re-declare the build arg in this stage so it labels the seed dictionary. One
# DICT_VERSION drives both the artifact the dawg stage downloads and the version # DICT_VERSION drives both the artifact the dawg stage downloads and the version
# label the binary pins, so the resident version equals the release tag. # label the binary pins, so the resident version equals the release tag.
ARG DICT_VERSION ARG DICT_VERSION=v1.2.1
COPY --from=build /out/backend /usr/local/bin/backend COPY --from=build /out/backend /usr/local/bin/backend
# Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume # Own the seed dictionary as the nonroot runtime user (UID 65532): a named volume
# mounted at /opt/dawg inherits this ownership on first use, so the admin console # mounted at /opt/dawg inherits this ownership on first use, so the admin console
+1 -1
View File
@@ -228,7 +228,7 @@ internal/banview/ # gateway active-ban mirror: the console's Active IP bans p
```sh ```sh
docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
# DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg): # DAWGs: extract the dictionary release artifact (or point at a local scrabble-solver/dawg):
mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.3.0/scrabble-dawg-v1.3.0.tar.gz | tar xz -C /tmp/dawg mkdir -p /tmp/dawg && curl -fsSL https://gitea.iliadenisov.ru/developer/scrabble-dictionary/releases/download/v1.2.1/scrabble-dawg-v1.2.1.tar.gz | tar xz -C /tmp/dawg
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \ BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
BACKEND_DICT_DIR=/tmp/dawg \ BACKEND_DICT_DIR=/tmp/dawg \
GOPRIVATE='gitea.iliadenisov.ru/*' \ GOPRIVATE='gitea.iliadenisov.ru/*' \
+14 -50
View File
@@ -119,16 +119,6 @@ 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
@@ -170,7 +160,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, browserTZ string) (Account, bool, error) { func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName 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.
@@ -179,9 +169,7 @@ 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
} }
seed := telegramSeed(languageCode, username, firstName) acc, err := s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
seed.timeZone = seedZone(browserTZ)
acc, err := s.provision(ctx, KindTelegram, externalID, seed)
return acc, created, err return acc, created, err
} }
@@ -209,24 +197,12 @@ 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 (first contact). Empty fields fall back to the accounts table defaults, // account (Telegram first contact). Empty fields fall back to the accounts table
// so an unknown language keeps the 'en' default, an empty name keeps the ” default // defaults, so an unknown language keeps the 'en' default and an empty name keeps
// and an empty time zone keeps the 'UTC' default. // the ” 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
@@ -392,22 +368,16 @@ 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, language and time zone (first contact); an // Seed the new row's display name and language (Telegram first contact); an
// empty seed reproduces the table defaults ('', 'en' and 'UTC') the other callers // empty seed reproduces the table defaults ('' and 'en') the other callers
// relied on, so their behaviour is unchanged. time_zone is written explicitly (the // relied on, so their behaviour is unchanged.
// 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, table.Accounts.TimeZone). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
VALUES(accountID, seed.displayName, lang, tz). VALUES(accountID, seed.displayName, lang).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
@@ -446,21 +416,15 @@ 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. browserTZ // and history. Guests are not reused — each bootstrap mints a new account.
// (the client's detected "±HH:MM" UTC offset) seeds the guest's time zone, falling func (s *Store) ProvisionGuest(ctx context.Context) (Account, error) {
// 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, table.Accounts.TimeZone). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.IsGuest).
VALUES(accountID, guestDisplayName, true, tz). VALUES(accountID, guestDisplayName, true).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts var row model.Accounts
+3 -5
View File
@@ -131,15 +131,13 @@ 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. On first contact browserTZ (the client's detected "±HH:MM" UTC offset) // the login. It returns the target account id for the subsequent LoginWithCode.
// seeds the new account's time zone. It returns the target account id for the func (s *EmailService) RequestLoginCode(ctx context.Context, email string) (uuid.UUID, error) {
// 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.ProvisionEmail(ctx, addr, browserTZ) acc, err := s.store.ProvisionByIdentity(ctx, KindEmail, addr)
if err != nil { if err != nil {
return uuid.UUID{}, err return uuid.UUID{}, err
} }
@@ -7,9 +7,8 @@
<li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li> <li><b>From</b> <a href="/_gm/users/{{.AccountID}}">{{.SenderName}}</a> ({{.Source}})</li>
<li><b>Channel</b> {{.Channel}}</li> <li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li> <li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>App version</b> {{if .Version}}<code>{{.Version}}</code>{{else}}<span class="note">unknown</span>{{end}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li> <li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}} UTC &middot; browser {{if .CreatedAtBrowser}}{{.CreatedAtBrowser}} ({{.BrowserTZ}}){{else}}<span class="note">N/A</span>{{end}} &middot; user {{if .CreatedAtUser}}{{.CreatedAtUser}} ({{.UserTZ}}){{else}}<span class="note">N/A</span>{{end}}</li> <li><b>Filed</b> {{.CreatedAt}}</li>
<li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li> <li><b>State</b> {{if .Archived}}archived{{else if .Read}}read{{else}}<span class="warn">unread</span>{{end}}</li>
{{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}} {{if .Banned}}<li><b>Feedback</b> <span class="warn">sender is banned from feedback</span></li>{{end}}
</ul> </ul>
-12
View File
@@ -554,17 +554,5 @@ type FeedbackDetailView struct {
ReplyBody string ReplyBody string
RepliedAt string RepliedAt string
CreatedAt string CreatedAt string
// Version is the client app build the report was sent from (empty for rows that predate it).
Version string
// The Filed time is shown in three zones so the operator can tell what is certainly known from
// what is merely defaulted. CreatedAt is the authoritative UTC time. CreatedAtBrowser is that
// instant in the client's UTC offset detected at submit (BrowserTZ its "±HH:MM" label), empty
// when the client reported none (an older build). CreatedAtUser is that instant in the sender's
// saved profile zone (UserTZ its label), empty when the account has no zone beyond the UTC
// default — the template then shows "N/A" so the missing datum is explicit.
CreatedAtBrowser string
BrowserTZ string
CreatedAtUser string
UserTZ string
Banned bool Banned bool
} }
+4 -5
View File
@@ -72,7 +72,7 @@ func (svc *Service) SetNotifier(p notify.Publisher) {
// validates the body (non-empty, within the rune limit) and the optional // validates the body (non-empty, within the rune limit) and the optional
// attachment (size and extension allow-list). senderIP is the gateway-forwarded // attachment (size and extension allow-list). senderIP is the gateway-forwarded
// client IP (validated); channel is the submitting platform. // client IP (validated); channel is the submitting platform.
func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, version, browserTZ, senderIP string) error { func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, senderIP string) error {
acc, err := svc.accounts.GetByID(ctx, accountID) acc, err := svc.accounts.GetByID(ctx, accountID)
if err != nil { if err != nil {
return err return err
@@ -112,10 +112,9 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment attachmentName = "" // a name without bytes carries no attachment
} }
ch := normalizeChannel(channel) ch := normalizeChannel(channel)
// Snapshot the sender's interface language, the client app version and the client's // Snapshot the sender's interface language at submit time (acc is already loaded
// detected UTC offset at submit time (acc is already loaded for the guest check) so the // for the guest check) so the operator later sees the state as it was.
// operator later sees the state as it was. _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, version, browserTZ, parseIP(senderIP))
return err return err
} }
+9 -17
View File
@@ -34,10 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment // Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default // is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (interface language), version (client app build) and // channel are stored as given. lang (the sender's interface language) is a snapshot
// browserTZ (the client's detected "±HH:MM" UTC offset) are snapshots taken now, so the operator // taken now, so the operator later sees the state at submit time. created_at defaults
// later sees the state at submit time. created_at defaults to now() in the database. // to now() in the database.
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, version, browserTZ string, ip *string) (uuid.UUID, error) { func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7() id, err := uuid.NewV7()
if err != nil { if err != nil {
return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err) return uuid.Nil, fmt.Errorf("feedback: new message id: %w", err)
@@ -48,9 +48,9 @@ func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, at
} }
if _, err := s.db.ExecContext(ctx, if _, err := s.db.ExecContext(ctx,
`INSERT INTO backend.feedback_messages `INSERT INTO backend.feedback_messages
(message_id, account_id, body, attachment, attachment_name, channel, lang, app_version, browser_tz, sender_ip) (message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(version), nullStr(browserTZ), ip); err != nil { id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err) return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
} }
return id, nil return id, nil
@@ -229,14 +229,6 @@ type AdminMessage struct {
Channel string Channel string
// Lang is the sender's interface language, snapshotted at submit time. // Lang is the sender's interface language, snapshotted at submit time.
Lang string Lang string
// Version is the client app build the report was sent from, snapshotted at submit time.
Version string
// BrowserTZ is the client's detected "±HH:MM" UTC offset at submit time, snapshotted so the
// filed time can be shown in the sender's browser-local zone even before they save a profile.
BrowserTZ string
// TimeZone is the sender account's stored zone ("±HH:MM" offset, IANA name, or ""), for
// rendering CreatedAt in the sender's own configured time alongside UTC.
TimeZone string
SenderIP string SenderIP string
HasAttachment bool HasAttachment bool
AttachmentName string AttachmentName string
@@ -351,7 +343,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage var m AdminMessage
var repliedAt sql.NullTime var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel, q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
COALESCE(m.lang, ''), COALESCE(m.app_version, ''), COALESCE(m.browser_tz, ''), a.time_zone, COALESCE(m.lang, ''),
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''), COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL), (m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -360,7 +352,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1` WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan( err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel, &m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
&m.Lang, &m.Version, &m.BrowserTZ, &m.TimeZone, &m.Lang,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName, &m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt) &m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+11 -59
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, display name and time zone from the launch // seeds the new account's language and display name from the launch fields,
// fields / detected offset, defaults the in-app-only flag on, and never overwrites // defaults the in-app-only flag on, and never overwrites an existing account on a
// an existing account on a later login (language and zone seeding). // later login (language 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", "Иван", "+03:00") acc, created, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
if err != nil { if err != nil {
t.Fatalf("provision telegram: %v", err) t.Fatalf("provision telegram: %v", err)
} }
@@ -131,15 +131,12 @@ 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", "+09:00") again, created, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
if err != nil { if err != nil {
t.Fatalf("re-provision telegram: %v", err) t.Fatalf("re-provision telegram: %v", err)
} }
@@ -149,53 +146,8 @@ 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 != "Иван" || again.TimeZone != "+03:00" { if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone) t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
}
}
// 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)
} }
} }
@@ -204,7 +156,7 @@ func TestProvisionSeedsTimeZone(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)
} }
@@ -220,7 +172,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)
} }
@@ -276,7 +228,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)
} }
@@ -301,7 +253,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)
} }
+2 -5
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, "+02:00") accountID, err := svc.RequestLoginCode(ctx, email)
if err != nil { if err != nil {
t.Fatalf("request login code: %v", err) t.Fatalf("request login code: %v", err)
} }
@@ -225,15 +225,12 @@ func TestEmailLoginFlow(t *testing.T) {
if acc.IsGuest { if acc.IsGuest {
t.Error("an email account must be durable, not a guest") t.Error("an email account must be durable, not a guest")
} }
if acc.TimeZone != "+02:00" {
t.Errorf("TimeZone = %q, want the +02:00 seeded at the request step", acc.TimeZone)
}
if !identityConfirmed(t, account.KindEmail, email) { if !identityConfirmed(t, account.KindEmail, email) {
t.Error("the email identity must be confirmed after login") t.Error("the email identity must be confirmed after login")
} }
// A second login for the same email is the returning user: same account. // A second login for the same email is the returning user: same account.
if _, err := svc.RequestLoginCode(ctx, email, ""); err != nil { if _, err := svc.RequestLoginCode(ctx, email); err != nil {
t.Fatalf("second request: %v", err) t.Fatalf("second request: %v", err)
} }
acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody)) acc2, err := svc.LoginWithCode(ctx, email, sixDigit.FindString(mailer.lastBody))
+13 -13
View File
@@ -38,7 +38,7 @@ func latestFeedbackID(t *testing.T, svc *feedback.Service, acc uuid.UUID) uuid.U
func TestFeedbackGuestRejected(t *testing.T) { func TestFeedbackGuestRejected(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
guest := provisionGuest(t) guest := provisionGuest(t)
if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "v1", "+05:00", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) { if err := svc.Submit(context.Background(), guest, "hi", nil, "", "web", "1.2.3.4"); !errors.Is(err, feedback.ErrGuestForbidden) {
t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err) t.Fatalf("guest submit err = %v, want ErrGuestForbidden", err)
} }
} }
@@ -48,11 +48,11 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "v1.2.0", "+03:00", "9.9.9.9"); err != nil { if err := svc.Submit(ctx, acc, " please fix the board ", []byte("PNGDATA"), "shot.png", "ios", "9.9.9.9"); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
// Anti-spam gate: a second message is refused while the first is unreviewed. // Anti-spam gate: a second message is refused while the first is unreviewed.
if err := svc.Submit(ctx, acc, "again", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrPendingReview) { if err := svc.Submit(ctx, acc, "again", nil, "", "web", ""); !errors.Is(err, feedback.ErrPendingReview) {
t.Fatalf("second submit err = %v, want ErrPendingReview", err) t.Fatalf("second submit err = %v, want ErrPendingReview", err)
} }
if st, err := svc.State(ctx, acc); err != nil { if st, err := svc.State(ctx, acc); err != nil {
@@ -69,7 +69,7 @@ func TestFeedbackSubmitGateAndReplyLifecycle(t *testing.T) {
if m.Body != "please fix the board" { // trimmed if m.Body != "please fix the board" { // trimmed
t.Fatalf("body = %q, want trimmed", m.Body) t.Fatalf("body = %q, want trimmed", m.Body)
} }
if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" || m.Version != "v1.2.0" || m.BrowserTZ != "+03:00" { if !m.HasAttachment || m.AttachmentName != "shot.png" || m.Channel != "ios" || m.SenderIP != "9.9.9.9" {
t.Fatalf("admin message = %+v", m) t.Fatalf("admin message = %+v", m)
} }
if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" { if name, data, ok, err := svc.Attachment(ctx, id); err != nil || !ok || name != "shot.png" || string(data) != "PNGDATA" {
@@ -116,7 +116,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
acc := provisionAccount(t) acc := provisionAccount(t)
// msg1, replied → the player can send again and currently sees the reply. // msg1, replied → the player can send again and currently sees the reply.
if err := svc.Submit(ctx, acc, "first", nil, "", "web", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "first", nil, "", "web", ""); err != nil {
t.Fatalf("submit msg1: %v", err) t.Fatalf("submit msg1: %v", err)
} }
if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil { if err := svc.Reply(ctx, latestFeedbackID(t, svc, acc), "the answer"); err != nil {
@@ -130,7 +130,7 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
// Sending a new message immediately drops the previous reply (it now belongs to an // Sending a new message immediately drops the previous reply (it now belongs to an
// older message), even though it is well within the one-week window. // older message), even though it is well within the one-week window.
if err := svc.Submit(ctx, acc, "second", nil, "", "web", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "second", nil, "", "web", ""); err != nil {
t.Fatalf("submit msg2: %v", err) t.Fatalf("submit msg2: %v", err)
} }
st, err := svc.State(ctx, acc) st, err := svc.State(ctx, acc)
@@ -154,7 +154,7 @@ func TestFeedbackSnapshotsLanguage(t *testing.T) {
t.Fatalf("set language: %v", err) t.Fatalf("set language: %v", err)
} }
// A message snapshots the sender's interface language at submit time. // A message snapshots the sender's interface language at submit time.
if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "from telegram", nil, "", "telegram", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
@@ -184,7 +184,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil { if err := accounts.GrantRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("grant role: %v", err) t.Fatalf("grant role: %v", err)
} }
if err := svc.Submit(ctx, acc, "hi", nil, "", "web", "", "", ""); !errors.Is(err, feedback.ErrBanned) { if err := svc.Submit(ctx, acc, "hi", nil, "", "web", ""); !errors.Is(err, feedback.ErrBanned) {
t.Fatalf("banned submit err = %v, want ErrBanned", err) t.Fatalf("banned submit err = %v, want ErrBanned", err)
} }
if st, err := svc.State(ctx, acc); err != nil { if st, err := svc.State(ctx, acc); err != nil {
@@ -196,7 +196,7 @@ func TestFeedbackBanRole(t *testing.T) {
if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil { if err := accounts.RevokeRole(ctx, acc, account.RoleFeedbackBanned); err != nil {
t.Fatalf("revoke role: %v", err) t.Fatalf("revoke role: %v", err)
} }
if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "hi again", nil, "", "web", ""); err != nil {
t.Fatalf("submit after unban: %v", err) t.Fatalf("submit after unban: %v", err)
} }
} }
@@ -219,7 +219,7 @@ func TestFeedbackValidation(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
acc := provisionAccount(t) // fresh account so the pending gate never fires first acc := provisionAccount(t) // fresh account so the pending gate never fires first
if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", "", "", ""); !errors.Is(err, tt.want) { if err := svc.Submit(ctx, acc, tt.body, tt.attachment, tt.attachmentName, "web", ""); !errors.Is(err, tt.want) {
t.Fatalf("submit err = %v, want %v", err, tt.want) t.Fatalf("submit err = %v, want %v", err, tt.want)
} }
}) })
@@ -231,7 +231,7 @@ func TestFeedbackAdminLifecycle(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "first report", nil, "", "web", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "first report", nil, "", "web", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
id := latestFeedbackID(t, svc, acc) id := latestFeedbackID(t, svc, acc)
@@ -276,7 +276,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if err := svc.Submit(ctx, acc, "one", nil, "", "web", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "one", nil, "", "web", ""); err != nil {
t.Fatalf("submit: %v", err) t.Fatalf("submit: %v", err)
} }
if err := svc.DeleteAllByAccount(ctx, acc); err != nil { if err := svc.DeleteAllByAccount(ctx, acc); err != nil {
@@ -286,7 +286,7 @@ func TestFeedbackDeleteAllByAccount(t *testing.T) {
if has, err := svc.ReplyUnread(ctx, acc); err != nil || has { if has, err := svc.ReplyUnread(ctx, acc); err != nil || has {
t.Fatalf("reply unread after delete-all = %v (err %v)", has, err) t.Fatalf("reply unread after delete-all = %v (err %v)", has, err)
} }
if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", "", "", ""); err != nil { if err := svc.Submit(ctx, acc, "fresh", nil, "", "web", ""); err != nil {
t.Fatalf("submit after delete-all: %v", err) t.Fatalf("submit after delete-all: %v", err)
} }
} }
+1 -1
View File
@@ -120,7 +120,7 @@ func provisionAccount(t *testing.T) uuid.UUID {
// provisionGuest creates a fresh ephemeral guest account and returns its id. // provisionGuest creates a fresh ephemeral guest account and returns its id.
func provisionGuest(t *testing.T) uuid.UUID { func provisionGuest(t *testing.T) uuid.UUID {
t.Helper() t.Helper()
acc, err := account.NewStore(testDB).ProvisionGuest(context.Background(), "") acc, err := account.NewStore(testDB).ProvisionGuest(context.Background())
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
@@ -38,7 +38,7 @@ func TestSuspensionGate(t *testing.T) {
Accounts: accounts, Accounts: accounts,
}) })
acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked", "") acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "ru", "", "Blocked")
if err != nil { if err != nil {
t.Fatalf("provision: %v", err) t.Fatalf("provision: %v", err)
} }
+2 -2
View File
@@ -18,7 +18,7 @@ func TestUserListFilter(t *testing.T) {
st := account.NewStore(testDB) st := account.NewStore(testDB)
uniq := uuid.NewString() uniq := uuid.NewString()
human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman", "") human, _, err := st.ProvisionTelegram(ctx, "tg-"+uniq, "en", "", "Zzqxhuman")
if err != nil { if err != nil {
t.Fatalf("provision human: %v", err) t.Fatalf("provision human: %v", err)
} }
@@ -26,7 +26,7 @@ func TestUserListFilter(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("provision robot: %v", err) t.Fatalf("provision robot: %v", err)
} }
guest, err := st.ProvisionGuest(ctx, "") guest, err := st.ProvisionGuest(ctx)
if err != nil { if err != nil {
t.Fatalf("provision guest: %v", err) t.Fatalf("provision guest: %v", err)
} }
@@ -1,64 +0,0 @@
-- Replace the default (house) ad campaign's single seed tip with the curated,
-- language-agnostic Scrabble tip set (one bilingual row per tip; the client picks the
-- column for the viewer's language). Data-only — the ad_messages schema is unchanged, so
-- a backend image rollback stays DB-safe. The default campaign is the fixed house id seeded
-- in 00001; ON DELETE CASCADE is irrelevant here (we only touch its messages).
-- +goose Up
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru) VALUES
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 0, 'Keep a balanced rack — a slight edge of consonants over vowels.', 'Держи на руках баланс — с лёгким перевесом согласных над гласными.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 1, 'Your "leave" (the tiles you keep) sets up your next turn — value it.', '«Остаток» (что оставляешь на руках) готовит следующий ход — цени его.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 2, 'Shed duplicate tiles — repeats clog your options.', 'Сбрасывай дубли фишек — повторы забивают возможности.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 3, 'A slightly consonant-heavy rack builds full-rack plays more easily.', 'Лёгкий перевес согласных проще складывается в выкладку всех фишек.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 4, 'Play several tiles per turn to keep your rack cycling.', 'Выкладывай по нескольку фишек за ход, чтобы рука обновлялась.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 5, 'Don''t hoard hard-to-place duplicates or a lone high-value tile.', 'Не копи труднопристраиваемые дубли или одинокую дорогую фишку.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 6, 'Using all your rack tiles in one move scores a large bonus — chase it.', 'Выкладка всех фишек с рук за ход даёт крупный бонус — стремись к ней.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 7, 'Learn common prefixes and suffixes — they extend words to use every tile.', 'Учи частые приставки и суффиксы — они растягивают слово на все фишки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 8, '"Fish": play few tiles to keep a near-complete rack when you''re ahead.', '«Рыбачь»: сыграй мало фишек, сохранив почти всю руку, когда ведёшь.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 9, 'Don''t hoard high-value tiles — play them in good time, not at the very end.', 'Не копи дорогие фишки — играй их вовремя, а не под самый конец.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 10, 'Don''t hold a high-value tile waiting for a rare partner — usually a loss.', 'Не держи дорогую фишку ради редкого партнёра — обычно это проигрыш.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 11, 'Land your priciest tile on a premium square for a big single score.', 'Сажай самую дорогую фишку на бонусную клетку ради крупных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 12, 'High-value tiles shine in parallel plays through short words.', 'Дорогие фишки сильны в параллельных выкладках через короткие слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 13, 'Stuck with an unplayable high-value tile late? Exchange it.', 'Завис с неиграбельной дорогой фишкой под конец? Обменяй её.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 14, 'The blanks are the most valuable tiles in the bag — guard them.', 'Пустышки — самые ценные фишки в мешке; береги их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 15, 'Save a blank for a full-rack play or a key premium square.', 'Береги пустышку для выкладки всех фишек или важной бонусной клетки.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 16, 'Don''t spend a blank cheaply — hold it for a much bigger gain.', 'Не трать пустышку по мелочи — придержи ради куда большей выгоды.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 17, 'Put high-value tiles on letter-bonus or word-bonus squares.', 'Клади дорогие фишки на бонус буквы или слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 18, 'Stack bonuses — a letter bonus under a word bonus multiplies both.', 'Совмещай бонусы — бонус буквы под бонусом слова умножает оба.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 19, 'Parallel plays can earn nearly half your points — look for them.', 'Параллельные выкладки могут давать почти половину очков — ищи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 20, 'A hook adds one tile to an existing word to make a new one.', '«Крючок» — одна фишка к готовому слову, образующая новое.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 21, 'Hooks work at the front or the back of a word.', 'Крючки работают спереди и сзади слова.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 22, 'Short words are the keys to tight parallel plays — memorize them.', 'Короткие слова — ключ к плотным параллелям; выучи их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 23, 'Your opening word crosses the centre — keep it compact, don''t open up.', 'Первое слово идёт через центр — держи компактным, не раскрывайся.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 24, 'It''s not only your score — limit your opponent''s options too.', 'Это не только твои очки — ограничивай и возможности соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 25, 'Denying a big reply often beats squeezing a few more points yourself.', 'Закрыть крупный ответ часто важнее, чем добрать пару своих очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 26, 'When ahead, keep the board tight and closed; avoid open lanes.', 'Ведёшь — держи доску плотной и закрытой, не открывай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 27, 'When behind, open the board up to create high-scoring chances.', 'Отстаёшь — раскрывай доску ради шансов на крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 28, 'Don''t leave a word-bonus square open right beside your word.', 'Не оставляй клетку бонуса слова открытой рядом со своим словом.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 29, 'Block a hot square even with a weak word to deny a big play.', 'Закрывай опасную клетку даже слабым словом, чтобы срубить крупный ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 30, 'Know words that take no hooks — use them to seal off lines.', 'Знай слова, не берущие крючков — ими запирай линии.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 31, 'Track the tiles played to judge what is still left in the bag.', 'Считай сыгранные фишки — так поймёшь, что осталось в мешке.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 32, 'Exchange when your rack is unbalanced or can only score low.', 'Меняй фишки, когда рука несбалансированна или тянет мало.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 33, 'A good exchange beats a bad play — a clean rack is worth a turn.', 'Хороший обмен лучше плохого хода — чистая рука стоит хода.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 34, 'Swap away a surplus of vowels or consonants to rebalance.', 'Сбрасывай в обмен избыток гласных или согласных, чтобы выровняться.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 35, 'Rare high-value tiles are gone once seen — note them as they appear.', 'Редкие дорогие фишки исчезают, едва мелькнув — отмечай их.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 36, 'Once the bag is empty, deduce your opponent''s remaining tiles.', 'Когда мешок пуст, вычисли оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 37, 'Shed high-value tiles before the bag empties — don''t get stuck with them.', 'Сбрось дорогие фишки до опустения мешка — не зависай с ними.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 38, 'Unplayed tiles count against you at the end — try to go out first.', 'Несыгранные фишки минусуют очки в конце — старайся выйти первым.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 39, 'Going out first adds your opponent''s leftover tiles to your score.', 'Кто вышел первым, добирает очки за оставшиеся фишки соперника.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 40, 'Sometimes leaving one tile in the bag buys you an extra turn.', 'Иногда оставить одну фишку в мешке — это лишний ход.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 41, 'In the endgame, block the exact squares your opponent needs.', 'В эндшпиле блокируй именно те клетки, что нужны сопернику.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 42, 'Shuffle your rack to spot new patterns.', 'Перемешивай фишки на руках — так замечаешь новые сочетания.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 43, 'Separate prefix, suffix and middle tiles to anagram faster.', 'Разнеси приставку, суффикс и середину — анаграммы решаются быстрее.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 44, 'Value board position and future turns over raw points this turn.', 'Цени позицию и будущие ходы выше сиюминутных очков.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 45, 'Early game build position; midgame maximize score; endgame defend.', 'В начале — позиция, в середине — очки, в конце — защита.'),
(gen_random_uuid(), '00000000-0000-0000-0000-0000000000ad', 46, 'Learn the short-word lists first — they pay off in every game.', 'Сначала учи списки коротких слов — окупаются в каждой партии.');
-- +goose Down
-- Restore the original single house tip seeded by the baseline.
DELETE FROM backend.ad_messages WHERE campaign_id = '00000000-0000-0000-0000-0000000000ad';
INSERT INTO backend.ad_messages (message_id, campaign_id, "position", body_en, body_ru)
VALUES ('00000000-0000-0000-0000-0000000000a1', '00000000-0000-0000-0000-0000000000ad', 0,
'Tip: a play using all 7 tiles earns a +50 bonus.',
'Совет: ход всеми 7 фишками приносит бонус +50 очков.');
@@ -1,10 +0,0 @@
-- Capture the client app version (the build a report was sent from) with each feedback
-- message, so the operator console can show which version a player was on. Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN app_version text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN app_version;
@@ -1,11 +0,0 @@
-- Capture the client's detected UTC offset ("±HH:MM") with each feedback message, so the
-- operator console can show the filed time in the sender's browser-local zone even before that
-- player has ever saved a profile (the account zone defaults to UTC until then). Nullable, so the
-- rows that predate this keep working — additive and backward-compatible, so a backend image
-- rollback stays DB-safe (older code simply ignores the column).
-- +goose Up
ALTER TABLE backend.feedback_messages ADD COLUMN browser_tz text;
-- +goose Down
ALTER TABLE backend.feedback_messages DROP COLUMN browser_tz;
@@ -1198,17 +1198,6 @@ func fmtTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04") return t.UTC().Format("2006-01-02 15:04")
} }
// fmtTimeIn formats a timestamp in the given zone — a "±HH:MM" offset or an IANA name, resolved
// by account.ResolveZone (falling back to UTC when empty or unknown) — or "" when zero. Used to
// show a time in a user's local zone beside UTC; the offset form is what the profile editor and
// the feedback browser-tz snapshot store, so it must not go through time.LoadLocation alone.
func fmtTimeIn(t time.Time, tz string) string {
if t.IsZero() {
return ""
}
return t.In(account.ResolveZone(tz)).Format("2006-01-02 15:04")
}
// fmtTimePtr formats an optional timestamp for display, or "" when nil. // fmtTimePtr formats an optional timestamp for display, or "" when nil.
func fmtTimePtr(t *time.Time) string { func fmtTimePtr(t *time.Time) string {
if t == nil { if t == nil {
@@ -77,17 +77,6 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
s.consoleError(c, err) s.consoleError(c, err)
return return
} }
// Filed time in three zones so the operator can tell what is certainly known from what is
// merely defaulted: always UTC; the client's offset detected at submit (when the build
// reported one); and the sender's saved profile zone (when set beyond the UTC default). An
// empty rendered time makes the template show "N/A" for that line.
browserCreated, userCreated := "", ""
if m.BrowserTZ != "" {
browserCreated = fmtTimeIn(m.CreatedAt, m.BrowserTZ)
}
if m.TimeZone != "" && m.TimeZone != "UTC" {
userCreated = fmtTimeIn(m.CreatedAt, m.TimeZone)
}
view := adminconsole.FeedbackDetailView{ view := adminconsole.FeedbackDetailView{
ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName, ID: m.ID.String(), AccountID: m.AccountID.String(), SenderName: m.SenderName,
Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang, Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
@@ -95,9 +84,6 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName), HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody, Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt), RepliedAt: fmtTime(m.RepliedAt), CreatedAt: fmtTime(m.CreatedAt),
Version: m.Version,
CreatedAtBrowser: browserCreated, BrowserTZ: m.BrowserTZ,
CreatedAtUser: userCreated, UserTZ: m.TimeZone,
} }
if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil { if banned, err := s.accounts.HasRole(ctx, m.AccountID, account.RoleFeedbackBanned); err == nil {
view.Banned = banned view.Banned = banned
+6 -23
View File
@@ -18,14 +18,12 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a // telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language; BrowserTZ (the client's detected // brand-new account's display name and language (first contact only).
// "±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
@@ -37,7 +35,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, req.BrowserTZ) acc, created, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
@@ -99,21 +97,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
}) })
} }
// guestAuthRequest carries the guest bootstrap's optional time-zone seed: BrowserTZ // handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
// (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) {
// The body is optional: an absent or malformed one simply yields no time-zone seed acc, err := s.accounts.ProvisionGuest(c.Request.Context())
// (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
@@ -121,12 +107,9 @@ func (s *Server) handleGuestAuth(c *gin.Context) {
s.mintSession(c, acc) s.mintSession(c, acc)
} }
// emailRequest is an email-login code request. BrowserTZ (the client's detected // emailRequest is an email-login code request.
// "±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
@@ -138,7 +121,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, req.BrowserTZ); err != nil { if _, err := s.emails.RequestLoginCode(c.Request.Context(), req.Email); err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
+1 -7
View File
@@ -16,12 +16,6 @@ type feedbackSubmitRequest struct {
Attachment string `json:"attachment"` Attachment string `json:"attachment"`
AttachmentName string `json:"attachment_name"` AttachmentName string `json:"attachment_name"`
Channel string `json:"channel"` Channel string `json:"channel"`
// Version is the client's app version (pkg/version / the SPA build), snapshotted so the
// operator sees which build a report came from.
Version string `json:"version"`
// BrowserTZ is the client's detected UTC offset ("±HH:MM") at submit, so the operator can
// see the filed time in the sender's local zone even before they save a profile.
BrowserTZ string `json:"browser_tz"`
} }
// feedbackReplyDTO is the operator's reply shown back to the player. // feedbackReplyDTO is the operator's reply shown back to the player.
@@ -67,7 +61,7 @@ func (s *Server) handleFeedbackSubmit(c *gin.Context) {
} }
attachment = data attachment = data
} }
if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, req.Version, req.BrowserTZ, clientIP(c)); err != nil { if err := s.feedback.Submit(c.Request.Context(), uid, req.Body, attachment, req.AttachmentName, req.Channel, clientIP(c)); err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
+1 -1
View File
@@ -16,7 +16,7 @@ POSTGRES_PASSWORD=change-me # required
# the active version lives in the DB. On a live volume a changed value is ignored (the # the active version lives in the DB. On a live volume a changed value is ignored (the
# recorded .seed_version marker wins — the seed-drift guard); change a running # recorded .seed_version marker wins — the seed-drift guard); change a running
# contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5). # contour's dictionary through /_gm/dictionary (ARCHITECTURE.md §5).
DICT_VERSION=v1.3.0 DICT_VERSION=v1.2.1
# --- Logging ---------------------------------------------------------------- # --- Logging ----------------------------------------------------------------
LOG_LEVEL=info LOG_LEVEL=info
+2 -26
View File
@@ -80,7 +80,7 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. | | `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.3.0` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | | `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
@@ -117,28 +117,6 @@ collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
intentionally **unset** — caddy owns `/_gm` in the contour. intentionally **unset** — caddy owns `/_gm` in the contour.
## Bumping the dictionary version
The dictionary ships as a versioned **release artifact** (`scrabble-dawg-vX.Y.Z.tar.gz`) from
[`scrabble-dictionary`](https://gitea.iliadenisov.ru/developer/scrabble-dictionary). The tag is
a build-time input with **no default** in the images, so it is set in exactly two places to
move the whole stack — change both to a new release:
1. **CI tests**`.gitea/workflows/ci.yaml` `env.DICT_VERSION` (the unit/integration jobs
download that dawg).
2. **Deploy seed** — the Gitea repo variables `TEST_DICT_VERSION` / `PROD_DICT_VERSION` (the tag
the deploy bakes into a **fresh** volume's image; the deploy job feeds it to `compose` as
`DICT_VERSION`).
For local builds set `DICT_VERSION` in `deploy/.env` (template: `.env.example`); a bare
`docker build` needs `--build-arg DICT_VERSION=vX.Y.Z`. The Dockerfiles and `compose` carry no
default — a missing value fails loudly instead of baking a stale tag.
Bumping the seed is a **no-op on a live volume** (the `.seed_version` marker wins — the
seed-drift guard). A running contour/prod moves to a new release **through the admin console**
`/_gm/dictionary` (upload the tarball, preview the per-variant diff, confirm); in-flight games
keep their pinned version, new games use the new one (ARCHITECTURE.md §5).
## Production rollout ## Production rollout
Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only, Prod runs on **two hosts** (main = full stack + ACME on the domain; tg = the bot only,
@@ -150,9 +128,7 @@ 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**; caddy is force-recreated on its roll so health-gated, **auto-rollback to the previous tag**), then the bot host, then probes the
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,18 +21,6 @@
} }
{$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 {
+3 -5
View File
@@ -68,11 +68,9 @@ services:
context: .. context: ..
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
args: args:
# Seed dictionary for a FRESH volume; required (no default) so the release tag is # Seed dictionary for a FRESH volume; the per-contour value comes from the
# set in exactly one place per context — the deploy env (Gitea TEST_/PROD_DICT_VERSION) # deploy env (Gitea TEST_/PROD_DICT_VERSION). See the volume note below.
# or .env for local builds. See the volume note below + deploy/README.md "Bumping the DICT_VERSION: ${DICT_VERSION:-v1.2.1}
# dictionary version".
DICT_VERSION: ${DICT_VERSION:?set DICT_VERSION — the scrabble-dictionary release tag, e.g. in deploy/.env}
# Build version stamped into the binary (git tag; see pkg/version). # Build version stamped into the binary (git tag; see pkg/version).
VERSION: ${APP_VERSION:-dev} VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
+1 -15
View File
@@ -36,21 +36,7 @@
"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": { "fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"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\"})" }]
}, },
+1 -7
View File
@@ -74,13 +74,7 @@ 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"
# caddy's image is pinned (caddy:2-alpine, no $TAG) and its Caddyfile is bind-mounted, so a dc up -d --no-build --no-deps "$svc" || return 1
# 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"
} }
+11 -29
View File
@@ -158,12 +158,7 @@ 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), but the **interface language follows the device** — the system launch `language_code` (§4); the interface language is otherwise edited in Settings.
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
@@ -645,7 +640,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; tracks the interface language — §4), display name, email - **Profile**: `preferred_language` (en/ru, edited in Settings), 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
@@ -654,11 +649,7 @@ 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), and is **seeded at account creation** from the client's detected offset — sent picker); the away window is at most **12 h** (midnight-wrap aware). Linked platform
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
@@ -1040,9 +1031,8 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
Single public origin, path-routed. The Vite build has two entries: a lightweight Single public origin, path-routed. The Vite build has two entries: a lightweight
**landing page** and the game **SPA**. The gateway **embeds** the SPA build **landing page** and the game **SPA**. The gateway **embeds** the SPA build
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at (`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data `/app/` (web) and `/telegram/` (the Telegram Mini App; outside Telegram that path
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead redirects to the root — the client-side guard); a stray hit on the gateway's `/`
of redirecting away); a stray hit on the gateway's `/`
308-redirects to `/app/`. The **landing** ships in its own static container: the 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build, `landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by `deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
@@ -1099,10 +1089,7 @@ 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. Caddy advertises HTTP/3 by default, but UDP/443 is not exposed (the `CADDY_SITE_ADDRESS` is the domain and caddy does its own ACME. The gateway **publishes**
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.
@@ -1162,11 +1149,9 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts Players reach the operators through a **Feedback** screen (Settings → Info, registered accounts
only). A message (≤1024 runes) plus an optional single attachment is stored in only). A message (≤1024 runes) plus an optional single attachment is stored in
`feedback_messages`; the sender's IP (gateway-forwarded, as for chat), the submitting `feedback_messages`; the sender's IP (gateway-forwarded, as for chat) and the submitting
**channel** (telegram/ios/android/web, client-reported and validated), the **client app version** **channel** (telegram/ios/android/web, client-reported and validated) are recorded. The domain
(`__APP_VERSION__`, the build a report was sent from), the client's **detected UTC offset** at is `internal/feedback` (store + service), modelled on the admin chat-moderation surface.
submit (`browser_tz`, `±HH:MM`) and a snapshot of the sender's interface language are recorded. The domain is `internal/feedback` (store + service), modelled on the admin
chat-moderation surface.
**Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the **Anti-spam.** A player with an unreviewed message (`read_at IS NULL`) cannot submit another; the
gate is server-side. Because the operator must act before the next message, this is itself the gate is server-side. Because the operator must act before the next message, this is itself the
@@ -1174,11 +1159,8 @@ rate limit — there is no separate per-user feedback limiter.
**Operator review** happens in the server-rendered console (`/_gm/feedback`): an **Operator review** happens in the server-rendered console (`/_gm/feedback`): an
unread / read / archived queue with per-user search (the `/users` glob masks), a detail card unread / read / archived queue with per-user search (the `/users` glob masks), a detail card
(user content rendered as auto-escaped `html/template` text; it shows the channel, interface (user content rendered as auto-escaped `html/template` text), and the read / reply / archive /
language and app version, and the filed time in three zones — UTC, the browser offset detected at delete / delete-all actions — each marks the message read; merely opening the detail does not.
submit, and the sender's saved profile zone, each `N/A` when not known), and the read /
reply / archive / delete / delete-all actions — each marks the message read; merely opening the
detail does not.
The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options: The attachment is served from `/_gm/feedback/:id/attachment` with `X-Content-Type-Options:
nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is nosniff`: images inline (loaded only via `<img>`, which never executes — a renamed non-image is
inert), everything else as an `application/octet-stream` download. The UI gates the attachment by inert), everything else as an `application/octet-stream` download. The UI gates the attachment by
-111
View File
@@ -1,111 +0,0 @@
# 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.
+2 -8
View File
@@ -213,9 +213,6 @@ 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
@@ -244,8 +241,7 @@ 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, and pre-filled from your device's detected offset when the account (chosen as a UTC offset), the
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 &
@@ -350,9 +346,7 @@ over-grant cannot be reversed there.
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
**unread / read / archived** with per-user search, each shown with its sender, source, channel **unread / read / archived** with per-user search, each shown with its sender, source, channel
(with the bot language — en/ru — for a Telegram message), the sender's interface (with the bot language — en/ru — for a Telegram message), the sender's interface
language, the **app version** it was sent from, IP, the filed time (in three zones — UTC, the language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
browser zone detected at submit, and the sender's saved zone, each shown `N/A` when not known) and
any attachment. The operator can mark a message read, **reply** to the player (delivered
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete, in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it **bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a stops only feedback submission). Roles are listed and granted/revoked on the user card. Opening a
+3 -8
View File
@@ -218,9 +218,6 @@ _Вход сейчас только через провайдера, поэто
заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в заблокированного соперника «подвал» чата скрыт (остаётся только лог). Блокировка с карточки в
партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей** партии повторяет блокировку в **Настройках → Друзья**; **разблокировка** и **удаление из друзей**
есть только там. есть только там.
В **Настройках → Друзья** каждый друг — однострочник, чей правый кебаб (⋮) выдвигает
иконки-действия **заблокировать 🚫** и **удалить ✖️**, и каждое действие подтверждается
диалогом с именем друга (*Заблокировать?* / *Удалить из друзей?*).
Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же Блокировка **авто-матч соперника, который втайне робот**, в этой партии ведёт себя так же
(зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем, (зачёркнутое имя, скрытый «подвал») и в списке заблокированных показывается под тем именем,
которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий которое ты видел, но записывается только для этой партии — маскировка сохраняется, общий
@@ -251,9 +248,8 @@ _Вход сейчас только через провайдера, поэто
Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» / Редактирование отображаемого имени (буквы, разделённые одиночным пробелом / «.» /
«_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не «_», с необязательной завершающей «.» или хвостом до пяти цифр, до 32 символов и не
более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от более 5 спецсимволов — пунктуации «.» / «_», пробелы и цифры не в счёт), таймзоны (выбор смещения от
UTC; при создании аккаунта она подставляется из определённого смещения устройства — чтобы UTC), суточного окна отсутствия (away; сетка по 10 минут, не более 12 часов, с
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия переходом через полночь) и переключателей блокировок. Форма профиля редактируется
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
@@ -360,8 +356,7 @@ high-rate флага. С карточки пользователя операт
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя, каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
**версией приложения**, с которой отправлено, IP, временем подачи (в трёх зонах — UTC, зоне браузера IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
на момент отправки и сохранённой зоне отправителя, каждая — «N/A», если неизвестна) и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке останавливает только отправку обратной связи). Роли перечислены и выдаются/снимаются на карточке
+2 -11
View File
@@ -8,13 +8,7 @@ emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark vi
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**: `prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed**:
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
runtime; on that path without sign-in data (no `initData` — outside Telegram, or a Mini App runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
launch that delivered none, as seen on some Android clients) the app renders a compact,
shareable launch-diagnostic screen (`screens/TelegramLaunchError.svelte`) rather than
redirecting to the site root. `telegram-web-app.js` is loaded **dynamically with a timeout**,
only on a Telegram entry — not a render-blocking `<script>` in the shared `index.html` shell —
so a network that blocks `telegram.org` cannot hang the page; `/app/` (web) and the native build
never load it.
## Layout shell (`components/Screen.svelte`) ## Layout shell (`components/Screen.svelte`)
@@ -115,10 +109,7 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
## Tiles & board ## Tiles & board
- **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the - **Tiles**: the letter sits in the **top-left** corner (offset a touch more than the
value), the point value bottom-right; blanks show no value. In **Erudit** the blank is the value), the point value bottom-right; blanks show no value.
"звёздочка" (star) chip: an unplaced blank shows the star (`✻`, U+273B) centred on the rack
tile, and a placed blank carries it in the value corner; the Scrabble variants leave the
blank unmarked (`usesStarBlank` in `lib/variants.ts`).
- **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing - **Board zoom** (`Board.svelte`): a two-state zoom (full 15×15 ↔ ~9 cells) by **growing
the board's width** inside a fixed-size viewport (a real layout change → native scroll the board's width** inside a fixed-size viewport (a real layout change → native scroll
that works consistently across browsers; no `transform`, which broke scrolling that works consistently across browsers; no `transform`, which broke scrolling
+8 -15
View File
@@ -184,10 +184,8 @@ type ChatResp struct {
} }
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a // TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
// brand-new account's display name and language from the validated launch fields and // brand-new account's display name and language from the validated launch fields.
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
// 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{
@@ -195,7 +193,6 @@ 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
} }
@@ -246,21 +243,17 @@ 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, seeding its time zone // GuestAuth provisions a guest account and mints a session.
// from browserTz (the client's detected "±HH:MM" UTC offset). func (c *Client) GuestAuth(ctx context.Context) (SessionResp, error) {
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", "", "", err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/guest", "", "", struct{}{}, &out)
map[string]string{"browser_tz": browserTz}, &out)
return out, err return out, err
} }
// EmailRequest asks the backend to mail a login code, provisioning the account on // EmailRequest asks the backend to mail a login code.
// first contact; browserTz (the client's detected "±HH:MM" UTC offset) seeds the new func (c *Client) EmailRequest(ctx context.Context, email string) error {
// 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, "browser_tz": browserTz}, nil) map[string]string{"email": email}, nil)
} }
// EmailLogin verifies a login code and mints a session. // EmailLogin verifies a login code and mints a session.
@@ -27,14 +27,12 @@ type FeedbackUnreadResp struct {
// FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded // FeedbackSubmit posts a feedback message. The attachment bytes are base64-encoded
// into the JSON body for the internal hop; clientIP rides X-Forwarded-For. // into the JSON body for the internal hop; clientIP rides X-Forwarded-For.
func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, version, browserTz, clientIP string) error { func (c *Client) FeedbackSubmit(ctx context.Context, userID, body string, attachment []byte, attachmentName, channel, clientIP string) error {
payload := map[string]string{ payload := map[string]string{
"body": body, "body": body,
"attachment": "", "attachment": "",
"attachment_name": attachmentName, "attachment_name": attachmentName,
"channel": channel, "channel": channel,
"version": version,
"browser_tz": browserTz,
} }
if len(attachment) > 0 { if len(attachment) > 0 {
payload["attachment"] = base64.StdEncoding.EncodeToString(attachment) payload["attachment"] = base64.StdEncoding.EncodeToString(attachment)
+5 -12
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, string(in.BrowserTz())) sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -167,15 +167,8 @@ 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, req Request) ([]byte, error) { return func(ctx context.Context, _ Request) ([]byte, error) {
// The guest bootstrap historically carried no payload; the detected zone is sess, err := backend.GuestAuth(ctx)
// 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
} }
@@ -186,7 +179,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()), string(in.BrowserTz())); err != nil { if err := backend.EmailRequest(ctx, string(in.Email())); err != nil {
return nil, err return nil, err
} }
return encodeAck(true), nil return encodeAck(true), nil
@@ -506,7 +499,7 @@ func hideGameHandler(backend *backendclient.Client) Handler {
func feedbackSubmitHandler(backend *backendclient.Client) Handler { func feedbackSubmitHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0) in := fb.GetRootAsFeedbackSubmitRequest(req.Payload, 0)
if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), string(in.Version()), string(in.BrowserTz()), req.ClientIP); err != nil { if err := backend.FeedbackSubmit(ctx, req.UserID, string(in.Body()), in.AttachmentBytes(), string(in.AttachmentName()), string(in.Channel()), req.ClientIP); err != nil {
return nil, err return nil, err
} }
return encodeAck(true), nil return encodeAck(true), nil
+1 -2
View File
@@ -12,8 +12,7 @@
# --- dictionary artifact ----------------------------------------------------- # --- dictionary artifact -----------------------------------------------------
FROM alpine:3.20 AS dawg FROM alpine:3.20 AS dawg
# Required, no default: the build caller supplies the scrabble-dictionary release tag. ARG DICT_VERSION=v1.2.1
ARG DICT_VERSION
RUN apk add --no-cache curl tar RUN apk add --no-cache curl tar
RUN mkdir -p /dawg \ RUN mkdir -p /dawg \
&& curl -fsSL -o /tmp/dawg.tar.gz \ && curl -fsSL -o /tmp/dawg.tar.gz \
+2 -2
View File
@@ -35,8 +35,8 @@ The harness reaches Postgres and the gateway directly, so run it as a one-shot
container on the contour's docker network (this bypasses the host→gateway hairpin): container on the contour's docker network (this bypasses the host→gateway hairpin):
```sh ```sh
# from the repo root (DICT_VERSION has no default — pass the scrabble-dictionary release tag) # from the repo root
docker build --build-arg DICT_VERSION=v1.3.0 -f loadtest/Dockerfile -t scrabble-loadtest . docker build -f loadtest/Dockerfile -t scrabble-loadtest .
docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \ docker run --rm --cpus=3 --name scrabble-loadtest --network scrabble-internal \
-e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \ -e POSTGRES_PASSWORD="$TEST_POSTGRES_PASSWORD" \
+5 -16
View File
@@ -99,33 +99,24 @@ 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. browser_tz is // its HMAC before forwarding the extracted identity to the backend.
// 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; browser_tz is the detected UTC offset seeded into the // preferred-language hint.
// 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. It // EmailRequestRequest asks the backend to send a login confirm-code to email.
// 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 to the account owning email (provisioned at the // EmailLoginRequest logs in (or provisions) the account owning email, verifying
// request step), verifying the confirm-code. // the confirm-code.
table EmailLoginRequest { table EmailLoginRequest {
email:string; email:string;
code:string; code:string;
@@ -392,8 +383,6 @@ table FeedbackSubmitRequest {
attachment:[ubyte]; attachment:[ubyte];
attachment_name:string; attachment_name:string;
channel:string; channel:string;
version:string;
browser_tz:string;
} }
// FeedbackReply is the operator's answer shown back to the player. // FeedbackReply is the operator's answer shown back to the player.
+1 -12
View File
@@ -49,23 +49,12 @@ 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(2) builder.StartObject(1)
} }
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()
} }
+1 -23
View File
@@ -99,24 +99,8 @@ func (rcv *FeedbackSubmitRequest) Channel() []byte {
return nil return nil
} }
func (rcv *FeedbackSubmitRequest) Version() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *FeedbackSubmitRequest) BrowserTz() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) { func FeedbackSubmitRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(6) builder.StartObject(4)
} }
func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) { func FeedbackSubmitRequestAddBody(builder *flatbuffers.Builder, body flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(body), 0)
@@ -133,12 +117,6 @@ func FeedbackSubmitRequestAddAttachmentName(builder *flatbuffers.Builder, attach
func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) { func FeedbackSubmitRequestAddChannel(builder *flatbuffers.Builder, channel flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0) builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(channel), 0)
} }
func FeedbackSubmitRequestAddVersion(builder *flatbuffers.Builder, version flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(version), 0)
}
func FeedbackSubmitRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(browserTz), 0)
}
func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func FeedbackSubmitRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+1 -12
View File
@@ -49,23 +49,12 @@ 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(2) builder.StartObject(1)
} }
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()
} }
+1 -12
View File
@@ -49,23 +49,12 @@ 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(2) builder.StartObject(1)
} }
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()
} }
+4 -11
View File
@@ -38,17 +38,10 @@ 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 localized - **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App
welcome and a Mini App launch button; a deep-link payload routes the launch to a game / launch button; a deep-link payload routes the launch to a game / invitation / friend
invitation / friend code. The welcome is **Russian or English** by the sender's reported code. This is **self-contained** the bot never calls back into the game, so `/start`
Telegram language (`Message.from.language_code`, which the Bot API carries on the message onboarding works even when the game is down.
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,7 +72,6 @@ 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
+4 -57
View File
@@ -33,12 +33,8 @@ 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. Its public // the bot to be an administrator there with the restrict-members right.
// @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
@@ -58,13 +54,6 @@ 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
@@ -80,7 +69,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, channelID: cfg.GameChannelID} t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID}
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)
} }
@@ -134,43 +123,9 @@ 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
@@ -243,19 +198,11 @@ 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: text, Text: "Tap to open Scrabble.",
ReplyMarkup: t.launchMarkup(button, startParam), ReplyMarkup: t.launchMarkup("Open Scrabble", 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))
} }
+1 -54
View File
@@ -29,10 +29,6 @@ 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}`)
} }
@@ -109,7 +105,7 @@ func TestTestEnvironmentRoutesGetMe(t *testing.T) {
} }
func TestHandleStartRepliesPrivateOnly(t *testing.T) { func TestHandleStartRepliesPrivateOnly(t *testing.T) {
t.Run("private replies in english by default", func(t *testing.T) { t.Run("private replies", 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{
@@ -118,35 +114,6 @@ 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{}
@@ -160,26 +127,6 @@ 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
@@ -1,60 +0,0 @@
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")
}
@@ -1,73 +0,0 @@
package bot
import (
"strings"
"testing"
)
func TestStartTextLocalizesByLanguage(t *testing.T) {
cases := []struct {
name string
lang string
wantButton string
wantSubstr string // a phrase unique to the chosen language body
}{
{"russian", "ru", "Открыть «Эрудит»", "Привет!"},
{"russian region tag", "ru-RU", "Открыть «Эрудит»", "Первая партия"},
{"english", "en", "Open “Erudite”", "Hi!"},
{"other language falls back to english", "de", "Open “Erudite”", "Hi!"},
{"absent language falls back to english", "", "Open “Erudite”", "no more talking"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
text, button := startText(tc.lang, "erudit", "erudite_chat")
if button != tc.wantButton {
t.Errorf("button = %q, want %q", button, tc.wantButton)
}
if !strings.Contains(text, tc.wantSubstr) {
t.Errorf("text %q does not contain %q", text, tc.wantSubstr)
}
})
}
}
func TestStartTextEmbedsFollowHandles(t *testing.T) {
for _, lang := range []string{"ru", "en"} {
text, _ := startText(lang, "erudit", "erudite_chat")
if !strings.Contains(text, "@erudit") || !strings.Contains(text, "@erudite_chat") {
t.Errorf("lang %q: follow paragraph missing the handles: %q", lang, text)
}
}
}
func TestStartTextFallsBackToGenericWhenHandleMissing(t *testing.T) {
// An unresolved handle degrades to a generic noun rather than a dangling "@" — and
// only that slot degrades; a resolved sibling still shows its "@username".
t.Run("both missing leaves no @", func(t *testing.T) {
for _, lang := range []string{"ru", "en"} {
text, _ := startText(lang, "", "")
if strings.Contains(text, "@") {
t.Errorf("lang %q: text shows a dangling @: %q", lang, text)
}
}
// The generic nouns are present in each language.
ru, _ := startText("ru", "", "")
if !strings.Contains(ru, "на канал") || !strings.Contains(ru, "в нашем чате") {
t.Errorf("russian generic fallback missing: %q", ru)
}
en, _ := startText("en", "", "")
if !strings.Contains(en, "Follow the channel") || !strings.Contains(en, "in our group chat") {
t.Errorf("english generic fallback missing: %q", en)
}
})
t.Run("only the missing slot degrades", func(t *testing.T) {
// Channel resolved, chat missing: the channel keeps its @handle, the chat is generic.
en, _ := startText("en", "erudit", "")
if !strings.Contains(en, "@erudit") || strings.Contains(en, "@erudite") {
t.Errorf("channel handle not shown / chat handle leaked: %q", en)
}
if !strings.Contains(en, "in our group chat") {
t.Errorf("chat slot did not degrade to a generic noun: %q", en)
}
})
}
+5 -5
View File
@@ -1,13 +1,13 @@
import { test as base } from '@playwright/test'; import { test as base } from '@playwright/test';
// All e2e specs run hermetically against the mock transport. Neutralise the real // All e2e specs run hermetically against the mock transport. Neutralise the real
// telegram-web-app.js (the app loads it dynamically — see lib/telegram.ts loadTelegramSDK) so the // telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks
// suite never reaches telegram.org, which is unreachable from the CI runner. Specs that exercise // on telegram.org — it is unreachable from the CI runner, and a render-blocking
// the Telegram launch inject their own window.Telegram via addInitScript before navigating, so the // <script> to it would hang every page load. Specs that exercise the Telegram launch
// dynamic load short-circuits on the already-present SDK. // inject their own window.Telegram via addInitScript before navigating.
export const test = base.extend({ export const test = base.extend({
page: async ({ page }, use) => { page: async ({ page }, use) => {
await page.route('**/telegram-web-app.js*', (route) => await page.route('**/telegram-web-app.js', (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }), route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
); );
await use(page); await use(page);
-54
View File
@@ -44,60 +44,6 @@ 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();
+5 -24
View File
@@ -107,30 +107,11 @@ test('inside Telegram, a failed launch shows the retry screen, not the web login
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0); await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
}); });
test('outside Telegram, the /telegram/ entry shows the launch diagnostic, not a redirect', async ({ test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
page,
}) => {
await page.goto('/telegram/'); await page.goto('/telegram/');
// The entry no longer bounces a visitor without Telegram sign-in data to the marketing landing; // The guard sends a non-Telegram visitor back to the root, where the normal
// it shows a compact diagnostic screen (Share + Retry) that helps pinpoint why initData was // (guest / email) login is shown.
// absent — notably the Android empty-initData failure. await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible(); await expect(page).not.toHaveURL(/\/telegram\//);
await expect(page.getByRole('button', { name: 'Retry' })).toBeVisible();
// It stays on /telegram/ (no redirect) and never shows the web (guest) login.
await expect(page).toHaveURL(/\/telegram\//);
await expect(page.getByRole('button', { name: /guest/i })).toHaveCount(0);
});
test('a blocked telegram-web-app.js does not hang the diagnostic screen', async ({ page }) => {
// Simulate a network where telegram.org is unreachable: the SDK fetch fails. Because the SPA
// loads the SDK dynamically with a timeout (not a render-blocking <script>), a failed/blocked
// fetch must not strand the page — the diagnostic screen still renders, reporting no SDK. (This
// route overrides the fixture's empty-body fulfill; the later registration wins.)
await page.route('**/telegram-web-app.js*', (route) => route.abort());
await page.goto('/telegram/');
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
// The diagnostic names the load outcome: a failed fetch reads as sdk-load: error.
await expect(page.getByText('sdk-load: error')).toBeVisible();
}); });
+3 -5
View File
@@ -2,11 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- The Telegram Mini App SDK (window.Telegram.WebApp) is deliberately NOT loaded here: a <!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside
render-blocking <script> to telegram.org hangs the whole page on a network that blocks Telegram (initData is empty), so it loads on every entry. -->
telegram.org (common where Telegram itself reaches users only over a proxy), stranding even <script src="https://telegram.org/js/telegram-web-app.js"></script>
the launch-diagnostic screen. The app loads it dynamically, with a timeout, only on a
Telegram entry — see lib/telegram.ts loadTelegramSDK and lib/app.svelte.ts bootstrap. -->
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch <!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). --> to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta <meta
+1 -7
View File
@@ -19,7 +19,6 @@
import Feedback from './screens/Feedback.svelte'; import Feedback from './screens/Feedback.svelte';
import Blocked from './screens/Blocked.svelte'; import Blocked from './screens/Blocked.svelte';
import BootError from './screens/BootError.svelte'; import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
onMount(() => { onMount(() => {
void bootstrap(); void bootstrap();
@@ -85,11 +84,6 @@
{#if !routeIsLobby} {#if !routeIsLobby}
<div class="splash">{t('common.loading')}</div> <div class="splash">{t('common.loading')}</div>
{/if} {/if}
{:else if app.launchError}
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
of bouncing to the marketing landing (also the probe for the empty-initData failure on
some Android clients). -->
<TelegramLaunchError />
{:else if app.bootError} {:else if app.bootError}
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy): <!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
show the retry screen instead of falling back to the web login. --> show the retry screen instead of falling back to the web login. -->
@@ -134,7 +128,7 @@
<StaleInviteModal /> <StaleInviteModal />
<WelcomeRedeemModal /> <WelcomeRedeemModal />
{#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError && !app.launchError} {#if routeIsLobby && !app.splashDone && !app.blocked && !app.bootError}
<Splash /> <Splash />
{/if} {/if}
+6 -18
View File
@@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
// A best-move word drawn as a row of game tiles, mirroring the board's placed-tile // A best-move word drawn as a row of game tiles, mirroring the board's placed-tile
// look (letter top-left, point value bottom-right) at a small fixed size. A blank tile // look (letter top-left, point value bottom-right) at a small fixed size. A blank tile
// shows its letter but no value, exactly as on the board; in Erudit it also carries the // shows its letter but no value, exactly as on the board. Letters are upper-cased for
// blank's star (✻) in the value corner. Letters are upper-cased for display. The tile // display. The tile values ride on each tile, so this renders without the variant's
// values ride on each tile, so this needs only the variant id (for the star) — not the // alphabet table (which the statistics screen has not cached).
// variant's alphabet table, which the statistics screen has not cached. import type { BestMoveTile } from '../lib/model';
import type { BestMoveTile, Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let { word, variant }: { word: BestMoveTile[]; variant: Variant } = $props(); let { word }: { word: BestMoveTile[] } = $props();
const label = $derived(word.map((t) => t.letter).join('').toUpperCase()); const label = $derived(word.map((t) => t.letter).join('').toUpperCase());
</script> </script>
@@ -17,11 +15,7 @@
{#each word as tile, i (i)} {#each word as tile, i (i)}
<span class="tile" class:blank={tile.blank} aria-hidden="true"> <span class="tile" class:blank={tile.blank} aria-hidden="true">
<span class="letter">{tile.letter.toUpperCase()}</span> <span class="letter">{tile.letter.toUpperCase()}</span>
{#if !tile.blank} {#if !tile.blank}<span class="val">{tile.value}</span>{/if}
<span class="val">{tile.value}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
</span> </span>
{/each} {/each}
</span> </span>
@@ -56,10 +50,4 @@
font-size: 7px; font-size: 7px;
font-weight: 600; font-weight: 600;
} }
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink kept on the value digits' line (mirrors the board tile). */
.blankmark {
font-size: 8px;
bottom: 0;
}
</style> </style>
+1 -12
View File
@@ -4,7 +4,6 @@
import type { Premium } from '../lib/premiums'; import type { Premium } from '../lib/premiums';
import { valueForLetter } from '../lib/alphabet'; import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels'; import { bonusLabel, type BoardLabelMode } from '../lib/boardlabels';
import type { Locale } from '../lib/i18n/catalog'; import type { Locale } from '../lib/i18n/catalog';
@@ -255,11 +254,7 @@
> >
{#if letter} {#if letter}
<span class="letter">{letter}</span> <span class="letter">{letter}</span>
{#if !blank} {#if !blank}<span class="val">{valueForLetter(variant, letter)}</span>{/if}
<span class="val">{valueForLetter(variant, letter)}</span>
{:else if usesStarBlank(variant)}
<span class="val blankmark">{BLANK_STAR}</span>
{/if}
{:else if r === centre.row && c === centre.col} {:else if r === centre.row && c === centre.col}
<span class="star"></span> <span class="star"></span>
{:else if bl?.kind === 'single'} {:else if bl?.kind === 'single'}
@@ -415,12 +410,6 @@
font-size: 2.4cqw; font-size: 2.4cqw;
font-weight: 600; font-weight: 600;
} }
/* A placed Erudit blank ("звёздочка") shows its star where the (absent) point value sits,
its ink centred on the same line as a neighbouring tile's value digit. */
.blankmark {
font-size: 2.8cqw;
bottom: 0;
}
.star { .star {
position: absolute; position: absolute;
inset: 0; inset: 0;
+2 -2
View File
@@ -17,7 +17,7 @@
import { badgeKind } from '../lib/unread'; import { badgeKind } from '../lib/unread';
import { historyGrid } from '../lib/history'; import { historyGrid } from '../lib/history';
import { centre, premiumGrid } from '../lib/premiums'; import { centre, premiumGrid } from '../lib/premiums';
import { variantNameKey, usesStarBlank, BLANK_STAR } from '../lib/variants'; import { variantNameKey } from '../lib/variants';
import { alphabetLetters, hasAlphabet } from '../lib/alphabet'; import { alphabetLetters, hasAlphabet } from '../lib/alphabet';
import { hintsLeft } from '../lib/hints'; import { hintsLeft } from '../lib/hints';
import { shareOrDownloadGcg } from '../lib/share'; import { shareOrDownloadGcg } from '../lib/share';
@@ -1293,7 +1293,7 @@
{#if drag} {#if drag}
<div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px"> <div class="ghost" class:touch={drag.touch} style="left:{drag.x}px; top:{drag.y}px">
<span>{drag.blank ? (usesStarBlank(variant) ? BLANK_STAR : '') : drag.letter}</span> <span>{drag.blank ? '' : drag.letter}</span>
</div> </div>
{/if} {/if}
+2 -18
View File
@@ -3,7 +3,6 @@
import { BLANK } from '../lib/placement'; import { BLANK } from '../lib/placement';
import { valueForLetter } from '../lib/alphabet'; import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model'; import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
let { let {
slots, slots,
@@ -67,12 +66,8 @@
animate:hop={shuffling} animate:hop={shuffling}
onpointerdown={(e) => ondown(e, slot.index)} onpointerdown={(e) => ondown(e, slot.index)}
> >
{#if slot.letter === BLANK} <span class="letter">{slot.letter === BLANK ? '' : slot.letter}</span>
{#if usesStarBlank(variant)}<span class="star">{BLANK_STAR}</span>{/if} {#if slot.letter !== BLANK}<span class="val">{valueForLetter(variant, slot.letter)}</span>{/if}
{:else}
<span class="letter">{slot.letter}</span>
<span class="val">{valueForLetter(variant, slot.letter)}</span>
{/if}
</button> </button>
{/each} {/each}
</div> </div>
@@ -138,15 +133,4 @@
font-size: 0.7rem; font-size: 0.7rem;
font-weight: 600; font-weight: 600;
} }
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
tile face; the top offset centres its ink against the neighbouring letters' block, nudged
up a pixel to sit right by eye (it is slightly larger than them so it reads). */
.star {
position: absolute;
top: calc(0.5% - 1px);
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
}
</style> </style>
@@ -27,34 +27,22 @@ 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(2); builder.startObject(1);
} }
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, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { static createEmailRequestRequest(builder:flatbuffers.Builder, emailOffset:flatbuffers.Offset):flatbuffers.Offset {
EmailRequestRequest.startEmailRequestRequest(builder); EmailRequestRequest.startEmailRequestRequest(builder);
EmailRequestRequest.addEmail(builder, emailOffset); EmailRequestRequest.addEmail(builder, emailOffset);
EmailRequestRequest.addBrowserTz(builder, browserTzOffset);
return EmailRequestRequest.endEmailRequestRequest(builder); return EmailRequestRequest.endEmailRequestRequest(builder);
} }
} }
@@ -56,22 +56,8 @@ channel(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
version():string|null
version(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
version(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
browserTz():string|null
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
browserTz(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 14);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startFeedbackSubmitRequest(builder:flatbuffers.Builder) { static startFeedbackSubmitRequest(builder:flatbuffers.Builder) {
builder.startObject(6); builder.startObject(4);
} }
static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) { static addBody(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset) {
@@ -102,27 +88,17 @@ static addChannel(builder:flatbuffers.Builder, channelOffset:flatbuffers.Offset)
builder.addFieldOffset(3, channelOffset, 0); builder.addFieldOffset(3, channelOffset, 0);
} }
static addVersion(builder:flatbuffers.Builder, versionOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, versionOffset, 0);
}
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, browserTzOffset, 0);
}
static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endFeedbackSubmitRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset, versionOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { static createFeedbackSubmitRequest(builder:flatbuffers.Builder, bodyOffset:flatbuffers.Offset, attachmentOffset:flatbuffers.Offset, attachmentNameOffset:flatbuffers.Offset, channelOffset:flatbuffers.Offset):flatbuffers.Offset {
FeedbackSubmitRequest.startFeedbackSubmitRequest(builder); FeedbackSubmitRequest.startFeedbackSubmitRequest(builder);
FeedbackSubmitRequest.addBody(builder, bodyOffset); FeedbackSubmitRequest.addBody(builder, bodyOffset);
FeedbackSubmitRequest.addAttachment(builder, attachmentOffset); FeedbackSubmitRequest.addAttachment(builder, attachmentOffset);
FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset); FeedbackSubmitRequest.addAttachmentName(builder, attachmentNameOffset);
FeedbackSubmitRequest.addChannel(builder, channelOffset); FeedbackSubmitRequest.addChannel(builder, channelOffset);
FeedbackSubmitRequest.addVersion(builder, versionOffset);
FeedbackSubmitRequest.addBrowserTz(builder, browserTzOffset);
return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder); return FeedbackSubmitRequest.endFeedbackSubmitRequest(builder);
} }
} }
@@ -27,34 +27,22 @@ 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(2); builder.startObject(1);
} }
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, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { static createGuestLoginRequest(builder:flatbuffers.Builder, localeOffset: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,34 +27,22 @@ 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(2); builder.startObject(1);
} }
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, browserTzOffset:flatbuffers.Offset):flatbuffers.Offset { static createTelegramLoginRequest(builder:flatbuffers.Builder, initDataOffset: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);
} }
} }
+29 -97
View File
@@ -8,15 +8,10 @@ import { gateway } from './gateway';
import { GatewayError } from './client'; import { GatewayError } from './client';
import { navigate, router } from './router.svelte'; import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { import {
insideTelegram, insideTelegram,
collectTelegramDiag,
type TelegramDiag,
onTelegramPath, onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
telegramColorScheme, telegramColorScheme,
telegramContentSafeAreaTop, telegramContentSafeAreaTop,
telegramSafeAreaTop, telegramSafeAreaTop,
@@ -51,11 +46,6 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen * backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */ * instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean; bootError: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
launchError: TelegramDiag | null;
/** Whether the lobby's first cold load has settled (success or error). The loading splash /** Whether the lobby's first cold load has settled (success or error). The loading splash
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */ * (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
lobbyReady: boolean; lobbyReady: boolean;
@@ -77,6 +67,7 @@ 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
@@ -105,7 +96,6 @@ export const app = $state<{
}>({ }>({
ready: false, ready: false,
bootError: false, bootError: false,
launchError: null,
lobbyReady: false, lobbyReady: false,
splashDone: false, splashDone: false,
streamAlive: false, streamAlive: false,
@@ -118,6 +108,7 @@ 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: {},
@@ -459,20 +450,11 @@ 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 (saved in // The live interface language follows the device — the explicit local choice (locked, saved
// prefs) or the system guess made at bootstrap — and is no longer overridden from the // in prefs) or the system guess made at bootstrap — and is no longer overridden from the
// account here: the Telegram bot a user signs in through must not dictate the UI, so a // account here. preferred_language stays the user's saved choice (written from Settings,
// ru-bot launch on an English system stays English. // and used for out-of-app push routing), but the Telegram bot a user signs in through must
// // not dictate the UI: a ru-bot launch on an English system stays English.
// The banner and out-of-app push are resolved server-side from preferred_language, so it
// must track whatever language the UI actually shows — the explicit choice AND the system
// guess. Reconcile it to the active locale on every adopt, not only after an explicit
// Settings choice: a user who never opened Settings would otherwise be stuck on the
// creation-time seed — e.g. an English banner under a Russian UI. This keeps every
// server-rendered, language-dependent surface (banner, out-of-app push) aligned with the
// interface, not just one. persistLanguageToServer self-gates (a no-op for guests and when
// already equal), so there is no write in the steady state.
void persistLanguageToServer(app.locale);
} catch (err) { } catch (err) {
handleError(err); handleError(err);
} }
@@ -493,10 +475,6 @@ export async function applyLinkResult(r: LinkResult): Promise<void> {
return; return;
} }
app.profile = await gateway.profileGet(); app.profile = await gateway.profileGet();
// A guest who linked in place now has a durable account: push the active interface language
// so the banner + push routing follow it (see adoptSession — reconciled regardless of an
// explicit Settings choice).
void persistLanguageToServer(app.locale);
} }
/** /**
@@ -540,35 +518,6 @@ function syncViewportHeight(): void {
if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`); if (h > 0) document.documentElement.style.setProperty('--vvh', `${h}px`);
} }
/**
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
* bootstrap and a manual launch retry call it.
*/
function applyTelegramChrome(launch: TelegramLaunch): void {
if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
// back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
// drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's
// full-size window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
}
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
* the launch-error screen. A network that blocks telegram.org makes the script hang rather than
* fail fast (a connection refusal resolves immediately via the script's error event), so this only
* bounds a true hang; it is generous enough not to misfire on a slow but working network. */
const TELEGRAM_SDK_TIMEOUT_MS = 10000;
export async function bootstrap(): Promise<void> { export async function bootstrap(): Promise<void> {
const prefs = await loadPrefs(); const prefs = await loadPrefs();
app.theme = prefs.theme ?? 'auto'; app.theme = prefs.theme ?? 'auto';
@@ -578,6 +527,7 @@ 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');
@@ -592,30 +542,33 @@ export async function bootstrap(): Promise<void> {
window.visualViewport.addEventListener('scroll', syncViewportHeight); window.visualViewport.addEventListener('scroll', syncViewportHeight);
} }
// Load the Telegram Mini App SDK dynamically, with a timeout, on a Telegram entry — it is no // Telegram Mini App launch: apply the platform theme, authenticate via initData,
// longer a render-blocking <script> in index.html, so a network that blocks telegram.org (common // and route any deep-link start parameter. On the dedicated /telegram/ entry path
// where Telegram itself reaches users only over a proxy) cannot hang the page and strand the app // outside Telegram (no initData), refuse to render and send the visitor to the
// or the diagnostic screen below. Skipped on a plain web / native entry, which never needs it. // site root.
if (onTelegramPath() || hasLaunchFragment()) {
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
}
// Telegram Mini App launch: apply the platform theme, authenticate via initData, and route any
// deep-link start parameter. On the dedicated /telegram/ entry without sign-in data (no/empty
// initData — outside Telegram, or a Mini App launch that delivered none, as seen on some Android
// clients), render the compact launch-error screen with a diagnostic snapshot the user can share
// with the developer, instead of bouncing the visitor to the marketing landing.
if (onTelegramPath() && !insideTelegram()) { if (onTelegramPath() && !insideTelegram()) {
app.launchError = collectTelegramDiag(); if (typeof location !== 'undefined') location.replace('/');
app.ready = true;
return; return;
} }
if (insideTelegram()) { if (insideTelegram()) {
const launch = telegramLaunch(); const launch = telegramLaunch();
applyTelegramChrome(launch); if (launch.theme) applyTelegramTheme(launch.theme);
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load). // Inside Telegram the colour scheme is Telegram's to decide; force it explicitly
// so the OS prefers-color-scheme (which leaks into the Telegram Desktop webview)
// cannot fight it. Falls back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme);
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from
// fighting tile drag / board scroll.
syncTelegramChrome();
syncTelegramSafeArea();
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea); telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
telegramDisableVerticalSwipes();
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
// listener above then re-syncs the safe-area insets. Desktop keeps the bot's full-size
// window. No-op on clients predating Bot API 8.0.
telegramRequestFullscreen();
await bootTelegram(launch); await bootTelegram(launch);
app.ready = true; app.ready = true;
return; return;
@@ -682,28 +635,6 @@ export async function retryTelegramBoot(): Promise<void> {
app.ready = true; app.ready = true;
} }
/**
* retryTelegramLaunch re-attempts a Mini App launch from the launch-error screen's Retry button. If
* sign-in data is present now (e.g. it arrived late on a slow client) it clears the error and runs
* the normal launch; otherwise it refreshes the diagnostic snapshot so the screen reflects the
* current state. It never reloads — telegram-web-app.js consumes the launch fragment on first load,
* so a reload could discard the very data we are waiting for.
*/
export async function retryTelegramLaunch(): Promise<void> {
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
if (!insideTelegram()) {
app.launchError = collectTelegramDiag();
return;
}
app.launchError = null;
app.ready = false;
const launch = telegramLaunch();
applyTelegramChrome(launch);
await bootTelegram(launch);
app.ready = true;
}
/** /**
* routeStartParam navigates a Telegram deep-link start parameter to its target: a * routeStartParam navigates a Telegram deep-link start parameter to its target: a
* specific game, the friends screen with a friend-code redemption, or the lobby * specific game, the friends screen with a friend-code redemption, or the lobby
@@ -812,6 +743,7 @@ 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);
@@ -824,7 +756,7 @@ export function setLocalePref(locale: Locale): void {
*/ */
async function persistLanguageToServer(locale: Locale): Promise<void> { async function persistLanguageToServer(locale: Locale): Promise<void> {
const p = app.profile; const p = app.profile;
if (!p || !languageNeedsServerSync(p, locale)) return; if (!p || p.isGuest || p.preferredLanguage === locale) return;
try { try {
app.profile = await gateway.profileUpdate({ app.profile = await gateway.profileUpdate({
displayName: p.displayName, displayName: p.displayName,
+2 -29
View File
@@ -19,16 +19,13 @@ 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';
@@ -76,45 +73,21 @@ describe('codec', () => {
}); });
}); });
it('carries the detected browser zone on every account-creating auth request', () => {
const tg = fb.TelegramLoginRequest.getRootAsTelegramLoginRequest(
new ByteBuffer(encodeTelegramLogin('init-data-blob', '+03:00')),
);
expect(tg.initData()).toBe('init-data-blob');
expect(tg.browserTz()).toBe('+03:00');
const guest = fb.GuestLoginRequest.getRootAsGuestLoginRequest(
new ByteBuffer(encodeGuestLogin('ru', '-05:30')),
);
expect(guest.locale()).toBe('ru');
expect(guest.browserTz()).toBe('-05:30');
const email = fb.EmailRequestRequest.getRootAsEmailRequestRequest(
new ByteBuffer(encodeEmailRequest('a@example.com', '+00:00')),
);
expect(email.email()).toBe('a@example.com');
expect(email.browserTz()).toBe('+00:00');
});
it('round-trips a feedback submit and decodes state + unread', () => { it('round-trips a feedback submit and decodes state + unread', () => {
const att = new Uint8Array([1, 2, 3, 4]); const att = new Uint8Array([1, 2, 3, 4]);
const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( const req = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios', 'v1.2.3', '+03:00')), new ByteBuffer(encodeFeedbackSubmit('please fix', att, 'shot.png', 'ios')),
); );
expect(req.body()).toBe('please fix'); expect(req.body()).toBe('please fix');
expect(req.attachmentName()).toBe('shot.png'); expect(req.attachmentName()).toBe('shot.png');
expect(req.channel()).toBe('ios'); expect(req.channel()).toBe('ios');
expect(req.version()).toBe('v1.2.3');
expect(req.browserTz()).toBe('+03:00');
expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]); expect(Array.from(req.attachmentArray() ?? [])).toEqual([1, 2, 3, 4]);
// No attachment: the vector is empty. // No attachment: the vector is empty.
const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest( const req2 = fb.FeedbackSubmitRequest.getRootAsFeedbackSubmitRequest(
new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web', 'dev', '+00:00')), new ByteBuffer(encodeFeedbackSubmit('hi', null, '', 'web')),
); );
expect(req2.body()).toBe('hi'); expect(req2.body()).toBe('hi');
expect(req2.version()).toBe('dev');
expect(req2.browserTz()).toBe('+00:00');
expect(req2.attachmentLength()).toBe(0); expect(req2.attachmentLength()).toBe(0);
// State carrying a reply. // State carrying a reply.
+3 -15
View File
@@ -179,33 +179,27 @@ 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, browserTz: string): Uint8Array { export function encodeTelegramLogin(initData: 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, browserTz: string): Uint8Array { export function encodeGuestLogin(locale: 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, browserTz: string): Uint8Array { export function encodeEmailRequest(email: 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));
} }
@@ -495,23 +489,17 @@ export function encodeFeedbackSubmit(
attachment: Uint8Array | null, attachment: Uint8Array | null,
attachmentName: string, attachmentName: string,
channel: string, channel: string,
version: string,
browserTz: string,
): Uint8Array { ): Uint8Array {
const b = new Builder(256); const b = new Builder(256);
const bodyOff = b.createString(body); const bodyOff = b.createString(body);
const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0; const attOff = attachment && attachment.length > 0 ? fb.FeedbackSubmitRequest.createAttachmentVector(b, attachment) : 0;
const nameOff = b.createString(attachmentName); const nameOff = b.createString(attachmentName);
const chOff = b.createString(channel); const chOff = b.createString(channel);
const verOff = b.createString(version);
const tzOff = b.createString(browserTz);
fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b); fb.FeedbackSubmitRequest.startFeedbackSubmitRequest(b);
fb.FeedbackSubmitRequest.addBody(b, bodyOff); fb.FeedbackSubmitRequest.addBody(b, bodyOff);
if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff); if (attOff) fb.FeedbackSubmitRequest.addAttachment(b, attOff);
fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff); fb.FeedbackSubmitRequest.addAttachmentName(b, nameOff);
fb.FeedbackSubmitRequest.addChannel(b, chOff); fb.FeedbackSubmitRequest.addChannel(b, chOff);
fb.FeedbackSubmitRequest.addVersion(b, verOff);
fb.FeedbackSubmitRequest.addBrowserTz(b, tzOff);
return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b)); return finish(b, fb.FeedbackSubmitRequest.endFeedbackSubmitRequest(b));
} }
-8
View File
@@ -14,11 +14,6 @@ export const en = {
'boot.errorTitle': "Couldn't load the game", 'boot.errorTitle': "Couldn't load the game",
'boot.errorBody': 'Please try again in a moment.', 'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
'common.back': 'Back', 'common.back': 'Back',
'common.cancel': 'Cancel', 'common.cancel': 'Cancel',
'common.ok': 'OK', 'common.ok': 'OK',
@@ -232,9 +227,6 @@ export const en = {
'friends.decline': 'Decline', 'friends.decline': 'Decline',
'friends.unfriend': 'Remove', 'friends.unfriend': 'Remove',
'friends.block': 'Block', 'friends.block': 'Block',
'friends.actions': 'Actions',
'friends.blockConfirm': 'Block this player?',
'friends.unfriendConfirm': 'Remove from friends?',
'friends.add': 'Add a friend', 'friends.add': 'Add a friend',
'friends.addFromGame': 'Add to friends', 'friends.addFromGame': 'Add to friends',
'friends.blockFromGame': 'Block player', 'friends.blockFromGame': 'Block player',
-8
View File
@@ -15,11 +15,6 @@ export const ru: Record<MessageKey, string> = {
'boot.errorTitle': 'Не удалось загрузить игру', 'boot.errorTitle': 'Не удалось загрузить игру',
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.', 'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
'common.back': 'Назад', 'common.back': 'Назад',
'common.cancel': 'Отмена', 'common.cancel': 'Отмена',
'common.ok': 'ОК', 'common.ok': 'ОК',
@@ -233,9 +228,6 @@ export const ru: Record<MessageKey, string> = {
'friends.decline': 'Отклонить', 'friends.decline': 'Отклонить',
'friends.unfriend': 'Удалить', 'friends.unfriend': 'Удалить',
'friends.block': 'Заблокировать', 'friends.block': 'Заблокировать',
'friends.actions': 'Действия',
'friends.blockConfirm': 'Заблокировать?',
'friends.unfriendConfirm': 'Удалить из друзей?',
'friends.add': 'Добавить друга', 'friends.add': 'Добавить друга',
'friends.addFromGame': 'В друзья', 'friends.addFromGame': 'В друзья',
'friends.blockFromGame': 'Заблокировать', 'friends.blockFromGame': 'Заблокировать',
-27
View File
@@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest';
import { languageNeedsServerSync } from './language';
import type { Profile } from './model';
// The reconciler only reads isGuest + preferredLanguage; a partial cast keeps the fixture small.
const profile = (over: Partial<Profile>): Profile => ({ isGuest: false, preferredLanguage: 'en', ...over }) as Profile;
describe('languageNeedsServerSync', () => {
it('is false without a profile', () => {
expect(languageNeedsServerSync(null, 'ru')).toBe(false);
expect(languageNeedsServerSync(undefined, 'ru')).toBe(false);
});
it('is false for a guest — guests keep only the client preference', () => {
expect(languageNeedsServerSync(profile({ isGuest: true, preferredLanguage: 'en' }), 'ru')).toBe(false);
});
it('is false when the account already matches the locale', () => {
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'ru')).toBe(false);
});
it('is true for a real account whose stored language differs (banner + push follow it)', () => {
expect(languageNeedsServerSync(profile({ preferredLanguage: 'en' }), 'ru')).toBe(true);
expect(languageNeedsServerSync(profile({ preferredLanguage: 'ru' }), 'en')).toBe(true);
});
});
-22
View File
@@ -1,22 +0,0 @@
// Interface-language reconciliation. Kept out of app.svelte.ts (a runes module that the
// node-env Vitest layer cannot import) so the decision is unit-testable.
import type { Locale } from './i18n/catalog';
import type { Profile } from './model';
/**
* languageNeedsServerSync reports whether the durable account's `preferred_language` should be
* rewritten to the chosen interface `locale`. It is true only for a real (non-guest) account
* whose stored language differs from the locale; guests keep only the client-side preference,
* and an already-matching account is a no-op.
*
* The UI language follows the device (the local choice / system guess), but the advertising
* banner and out-of-app push routing are resolved server-side from `preferred_language`. A saved
* device choice the account has not yet recorded — picked while a guest, or differing from the
* Telegram system-language seed — would otherwise leave the banner and pushes in the wrong
* language until the next Settings change. Both the Settings control and the on-load reconciler
* gate their write on this.
*/
export function languageNeedsServerSync(profile: Profile | null | undefined, locale: Locale): boolean {
return !!profile && !profile.isGuest && profile.preferredLanguage !== locale;
}
+1 -40
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { pickGcgDelivery, pickTextShare, shareOrDownloadGcg, shareText } from './share'; import { pickGcgDelivery, shareOrDownloadGcg } from './share';
import type { GcgExport } from './model'; import type { GcgExport } from './model';
const file = {} as File; const file = {} as File;
@@ -60,42 +60,3 @@ describe('shareOrDownloadGcg', () => {
expect(anchor.click).toHaveBeenCalledOnce(); expect(anchor.click).toHaveBeenCalledOnce();
}); });
}); });
describe('pickTextShare', () => {
it('shares when Web Share is available', () => {
expect(pickTextShare({ share: async () => {}, canShare: () => true })).toBe('share');
});
it('shares when share exists without canShare (text needs no file capability check)', () => {
expect(pickTextShare({ share: async () => {} })).toBe('share');
});
it('copies when there is no Web Share (desktop)', () => {
expect(pickTextShare(undefined)).toBe('copy');
expect(pickTextShare({} as never)).toBe('copy');
});
});
describe('shareText', () => {
afterEach(() => vi.unstubAllGlobals());
it('uses the OS share sheet when available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'title', text: 'diag' });
});
it('copies to the clipboard when Web Share is absent (desktop)', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { clipboard: { writeText } });
expect(await shareText('diag', 'title')).toBe('copied');
expect(writeText).toHaveBeenCalledWith('diag');
});
it('reports failure without a fallback when the share is cancelled', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('cancelled', 'AbortError'));
vi.stubGlobal('navigator', { share, canShare: () => true });
expect(await shareText('diag', 'title')).toBe('failed');
});
});
-39
View File
@@ -51,42 +51,3 @@ function downloadFile(content: string, filename: string): void {
a.remove(); a.remove();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
type TextShareNav = Pick<Navigator, 'share'> & { canShare?: Navigator['canShare'] };
/**
* pickTextShare decides how to deliver a plain-text payload: through the OS share sheet (Web Share,
* available on mobile including the Telegram Mini App) or, on a desktop browser without it, a
* clipboard copy. Pure, so it is unit-tested with a mock navigator.
*/
export function pickTextShare(nav: TextShareNav | undefined): 'share' | 'copy' {
if (nav && typeof nav.share === 'function' && (typeof nav.canShare !== 'function' || nav.canShare({ text: 'x' }))) {
return 'share';
}
return 'copy';
}
/**
* shareText delivers text through the OS share sheet where supported, else copies it to the
* clipboard. It reports the path taken — 'shared', 'copied', or 'failed' (a cancelled share or an
* unavailable clipboard) — so the caller can confirm a silent copy to the user. Like
* shareOrDownloadGcg it never strands the webview: a cancelled share simply does nothing.
*/
export async function shareText(text: string, title: string): Promise<'shared' | 'copied' | 'failed'> {
const nav = typeof navigator !== 'undefined' ? navigator : undefined;
if (!nav) return 'failed';
if (pickTextShare(nav) === 'share') {
try {
await nav.share({ title, text });
return 'shared';
} catch {
return 'failed';
}
}
try {
await nav.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
-106
View File
@@ -1,9 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
collectTelegramDiag,
insideTelegram, insideTelegram,
loadTelegramSDK,
telegramSdkOutcome,
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramClosingConfirmation, telegramClosingConfirmation,
telegramLaunch, telegramLaunch,
@@ -158,106 +155,3 @@ describe('routeExternalLinkInTelegram', () => {
expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false); expect(routeExternalLinkInTelegram({ href: 'https://x.io', target: '_blank' })).toBe(false);
}); });
}); });
describe('collectTelegramDiag', () => {
afterEach(() => vi.unstubAllGlobals());
it('reports a missing SDK outside Telegram', () => {
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(false);
expect(d.hasWebApp).toBe(false);
expect(d.initDataLen).toBe(0);
expect(d.fieldsPresent).toEqual([]);
expect(d.fieldsMissing).toEqual(['user', 'auth_date', 'hash', 'signature']);
});
it('reads field NAMES (never values) from a non-empty initData', () => {
stubWebApp('query_id=abc&user=%7B%7D&auth_date=1&hash=deadbeef');
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.hasWebApp).toBe(true);
expect(d.initDataLen).toBeGreaterThan(0);
expect(d.fieldsPresent).toEqual(['query_id', 'user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
it('recovers field names from the URL fragment when the SDK left initData empty', () => {
// Telegram passed launch data in the fragment, but WebApp.initData is empty (the Android
// failure this screen diagnoses): the names come from the raw fragment instead, and
// hashHadTgData flags that the data did arrive in the URL.
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '', platform: 'android' } } });
vi.stubGlobal('location', {
hash: '#tgWebAppData=user%3D%257B%257D%26auth_date%3D1%26hash%3Ddeadbeef&tgWebAppVersion=7.0',
pathname: '/telegram/',
});
const d = collectTelegramDiag();
expect(d.hasSDK).toBe(true);
expect(d.platform).toBe('android');
expect(d.initDataLen).toBe(0);
expect(d.hashHadTgData).toBe(true);
expect(d.fieldsPresent).toEqual(['user', 'auth_date', 'hash']);
expect(d.fieldsMissing).toEqual(['signature']);
});
});
describe('loadTelegramSDK', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
it('resolves true and records "present" when the SDK is already there', async () => {
vi.stubGlobal('window', { Telegram: { WebApp: { initData: '' } } });
vi.stubGlobal('document', { createElement: vi.fn(), head: { appendChild: vi.fn() } });
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('present');
});
it('records "loaded" when the script defines the WebApp', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: {
appendChild: () => {
(window as unknown as { Telegram: unknown }).Telegram = { WebApp: { initData: '' } };
(script.onload as () => void)();
},
},
});
await expect(loadTelegramSDK(10000)).resolves.toBe(true);
expect(telegramSdkOutcome()).toBe('loaded');
});
it('records "no-webapp" when the script loads but defines nothing', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onload as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('no-webapp');
});
it('records "error" when the script fails to load (telegram.org unreachable)', async () => {
const script: Record<string, unknown> = {};
vi.stubGlobal('window', {});
vi.stubGlobal('document', {
createElement: () => script,
head: { appendChild: () => (script.onerror as () => void)() },
});
await expect(loadTelegramSDK(10000)).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('error');
});
it('records "timeout" when the script neither loads nor fails in time', async () => {
vi.useFakeTimers();
vi.stubGlobal('window', {});
vi.stubGlobal('document', { createElement: () => ({}), head: { appendChild: vi.fn() } });
const p = loadTelegramSDK(10000);
await vi.advanceTimersByTimeAsync(10000);
await expect(p).resolves.toBe(false);
expect(telegramSdkOutcome()).toBe('timeout');
});
});
+3 -197
View File
@@ -1,7 +1,6 @@
// Telegram Mini App SDK access. The official telegram-web-app.js (loaded dynamically with a // Telegram Mini App SDK access. The official telegram-web-app.js (loaded in
// timeout by loadTelegramSDK — not a render-blocking <script> in index.html, so a network that // index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses:
// blocks telegram.org cannot hang the page) exposes window.Telegram.WebApp; this wraps the subset // launch detection, initData (for auth.telegram), the deep-link start parameter,
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram. // theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
import type { TelegramThemeParams } from './theme'; import type { TelegramThemeParams } from './theme';
@@ -10,7 +9,6 @@ interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
platform?: string; platform?: string;
version?: string;
themeParams?: TelegramThemeParams; themeParams?: TelegramThemeParams;
colorScheme?: 'light' | 'dark'; colorScheme?: 'light' | 'dark';
isFullscreen?: boolean; isFullscreen?: boolean;
@@ -55,72 +53,6 @@ export function insideTelegram(): boolean {
return !!w && typeof w.initData === 'string' && w.initData.length > 0; return !!w && typeof w.initData === 'string' && w.initData.length > 0;
} }
// The ?NN suffix pins the Bot API SDK version Telegram serves (and busts the cache); keep it at the
// version the official Mini Apps page currently recommends so newer client features (fullscreen,
// safe-area insets, vertical-swipe guard, …) are available. Bump it when Telegram bumps theirs.
const sdkScriptSrc = 'https://telegram.org/js/telegram-web-app.js?62';
/**
* TelegramSdkOutcome records how the dynamic telegram-web-app.js load resolved, surfaced on the
* launch-error screen to tell the failure modes apart — notably 'error' / 'timeout', which mean the
* network could not reach telegram.org (the script blocked or hung):
*
* not-attempted — loadTelegramSDK was never called (a plain web / native entry)
* present — window.Telegram.WebApp was already there (a cached load or native injection)
* loaded — the script loaded and defined window.Telegram.WebApp
* no-webapp — the script loaded (HTTP 200) but did not define window.Telegram.WebApp
* error — the script failed to load (telegram.org unreachable / blocked, a fast failure)
* timeout — the script neither loaded nor failed within the timeout (a blocked, hanging fetch)
*/
export type TelegramSdkOutcome = 'not-attempted' | 'present' | 'loaded' | 'no-webapp' | 'error' | 'timeout';
let sdkLoadOutcome: TelegramSdkOutcome = 'not-attempted';
/** telegramSdkOutcome returns how the last loadTelegramSDK attempt resolved (see TelegramSdkOutcome). */
export function telegramSdkOutcome(): TelegramSdkOutcome {
return sdkLoadOutcome;
}
/**
* loadTelegramSDK injects the official telegram-web-app.js and resolves true once
* window.Telegram.WebApp is available, or false if the script errors or does not load within
* timeoutMs. It is loaded dynamically — not a render-blocking <script> in index.html — so a network
* that blocks telegram.org (common where Telegram itself reaches users only over a proxy) cannot
* hang the page and strand the app or the launch-error screen. Resolves true immediately when the
* SDK is already present (a cached load, or a future native injection); a fast connection failure
* resolves via the error event without waiting out the timeout, so the timeout only bounds a true
* hang.
*/
export function loadTelegramSDK(timeoutMs: number): Promise<boolean> {
if (typeof document === 'undefined') return Promise.resolve(false);
if (webApp()) {
sdkLoadOutcome = 'present';
return Promise.resolve(true);
}
return new Promise<boolean>((resolve) => {
let done = false;
const finish = (outcome: TelegramSdkOutcome): void => {
if (done) return;
done = true;
sdkLoadOutcome = outcome;
resolve(outcome === 'loaded');
};
const timer = setTimeout(() => finish(webApp() ? 'loaded' : 'timeout'), timeoutMs);
const s = document.createElement('script');
s.src = sdkScriptSrc;
s.async = true;
s.onload = () => {
clearTimeout(timer);
finish(webApp() ? 'loaded' : 'no-webapp');
};
s.onerror = () => {
clearTimeout(timer);
finish('error');
};
document.head.appendChild(s);
});
}
/** /**
* telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to * telegramOpenLink opens a t.me link through the Mini App SDK, so Telegram navigates to
* it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false * it natively (e.g. a bot chat) rather than spawning an in-app browser tab. Returns false
@@ -370,132 +302,6 @@ export function onTelegramPath(): boolean {
return location.pathname.startsWith('/telegram/'); return location.pathname.startsWith('/telegram/');
} }
/** hasLaunchFragment reports whether the URL fragment carries Telegram launch params (tgWebApp…),
* the form Telegram appends when opening a Mini App — so the SDK is loaded for a Mini App opened
* at the site root too, not only the /telegram/ path. */
export function hasLaunchFragment(): boolean {
if (typeof location === 'undefined') return false;
return location.hash.includes('tgWebApp');
}
// --- Launch diagnostics (the /telegram/ entry without sign-in data) ---
/** The initData fields a valid Telegram launch is expected to carry; their absence is the signal
* the launch-error screen reports. Only field names are ever inspected, never their values. */
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
interface uaBrand {
brand: string;
version: string;
}
interface uaDataValue {
platform?: string;
mobile?: boolean;
brands?: uaBrand[];
}
/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android Telegram
* webview), or undefined where it is unavailable (iOS / Safari / Firefox). */
function uaData(): uaDataValue | undefined {
if (typeof navigator === 'undefined') return undefined;
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
}
/** launchFragmentData returns the raw tgWebAppData carried in the URL fragment (the form Telegram
* appends on launch), or '' when absent. With no SDK present a non-empty value means Telegram
* delivered the data but telegram-web-app.js never ran to parse it. */
function launchFragmentData(): string {
if (typeof location === 'undefined') return '';
const frag = location.hash.replace(/^#/, '');
if (!frag) return '';
try {
return new URLSearchParams(frag).get('tgWebAppData') ?? '';
} catch {
return '';
}
}
/** initDataFieldNames parses a Telegram initData (or raw fragment data) query string and returns
* only its field NAMES — never the values, since the hash / signature are auth material. */
function initDataFieldNames(raw: string): string[] {
if (!raw) return [];
try {
return [...new URLSearchParams(raw).keys()];
} catch {
return [];
}
}
/**
* TelegramDiag is a privacy-safe snapshot of why a Mini App launch lacked sign-in data, taken at
* the moment of failure and rendered on the launch-error screen so a stuck user can share it with
* the developer. It carries no secret values — only presence flags, client / OS identification and
* the field NAMES of the launch data, never the signed initData itself, and never an IP.
*/
export interface TelegramDiag {
/** Whether window.Telegram (the telegram-web-app.js script) is present at all. */
hasSDK: boolean;
/** Whether window.Telegram.WebApp is present. */
hasWebApp: boolean;
/** How the dynamic telegram-web-app.js load resolved (see TelegramSdkOutcome) — 'error' /
* 'timeout' mean telegram.org was unreachable, the prime suspect for an empty launch. */
sdkLoad: TelegramSdkOutcome;
/** Telegram's own platform string (ios | android | android_x | tdesktop | web | …), or ''. */
platform: string;
/** The Bot API version the client reports, or ''. */
version: string;
/** The length of WebApp.initData — 0 is the failure this screen reports. */
initDataLen: number;
/** Whether the URL fragment still carried tgWebAppData at launch; true with hasSDK false means
* Telegram delivered the data but the SDK script did not load to parse it. */
hashHadTgData: boolean;
/** The field names present in the launch data (from initData, or the raw fragment when the SDK
* left initData empty); values are never included. */
fieldsPresent: string[];
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
fieldsMissing: string[];
/** The OS / platform per User-Agent Client Hints (else navigator.platform), e.g. 'Android', ''. */
osPlatform: string;
/** Whether the client reports itself mobile per Client Hints: 'yes' | 'no' | '' (unknown). */
mobile: string;
/** The browser brands + major versions per Client Hints (Chromium only), or ''. */
browser: string;
/** The full User-Agent string — the catch-all that also carries the OS version and webview build. */
userAgent: string;
}
/**
* collectTelegramDiag captures a TelegramDiag snapshot of the current launch state. Call it at the
* point a /telegram/ launch is found to lack sign-in data, so the snapshot reflects that moment —
* sign-in data arriving late would otherwise mask the failure.
*/
export function collectTelegramDiag(): TelegramDiag {
const w = webApp();
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
const initData = w?.initData ?? '';
const fragData = initData ? '' : launchFragmentData();
const present = initDataFieldNames(initData || fragData);
const ua = uaData();
const navPlatform =
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
return {
hasSDK: sdk,
hasWebApp: !!w,
sdkLoad: sdkLoadOutcome,
platform: w?.platform ?? '',
version: w?.version ?? '',
initDataLen: initData.length,
hashHadTgData: fragData.length > 0,
fieldsPresent: present,
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
osPlatform: ua?.platform ?? navPlatform,
mobile: ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no',
browser: (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', '),
userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent,
};
}
// --- Login Widget (web sign-in for account linking) --- // --- Login Widget (web sign-in for account linking) ---
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to // The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
+4 -8
View File
@@ -10,7 +10,6 @@ import { createConnectTransport } from '@connectrpc/connect-web';
import { Gateway } from '../gen/edge/v1/edge_pb'; import { Gateway } from '../gen/edge/v1/edge_pb';
import { GatewayError, type GatewayClient } from './client'; import { GatewayError, type GatewayClient } from './client';
import * as codec from './codec'; import * as codec from './codec';
import { browserOffset } from './profileValidation';
import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; import { registerProbe, reportOffline, reportOnline } from './connection.svelte';
import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry'; import { backoffMs, isConnectionCode, retryable, toGatewayError } from './retry';
@@ -63,13 +62,13 @@ export function createTransport(baseUrl: string): GatewayClient {
}, },
async authTelegram(initData) { async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset()))); return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData)));
}, },
async authGuest(locale) { async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset()))); return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
}, },
async authEmailRequest(email) { async authEmailRequest(email) {
await exec('auth.email.request', codec.encodeEmailRequest(email, browserOffset())); await exec('auth.email.request', codec.encodeEmailRequest(email));
}, },
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)));
@@ -148,10 +147,7 @@ export function createTransport(baseUrl: string): GatewayClient {
await exec('chat.read', codec.encodeGameAction(id)); await exec('chat.read', codec.encodeGameAction(id));
}, },
async feedbackSubmit(body, attachment, attachmentName, channel) { async feedbackSubmit(body, attachment, attachmentName, channel) {
// The app build (Vite define) and the device's detected UTC offset ride with the report await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel));
// so the operator sees which version it came from and the local time it was filed; the
// caller need not pass them.
await exec('feedback.submit', codec.encodeFeedbackSubmit(body, attachment, attachmentName, channel, __APP_VERSION__, browserOffset()));
}, },
async feedbackGet() { async feedbackGet() {
return codec.decodeFeedbackState(await exec('feedback.get', codec.empty())); return codec.decodeFeedbackState(await exec('feedback.get', codec.empty()));
-15
View File
@@ -5,8 +5,6 @@ import {
availableVariants, availableVariants,
supportsMultipleWordsToggle, supportsMultipleWordsToggle,
multipleWordsForRequest, multipleWordsForRequest,
usesStarBlank,
BLANK_STAR,
} from './variants'; } from './variants';
describe('ALL_VARIANTS', () => { describe('ALL_VARIANTS', () => {
@@ -55,16 +53,3 @@ describe('multipleWordsForRequest', () => {
expect(multipleWordsForRequest('scrabble_en', true)).toBe(true); expect(multipleWordsForRequest('scrabble_en', true)).toBe(true);
}); });
}); });
describe('usesStarBlank', () => {
it('marks the blank with a star for Erudit only', () => {
expect(usesStarBlank('erudit_ru')).toBe(true);
expect(usesStarBlank('scrabble_ru')).toBe(false);
expect(usesStarBlank('scrabble_en')).toBe(false);
});
it('BLANK_STAR is the heavy teardrop-spoked asterisk (U+273B)', () => {
expect(BLANK_STAR).toBe('✻');
expect(BLANK_STAR.codePointAt(0)).toBe(0x273b);
});
});
-14
View File
@@ -48,20 +48,6 @@ export const VARIANT_FLAG: Record<Variant, string> = {
// ru -> Russian + Эрудит. // ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' }; export const VARIANT_LANGUAGE: Record<Variant, 'en' | 'ru'> = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
// BLANK_STAR is the glyph drawn on an Эрудит blank tile: the variant's blank is the
// "звёздочка" (star) chip, so it carries a star rather than a bare face. U+273B HEAVY
// TEARDROP-SPOKED ASTERISK.
export const BLANK_STAR = '✻';
// usesStarBlank reports whether a variant marks its blank tiles with BLANK_STAR. Only
// Эрудит does: an empty rack blank shows the star centred, and a placed blank carries it
// in the value corner (the corner is free — a blank has no point value). The Scrabble
// variants leave the blank unmarked (an empty rack face; a placed blank shown by its
// designated letter alone).
export function usesStarBlank(v: Variant): boolean {
return v === 'erudit_ru';
}
// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set // availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// they enabled in Settings). An empty or absent set is ungated (returns every variant) // they enabled in Settings). An empty or absent set is ungated (returns every variant)
// — a safety fallback; a real profile always carries at least one preference. // — a safety fallback; a real profile always carries at least one preference.
+21 -168
View File
@@ -1,6 +1,5 @@
<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';
@@ -17,11 +16,6 @@
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 {
@@ -58,41 +52,6 @@
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();
@@ -193,39 +152,30 @@
{#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="rowwrap"> <div class="item">
<div class="row">
<span class="who">{r.displayName}</span> <span class="who">{r.displayName}</span>
<span class="btns"> <span class="acts">
<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="rowwrap" class:revealed={revealedId === f.accountId}> <div class="item">
<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>
<button class="kebab" onclick={() => toggleReveal(f.accountId)} aria-label={t('friends.actions')}></button> <span class="acts">
</div> <button class="ghost" onclick={() => remove(f.accountId)} disabled={!connection.online}>{t('friends.unfriend')}</button>
<button class="ghost danger" onclick={() => blockUser(f.accountId)} disabled={!connection.online}>{t('friends.block')}</button>
</span>
</div> </div>
{/each} {/each}
</div>
{:else} {:else}
<p class="muted">{t('friends.none')}</p> <p class="muted">{t('friends.none')}</p>
{/if} {/if}
@@ -234,49 +184,20 @@
{#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="rowwrap"> <div class="item">
<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="rowwrap"> <div class="item">
<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>
@@ -358,76 +279,28 @@
padding: 4px 0; padding: 4px 0;
text-align: left; text-align: left;
} }
.list { .item {
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;
background: var(--bg); border: 1px solid var(--border);
transform: translateX(0); background: var(--surface);
transition: transform 0.18s ease; border-radius: var(--radius-sm);
} 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);
@@ -442,27 +315,7 @@
color: var(--text); color: var(--text);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.confirm-name { .ghost.danger {
margin: 0 0 12px; color: var(--danger, #c0392b);
font-weight: 600;
overflow-wrap: anywhere; /* a long display name wraps instead of stretching the sheet */
}
.confirm-row {
display: flex;
gap: 8px;
}
.confirm-row button {
flex: 1;
padding: 11px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 600;
}
.confirm-row .danger {
background: var(--danger);
color: #fff;
border-color: var(--danger);
} }
</style> </style>
+1 -1
View File
@@ -73,7 +73,7 @@
{#each bestMoves as bm (bm.variant)} {#each bestMoves as bm (bm.variant)}
<span class="variant">{t(variantNameKey(bm.variant))}</span> <span class="variant">{t(variantNameKey(bm.variant))}</span>
<span class="score">{bm.score}</span> <span class="score">{bm.score}</span>
<span class="wordcell"><WordTiles word={bm.word} variant={bm.variant} /></span> <span class="wordcell"><WordTiles word={bm.word} /></span>
{/each} {/each}
</div> </div>
</div> </div>
-133
View File
@@ -1,133 +0,0 @@
<script lang="ts">
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
import { app, retryTelegramLaunch } from '../lib/app.svelte';
import { shareText } from '../lib/share';
import { t } from '../lib/i18n/index.svelte';
const diag = $derived(app.launchError);
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
// prose), so the developer reads the same report in any locale. Kept terse to fit one screenshot.
const report = $derived(
diag
? [
`sdk-load: ${diag.sdkLoad}`,
`sdk: ${diag.hasSDK ? 'yes' : 'no'} webapp: ${diag.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${diag.platform || '—'} tg-version: ${diag.version || '—'}`,
`initData: ${diag.initDataLen > 0 ? diag.initDataLen : 'empty'} tgdata-in-url: ${diag.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${diag.fieldsPresent.join(',') || '—'}`,
`missing: ${diag.fieldsMissing.join(',') || '—'}`,
`os: ${diag.osPlatform || '—'} mobile: ${diag.mobile || '—'}`,
`browser: ${diag.browser || '—'}`,
`ua: ${diag.userAgent || '—'}`,
].join('\n')
: '',
);
let retrying = $state(false);
async function retry(): Promise<void> {
if (retrying) return;
retrying = true;
try {
await retryTelegramLaunch();
} finally {
retrying = false;
}
}
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function share(): Promise<void> {
const r = await shareText(report, t('launch.errorTitle'));
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
if (r === 'copied') {
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => (copied = false), 1500);
}
}
</script>
{#if diag}
<div class="boot">
<div class="card">
<h1>{t('launch.errorTitle')}</h1>
<p class="msg">{t('launch.errorBody')}</p>
<pre class="diag">{report}</pre>
<div class="actions">
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
</div>
</div>
</div>
{/if}
<style>
.boot {
height: 100%;
display: grid;
place-items: center;
padding: 16px;
background: var(--bg);
}
.card {
max-width: 32rem;
width: 100%;
display: flex;
flex-direction: column;
gap: 0.6rem;
text-align: center;
color: var(--text);
}
h1 {
margin: 0;
font-size: 1.1rem;
}
.msg {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
}
.diag {
margin: 0;
text-align: left;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
line-height: 1.4;
color: var(--text);
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
}
.actions {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.share,
.retry {
padding: 8px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--accent);
}
.share {
background: var(--accent);
color: var(--accent-text);
}
.retry {
background: transparent;
color: var(--accent);
}
.retry:disabled {
opacity: 0.5;
}
</style>