Merge pull request 'feat(telegram,game): single bot + per-user variant preferences' (#90) from feature/single-bot-variant-preferences into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 14s
CI / ui (push) Successful in 54s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m10s

This commit was merged in pull request #90.
This commit is contained in:
2026-06-20 12:48:10 +00:00
99 changed files with 1006 additions and 1256 deletions
+3 -9
View File
@@ -254,25 +254,19 @@ jobs:
AWG_CONF: ${{ secrets.TEST_AWG_CONF }} AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }} GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }} GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }} TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }} GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }} GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }} CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }} TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }} TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
# The test contour always uses Telegram's test environment — pinned here, # The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false. # not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true" TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }} VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }} VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
VITE_TELEGRAM_LINK_RU: ${{ vars.TEST_VITE_TELEGRAM_LINK_RU }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_EN }}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME_RU }}
VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }} VITE_GATEWAY_URL: ${{ vars.TEST_VITE_GATEWAY_URL }}
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${{ vars.TEST_GATEWAY_DEFAULT_SUPPORTED_LANGUAGES }}
# Unset vars render empty -> the compose ":-" defaults apply. # Unset vars render empty -> the compose ":-" defaults apply.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }} POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }} POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
+6
View File
@@ -281,6 +281,12 @@ back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the g
(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`) (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`)
pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`. pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`.
> **Superseded (2026-06-20, pre-release phase SB):** the two bots collapsed into **one** and the
> language-gated variant choice moved to a per-user **`variant_preferences`** profile set (default Erudit
> only). `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars,
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` and game-language push routing are gone; the single bot renders in
> the recipient's `preferred_language`. Current state: `docs/ARCHITECTURE.md` §3/§10, `PRERELEASE.md` row SB.
### Stage 16 — Deploy infra & test contour *(done)* ### Stage 16 — Deploy infra & test contour *(done)*
Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend + Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend +
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
+1
View File
@@ -37,6 +37,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** | | CR | In-game chat read receipts: per-message `unread_seats` bitmask (migration `00008`); a per-viewer unread **dot** in the lobby + game header (a nudge counts and clears when its recipient moves); reading = opening the move history (the 💬 fade-blinks twice) or the chat, acked (`chat.read`) only when unread; `chat_read_duration` + `chat_unread_messages` metrics + tracing + the **Scrabble — Messages** Grafana dashboard (follow-up PR); a message to a disguised robot opponent is born read; admin unread-only filter / read column / per-seat read card | owner ad-hoc | **done** |
| BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** | | BX | Asymmetric per-user block + in-game controls: a block now silently suppresses everything **from** the blocked user (chat, nudge, friend requests, invitations are kept but never delivered/surfaced, born-read) while they notice nothing, **without** deleting the friendship (unblock restores it); auto-match excludes a block-related pair (either direction); in-game opponent card gains a ✖️ **block** control (mirroring 🤝, red "Block?" confirm, mutual-hide, struck name + hidden chat composer when blocked); optimistic apply + `user_blocked`/`user_unblocked` event confirm + rollback; admin user card gains **blocks / blocked-by / friends** cross-linked lists. Blocking a disguised-robot opponent is recorded per-game in a separate **`robot_blocks`** table (migration `00011`), keyed on game+seat with the seen name — never the shared robot account — so the matchmaker keeps giving robots; it shows in the blocked list and re-marks the in-game card | owner ad-hoc | **done** |
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** | | FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
+4 -6
View File
@@ -117,15 +117,13 @@ archive, preview the per-variant word diff, then install + activate — `interna
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the `engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a `BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language` broadcast renders through the single bot in an operator-chosen language. There is one bot,
holds the language tag of the bot a Telegram so `/internal/push-target` returns the recipient's `preferred_language` as the render
user last signed in through, written on every login and returned by language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
to the right bot. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches window and bilingual messages, plus the global display timings. `GET /api/v1/user/profile` attaches
the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0 the resolved, weighted campaign feed for an **eligible** viewer (`!paid_account && hint_balance == 0
&& !no_banner` role, the message language picked by `service_language`); changing those inputs && !no_banner` role, the message language picked by `preferred_language`); changing those inputs
publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire publishes a `notify` `banner` re-poll signal so the client shows/hides it in place. The shared wire
contracts live in the sibling [`../pkg`](../pkg) module. contracts live in the sibling [`../pkg`](../pkg) module.
+7 -31
View File
@@ -55,12 +55,12 @@ type Account struct {
HintBalance int HintBalance int
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
// ServiceLanguage is the language tag (en/ru) of the bot the account last // VariantPreferences is the set of game variants (engine.Variant stable labels:
// authenticated through (its last Telegram ValidateInitData); it routes the // "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
// account's out-of-app push back through the right bot. Empty when the account // into. It gates the New Game picker, the matchmaker and the friend-invite the
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the // player creates; an invited friend may still accept any variant. A new account
// interface language) and from a game's variant language. // defaults to Erudit only. Never empty — enforced on update and by a DB check.
ServiceLanguage string VariantPreferences []string
// IsGuest marks an ephemeral guest account: a durable row with no identity, // IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history. // excluded from statistics, friends and history.
IsGuest bool IsGuest bool
@@ -491,36 +491,12 @@ func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
return nil return nil
} }
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
// user authenticated through. It is called on every Telegram login — new and
// existing accounts — so it tracks the bot the user last came through (last-login-
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
// (a non-Telegram login carries none) and does not bump updated_at (an infra
// routing field, not a user profile edit).
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
if language == "" {
return nil
}
stmt := table.Accounts.
UPDATE(table.Accounts.ServiceLanguage).
SET(postgres.String(language)).
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("account: set service language %s: %w", id, err)
}
return nil
}
// modelToAccount projects a generated model row into the public Account struct. // modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account { func modelToAccount(row model.Accounts) Account {
var mergedInto uuid.UUID var mergedInto uuid.UUID
if row.MergedInto != nil { if row.MergedInto != nil {
mergedInto = *row.MergedInto mergedInto = *row.MergedInto
} }
var serviceLanguage string
if row.ServiceLanguage != nil {
serviceLanguage = *row.ServiceLanguage
}
var flaggedHighRateAt time.Time var flaggedHighRateAt time.Time
if row.FlaggedHighRateAt != nil { if row.FlaggedHighRateAt != nil {
flaggedHighRateAt = *row.FlaggedHighRateAt flaggedHighRateAt = *row.FlaggedHighRateAt
@@ -529,7 +505,7 @@ func modelToAccount(row model.Accounts) Account {
ID: row.AccountID, ID: row.AccountID,
DisplayName: row.DisplayName, DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage, PreferredLanguage: row.PreferredLanguage,
ServiceLanguage: serviceLanguage, VariantPreferences: []string(row.VariantPreferences),
TimeZone: row.TimeZone, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
+52 -2
View File
@@ -14,6 +14,7 @@ import (
"github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm" "github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/model" "scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table" "scrabble/backend/internal/postgres/jet/backend/table"
@@ -58,6 +59,46 @@ type ProfileUpdate struct {
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
NotificationsInAppOnly bool NotificationsInAppOnly bool
// VariantPreferences is the set of game variants the player allows themselves to
// be matched into (engine.Variant stable labels). UpdateProfile cleans it to a
// deduplicated, canonically ordered subset of the known variants and rejects an
// empty set.
VariantPreferences []string
}
// knownVariants is the closed set of game-variant labels (engine.Variant stable
// labels) a profile's variant preferences may contain. It lives here so the store
// does not depend on the engine package; the server handler additionally validates
// against engine.ParseVariant, and a DB check enforces the same subset.
var knownVariants = map[string]bool{"erudit_ru": true, "scrabble_ru": true, "scrabble_en": true}
// canonicalVariantOrder is the deterministic order variant preferences are stored
// in (Erudit, Russian Scrabble, English), independent of the client's order.
var canonicalVariantOrder = []string{"erudit_ru", "scrabble_ru", "scrabble_en"}
// validateVariantPreferences cleans a profile's variant-preference set: it drops
// duplicates, rejects an unknown label or an empty set (ErrInvalidProfile) and
// returns the preferences in canonicalVariantOrder so the stored value is
// deterministic regardless of the order the client sent.
func validateVariantPreferences(prefs []string) ([]string, error) {
seen := make(map[string]bool, len(prefs))
for _, p := range prefs {
p = strings.TrimSpace(p)
if !knownVariants[p] {
return nil, fmt.Errorf("%w: variant preference %q", ErrInvalidProfile, p)
}
seen[p] = true
}
if len(seen) == 0 {
return nil, fmt.Errorf("%w: variant preferences must not be empty", ErrInvalidProfile)
}
out := make([]string, 0, len(seen))
for _, v := range canonicalVariantOrder {
if seen[v] {
out = append(out, v)
}
}
return out, nil
} }
// UpdateProfile validates and overwrites the editable fields of the account, then // UpdateProfile validates and overwrites the editable fields of the account, then
@@ -79,17 +120,26 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil { if err := validateAwayWindow(p.AwayStart, p.AwayEnd); err != nil {
return Account{}, err return Account{}, err
} }
prefs, err := validateVariantPreferences(p.VariantPreferences)
if err != nil {
return Account{}, err
}
stmt := table.Accounts.UPDATE( stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd, table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt, table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
table.Accounts.UpdatedAt,
).SET( ).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz), postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd), postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()), postgres.Bool(p.NotificationsInAppOnly),
// prefs are validated against the closed knownVariants set; bind as a text[]
// parameter (lib/pq encodes the array, the cast pins the column type).
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(prefs)}),
postgres.TimestampzT(time.Now().UTC()),
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
+23 -1
View File
@@ -3,6 +3,7 @@ package account
import ( import (
"context" "context"
"errors" "errors"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -16,7 +17,7 @@ import (
// offset/IANA timezone), not just their unit tests in validate_test.go. // offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) { func TestUpdateProfileValidation(t *testing.T) {
s := &Store{} s := &Store{}
base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC"} base := ProfileUpdate{DisplayName: "Kaya", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"}}
hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) } hm := func(h, m int) time.Time { return time.Date(0, 1, 1, h, m, 0, 0, time.UTC) }
tests := []struct { tests := []struct {
name string name string
@@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) {
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }}, {"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
{"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }}, {"bad name layout", func(p *ProfileUpdate) { p.DisplayName = "Bad__Name" }},
{"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }}, {"away over 12h", func(p *ProfileUpdate) { p.AwayStart, p.AwayEnd = hm(8, 0), hm(21, 0) }},
{"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
{"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
} }
for _, tc := range tests { for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -39,3 +42,22 @@ func TestUpdateProfileValidation(t *testing.T) {
}) })
} }
} }
// TestValidateVariantPreferences checks the cleaning of a profile's variant set:
// duplicates collapse, the result is canonically ordered (Erudit, Russian Scrabble,
// English) regardless of input order, and an empty or unknown set is rejected.
func TestValidateVariantPreferences(t *testing.T) {
got, err := validateVariantPreferences([]string{"scrabble_en", "erudit_ru", "scrabble_en"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
if _, err := validateVariantPreferences(nil); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("empty err = %v, want ErrInvalidProfile", err)
}
if _, err := validateVariantPreferences([]string{"chess"}); !errors.Is(err, ErrInvalidProfile) {
t.Fatalf("unknown err = %v, want ErrInvalidProfile", err)
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
{"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"}, {"messages", MessagesView{Items: []MessageRow{{ID: "m1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Body: "good luck", GameID: "g1", Unread: true}}, UnreadOnly: true, Pager: NewPager(1, 50, 1)}, "unread only"},
{"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"}, {"chatmessage", ChatMessageDetailView{ID: "m1", GameID: "g1", SenderID: "a1", SenderName: "Kaya", Source: "telegram", Kind: "message", Body: "good luck", Unread: true, Seats: []ChatSeatStatusRow{{Seat: 0, AccountID: "a1", DisplayName: "Kaya", Role: "sender"}, {Seat: 1, AccountID: "b2", DisplayName: "Opp", Role: "unread"}}}, "Read by seat"},
{"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"}, {"feedback", FeedbackView{Items: []FeedbackRow{{ID: "f1", AccountID: "a1", SenderName: "Kaya", Source: "telegram", Channel: "web", HasAttachment: true, Replied: true}}, Status: "unread", Pager: NewPager(1, 50, 1)}, "replied"},
{"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", BotLanguage: "ru", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "bot: ru"}, {"feedback_detail", FeedbackDetailView{ID: "f1", AccountID: "a1", SenderName: "Kaya", Channel: "telegram", InterfaceLanguage: "en", Body: "please fix the board", HasAttachment: true, AttachmentName: "shot.png", IsImage: true, Banned: true}, "Interface language"},
{"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"}, {"complaint_detail", ComplaintDetailView{ID: "c1", Word: "qi", Variant: "scrabble_en"}, "Resolve"},
{"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"}, {"dictionary", DictionaryView{ActiveVersion: "v1.0.0", Variants: []VariantVersions{{Variant: "scrabble_en", Versions: []string{"v1.0.0"}}}, Changes: []DictChangeRow{{Variant: "scrabble_en", Word: "qi", Action: "add"}}}, "Update dictionaries"},
{"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"}, {"dictionary_preview", DictionaryPreviewView{Version: "v1.1.0", Token: "0123456789abcdef0123456789abcdef", ActiveVersion: "v1.0.0", Variants: []VariantDiffRow{{Variant: "scrabble_en", AddedCount: 2, RemovedCount: 1, AddedSample: []string{"qi", "za"}, RemovedSample: []string{"xqz"}, RemovedTruncated: true}}}, "v1.1.0"},
@@ -5,7 +5,6 @@
{{if .ConnectorEnabled}} {{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/broadcast"> <form class="form col" method="post" action="/_gm/broadcast">
<label>Message <textarea name="text" required></textarea></label> <label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Post to channel</button></div> <div><button type="submit">Post to channel</button></div>
</form> </form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}} {{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
@@ -5,7 +5,7 @@
<section class="panel"><h2>Message</h2> <section class="panel"><h2>Message</h2>
<ul class="kv"> <ul class="kv">
<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}}{{if .BotLanguage}} (bot: {{.BotLanguage}}){{end}}</li> <li><b>Channel</b> {{.Channel}}</li>
<li><b>Interface language</b> {{.InterfaceLanguage}}</li> <li><b>Interface language</b> {{.InterfaceLanguage}}</li>
<li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li> <li><b>IP</b> {{if .IP}}<code>{{.IP}}</code>{{else}}<span class="note">none</span>{{end}}</li>
<li><b>Filed</b> {{.CreatedAt}}</li> <li><b>Filed</b> {{.CreatedAt}}</li>
@@ -137,7 +137,6 @@
{{if .ConnectorEnabled}} {{if .ConnectorEnabled}}
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message"> <form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
<label>Message <textarea name="text" required></textarea></label> <label>Message <textarea name="text" required></textarea></label>
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
<div><button type="submit">Send to user</button></div> <div><button type="submit">Send to user</button></div>
</form> </form>
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}} {{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
+1 -4
View File
@@ -531,11 +531,8 @@ type FeedbackDetailView struct {
SenderName string SenderName string
Source string Source string
Channel string Channel string
// InterfaceLanguage is the sender's interface language (account preference); // InterfaceLanguage is the sender's interface language (account preference).
// BotLanguage is the connector bot they last used (en/ru), set only for a
// message that arrived through an external connector (Telegram).
InterfaceLanguage string InterfaceLanguage string
BotLanguage string
IP string IP string
Body string Body string
HasAttachment bool HasAttachment bool
+14 -16
View File
@@ -1,11 +1,10 @@
// Package connector is the backend's gRPC client for the Telegram platform // Package connector is the backend's gRPC client for the Telegram platform
// connector side-service. The admin console uses it to send operator broadcasts: // connector side-service. The admin console uses it to send operator broadcasts:
// a direct message to one user, or a post to a game channel. Each broadcast // a direct message to one user, or a post to the game channel, through the single
// selects the delivering bot by language (an operator choice, since the connector // bot. The connector lives on the trusted internal network, so the connection uses
// hosts one bot per service language). The connector lives on the trusted internal // insecure (plaintext) transport credentials (docs/ARCHITECTURE.md §12). It mirrors
// network, so the connection uses insecure (plaintext) transport credentials // gateway/internal/connector, narrowed to the two broadcast methods the admin
// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to // surface needs.
// the two broadcast methods the admin surface needs.
package connector package connector
import ( import (
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
func (c *Client) Close() error { return c.conn.Close() } func (c *Client) Close() error { return c.conn.Close() }
// SendToUser sends an operator text message to one user, addressed by their // SendToUser sends an operator text message to one user, addressed by their
// platform external_id, through the bot for the given language. delivered reports // platform external_id, through the bot. delivered reports whether the connector
// whether the connector actually sent it (false when the user has not started that // actually sent it (false when the user has not started the bot).
// bot). func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
func (c *Client) SendToUser(ctx context.Context, externalID, text, language string) (bool, error) { resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text, Language: language})
if err != nil { if err != nil {
return false, err return false, err
} }
return resp.GetDelivered(), nil return resp.GetDelivered(), nil
} }
// SendToGameChannel posts an operator text message to the game channel of the bot // SendToGameChannel posts an operator text message to the bot's game channel.
// for the given language. delivered reports whether the connector sent it (false // delivered reports whether the connector sent it (false when the bot has no
// when that bot has no channel configured). // channel configured).
func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) { func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language}) resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
if err != nil { if err != nil {
return false, err return false, err
} }
+3 -8
View File
@@ -112,14 +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 languages at submit time (acc is already loaded for the guest check): // Snapshot the sender's interface language at submit time (acc is already loaded
// the sender's interface language, and the connector bot language when the message // for the guest check) so the operator later sees the state as it was.
// came through an external connector (currently Telegram). _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
var channelLang string
if ch == "telegram" {
channelLang = acc.ServiceLanguage
}
_, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
return err return err
} }
+10 -13
View File
@@ -34,11 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment // Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default // is the raw file bytes (nil for none); attachmentName, ip and a non-default
// channel are stored as given. lang (the sender's interface language) and channelLang // channel are stored as given. lang (the sender's interface language) is a snapshot
// (the connector bot language, empty for a non-connector channel) are snapshots taken // taken now, so the operator later sees the state at submit time. created_at defaults
// now, so the operator later sees the state at submit time. created_at defaults to // to now() in the database.
// now() in the database. func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang 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)
@@ -49,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, channel_lang, sender_ip) (message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), 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
@@ -228,10 +227,8 @@ type AdminMessage struct {
Source string Source string
Body string Body string
Channel string Channel string
// Lang is the sender's interface language and ChannelLang the connector bot language // Lang is the sender's interface language, snapshotted at submit time.
// (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
Lang string Lang string
ChannelLang string
SenderIP string SenderIP string
HasAttachment bool HasAttachment bool
AttachmentName string AttachmentName string
@@ -346,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.channel_lang, ''), 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
@@ -355,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.ChannelLang, &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) {
-7
View File
@@ -68,10 +68,6 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0 if got := string(yt.ScoreLine()); got != "13:19" { // seat 1 (recipient) first, then seat 0
t.Errorf("your_turn score_line = %q, want 13:19", got) t.Errorf("your_turn score_line = %q, want 13:19", got)
} }
// Routed out-of-app by the game's language (the default Variant is English).
if yourTurn.Language != "en" {
t.Errorf("your_turn language = %q, want en", yourTurn.Language)
}
} }
// TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat, // TestEmitMoveAnnouncesGameOver checks the closing move sends a game_over push to every seat,
@@ -106,7 +102,4 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" { if string(l.Result()) != "lost" || string(l.ScoreLine()) != "95:120" {
t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine()) t.Errorf("loser game_over = %q / %q, want lost / 95:120", l.Result(), l.ScoreLine())
} }
if over[winner].Language != "en" || over[loser].Language != "en" {
t.Errorf("game_over languages = %q/%q, want en/en", over[winner].Language, over[loser].Language)
}
} }
-15
View File
@@ -554,16 +554,6 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
return svc.store.GetGameVariant(ctx, gameID) return svc.store.GetGameVariant(ctx, gameID)
} }
// GameLanguage returns the game's language tag ("en"/"ru"), derived from its variant, so a
// game push routes out-of-app to the game's own bot rather than the recipient's last-login bot.
func (svc *Service) GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error) {
v, err := svc.GameVariant(ctx, gameID)
if err != nil {
return "", err
}
return v.Language(), nil
}
// RobotSchedule returns a game's bag seed and turn-start time, for the admin console's // RobotSchedule returns a game's bag seed and turn-start time, for the admin console's
// robot-schedule panel (the deterministic play-to-win intent and next-move ETA). // robot-schedule panel (the deterministic play-to-win intent and next-move ETA).
func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) { func (svc *Service) RobotSchedule(ctx context.Context, gameID uuid.UUID) (seed int64, turnStartedAt time.Time, err error) {
@@ -740,9 +730,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
} }
intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen)) intents = append(intents, notify.OpponentMoved(s.AccountID, post.ID, rec, summary, bagLen))
} }
// Game pushes are routed out-of-app by the game's own language, not the recipient's
// last-login bot.
lang := post.Variant.Language()
switch post.Status { switch post.Status {
case StatusActive: case StatusActive:
// Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn" // Honest-AI games suppress your_turn: the robot replies instantly, so a "your turn"
@@ -757,7 +744,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
} }
opponent := svc.displayName(ctx, post.Seats, rec.Player) opponent := svc.displayName(ctx, post.Seats, rec.Player)
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount) yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
yourTurn.Language = lang
intents = append(intents, yourTurn) intents = append(intents, yourTurn)
} }
case StatusFinished: case StatusFinished:
@@ -769,7 +755,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
continue continue
} }
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary) over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
over.Language = lang
intents = append(intents, over) intents = append(intents, over)
} }
} }
+1 -40
View File
@@ -159,46 +159,6 @@ func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
} }
} }
// TestServiceLanguageRoundTrip checks SetServiceLanguage persists the push-routing
// language (the bot a Telegram user last signed in through): a fresh account has
// none, a set value reads back, a later login overwrites it (last-login-wins), and
// an empty value is a no-op. The push-target route coalesces it with the preferred
// language.
func TestServiceLanguageRoundTrip(t *testing.T) {
ctx := context.Background()
store := account.NewStore(testDB)
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
if err != nil {
t.Fatalf("provision telegram: %v", err)
}
if acc.ServiceLanguage != "" {
t.Errorf("fresh ServiceLanguage = %q, want empty", acc.ServiceLanguage)
}
if err := store.SetServiceLanguage(ctx, acc.ID, "ru"); err != nil {
t.Fatalf("set service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "ru" {
t.Errorf("ServiceLanguage = %q, want ru", got.ServiceLanguage)
}
// A later login through the other bot updates it; a subsequent empty value
// (a non-Telegram login) leaves it unchanged.
if err := store.SetServiceLanguage(ctx, acc.ID, "en"); err != nil {
t.Fatalf("update service language: %v", err)
}
if err := store.SetServiceLanguage(ctx, acc.ID, ""); err != nil {
t.Fatalf("noop service language: %v", err)
}
if got, err := store.GetByID(ctx, acc.ID); err != nil {
t.Fatalf("get by id: %v", err)
} else if got.ServiceLanguage != "en" {
t.Errorf("ServiceLanguage after update+noop = %q, want en", got.ServiceLanguage)
}
}
// TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account // TestHighRateFlagRoundTrip covers the soft high-rate marker: a fresh account
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode // is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after // never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
@@ -299,6 +259,7 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
PreferredLanguage: "en", PreferredLanguage: "en",
TimeZone: "UTC", TimeZone: "UTC",
NotificationsInAppOnly: false, NotificationsInAppOnly: false,
VariantPreferences: []string{"erudit_ru"},
}) })
if err != nil { if err != nil {
t.Fatalf("update profile: %v", err) t.Fatalf("update profile: %v", err)
@@ -256,7 +256,8 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
id := provisionAccount(t) id := provisionAccount(t)
body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` + body := `{"display_name":"Tester","preferred_language":"ru","time_zone":"UTC","away_start":"00:00",` +
`"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true}` `"away_end":"00:00","block_chat":false,"block_friend_requests":false,"notifications_in_app_only":true,` +
`"variant_preferences":["erudit_ru"]}`
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body)) req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
req.Header.Set("X-User-ID", id.String()) req.Header.Set("X-User-ID", id.String())
+17 -3
View File
@@ -6,6 +6,7 @@ import (
"context" "context"
"errors" "errors"
"regexp" "regexp"
"slices"
"testing" "testing"
"time" "time"
@@ -134,12 +135,20 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB) store := account.NewStore(testDB)
acc := provisionAccount(t) acc := provisionAccount(t)
// A fresh account defaults to Erudit only (the DB-level column default).
if def, err := store.GetByID(ctx, acc); err != nil {
t.Fatalf("get default: %v", err)
} else if want := []string{"erudit_ru"}; !slices.Equal(def.VariantPreferences, want) {
t.Errorf("default variant preferences = %v, want %v", def.VariantPreferences, want)
}
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{ updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya", DisplayName: "Kaya",
PreferredLanguage: "ru", PreferredLanguage: "ru",
TimeZone: "Europe/Moscow", TimeZone: "Europe/Moscow",
BlockChat: true, BlockChat: true,
BlockFriendRequests: true, BlockFriendRequests: true,
VariantPreferences: []string{"scrabble_en", "erudit_ru"},
}) })
if err != nil { if err != nil {
t.Fatalf("update profile: %v", err) t.Fatalf("update profile: %v", err)
@@ -157,6 +166,10 @@ func TestUpdateProfilePersists(t *testing.T) {
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat { if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
t.Errorf("profile did not persist: %+v", reloaded) t.Errorf("profile did not persist: %+v", reloaded)
} }
// The text[] column round-trips and is stored canonically (Erudit-first).
if want := []string{"erudit_ru", "scrabble_en"}; !slices.Equal(reloaded.VariantPreferences, want) {
t.Errorf("variant preferences = %v, want %v", reloaded.VariantPreferences, want)
}
} }
// TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is // TestUpdateProfileOffsetTimezone checks the UTC-offset timezone: it is
@@ -167,9 +180,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
acc := provisionAccount(t) acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{ updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
DisplayName: "Kaya", DisplayName: "Kaya",
PreferredLanguage: "en", PreferredLanguage: "en",
TimeZone: "+03:00", TimeZone: "+03:00",
VariantPreferences: []string{"erudit_ru"},
}) })
if err != nil { if err != nil {
t.Fatalf("update with offset timezone: %v", err) t.Fatalf("update with offset timezone: %v", err)
+10 -25
View File
@@ -145,48 +145,33 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
} }
} }
func TestFeedbackSnapshotsLanguages(t *testing.T) { func TestFeedbackSnapshotsLanguage(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc := newFeedbackService() svc := newFeedbackService()
acc := provisionAccount(t) acc := provisionAccount(t)
if _, err := testDB.ExecContext(ctx, if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil { `UPDATE backend.accounts SET preferred_language = 'en' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("set languages: %v", err) t.Fatalf("set language: %v", err)
} }
// A Telegram (connector) message snapshots both the interface language and the bot. // 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)
if m, err := svc.AdminGet(ctx, id); err != nil { if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatalf("admin get: %v", err) t.Fatalf("admin get: %v", err)
} else if m.Lang != "en" || m.ChannelLang != "ru" { } else if m.Lang != "en" {
t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang) t.Fatalf("snapshot = lang %q, want en", m.Lang)
} }
// Changing the account afterwards must not change the stored snapshot. // Changing the account afterwards must not change the stored snapshot.
if _, err := testDB.ExecContext(ctx, if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil { `UPDATE backend.accounts SET preferred_language = 'ru' WHERE account_id = $1`, acc); err != nil {
t.Fatalf("change languages: %v", err) t.Fatalf("change language: %v", err)
} }
if m, err := svc.AdminGet(ctx, id); err != nil { if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatal(err) t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "ru" { } else if m.Lang != "en" {
t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang) t.Fatalf("snapshot drifted after account change = lang %q, want en", m.Lang)
}
// A non-connector channel records no bot language even when the account has one.
acc2 := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
`UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc2); err != nil {
t.Fatalf("set languages 2: %v", err)
}
if err := svc.Submit(ctx, acc2, "from web", nil, "", "web", ""); err != nil {
t.Fatalf("submit web: %v", err)
}
if m, err := svc.AdminGet(ctx, latestFeedbackID(t, svc, acc2)); err != nil {
t.Fatal(err)
} else if m.Lang != "en" || m.ChannelLang != "" {
t.Fatalf("web snapshot = lang %q / channel_lang %q, want en / empty", m.Lang, m.ChannelLang)
} }
} }
+4 -2
View File
@@ -97,10 +97,12 @@ func TestGameLimitGate(t *testing.T) {
if !gamesListAtLimit(t, srv, human) { if !gamesListAtLimit(t, srv, human) {
t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames) t.Fatalf("at %d games at_game_limit must be true", game.MaxActiveQuickGames)
} }
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { // erudit_ru is in the default variant preferences, so the variant gate passes and the
// game-limit gate is what fires here.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) t.Fatalf("enqueue at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
} }
invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String()) invBody := fmt.Sprintf(`{"variant":"erudit_ru","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" { if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusConflict || errorCode(t, rec) != "game_limit_reached" {
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec)) t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
} }
@@ -37,7 +37,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
starter := provisionAccount(t) starter := provisionAccount(t)
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{ if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC", DisplayName: "Original Name", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"},
}); err != nil { }); err != nil {
t.Fatalf("set name: %v", err) t.Fatalf("set name: %v", err)
} }
@@ -50,7 +50,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
// Rename the account; the snapshot on the already-taken seat must not follow. // Rename the account; the snapshot on the already-taken seat must not follow.
if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{ if _, err := accounts.UpdateProfile(ctx, starter, account.ProfileUpdate{
DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC", DisplayName: "Renamed Player99", PreferredLanguage: "en", TimeZone: "UTC", VariantPreferences: []string{"erudit_ru"},
}); err != nil { }); err != nil {
t.Fatalf("rename: %v", err) t.Fatalf("rename: %v", err)
} }
+2 -28
View File
@@ -135,7 +135,7 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
// Toggle: the addressee does not accept friend requests. // Toggle: the addressee does not accept friend requests.
a, b := provisionAccount(t), provisionAccount(t) a, b := provisionAccount(t), provisionAccount(t)
if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true}); err != nil { if _, err := store.UpdateProfile(ctx, b, account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockFriendRequests: true, VariantPreferences: []string{"erudit_ru"}}); err != nil {
t.Fatalf("set toggle: %v", err) t.Fatalf("set toggle: %v", err)
} }
if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) { if err := svc.SendFriendRequest(ctx, a, b); !errors.Is(err, social.ErrRequestBlocked) {
@@ -353,7 +353,7 @@ func TestChatPostListAndBlocks(t *testing.T) {
if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil { if _, err := svc.PostMessage(ctx, other, seats2[0], "hi", ""); err != nil {
t.Fatalf("post 2: %v", err) t.Fatalf("post 2: %v", err)
} }
if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true}); err != nil { if _, err := store.UpdateProfile(ctx, seats2[1], account.ProfileUpdate{DisplayName: "Player", PreferredLanguage: "en", TimeZone: "UTC", BlockChat: true, VariantPreferences: []string{"erudit_ru"}}); err != nil {
t.Fatalf("set block_chat: %v", err) t.Fatalf("set block_chat: %v", err)
} }
if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 { if msgs, _ := svc.Messages(ctx, other, seats2[1]); len(msgs) != 0 {
@@ -588,32 +588,6 @@ func TestRespondPublishesToRequester(t *testing.T) {
} }
} }
// TestNudgeRoutedByGameLanguage checks a nudge's out-of-app push carries the game's language, so
// it is delivered by the game's bot rather than the recipient's last-login bot.
func TestNudgeRoutedByGameLanguage(t *testing.T) {
ctx := context.Background()
svc := newSocialService()
pub := &capturePublisher{}
svc.SetNotifier(pub)
gameID, seats := newGameWithSeats(t, 2) // an English game; seat 0 is to move
if _, err := svc.Nudge(ctx, gameID, seats[1]); err != nil {
t.Fatalf("nudge: %v", err)
}
found := false
for _, in := range pub.intents {
if in.Kind == notify.KindNudge {
found = true
if in.Language != "en" {
t.Errorf("nudge language = %q, want en (the game's language)", in.Language)
}
}
}
if !found {
t.Fatal("no nudge intent published")
}
}
// TestAdminListMessages checks the admin moderation list: real messages only // TestAdminListMessages checks the admin moderation list: real messages only
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label. // (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) { func TestAdminListMessages(t *testing.T) {
@@ -0,0 +1,51 @@
//go:build integration
package inttest
import (
"fmt"
"net/http"
"testing"
"time"
"go.uber.org/zap/zaptest"
"scrabble/backend/internal/account"
"scrabble/backend/internal/server"
)
// TestVariantPreferenceGate covers the New Game gate: a player may start a quick game
// or create a friend invitation only in a variant they have enabled in their profile
// (a fresh account defaults to Erudit only), and any other variant is refused with 400.
// The complementary case — an invited friend accepting an invitation in a variant they
// have NOT enabled — is exercised by TestGameLimitGate, where a default-Erudit human
// accepts an English invitation over HTTP.
func TestVariantPreferenceGate(t *testing.T) {
clearOpenGames(t)
srv := server.New(":0", server.Deps{
Logger: zaptest.NewLogger(t),
DB: testDB,
Accounts: account.NewStore(testDB),
Games: newGameService(),
Matchmaker: newMatchmaker(t, newRobotService(t, newGameService()), time.Minute, 0),
Invitations: newInvitationService(),
})
human := provisionAccount(t) // default preferences: erudit_ru only
opp := provisionAccount(t)
// A variant outside the player's preferences is refused on both create paths.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"scrabble_en"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("enqueue non-preferred variant = %d (%s), want 400", rec.Code, rec.Body.String())
}
invBody := fmt.Sprintf(`{"variant":"scrabble_en","invitee_ids":[%q]}`, opp.String())
if rec := userPost(t, srv, "/api/v1/user/invitations", human, invBody); rec.Code != http.StatusBadRequest {
t.Fatalf("invitation non-preferred variant = %d (%s), want 400", rec.Code, rec.Body.String())
}
// The default-enabled variant (Erudit) is allowed: the quick enqueue opens a game.
if rec := userPost(t, srv, "/api/v1/user/lobby/enqueue", human, `{"variant":"erudit_ru"}`); rec.Code != http.StatusOK {
t.Fatalf("enqueue preferred variant = %d (%s), want 200", rec.Code, rec.Body.String())
}
clearOpenGames(t)
}
-1
View File
@@ -206,7 +206,6 @@ func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID
return return
} }
intent := notify.OpponentJoined(starter, g.ID, state) intent := notify.OpponentJoined(starter, g.ID, state)
intent.Language = g.Variant.Language()
m.pub.Publish(intent) m.pub.Publish(intent)
} }
-5
View File
@@ -82,11 +82,6 @@ type Intent struct {
Kind string Kind string
Payload []byte Payload []byte
EventID string EventID string
// Language routes an out-of-app push to a specific per-language bot: for a
// game event it is the game's language ("en"/"ru"), so the notification comes from the
// game's bot rather than the recipient's last-login bot. Empty falls back to the
// recipient's service language at the gateway.
Language string
} }
// Publisher accepts live-event intents. Implementations must be safe for // Publisher accepts live-event intents. Implementations must be safe for
@@ -9,6 +9,7 @@ package model
import ( import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/lib/pq"
"time" "time"
) )
@@ -29,6 +30,6 @@ type Accounts struct {
PaidAccount bool PaidAccount bool
MergedInto *uuid.UUID MergedInto *uuid.UUID
MergedAt *time.Time MergedAt *time.Time
ServiceLanguage *string
FlaggedHighRateAt *time.Time FlaggedHighRateAt *time.Time
VariantPreferences pq.StringArray
} }
@@ -20,12 +20,11 @@ type FeedbackMessages struct {
AttachmentName *string AttachmentName *string
SenderIP *string SenderIP *string
Channel string Channel string
Lang *string
ChannelLang *string
ReadAt *time.Time ReadAt *time.Time
ArchivedAt *time.Time ArchivedAt *time.Time
ReplyBody *string ReplyBody *string
RepliedAt *time.Time RepliedAt *time.Time
ReplyReadAt *time.Time ReplyReadAt *time.Time
CreatedAt time.Time CreatedAt time.Time
Lang *string
} }
@@ -33,8 +33,8 @@ type accountsTable struct {
PaidAccount postgres.ColumnBool PaidAccount postgres.ColumnBool
MergedInto postgres.ColumnString MergedInto postgres.ColumnString
MergedAt postgres.ColumnTimestampz MergedAt postgres.ColumnTimestampz
ServiceLanguage postgres.ColumnString
FlaggedHighRateAt postgres.ColumnTimestampz FlaggedHighRateAt postgres.ColumnTimestampz
VariantPreferences postgres.ColumnStringArray
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -92,11 +92,11 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccountColumn = postgres.BoolColumn("paid_account") PaidAccountColumn = postgres.BoolColumn("paid_account")
MergedIntoColumn = postgres.StringColumn("merged_into") MergedIntoColumn = postgres.StringColumn("merged_into")
MergedAtColumn = postgres.TimestampzColumn("merged_at") MergedAtColumn = postgres.TimestampzColumn("merged_at")
ServiceLanguageColumn = postgres.StringColumn("service_language")
FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at") FlaggedHighRateAtColumn = postgres.TimestampzColumn("flagged_high_rate_at")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn} VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn} allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn} mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, VariantPreferencesColumn}
) )
return accountsTable{ return accountsTable{
@@ -119,8 +119,8 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccount: PaidAccountColumn, PaidAccount: PaidAccountColumn,
MergedInto: MergedIntoColumn, MergedInto: MergedIntoColumn,
MergedAt: MergedAtColumn, MergedAt: MergedAtColumn,
ServiceLanguage: ServiceLanguageColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn, FlaggedHighRateAt: FlaggedHighRateAtColumn,
VariantPreferences: VariantPreferencesColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -24,14 +24,13 @@ type feedbackMessagesTable struct {
AttachmentName postgres.ColumnString AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString SenderIP postgres.ColumnString
Channel postgres.ColumnString Channel postgres.ColumnString
Lang postgres.ColumnString
ChannelLang postgres.ColumnString
ReadAt postgres.ColumnTimestampz ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString ReplyBody postgres.ColumnString
RepliedAt postgres.ColumnTimestampz RepliedAt postgres.ColumnTimestampz
ReplyReadAt postgres.ColumnTimestampz ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
Lang postgres.ColumnString
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -80,16 +79,15 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentNameColumn = postgres.StringColumn("attachment_name") AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip") SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel") ChannelColumn = postgres.StringColumn("channel")
LangColumn = postgres.StringColumn("lang")
ChannelLangColumn = postgres.StringColumn("channel_lang")
ReadAtColumn = postgres.TimestampzColumn("read_at") ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at") ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body") ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at") RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at") ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} LangColumn = postgres.StringColumn("lang")
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn} allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn, LangColumn}
defaultColumns = postgres.ColumnList{CreatedAtColumn} defaultColumns = postgres.ColumnList{CreatedAtColumn}
) )
@@ -104,14 +102,13 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentName: AttachmentNameColumn, AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn, SenderIP: SenderIPColumn,
Channel: ChannelColumn, Channel: ChannelColumn,
Lang: LangColumn,
ChannelLang: ChannelLangColumn,
ReadAt: ReadAtColumn, ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn, ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn, ReplyBody: ReplyBodyColumn,
RepliedAt: RepliedAtColumn, RepliedAt: RepliedAtColumn,
ReplyReadAt: ReplyReadAtColumn, ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
Lang: LangColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -0,0 +1,45 @@
-- +goose Up
-- Single-bot collapse + per-user variant preferences.
--
-- With one Telegram bot there is no longer a "service language" (which bot a player
-- signed in through), and variant availability is no longer gated by the bot's
-- supported languages but by an explicit per-account preference. variant_preferences
-- is the set of game variants (engine.Variant stable labels) a player is willing to be
-- matched into: it gates the New Game picker and the matchmaker, while an invited friend
-- may still accept any variant. New accounts default to Erudit only (a DB-level default,
-- so no application seed is needed); the set may never be empty and must be a subset of
-- the three known variants. feedback_messages.channel_lang (the bot a message arrived
-- through) likewise loses meaning under one bot and is dropped; the sender's interface
-- language (lang) is kept.
SET search_path = backend, pg_catalog;
ALTER TABLE accounts
ADD COLUMN variant_preferences text[] NOT NULL DEFAULT ARRAY['erudit_ru']::text[],
ADD CONSTRAINT accounts_variant_preferences_nonempty_chk
CHECK (cardinality(variant_preferences) >= 1),
ADD CONSTRAINT accounts_variant_preferences_subset_chk
CHECK (variant_preferences <@ ARRAY['scrabble_en', 'scrabble_ru', 'erudit_ru']::text[]);
ALTER TABLE accounts DROP COLUMN service_language;
ALTER TABLE feedback_messages DROP COLUMN channel_lang;
-- The auto-match pairing query filters open games by (variant, multiple_words_per_turn)
-- and takes the oldest by created_at; games_open_idx (open_deadline_at) does not serve it.
-- A partial index over open games turns the scan-and-sort into a single index seek.
CREATE INDEX games_open_match_idx ON games (variant, multiple_words_per_turn, created_at)
WHERE status = 'open';
-- +goose Down
SET search_path = backend, pg_catalog;
DROP INDEX games_open_match_idx;
ALTER TABLE feedback_messages ADD COLUMN channel_lang text;
ALTER TABLE accounts ADD COLUMN service_language text CHECK (service_language IN ('en', 'ru'));
ALTER TABLE accounts
DROP CONSTRAINT accounts_variant_preferences_subset_chk,
DROP CONSTRAINT accounts_variant_preferences_nonempty_chk,
DROP COLUMN variant_preferences;
+4 -5
View File
@@ -54,11 +54,10 @@ func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStre
return nil return nil
} }
ev := &pushv1.Event{ ev := &pushv1.Event{
UserId: in.UserID.String(), UserId: in.UserID.String(),
Kind: in.Kind, Kind: in.Kind,
Payload: in.Payload, Payload: in.Payload,
EventId: in.EventID, EventId: in.EventID,
Language: in.Language,
} }
if err := stream.Send(ev); err != nil { if err := stream.Send(ev); err != nil {
return err return err
+2 -6
View File
@@ -88,13 +88,9 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
} }
} }
// bannerLang resolves the message language for a viewer: the bot (service) // bannerLang resolves the message language for a viewer: their interface language
// language they last signed in through, falling back to the interface language // (Russian, or English by default).
// and then English.
func bannerLang(acc account.Account) string { func bannerLang(acc account.Account) string {
if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
return acc.ServiceLanguage
}
if acc.PreferredLanguage == "ru" { if acc.PreferredLanguage == "ru" {
return "ru" return "ru"
} }
+13 -10
View File
@@ -16,11 +16,10 @@ import (
// sessionResponse is the credential returned by every auth endpoint. // sessionResponse is the credential returned by every auth endpoint.
type sessionResponse struct { type sessionResponse struct {
Token string `json:"token"` Token string `json:"token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
ServiceLanguage string `json:"service_language"`
} }
// okResponse is a simple success acknowledgement. // okResponse is a simple success acknowledgement.
@@ -49,6 +48,10 @@ type profileResponse struct {
BlockFriendRequests bool `json:"block_friend_requests"` BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"` NotificationsInAppOnly bool `json:"notifications_in_app_only"`
// VariantPreferences is the set of game variants the player allows themselves to
// be matched into (engine.Variant stable labels), Erudit-first. It gates the New
// Game picker and the matchmaker; never empty.
VariantPreferences []string `json:"variant_preferences"`
// Banner is the advertising-banner block: present only for a viewer eligible to // Banner is the advertising-banner block: present only for a viewer eligible to
// see the banner (a free account with an empty hint wallet and without the // see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go. // no_banner role), absent otherwise. See banner.go.
@@ -177,11 +180,10 @@ type errorBody struct {
// sessionResponseFor builds the credential payload for a minted session. // sessionResponseFor builds the credential payload for a minted session.
func sessionResponseFor(token string, acc account.Account) sessionResponse { func sessionResponseFor(token string, acc account.Account) sessionResponse {
return sessionResponse{ return sessionResponse{
Token: token, Token: token,
UserID: acc.ID.String(), UserID: acc.ID.String(),
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
DisplayName: acc.DisplayName, DisplayName: acc.DisplayName,
ServiceLanguage: acc.ServiceLanguage,
} }
} }
@@ -199,6 +201,7 @@ func profileResponseFor(acc account.Account) profileResponse {
BlockFriendRequests: acc.BlockFriendRequests, BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
NotificationsInAppOnly: acc.NotificationsInAppOnly, NotificationsInAppOnly: acc.NotificationsInAppOnly,
VariantPreferences: acc.VariantPreferences,
} }
} }
+10 -8
View File
@@ -18,14 +18,15 @@ import (
// updateProfileRequest is the full editable profile. away_start/away_end are // updateProfileRequest is the full editable profile. away_start/away_end are
// "HH:MM" local-time bounds of the daily away window. // "HH:MM" local-time bounds of the daily away window.
type updateProfileRequest struct { type updateProfileRequest struct {
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"` PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"` TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"` AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"` AwayEnd string `json:"away_end"`
BlockChat bool `json:"block_chat"` BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"` BlockFriendRequests bool `json:"block_friend_requests"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"` NotificationsInAppOnly bool `json:"notifications_in_app_only"`
VariantPreferences []string `json:"variant_preferences"`
} }
// statsDTO is a durable account's lifetime statistics (the derived games-played and // statsDTO is a durable account's lifetime statistics (the derived games-played and
@@ -92,6 +93,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
BlockChat: req.BlockChat, BlockChat: req.BlockChat,
BlockFriendRequests: req.BlockFriendRequests, BlockFriendRequests: req.BlockFriendRequests,
NotificationsInAppOnly: req.NotificationsInAppOnly, NotificationsInAppOnly: req.NotificationsInAppOnly,
VariantPreferences: req.VariantPreferences,
}) })
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
@@ -424,7 +424,6 @@ func (s *Server) consoleUserMessage(c *gin.Context) {
} }
back := "/_gm/users/" + id.String() back := "/_gm/users/" + id.String()
text := trimForm(c, "text") text := trimForm(c, "text")
language := trimForm(c, "language")
switch { switch {
case text == "": case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back) s.renderConsoleMessage(c, "Nothing sent", "the message was empty", back)
@@ -436,14 +435,14 @@ func (s *Server) consoleUserMessage(c *gin.Context) {
s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back) s.renderConsoleMessage(c, "No Telegram", "this account has no Telegram identity", back)
return return
} }
delivered, err := s.connector.SendToUser(ctx, ext, text, language) delivered, err := s.connector.SendToUser(ctx, ext, text)
if err != nil { if err != nil {
s.consoleError(c, err) s.consoleError(c, err)
return return
} }
body := "message delivered" body := "message delivered"
if !delivered { if !delivered {
body = "not delivered (the user may not have started that bot)" body = "not delivered (the user may not have started the bot)"
} }
s.renderConsoleMessage(c, "Sent", body, back) s.renderConsoleMessage(c, "Sent", body, back)
} }
@@ -833,21 +832,20 @@ func (s *Server) consoleBroadcast(c *gin.Context) {
// consolePostBroadcast posts an operator message to the connector's game channel. // consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) { func (s *Server) consolePostBroadcast(c *gin.Context) {
text := trimForm(c, "text") text := trimForm(c, "text")
language := trimForm(c, "language")
switch { switch {
case text == "": case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast") s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
case s.connector == nil: case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast") s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
default: default:
delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text, language) delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
if err != nil { if err != nil {
s.consoleError(c, err) s.consoleError(c, err)
return return
} }
body := "posted to the game channel" body := "posted to the game channel"
if !delivered { if !delivered {
body = "not delivered (that bot has no game channel configured)" body = "not delivered (the bot has no game channel configured)"
} }
s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast") s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast")
} }
@@ -79,7 +79,7 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
} }
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, BotLanguage: m.ChannelLang, Source: m.Source, Channel: m.Channel, InterfaceLanguage: m.Lang,
IP: m.SenderIP, Body: m.Body, IP: m.SenderIP, Body: m.Body,
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,
+9 -25
View File
@@ -19,20 +19,16 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a // telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only). // brand-new account's display name and language (first contact only).
// ServiceLanguage is the validating bot's language tag (en/ru); it is recorded on
// every login (the bot the user last came through) and routes their out-of-app push.
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"`
ServiceLanguage string `json:"service_language"`
} }
// handleTelegramAuth provisions (or finds) the account bound to a Telegram // handleTelegramAuth provisions (or finds) the account bound to a Telegram
// identity and mints a session for it, seeding a new account's display name and // identity and mints a session for it, seeding a new account's display name and
// language from the supplied Telegram fields and recording the validating bot's // language from the supplied Telegram fields (first contact only).
// service language (updated every login) so out-of-app push routes to that bot.
func (s *Server) handleTelegramAuth(c *gin.Context) { func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
@@ -44,11 +40,6 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
if err := s.accounts.SetServiceLanguage(c.Request.Context(), acc.ID, req.ServiceLanguage); err != nil {
s.abortErr(c, err)
return
}
acc.ServiceLanguage = req.ServiceLanguage // reflect this login's bot in the session response
s.mintSession(c, acc) s.mintSession(c, acc)
} }
@@ -59,10 +50,9 @@ type pushTargetRequest struct {
// pushTargetResponse carries what the gateway needs to route an out-of-app push: // pushTargetResponse carries what the gateway needs to route an out-of-app push:
// the recipient's Telegram external_id (empty when they have no Telegram // the recipient's Telegram external_id (empty when they have no Telegram
// identity, e.g. a guest or email-only account), the language that both selects the // identity, e.g. a guest or email-only account), the language the single bot renders
// delivering bot and renders the message (the account's service language, the bot // the message in (the account's interface language), and whether they confined
// it last signed in through, falling back to its preferred language), and whether // notifications to the in-app stream.
// they confined notifications to the in-app stream.
type pushTargetResponse struct { type pushTargetResponse struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
Language string `json:"language"` Language string `json:"language"`
@@ -94,15 +84,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
s.abortErr(c, err) s.abortErr(c, err)
return return
} }
// Route by the bot the user last signed in through; fall back to the interface
// language for an account that has never come through a tagged bot.
language := acc.ServiceLanguage
if language == "" {
language = acc.PreferredLanguage
}
c.JSON(http.StatusOK, pushTargetResponse{ c.JSON(http.StatusOK, pushTargetResponse{
ExternalID: ext, ExternalID: ext,
Language: language, Language: acc.PreferredLanguage,
NotificationsInAppOnly: acc.NotificationsInAppOnly, NotificationsInAppOnly: acc.NotificationsInAppOnly,
}) })
} }
@@ -104,6 +104,9 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
abortBadRequest(c, "unknown variant") abortBadRequest(c, "unknown variant")
return return
} }
if !s.ensureVariantAllowed(c, uid, variant.String()) {
return
}
settings := lobby.InvitationSettings{ settings := lobby.InvitationSettings{
Variant: variant, Variant: variant,
HintsAllowed: req.HintsAllowed, HintsAllowed: req.HintsAllowed,
+23
View File
@@ -2,8 +2,10 @@ package server
import ( import (
"net/http" "net/http"
"slices"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/engine" "scrabble/backend/internal/engine"
) )
@@ -134,6 +136,24 @@ func (s *Server) handleGameState(c *gin.Context) {
c.JSON(http.StatusOK, dto) c.JSON(http.StatusOK, dto)
} }
// ensureVariantAllowed reports whether the caller's profile permits creating a game
// of variant — it must be one of their VariantPreferences. It aborts the request
// with 400 and returns false otherwise. Only the player who creates a game (quick
// match, vs-AI or a friend invitation) is gated this way; an invited friend may
// accept an invitation in any variant.
func (s *Server) ensureVariantAllowed(c *gin.Context, uid uuid.UUID, variant string) bool {
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
if err != nil {
s.abortErr(c, err)
return false
}
if !slices.Contains(acc.VariantPreferences, variant) {
abortBadRequest(c, "variant is not in your preferences")
return false
}
return true
}
// enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true // enqueueRequest enters per-variant auto-match under a per-turn word rule. VsAI true
// starts an honest-AI game against a robot instead of the open/wait matchmaking path. // starts an honest-AI game against a robot instead of the open/wait matchmaking path.
type enqueueRequest struct { type enqueueRequest struct {
@@ -162,6 +182,9 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant") abortBadRequest(c, "unknown variant")
return return
} }
if !s.ensureVariantAllowed(c, uid, variant.String()) {
return
}
if !s.ensureUnderGameLimit(c, uid) { if !s.ensureUnderGameLimit(c, uid) {
return return
} }
-3
View File
@@ -206,9 +206,6 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
// read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase. // read "<name>: …"; an unresolved name (best-effort) falls back to the plain phrase.
senderName, _ := svc.games.SeatName(ctx, gameID, senderID) senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
nudge := notify.Nudge(target, gameID, senderID, senderName) nudge := notify.Nudge(target, gameID, senderID, senderName)
if lang, err := svc.games.GameLanguage(ctx, gameID); err == nil {
nudge.Language = lang // route by the game's bot, not the recipient's last-login one
}
svc.pub.Publish(nudge) svc.pub.Publish(nudge)
} }
return msg, nil return msg, nil
-3
View File
@@ -44,9 +44,6 @@ type GameReader interface {
// one-chat-message-per-turn limit (a message counts toward the current turn when it // one-chat-message-per-turn limit (a message counts toward the current turn when it
// was posted at or after this time). // was posted at or after this time).
TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error) TurnStartedAt(ctx context.Context, gameID uuid.UUID) (time.Time, error)
// GameLanguage is the game's language tag ("en"/"ru"), so a nudge's out-of-app push routes
// to the game's bot rather than the recipient's last-login bot.
GameLanguage(ctx context.Context, gameID uuid.UUID) (string, error)
// VsAI reports whether the game is an honest-AI game, in which chat and nudge are // VsAI reports whether the game is an honest-AI game, in which chat and nudge are
// both disabled (the game still reports status 'active'). // both disabled (the game still reports status 'active').
VsAI(ctx context.Context, gameID uuid.UUID) (bool, error) VsAI(ctx context.Context, gameID uuid.UUID) (bool, error)
+4 -12
View File
@@ -29,26 +29,18 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) --------------------------- # --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID= VITE_TELEGRAM_BOT_ID=
VITE_TELEGRAM_LINK= # fallback friend-invite Mini App link (per-bot below) VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
VITE_TELEGRAM_LINK_EN= # friend-invite Mini App link, English bot (full URL, e.g. https://t.me/<bot>/<app>) VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_TELEGRAM_LINK_RU= # friend-invite Mini App link, Russian bot (full URL)
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN= # landing "Play in Telegram" link, English bot
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU= # landing "Play in Telegram" link, Russian bot
VITE_GATEWAY_URL= VITE_GATEWAY_URL=
# --- Gateway ----------------------------------------------------------------
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES=en,ru
# --- Grafana ---------------------------------------------------------------- # --- Grafana ----------------------------------------------------------------
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
GRAFANA_ADMIN_PASSWORD=admin GRAFANA_ADMIN_PASSWORD=admin
# --- Telegram connector ----------------------------------------------------- # --- Telegram connector -----------------------------------------------------
AWG_CONF= # required; AmneziaWG sidecar config AWG_CONF= # required; AmneziaWG sidecar config
TELEGRAM_BOT_TOKEN_EN= # at least one of EN/RU required TELEGRAM_BOT_TOKEN= # required
TELEGRAM_BOT_TOKEN_RU= TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_GAME_CHANNEL_ID_EN=
TELEGRAM_GAME_CHANNEL_ID_RU=
TELEGRAM_MINIAPP_URL= # required TELEGRAM_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= TELEGRAM_API_BASE_URL=
+5 -11
View File
@@ -62,9 +62,8 @@ compose binds from this directory.
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. | | `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. | | `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. |
**Plus at least one bot token**`TELEGRAM_BOT_TOKEN_EN` or `TELEGRAM_BOT_TOKEN_RU` **Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret). It defaults to empty in
(secrets). Compose cannot express "one of", so they default to empty, but the compose, but the connector **fails at boot** when it is empty.
connector **fails at boot** if both are empty.
## Optional variables (with defaults) ## Optional variables (with defaults)
@@ -78,17 +77,12 @@ connector **fails at boot** if both are empty.
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. | | `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
| `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. | | `GRAFANA_ADMIN_PASSWORD` | secret | `admin` | Grafana admin password. Low impact (the login form is disabled, access is anonymous-admin behind caddy) but set it anyway. |
| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English game-channel id; empty/`0` disables channel posts. | | `TELEGRAM_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_GAME_CHANNEL_ID_RU` | variable | _(empty)_ | Russian game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. | | `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. | | `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | variable | `en,ru` | Variant-gating set for non-Telegram logins (web/email/guest). |
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. | | `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: **fallback** friend-invite Mini App link (e.g. `https://t.me/<bot>/<app>`), used when the per-bot link below is unset. | | `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). |
| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link for the **English** bot (full URL, `https://t.me/<bot>/<app>``<app>` is the Mini App short name from BotFather). The friend-code share uses the link matching the bot the player signed in through. | | `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
| `VITE_TELEGRAM_LINK_RU` | variable | _(empty)_ | UI build-arg: same for the **Russian** bot. |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **English** bot (e.g. `https://t.me/Scrabble_Game`). |
| `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link for the **Russian** bot (e.g. `https://t.me/Erudit_Game`). |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). | | `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
The five `VITE_*` are **build-args** baked into the gateway and landing images at The five `VITE_*` are **build-args** baked into the gateway and landing images at
+7 -15
View File
@@ -121,10 +121,7 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
@@ -135,7 +132,6 @@ services:
GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
GATEWAY_CONNECTOR_ADDR: telegram:9091 GATEWAY_CONNECTOR_ADDR: telegram:9091
GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${GATEWAY_DEFAULT_SUPPORTED_LANGUAGES:-en,ru}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp GATEWAY_OTEL_TRACES_EXPORTER: otlp
@@ -171,10 +167,7 @@ services:
args: args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-} VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-} VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-} VITE_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_TELEGRAM_LINK_RU: ${VITE_TELEGRAM_LINK_RU:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_EN: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_EN:-}
VITE_TELEGRAM_GAME_CHANNEL_NAME_RU: ${VITE_TELEGRAM_GAME_CHANNEL_NAME_RU:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-} VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev} VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped restart: unless-stopped
@@ -209,12 +202,11 @@ services:
depends_on: [vpn] depends_on: [vpn]
network_mode: "service:vpn" network_mode: "service:vpn"
environment: environment:
# The bot tokens live ONLY in this container (ARCHITECTURE.md §12). At least # The bot token lives ONLY in this container (ARCHITECTURE.md §12). The connector
# one token is required (the connector validates this at boot). # requires it at boot; an empty value leaves the Telegram side down while the rest
TELEGRAM_BOT_TOKEN_EN: ${TELEGRAM_BOT_TOKEN_EN:-} # of the contour still comes up.
TELEGRAM_BOT_TOKEN_RU: ${TELEGRAM_BOT_TOKEN_RU:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_GAME_CHANNEL_ID_EN: ${TELEGRAM_GAME_CHANNEL_ID_EN:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_GAME_CHANNEL_ID_RU: ${TELEGRAM_GAME_CHANNEL_ID_RU:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
TELEGRAM_GRPC_ADDR: ":9091" TELEGRAM_GRPC_ADDR: ":9091"
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false} TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
+50 -36
View File
@@ -46,10 +46,9 @@ Three executables plus per-platform side-services:
mode). The visual/interaction design system is documented in mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md). [`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/telegram`** — the Telegram side-service (the "connector", module - **`platform/telegram`** — the Telegram side-service (the "connector", module
`scrabble/platform/telegram`). It is the only component holding the bot tokens — **one `scrabble/platform/telegram`). It is the only component holding the bot token — **one
bot per service language** (`en`/`ru`), each its own token + game channel, the same unified bot** (one token + one optional game channel, §3). It
Telegram user id spanning both (§3). It runs a Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
runs a Bot API long-poll loop per bot (Mini App launch + `/start` deep-links) and serves
a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation
and out-of-app push) and `backend` (operator broadcasts) call over the and out-of-app push) and `backend` (operator broadcasts) call over the
trusted internal network. Its generic delivery methods are **platform-agnostic** trusted internal network. Its generic delivery methods are **platform-agnostic**
@@ -138,24 +137,27 @@ arrive from a platform rather than completing a mandatory registration).
bootstrap — then mints a **thin opaque server session token** (`session_id`). First bootstrap — then mints a **thin opaque server session token** (`session_id`). First
Telegram contact seeds the new account's language (from the launch `language_code`) Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4). and display name (§4).
- **Service language & variant gating.** The connector hosts **one bot per - **Single bot.** The connector hosts **one unified bot** (one token + one optional
service language** (`en`/`ru`), each its own token + game channel; the same Telegram game channel). `ValidateInitData` validates `initData` against that single token and
user id spans both. `ValidateInitData` tries each token in turn and returns the returns only the Telegram user identity — there is no per-bot "service language" and no
validating bot's **service language** and its **supported-languages set**. The set supported-languages set on the wire. The bot's chat messages and out-of-app push are
rides the **`Session`** (FlatBuffers, session-scoped, not persisted): the UI offers rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
only the variants those languages support on New Game (`en` → English; `ru` → Russian any bot-scoped language, and the friend-invite **share link** (and its caption) point at
+ Эрудит). **Starting** a new game is the only gated action — opening and playing that one bot. First Telegram contact seeds the new account's `preferred_language` from the
existing games of any language is unrestricted, and the backend does not enforce the launch `language_code` (§4); the interface language is otherwise edited in Settings.
gate (it is a product affordance, not a trust boundary). The service language is - **Variant preferences (New Game gating).** Which variants a player may be matched into is a
**persisted** per account (`accounts.service_language`, updated on every Telegram per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
login — last-login-wins) and routes the user's out-of-app push back through the right (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
bot (§10) — **except a game event, which routes by the game's own language** (its variant → accounts default to **Erudit only** (a DB column default); at least one variant must stay
en/ru), so a game's notification always comes from the game's bot rather than the selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New
recipient's latest login bot. It also rides the **Session wire** to the client, which uses it Game picker on every create path the player **initiates** — auto-match, vs-AI and a friend
to build the friend-invite **share link** (and its caption) for the **same bot** the player invitation the player **creates** and the backend **enforces** it on those paths (a chosen
is in. The service language is distinct from `preferred_language` (the variant outside the caller's preferences is rejected with HTTP 400). An **invited** friend
interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the may still **accept** an invitation in **any** variant (accepting is never gated), and opening
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default). or playing existing games of any variant is unrestricted. This replaces the former
login-language variant gating; it is a per-account product affordance plus a server-side
create-path check, distinct from `preferred_language` (the interface language) and from a
game's variant language.
- The client holds `session_id` in memory for the app session (browser/OS - The client holds `session_id` in memory for the app session (browser/OS
storage is optional and may be unavailable; losing it means re-login). storage is optional and may be unavailable; losing it means re-login).
- The gateway caches `session → user_id` and injects `X-User-ID`. Session - The gateway caches `session → user_id` and injects `X-User-ID`. Session
@@ -176,6 +178,18 @@ arrive from a platform rather than completing a mandatory registration).
Platform and email users are auto-provisioned **durable** accounts with an Platform and email users are auto-provisioned **durable** accounts with an
identity. identity.
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a
> `supported_languages` set on the `Session` wire and game-language push routing) was
> collapsed into **one unified bot**: it renders chat and out-of-app push in the
> recipient's interface language (`preferred_language`), with no per-bot routing. New
> Game variant gating moved off the login language onto a per-user profile setting
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
> paths; an invited friend may still accept any variant). The per-bot env vars and
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile.
## 4. Accounts, identities, linking & merge ## 4. Accounts, identities, linking & merge
- One internal account may carry several **platform identities** - One internal account may carry several **platform identities**
@@ -525,10 +539,10 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric, code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
rate-limited) is redeemed by the other player to become friends immediately. It is shared as rate-limited) is redeemed by the other player to become friends immediately. It is shared as
a Telegram `startapp` deep-link to the issuer's own bot (by service language, with a matching a Telegram `startapp` deep-link to the single bot (with a matching caption),
caption), redeemed by the recipient's Mini App on launch; a **spent or expired** code is not redeemed by the recipient's Mini App on launch; a **spent or expired** code is not
surfaced as an error there but lands the visitor in the lobby with a gentle pointer to the surfaced as an error there but lands the visitor in the lobby with a gentle pointer to the
right bot, since the shared link outlives the single-use code. bot, since the shared link outlives the single-use code.
Alternatively a **request → accept** is sent to someone you **share a game with** Alternatively a **request → accept** is sent to someone you **share a game with**
(active or finished); the recipient may accept, ignore (the pending row lazily (active or finished); the recipient may accept, ignore (the pending row lazily
expires after **30 days** and may be re-sent), or **decline** — a decline is expires after **30 days** and may be re-sent), or **decline** — a decline is
@@ -602,7 +616,9 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
sections (the finished section keeps its activity order). On each clear the publish-to-read sections (the finished section keeps its activity order). On each clear the publish-to-read
latency is recorded; the read time itself is not retained. latency is recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email - **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the (confirm-code binding, see §4), **timezone**, the daily **away window**, the
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
Game — §3, defaulting to Erudit only, at least one enforced) and the
block toggles — all editable through `account.UpdateProfile`, which validates them: block toggles — all editable through `account.UpdateProfile`, which validates them:
a display name is Unicode letters joined by single ` `/`.`/`_` a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
@@ -772,20 +788,18 @@ invitations) the client re-polls on the `notify` event and on lobby open / focus
missed while the app was hidden. **Out-of-app platform push** is a fallback missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the **service language** — the bot they last signed in through, falling `external_id`, the recipient's **interface language** (`preferred_language`) as the render
back to the interface language and the `notifications_in_app_only` flag). A **game** event, language, and the `notifications_in_app_only` flag). It then asks the **Telegram connector**
however, carries the **game's own language** on the push, and the gateway routes by to deliver — through the **single bot** — a
that instead of the service language — so a game's notification always comes from the game's bot, localized message with a Mini App deep-link button, only when the recipient has a Telegram
not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a
localized message with a Mini App deep-link button — only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is connector renders the message in that language; there is no per-bot routing. The out-of-app set is
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds; your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so in-app-only sub-kinds like the connector renders the message and skips the rest — so in-app-only sub-kinds like
**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a **invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a
block-state sync to the blocker) never become a platform push. Operator broadcasts block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an (`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and the console, sent through the same single bot. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP). cursor-based stream resume stay deferred (single-instance MVP).
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md), A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
@@ -798,7 +812,7 @@ remainder up to 100% and is undeletable. Eligibility — who sees a banner at al
it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`** it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`**
response (the one bootstrap every client fetches on open, authed or guest — no separate request, response (the one bootstrap every client fetches on open, authed or guest — no separate request,
nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's
**service language** (the bot they signed in through, falling back to the interface language) and **interface language** (`preferred_language`) and
computes the active set — window-filtered campaigns, the default's effective weight computes the active set — window-filtered campaigns, the default's effective weight
(`max(0, 100 Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set (`max(0, 100 Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set
with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per
+17 -12
View File
@@ -31,13 +31,9 @@ ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
App** launch authenticates from the platform's signed `initData`, themes the UI to App** launch authenticates from the platform's signed `initData`, themes the UI to
the Telegram colours, and — on first contact — seeds the new account's interface the Telegram colours, and — on first contact — seeds the new account's interface
language from the Telegram client. The sign-in service also declares the **game language from the Telegram client. Telegram runs a **single bot**: every player uses
languages** it offers (a set of en/ru, at least one), which gate the New Game variant the same bot, and all of its chat and out-of-app notifications are written in the
choice in the lobby. Telegram runs a separate bot per language (an English bot and a player's own **interface language** (en/ru). Guests are session-only with restricted features
Russian bot, the same player spanning both); the bot a player signed in through sets their
offered languages, and their non-game notifications come from it. A **game's** notifications
(your turn, game over, a nudge), though, always come from **that game's** bot — by the game's
language, not whichever bot the player signed in through last. Guests are session-only with restricted features
(auto-match only; no friends, stats or history); an abandoned guest that never (auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move, keeps a live stream and receives in-app updates in real time — the opponent's move,
@@ -85,12 +81,12 @@ in the other players' lists, and there is no undo. A finished **AI game (🤖) y
resigning or by letting it lapse to the 7-day timeout — drops from your *finished* list resigning or by letting it lapse to the 7-day timeout — drops from your *finished* list
automatically, with no swipe needed; a normally finished AI game stays until you remove it, and automatically, with no swipe needed; a normally finished AI game stays until you remove it, and
no other game type is auto-removed. The game types offered on **New Game** are no other game type is auto-removed. The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → Scrabble; limited to the player's chosen **variant preferences** (see *Profile & settings*) —
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and "Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend the same name titles the in-game screen. This gates only **starting** a new game you initiate —
invitation — so a player still sees and plays existing games of any language. auto-match, a vs-AI game and a friend invitation **you create** — so a player still sees and plays
existing games of any variant, and being **invited** to a game lets you accept it in any variant.
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**. **Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting, With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
@@ -237,6 +233,15 @@ block toggles. The profile form is edited inline (no separate edit mode). Linkin
an email or Telegram and merging accounts are covered under "Accounts, linking & an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". merge".
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
least one** selected. This list is exactly what **New Game** offers when you start a game
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
invited friend may accept an invitation in **any** variant, and you can always open and play
existing games of any variant.
### Feedback ### Feedback
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images, message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
+18 -13
View File
@@ -32,13 +32,9 @@ top-1 подсказку, безлимитную проверку слова с
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним
предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок интерфейса** самого игрока (en/ru). Гость — только сессия, с урезанными функциями (только
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки, и от него
приходят его **внеигровые** уведомления. А уведомления по **партии** (ваш ход, конец партии,
nudge) приходят от бота **этой партии** — по языку партии, а не по тому боту, через которого
игрок входил последним. Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
@@ -87,13 +83,13 @@ nudge) приходят от бота **этой партии** — по язы
из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
на экране **Новая игра** на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble; ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса), читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
любом языке. по-прежнему видит и играет существующие игры любого варианта, а при **приглашении** в партию её
можно принять в любом варианте.
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ** **Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
@@ -243,6 +239,15 @@ UTC), суточного окна отсутствия (away; сетка по 10
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
а открывать и играть существующие игры любого варианта можно всегда.
### Обратная связь ### Обратная связь
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF, сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
+3 -3
View File
@@ -313,10 +313,10 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or - **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
the icon copies it. **Share via Telegram** wraps the code in a `t.me/<bot>/<app>?startapp=` the icon copies it. **Share via Telegram** wraps the code in a `t.me/<bot>/<app>?startapp=`
deep-link and opens Telegram's native share-to-chat sheet (Web Share / clipboard fallback deep-link and opens Telegram's native share-to-chat sheet (Web Share / clipboard fallback
outside Telegram); the link and its caption are for the **same bot the player signed in outside Telegram); the link and its caption are for the **single bot**. Redeeming your **own**
through** (its service language). Redeeming your **own** invite shows a friendly note, not an invite shows a friendly note, not an
error; opening an **outdated** invite link (a used or expired single-use code) lands in the error; opening an **outdated** invite link (a used or expired single-use code) lands in the
lobby with a calm modal pointing at the right bot (`@<username>`), not a red error on the lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
Safari. Safari.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid - **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
-1
View File
@@ -77,7 +77,6 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call | | `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | | `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) | | `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) |
| `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` | `en,ru` | New Game variant gating set placed on the Session for non-platform logins (web / email / guest); a deployment may narrow it |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime | | `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) | | `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
+5 -10
View File
@@ -126,7 +126,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)") logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
} }
registry := transcode.NewRegistry(backend, validator, cfg.DefaultSupportedLanguages...) registry := transcode.NewRegistry(backend, validator)
edge := connectsrv.NewServer(connectsrv.Deps{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
@@ -258,7 +258,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
// deliver the event over the platform push channel. Done in a goroutine // deliver the event over the platform push channel. Done in a goroutine
// so a slow connector never stalls the in-app firehose. // so a slow connector never stalls the in-app firehose.
if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) { if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), ev.GetLanguage(), logger) go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
} }
} }
if !sleep(ctx, pushReconnectDelay) { if !sleep(ctx, pushReconnectDelay) {
@@ -271,7 +271,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
// Telegram identity and have not confined notifications to the app, asks the // Telegram identity and have not confined notifications to the app, asks the
// connector to deliver the event. It is best-effort: every failure is logged and // connector to deliver the event. It is best-effort: every failure is logged and
// dropped (the in-app stream remains the primary channel). // dropped (the in-app stream remains the primary channel).
func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, gameLang string, logger *zap.Logger) { func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, logger *zap.Logger) {
target, err := backend.PushTarget(ctx, userID) target, err := backend.PushTarget(ctx, userID)
if err != nil { if err != nil {
logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err)) logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err))
@@ -280,13 +280,8 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c
if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) { if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) {
return return
} }
// A game event carries its own language, so the push comes from the game's bot rather than // The single bot renders the message in the recipient's interface language.
// the recipient's last-login bot; other events fall back to the service language. if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil {
lang := target.Language
if gameLang != "" {
lang = gameLang
}
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, lang); err != nil {
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err)) logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err))
} }
} }
+22 -25
View File
@@ -14,26 +14,26 @@ import (
// SessionResp is the credential minted by an auth operation. // SessionResp is the credential minted by an auth operation.
type SessionResp struct { type SessionResp struct {
Token string `json:"token"` Token string `json:"token"`
UserID string `json:"user_id"` UserID string `json:"user_id"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
ServiceLanguage string `json:"service_language"`
} }
// ProfileResp is an account's own profile. // ProfileResp is an account's own profile.
type ProfileResp struct { type ProfileResp struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"` PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"` TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"` AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"` AwayEnd string `json:"away_end"`
HintBalance int `json:"hint_balance"` HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"` BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"` BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"` NotificationsInAppOnly bool `json:"notifications_in_app_only"`
VariantPreferences []string `json:"variant_preferences"`
// Banner is the advertising-banner block, present only for a viewer eligible to // Banner is the advertising-banner block, present only for a viewer eligible to
// see the banner. The gateway forwards it verbatim into the Profile payload. // see the banner. The gateway forwards it verbatim into the Profile payload.
Banner *BannerResp `json:"banner,omitempty"` Banner *BannerResp `json:"banner,omitempty"`
@@ -184,18 +184,15 @@ 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.
// recording the validating bot's serviceLanguage (which routes the account's later func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
// out-of-app push).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, serviceLanguage 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{
"external_id": externalID, "external_id": externalID,
"language_code": languageCode, "language_code": languageCode,
"username": username, "username": username,
"first_name": firstName, "first_name": firstName,
"service_language": serviceLanguage,
}, &out) }, &out)
return out, err return out, err
} }
@@ -287,6 +287,7 @@ func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp
"block_chat": p.BlockChat, "block_chat": p.BlockChat,
"block_friend_requests": p.BlockFriendRequests, "block_friend_requests": p.BlockFriendRequests,
"notifications_in_app_only": p.NotificationsInAppOnly, "notifications_in_app_only": p.NotificationsInAppOnly,
"variant_preferences": p.VariantPreferences,
} }
err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out) err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out)
return out, err return out, err
-41
View File
@@ -6,7 +6,6 @@ import (
"fmt" "fmt"
"os" "os"
"strconv" "strconv"
"strings"
"time" "time"
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
@@ -33,11 +32,6 @@ type Config struct {
// gateway calls it to validate Mini App initData and to deliver out-of-app push. // gateway calls it to validate Mini App initData and to deliver out-of-app push.
// Empty disables the telegram auth path and the out-of-app push channel. // Empty disables the telegram auth path and the out-of-app push channel.
ConnectorAddr string ConnectorAddr string
// DefaultSupportedLanguages is the New Game variant gating set put on the Session
// for non-platform logins (web / email / guest), which carry no service container
// to declare one. The UI offers only variants in this set (en -> English; ru ->
// Russian + Эрудит). Defaults to all of them; a deployment may narrow it.
DefaultSupportedLanguages []string
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax // SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
// caps the number of cached sessions. // caps the number of cached sessions.
SessionTTL time.Duration SessionTTL time.Duration
@@ -85,13 +79,6 @@ const (
// yet small enough to stop a cheap memory-amplification upload. // yet small enough to stop a cheap memory-amplification upload.
const DefaultMaxBodyBytes = 1 << 20 const DefaultMaxBodyBytes = 1 << 20
// supportedLanguages is the set of game languages a service may declare for the
// New Game variant gating; defaultSupportedLanguages is the non-platform fallback.
var (
supportedLanguages = map[string]bool{"en": true, "ru": true}
defaultSupportedLanguages = []string{"en", "ru"}
)
// DefaultRateLimit returns the built-in anti-abuse limits. // DefaultRateLimit returns the built-in anti-abuse limits.
func DefaultRateLimit() RateLimitConfig { func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{ return RateLimitConfig{
@@ -141,9 +128,6 @@ func Load() (Config, error) {
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil { if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
return Config{}, err return Config{}, err
} }
if c.DefaultSupportedLanguages, err = envLanguages("GATEWAY_DEFAULT_SUPPORTED_LANGUAGES", defaultSupportedLanguages); err != nil {
return Config{}, err
}
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
} }
@@ -190,31 +174,6 @@ func envOr(key, fallback string) string {
return fallback return fallback
} }
// envLanguages parses a comma-separated language list (e.g. "en,ru") from the
// environment variable named key, returning fallback when it is unset. Every entry
// must be a supported language and the result must be non-empty.
func envLanguages(key string, fallback []string) ([]string, error) {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback, nil
}
var out []string
for part := range strings.SplitSeq(raw, ",") {
lang := strings.ToLower(strings.TrimSpace(part))
if lang == "" {
continue
}
if !supportedLanguages[lang] {
return nil, fmt.Errorf("config: %s: unsupported language %q", key, lang)
}
out = append(out, lang)
}
if len(out) == 0 {
return nil, fmt.Errorf("config: %s must list at least one language", key)
}
return out, nil
}
// envInt parses the environment variable named key as an int, returning fallback // envInt parses the environment variable named key as an int, returning fallback
// when it is unset and an error when it is set but malformed. // when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) { func envInt(key string, fallback int) (int, error) {
+10 -17
View File
@@ -27,18 +27,13 @@ var ErrInvalidInitData = errors.New("connector: invalid telegram init data")
// rejects the Login Widget data (a gRPC InvalidArgument). // rejects the Login Widget data (a gRPC InvalidArgument).
var ErrInvalidLoginWidget = errors.New("connector: invalid telegram login widget data") var ErrInvalidLoginWidget = errors.New("connector: invalid telegram login widget data")
// User is a validated Mini App identity. ServiceLanguage is the validating bot's // User is a validated Mini App identity. LanguageCode seeds a brand-new account's
// language tag (en/ru), persisted to route the user's out-of-app push back through // preferred (interface) language; it is empty for a Login Widget validation.
// the right bot; SupportedLanguages is that bot's set of offered game languages,
// which the UI gates the New Game variant choice by. Both are empty for a Login
// Widget validation (it carries no bot language).
type User struct { type User struct {
ExternalID string ExternalID string
Username string Username string
FirstName string FirstName string
LanguageCode string LanguageCode string
ServiceLanguage string
SupportedLanguages []string
} }
// Client wraps the connector's Telegram gRPC service. // Client wraps the connector's Telegram gRPC service.
@@ -73,12 +68,10 @@ func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, e
return User{}, err return User{}, err
} }
return User{ return User{
ExternalID: resp.GetExternalId(), ExternalID: resp.GetExternalId(),
Username: resp.GetUsername(), Username: resp.GetUsername(),
FirstName: resp.GetFirstName(), FirstName: resp.GetFirstName(),
LanguageCode: resp.GetLanguageCode(), LanguageCode: resp.GetLanguageCode(),
ServiceLanguage: resp.GetServiceLanguage(),
SupportedLanguages: resp.GetSupportedLanguages(),
}, nil }, nil
} }
+6 -29
View File
@@ -13,38 +13,17 @@ import (
// created before the table that references it, and no two tables/vectors are // created before the table that references it, and no two tables/vectors are
// under construction at once. // under construction at once.
// buildSupportedLanguagesVector creates the Session.supported_languages [string] // encodeSession builds a Session payload.
// vector from langs. FlatBuffers is built bottom-up, so the caller must invoke this func encodeSession(s backendclient.SessionResp) []byte {
// (which itself creates the element strings) before SessionStart and with no table
// under construction.
func buildSupportedLanguagesVector(b *flatbuffers.Builder, langs []string) flatbuffers.UOffsetT {
offsets := make([]flatbuffers.UOffsetT, len(langs))
for i, lang := range langs {
offsets[i] = b.CreateString(lang)
}
fb.SessionStartSupportedLanguagesVector(b, len(langs))
for i := len(offsets) - 1; i >= 0; i-- {
b.PrependUOffsetT(offsets[i])
}
return b.EndVector(len(langs))
}
// encodeSession builds a Session payload. supportedLangs is the service's set of
// offered game languages, which the UI gates the New Game variant choice by.
func encodeSession(s backendclient.SessionResp, supportedLangs []string) []byte {
b := flatbuffers.NewBuilder(128) b := flatbuffers.NewBuilder(128)
token := b.CreateString(s.Token) token := b.CreateString(s.Token)
uid := b.CreateString(s.UserID) uid := b.CreateString(s.UserID)
name := b.CreateString(s.DisplayName) name := b.CreateString(s.DisplayName)
svcLang := b.CreateString(s.ServiceLanguage)
langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b) fb.SessionStart(b)
fb.SessionAddToken(b, token) fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid) fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, s.IsGuest) fb.SessionAddIsGuest(b, s.IsGuest)
fb.SessionAddDisplayName(b, name) fb.SessionAddDisplayName(b, name)
fb.SessionAddSupportedLanguages(b, langs)
fb.SessionAddServiceLanguage(b, svcLang)
b.Finish(fb.SessionEnd(b)) b.Finish(fb.SessionEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
@@ -74,6 +53,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
if p.Banner != nil { if p.Banner != nil {
banner = encodeBanner(b, *p.Banner) banner = encodeBanner(b, *p.Banner)
} }
prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
fb.ProfileStart(b) fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid) fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name) fb.ProfileAddDisplayName(b, name)
@@ -86,6 +66,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddAwayStart(b, awayStart) fb.ProfileAddAwayStart(b, awayStart)
fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddAwayEnd(b, awayEnd)
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
fb.ProfileAddVariantPreferences(b, prefs)
if p.Banner != nil { if p.Banner != nil {
fb.ProfileAddBanner(b, banner) fb.ProfileAddBanner(b, banner)
} }
@@ -147,10 +128,8 @@ func encodeBlockStatus(s backendclient.BlockStatusResp) []byte {
// encodeLinkResult builds a LinkResult payload. A switched-session token // encodeLinkResult builds a LinkResult payload. A switched-session token
// (a guest initiator whose durable counterpart won) is carried as a nested Session // (a guest initiator whose durable counterpart won) is carried as a nested Session
// for the client to adopt; it is omitted otherwise. supportedLangs is the variant // for the client to adopt; it is omitted otherwise.
// gating set for that switched session — the link flows run on the web, so it is the func encodeLinkResult(r backendclient.LinkResultResp) []byte {
// gateway's default (non-platform) set.
func encodeLinkResult(r backendclient.LinkResultResp, supportedLangs []string) []byte {
b := flatbuffers.NewBuilder(256) b := flatbuffers.NewBuilder(256)
status := b.CreateString(r.Status) status := b.CreateString(r.Status)
secID := b.CreateString(r.SecondaryUserID) secID := b.CreateString(r.SecondaryUserID)
@@ -161,13 +140,11 @@ func encodeLinkResult(r backendclient.LinkResultResp, supportedLangs []string) [
token := b.CreateString(r.Token) token := b.CreateString(r.Token)
uid := b.CreateString(r.Profile.UserID) uid := b.CreateString(r.Profile.UserID)
name := b.CreateString(r.Profile.DisplayName) name := b.CreateString(r.Profile.DisplayName)
langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b) fb.SessionStart(b)
fb.SessionAddToken(b, token) fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid) fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, r.Profile.IsGuest) fb.SessionAddIsGuest(b, r.Profile.IsGuest)
fb.SessionAddDisplayName(b, name) fb.SessionAddDisplayName(b, name)
fb.SessionAddSupportedLanguages(b, langs)
sess = fb.SessionEnd(b) sess = fb.SessionEnd(b)
} }
fb.LinkResultStart(b) fb.LinkResultStart(b)
+10 -16
View File
@@ -89,20 +89,14 @@ type TelegramValidator interface {
// NewRegistry builds the slice's message-type catalog over the backend client. // NewRegistry builds the slice's message-type catalog over the backend client.
// The Telegram auth op is registered only when a validator is supplied (the // The Telegram auth op is registered only when a validator is supplied (the
// connector is configured); otherwise auth.telegram is simply unknown. // connector is configured); otherwise auth.telegram is simply unknown.
// defaultLanguages is the New Game variant gating set placed on the Session for func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
// non-platform logins (web / email / guest) and on a switched link session; an
// empty argument falls back to all languages (matching the config default).
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLanguages ...string) *Registry {
if len(defaultLanguages) == 0 {
defaultLanguages = []string{"en", "ru"}
}
r := &Registry{ops: make(map[string]Op)} r := &Registry{ops: make(map[string]Op)}
if tg != nil { if tg != nil {
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
} }
r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend, defaultLanguages)} r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend)}
r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true}
r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true} r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true}
r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true} r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
@@ -130,7 +124,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, defaultLan
r.ops[MsgFeedbackGet] = Op{Handler: feedbackGetHandler(backend), Auth: true} r.ops[MsgFeedbackGet] = Op{Handler: feedbackGetHandler(backend), Auth: true}
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true} r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend) registerSocialOps(r, backend)
registerLinkOps(r, backend, tg, defaultLanguages) registerLinkOps(r, backend, tg)
return r return r
} }
@@ -164,21 +158,21 @@ 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, user.ServiceLanguage) 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
} }
return encodeSession(sess, user.SupportedLanguages), nil return encodeSession(sess), nil
} }
} }
func authGuestHandler(backend *backendclient.Client, supportedLangs []string) Handler { func authGuestHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, _ Request) ([]byte, error) { return func(ctx context.Context, _ Request) ([]byte, error) {
sess, err := backend.GuestAuth(ctx) sess, err := backend.GuestAuth(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return encodeSession(sess, supportedLangs), nil return encodeSession(sess), nil
} }
} }
@@ -192,14 +186,14 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler {
} }
} }
func authEmailLoginHandler(backend *backendclient.Client, supportedLangs []string) Handler { func authEmailLoginHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0) in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code())) sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return encodeSession(sess, supportedLangs), nil return encodeSession(sess), nil
} }
} }
+11 -13
View File
@@ -22,15 +22,13 @@ const (
// registerLinkOps adds the linking & merge operations. The telegram ops need the // registerLinkOps adds the linking & merge operations. The telegram ops need the
// connector's Login Widget validator, so they are registered only when tg is set. // connector's Login Widget validator, so they are registered only when tg is set.
// supportedLangs is the variant gating set for a switched link session (the link func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator) {
// flows run on the web, so the gateway default set).
func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true} r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), Auth: true, Email: true} r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true} r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
if tg != nil { if tg != nil {
r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false, supportedLangs), Auth: true} r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true}
r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true, supportedLangs), Auth: true} r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true}
} }
} }
@@ -44,31 +42,31 @@ func linkEmailRequestHandler(backend *backendclient.Client) Handler {
} }
} }
func linkEmailConfirmHandler(backend *backendclient.Client, supportedLangs []string) Handler { func linkEmailConfirmHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0) in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code())) res, err := backend.LinkEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return encodeLinkResult(res, supportedLangs), nil return encodeLinkResult(res), nil
} }
} }
func linkEmailMergeHandler(backend *backendclient.Client, supportedLangs []string) Handler { func linkEmailMergeHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0) in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailMerge(ctx, req.UserID, string(in.Email()), string(in.Code())) res, err := backend.LinkEmailMerge(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil { if err != nil {
return nil, err return nil, err
} }
return encodeLinkResult(res, supportedLangs), nil return encodeLinkResult(res), nil
} }
} }
// linkTelegramHandler validates Login Widget data via the connector and then calls // linkTelegramHandler validates Login Widget data via the connector and then calls
// the backend's link or merge endpoint with the trusted Telegram external id. // the backend's link or merge endpoint with the trusted Telegram external id.
func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool, supportedLangs []string) Handler { func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0) in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0)
user, err := tg.ValidateLoginWidget(ctx, string(in.Data())) user, err := tg.ValidateLoginWidget(ctx, string(in.Data()))
@@ -84,6 +82,6 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
if err != nil { if err != nil {
return nil, err return nil, err
} }
return encodeLinkResult(res, supportedLangs), nil return encodeLinkResult(res), nil
} }
} }
@@ -246,6 +246,10 @@ func invitationCancelHandler(backend *backendclient.Client) Handler {
func profileUpdateHandler(backend *backendclient.Client) Handler { func profileUpdateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0) in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0)
prefs := make([]string, in.VariantPreferencesLength())
for i := range prefs {
prefs[i] = string(in.VariantPreferences(i))
}
p := backendclient.ProfileResp{ p := backendclient.ProfileResp{
DisplayName: string(in.DisplayName()), DisplayName: string(in.DisplayName()),
PreferredLanguage: string(in.PreferredLanguage()), PreferredLanguage: string(in.PreferredLanguage()),
@@ -255,6 +259,7 @@ func profileUpdateHandler(backend *backendclient.Client) Handler {
BlockChat: in.BlockChat(), BlockChat: in.BlockChat(),
BlockFriendRequests: in.BlockFriendRequests(), BlockFriendRequests: in.BlockFriendRequests(),
NotificationsInAppOnly: in.NotificationsInAppOnly(), NotificationsInAppOnly: in.NotificationsInAppOnly(),
VariantPreferences: prefs,
} }
out, err := backend.UpdateProfile(ctx, req.UserID, p) out, err := backend.UpdateProfile(ctx, req.UserID, p)
if err != nil { if err != nil {
@@ -43,11 +43,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Errorf("unexpected path %q", r.URL.Path) t.Errorf("unexpected path %q", r.URL.Path)
} }
_ = json.NewDecoder(r.Body).Decode(&gotBody) _ = json.NewDecoder(r.Body).Decode(&gotBody)
_, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван","service_language":"ru"}`)) _, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван"}`))
}) })
defer cleanup() defer cleanup()
v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru", ServiceLanguage: "ru", SupportedLanguages: []string{"ru"}}} v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru"}}
reg := transcode.NewRegistry(backend, v) reg := transcode.NewRegistry(backend, v)
op, ok := reg.Lookup(transcode.MsgAuthTelegram) op, ok := reg.Lookup(transcode.MsgAuthTelegram)
if !ok { if !ok {
@@ -62,48 +62,9 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if string(sess.Token()) != "tok-tg" || string(sess.UserId()) != "u-tg" { if string(sess.Token()) != "tok-tg" || string(sess.UserId()) != "u-tg" {
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId()) t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
} }
// The validating bot's supported languages ride the Session so the UI gates the // The validated launch fields are forwarded so the backend can seed a new account.
// New Game variant choice (here: ru -> Russian + Эрудит only). if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" { t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
t.Errorf("session supported_languages = %v, want [ru]", got)
}
// The bot's service language rides the Session so the UI can build a share link to
// the same bot.
if got := string(sess.ServiceLanguage()); got != "ru" {
t.Errorf("session service_language = %q, want ru", got)
}
// The validated launch fields are forwarded so the backend can seed a new account;
// service_language is recorded to route the account's later out-of-app push.
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" || gotBody["service_language"] != "ru" {
t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван service_language=ru", gotBody)
}
}
// sessionLanguages reads the supported_languages vector off a decoded Session.
func sessionLanguages(s *fb.Session) []string {
out := make([]string, s.SupportedLanguagesLength())
for i := range out {
out[i] = string(s.SupportedLanguages(i))
}
return out
}
// TestGuestAuthSeedsDefaultLanguages confirms a non-platform (guest) session carries
// the gateway's default supported-languages set, so the web client is ungated.
func TestGuestAuthSeedsDefaultLanguages(t *testing.T) {
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok-g","user_id":"u-g","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
reg := transcode.NewRegistry(backend, nil, "en", "ru")
op, _ := reg.Lookup(transcode.MsgAuthGuest)
payload, err := op.Handler(context.Background(), transcode.Request{})
if err != nil {
t.Fatalf("handler: %v", err)
}
if got := sessionLanguages(fb.GetRootAsSession(payload, 0)); len(got) != 2 || got[0] != "en" || got[1] != "ru" {
t.Errorf("guest session supported_languages = %v, want [en ru]", got)
} }
} }
+4 -9
View File
@@ -123,20 +123,11 @@ table EmailLoginRequest {
} }
// Session is the minted credential returned by every auth operation. // Session is the minted credential returned by every auth operation.
// supported_languages is the set of game languages (subset of {en, ru}, at least
// one) the service the user signed in through offers; the UI gates the New Game
// variant choice by it (en -> English; ru -> Russian + Эрудит). It is session-
// scoped (not persisted) and added trailing — backward-compatible.
table Session { table Session {
token:string; token:string;
user_id:string; user_id:string;
is_guest:bool; is_guest:bool;
display_name:string; display_name:string;
supported_languages:[string];
// service_language is the language tag (en/ru) of the Telegram bot the session was
// minted through; empty for a non-Telegram login. The UI uses it to build a share
// link that points at the same bot.
service_language:string;
} }
// Ack is a simple success acknowledgement (e.g. an email-code request). // Ack is a simple success acknowledgement (e.g. an email-code request).
@@ -190,6 +181,9 @@ table Profile {
away_end:string; away_end:string;
notifications_in_app_only:bool = true; notifications_in_app_only:bool = true;
banner:BannerInfo; banner:BannerInfo;
// variant_preferences is the set of game variants the player allows themselves to be
// matched into (engine.Variant labels), Erudit-first; the New Game picker is gated by it.
variant_preferences:[string];
} }
// BlockStatus reports the caller's current manual block. The UI fetches it after any operation // BlockStatus reports the caller's current manual block. The UI fetches it after any operation
@@ -434,6 +428,7 @@ table UpdateProfileRequest {
block_chat:bool; block_chat:bool;
block_friend_requests:bool; block_friend_requests:bool;
notifications_in_app_only:bool = true; notifications_in_app_only:bool = true;
variant_preferences:[string];
} }
// --- account linking & merge (authenticated) --- // --- account linking & merge (authenticated) ---
+24 -1
View File
@@ -162,8 +162,25 @@ func (rcv *Profile) Banner(obj *BannerInfo) *BannerInfo {
return nil return nil
} }
func (rcv *Profile) VariantPreferences(j int) []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(28))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
}
return nil
}
func (rcv *Profile) VariantPreferencesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(28))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func ProfileStart(builder *flatbuffers.Builder) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(12) builder.StartObject(13)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
@@ -201,6 +218,12 @@ func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notification
func ProfileAddBanner(builder *flatbuffers.Builder, banner flatbuffers.UOffsetT) { func ProfileAddBanner(builder *flatbuffers.Builder, banner flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(11, flatbuffers.UOffsetT(banner), 0) builder.PrependUOffsetTSlot(11, flatbuffers.UOffsetT(banner), 0)
} }
func ProfileAddVariantPreferences(builder *flatbuffers.Builder, variantPreferences flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(12, flatbuffers.UOffsetT(variantPreferences), 0)
}
func ProfileStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+1 -35
View File
@@ -77,33 +77,8 @@ func (rcv *Session) DisplayName() []byte {
return nil return nil
} }
func (rcv *Session) SupportedLanguages(j int) []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
}
return nil
}
func (rcv *Session) SupportedLanguagesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(12))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func (rcv *Session) ServiceLanguage() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(14))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func SessionStart(builder *flatbuffers.Builder) { func SessionStart(builder *flatbuffers.Builder) {
builder.StartObject(6) builder.StartObject(4)
} }
func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0)
@@ -117,15 +92,6 @@ func SessionAddIsGuest(builder *flatbuffers.Builder, isGuest bool) {
func SessionAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { func SessionAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(displayName), 0) builder.PrependUOffsetTSlot(3, flatbuffers.UOffsetT(displayName), 0)
} }
func SessionAddSupportedLanguages(builder *flatbuffers.Builder, supportedLanguages flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(4, flatbuffers.UOffsetT(supportedLanguages), 0)
}
func SessionStartSupportedLanguagesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func SessionAddServiceLanguage(builder *flatbuffers.Builder, serviceLanguage flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(5, flatbuffers.UOffsetT(serviceLanguage), 0)
}
func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+24 -1
View File
@@ -117,8 +117,25 @@ func (rcv *UpdateProfileRequest) MutateNotificationsInAppOnly(n bool) bool {
return rcv._tab.MutateBoolSlot(18, n) return rcv._tab.MutateBoolSlot(18, n)
} }
func (rcv *UpdateProfileRequest) VariantPreferences(j int) []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 {
a := rcv._tab.Vector(o)
return rcv._tab.ByteVector(a + flatbuffers.UOffsetT(j*4))
}
return nil
}
func (rcv *UpdateProfileRequest) VariantPreferencesLength() int {
o := flatbuffers.UOffsetT(rcv._tab.Offset(20))
if o != 0 {
return rcv._tab.VectorLen(o)
}
return 0
}
func UpdateProfileRequestStart(builder *flatbuffers.Builder) { func UpdateProfileRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(8) builder.StartObject(9)
} }
func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0) builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0)
@@ -144,6 +161,12 @@ func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, bl
func UpdateProfileRequestAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) { func UpdateProfileRequestAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
builder.PrependBoolSlot(7, notificationsInAppOnly, true) builder.PrependBoolSlot(7, notificationsInAppOnly, true)
} }
func UpdateProfileRequestAddVariantPreferences(builder *flatbuffers.Builder, variantPreferences flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(8, flatbuffers.UOffsetT(variantPreferences), 0)
}
func UpdateProfileRequestStartVariantPreferencesVector(builder *flatbuffers.Builder, numElems int) flatbuffers.UOffsetT {
return builder.StartVector(4, numElems, 4)
}
func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+7 -20
View File
@@ -79,16 +79,11 @@ func (x *SubscribeRequest) GetGatewayId() string {
// FlatBuffers-encoded body for that kind. event_id is a correlation id the // FlatBuffers-encoded body for that kind. event_id is a correlation id the
// gateway carries through to the client envelope. // gateway carries through to the client envelope.
type Event struct { type Event struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"`
Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"`
// language routes an out-of-app push to a specific per-language bot: for a game
// event it carries the game's language ("en"/"ru") so the notification comes from the game's
// bot rather than the recipient's last-login bot. Empty falls back to the recipient's service
// language at the gateway.
Language string `protobuf:"bytes,5,opt,name=language,proto3" json:"language,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -151,13 +146,6 @@ func (x *Event) GetEventId() string {
return "" return ""
} }
func (x *Event) GetLanguage() string {
if x != nil {
return x.Language
}
return ""
}
var File_push_v1_push_proto protoreflect.FileDescriptor var File_push_v1_push_proto protoreflect.FileDescriptor
const file_push_v1_push_proto_rawDesc = "" + const file_push_v1_push_proto_rawDesc = "" +
@@ -165,13 +153,12 @@ const file_push_v1_push_proto_rawDesc = "" +
"\x12push/v1/push.proto\x12\x10scrabble.push.v1\"1\n" + "\x12push/v1/push.proto\x12\x10scrabble.push.v1\"1\n" +
"\x10SubscribeRequest\x12\x1d\n" + "\x10SubscribeRequest\x12\x1d\n" +
"\n" + "\n" +
"gateway_id\x18\x01 \x01(\tR\tgatewayId\"\x85\x01\n" + "gateway_id\x18\x01 \x01(\tR\tgatewayId\"i\n" +
"\x05Event\x12\x17\n" + "\x05Event\x12\x17\n" +
"\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" +
"\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x19\n" + "\apayload\x18\x03 \x01(\fR\apayload\x12\x19\n" +
"\bevent_id\x18\x04 \x01(\tR\aeventId\x12\x1a\n" + "\bevent_id\x18\x04 \x01(\tR\aeventId2R\n" +
"\blanguage\x18\x05 \x01(\tR\blanguage2R\n" +
"\x04Push\x12J\n" + "\x04Push\x12J\n" +
"\tSubscribe\x12\".scrabble.push.v1.SubscribeRequest\x1a\x17.scrabble.push.v1.Event0\x01B#Z!scrabble/pkg/proto/push/v1;pushv1b\x06proto3" "\tSubscribe\x12\".scrabble.push.v1.SubscribeRequest\x1a\x17.scrabble.push.v1.Event0\x01B#Z!scrabble/pkg/proto/push/v1;pushv1b\x06proto3"
-5
View File
@@ -33,9 +33,4 @@ message Event {
string kind = 2; string kind = 2;
bytes payload = 3; bytes payload = 3;
string event_id = 4; string event_id = 4;
// language routes an out-of-app push to a specific per-language bot: for a game
// event it carries the game's language ("en"/"ru") so the notification comes from the game's
// bot rather than the recipient's last-login bot. Empty falls back to the recipient's service
// language at the gateway.
string language = 5;
} }
+17 -63
View File
@@ -79,22 +79,15 @@ func (x *ValidateInitDataRequest) GetInitData() string {
// ValidateInitDataResponse is the validated identity. external_id is the Telegram // ValidateInitDataResponse is the validated identity. external_id is the Telegram
// user id used as the identities external_id; language_code seeds a new account's // user id used as the identities external_id; language_code seeds a new account's
// preferred (interface) language. service_language (en/ru) is the language tag of // preferred (interface) language.
// the bot that validated the launch data; it is persisted per account and routes
// the user's out-of-app push back through the right bot (it is NOT the game's
// language). supported_languages is that bot's set of offered game languages
// (subset of {en, ru}, at least one — a singleton for a single-language bot); the
// UI gates the New Game variant choice by it.
type ValidateInitDataResponse struct { type ValidateInitDataResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"`
Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"`
FirstName string `protobuf:"bytes,3,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"` FirstName string `protobuf:"bytes,3,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"`
LanguageCode string `protobuf:"bytes,4,opt,name=language_code,json=languageCode,proto3" json:"language_code,omitempty"` LanguageCode string `protobuf:"bytes,4,opt,name=language_code,json=languageCode,proto3" json:"language_code,omitempty"`
ServiceLanguage string `protobuf:"bytes,5,opt,name=service_language,json=serviceLanguage,proto3" json:"service_language,omitempty"` unknownFields protoimpl.UnknownFields
SupportedLanguages []string `protobuf:"bytes,6,rep,name=supported_languages,json=supportedLanguages,proto3" json:"supported_languages,omitempty"` sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *ValidateInitDataResponse) Reset() { func (x *ValidateInitDataResponse) Reset() {
@@ -155,20 +148,6 @@ func (x *ValidateInitDataResponse) GetLanguageCode() string {
return "" return ""
} }
func (x *ValidateInitDataResponse) GetServiceLanguage() string {
if x != nil {
return x.ServiceLanguage
}
return ""
}
func (x *ValidateInitDataResponse) GetSupportedLanguages() []string {
if x != nil {
return x.SupportedLanguages
}
return nil
}
// ValidateLoginWidgetRequest carries the Login Widget result serialized as a URL // ValidateLoginWidgetRequest carries the Login Widget result serialized as a URL
// query string (the widget fields plus the hash, e.g. "auth_date=...&id=...&hash=..."). // query string (the widget fields plus the hash, e.g. "auth_date=...&id=...&hash=...").
type ValidateLoginWidgetRequest struct { type ValidateLoginWidgetRequest struct {
@@ -280,9 +259,8 @@ func (x *ValidateLoginWidgetResponse) GetFirstName() string {
// NotifyRequest addresses a push event to one recipient. kind is the backend push // NotifyRequest addresses a push event to one recipient. kind is the backend push
// catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers // catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers
// scrabblefb.* body for that kind; language (en/ru) is the recipient's service // scrabblefb.* body for that kind; language (en/ru) is the recipient's interface
// language (from their last ValidateInitData) — it both selects the delivering bot // language, used to render the message template (the single bot needs no routing).
// and the message template.
type NotifyRequest struct { type NotifyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"`
@@ -397,14 +375,11 @@ func (x *NotifyResponse) GetDelivered() bool {
return false return false
} }
// SendToUserRequest is an admin text message to one user by external_id. language // SendToUserRequest is an admin text message to one user by external_id.
// (en/ru) selects which bot delivers it — an operator choice in the admin console,
// unrelated to the user's service language.
type SendToUserRequest struct { type SendToUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"`
Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"`
Language string `protobuf:"bytes,3,opt,name=language,proto3" json:"language,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -453,20 +428,10 @@ func (x *SendToUserRequest) GetText() string {
return "" return ""
} }
func (x *SendToUserRequest) GetLanguage() string { // SendToGameChannelRequest is an admin text message to the bot's game channel.
if x != nil {
return x.Language
}
return ""
}
// SendToGameChannelRequest is an admin text message to a game channel. language
// (en/ru) selects which bot's configured channel receives it — an operator choice
// in the admin console.
type SendToGameChannelRequest struct { type SendToGameChannelRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"`
Language string `protobuf:"bytes,2,opt,name=language,proto3" json:"language,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@@ -508,13 +473,6 @@ func (x *SendToGameChannelRequest) GetText() string {
return "" return ""
} }
func (x *SendToGameChannelRequest) GetLanguage() string {
if x != nil {
return x.Language
}
return ""
}
// SendResponse reports whether the message was sent. // SendResponse reports whether the message was sent.
type SendResponse struct { type SendResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@@ -566,16 +524,14 @@ const file_telegram_v1_telegram_proto_rawDesc = "" +
"\n" + "\n" +
"\x1atelegram/v1/telegram.proto\x12\x14scrabble.telegram.v1\"6\n" + "\x1atelegram/v1/telegram.proto\x12\x14scrabble.telegram.v1\"6\n" +
"\x17ValidateInitDataRequest\x12\x1b\n" + "\x17ValidateInitDataRequest\x12\x1b\n" +
"\tinit_data\x18\x01 \x01(\tR\binitData\"\xf7\x01\n" + "\tinit_data\x18\x01 \x01(\tR\binitData\"\x9b\x01\n" +
"\x18ValidateInitDataResponse\x12\x1f\n" + "\x18ValidateInitDataResponse\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" + "\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x1a\n" + "externalId\x12\x1a\n" +
"\busername\x18\x02 \x01(\tR\busername\x12\x1d\n" + "\busername\x18\x02 \x01(\tR\busername\x12\x1d\n" +
"\n" + "\n" +
"first_name\x18\x03 \x01(\tR\tfirstName\x12#\n" + "first_name\x18\x03 \x01(\tR\tfirstName\x12#\n" +
"\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\x12)\n" + "\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\"0\n" +
"\x10service_language\x18\x05 \x01(\tR\x0fserviceLanguage\x12/\n" +
"\x13supported_languages\x18\x06 \x03(\tR\x12supportedLanguages\"0\n" +
"\x1aValidateLoginWidgetRequest\x12\x12\n" + "\x1aValidateLoginWidgetRequest\x12\x12\n" +
"\x04data\x18\x01 \x01(\tR\x04data\"y\n" + "\x04data\x18\x01 \x01(\tR\x04data\"y\n" +
"\x1bValidateLoginWidgetResponse\x12\x1f\n" + "\x1bValidateLoginWidgetResponse\x12\x1f\n" +
@@ -591,15 +547,13 @@ const file_telegram_v1_telegram_proto_rawDesc = "" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x1a\n" + "\apayload\x18\x03 \x01(\fR\apayload\x12\x1a\n" +
"\blanguage\x18\x04 \x01(\tR\blanguage\".\n" + "\blanguage\x18\x04 \x01(\tR\blanguage\".\n" +
"\x0eNotifyResponse\x12\x1c\n" + "\x0eNotifyResponse\x12\x1c\n" +
"\tdelivered\x18\x01 \x01(\bR\tdelivered\"d\n" + "\tdelivered\x18\x01 \x01(\bR\tdelivered\"H\n" +
"\x11SendToUserRequest\x12\x1f\n" + "\x11SendToUserRequest\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" + "\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x12\n" + "externalId\x12\x12\n" +
"\x04text\x18\x02 \x01(\tR\x04text\x12\x1a\n" + "\x04text\x18\x02 \x01(\tR\x04text\".\n" +
"\blanguage\x18\x03 \x01(\tR\blanguage\"J\n" +
"\x18SendToGameChannelRequest\x12\x12\n" + "\x18SendToGameChannelRequest\x12\x12\n" +
"\x04text\x18\x01 \x01(\tR\x04text\x12\x1a\n" + "\x04text\x18\x01 \x01(\tR\x04text\",\n" +
"\blanguage\x18\x02 \x01(\tR\blanguage\",\n" +
"\fSendResponse\x12\x1c\n" + "\fSendResponse\x12\x1c\n" +
"\tdelivered\x18\x01 \x01(\bR\tdelivered2\x92\x04\n" + "\tdelivered\x18\x01 \x01(\bR\tdelivered2\x92\x04\n" +
"\bTelegram\x12q\n" + "\bTelegram\x12q\n" +
+9 -25
View File
@@ -31,13 +31,11 @@ service Telegram {
// localized message with a Mini App deep-link button from the FlatBuffers // localized message with a Mini App deep-link button from the FlatBuffers
// payload; unrenderable kinds are skipped (delivered=false). // payload; unrenderable kinds are skipped (delivered=false).
rpc Notify(NotifyRequest) returns (NotifyResponse); rpc Notify(NotifyRequest) returns (NotifyResponse);
// SendToUser sends an arbitrary text message to one user through the bot the // SendToUser sends an arbitrary text message to one user (admin use). delivered is
// request selects by language (admin use). delivered is false // false when the user has not started the bot.
// when the user has not started that bot.
rpc SendToUser(SendToUserRequest) returns (SendResponse); rpc SendToUser(SendToUserRequest) returns (SendResponse);
// SendToGameChannel posts an arbitrary text message to the game channel of the // SendToGameChannel posts an arbitrary text message to the bot's game channel
// bot the request selects by language (admin use); the channel // (admin use); the channel id lives only in the connector configuration.
// ids live only in the connector configuration.
rpc SendToGameChannel(SendToGameChannelRequest) returns (SendResponse); rpc SendToGameChannel(SendToGameChannelRequest) returns (SendResponse);
} }
@@ -48,19 +46,12 @@ message ValidateInitDataRequest {
// ValidateInitDataResponse is the validated identity. external_id is the Telegram // ValidateInitDataResponse is the validated identity. external_id is the Telegram
// user id used as the identities external_id; language_code seeds a new account's // user id used as the identities external_id; language_code seeds a new account's
// preferred (interface) language. service_language (en/ru) is the language tag of // preferred (interface) language.
// the bot that validated the launch data; it is persisted per account and routes
// the user's out-of-app push back through the right bot (it is NOT the game's
// language). supported_languages is that bot's set of offered game languages
// (subset of {en, ru}, at least one — a singleton for a single-language bot); the
// UI gates the New Game variant choice by it.
message ValidateInitDataResponse { message ValidateInitDataResponse {
string external_id = 1; string external_id = 1;
string username = 2; string username = 2;
string first_name = 3; string first_name = 3;
string language_code = 4; string language_code = 4;
string service_language = 5;
repeated string supported_languages = 6;
} }
// ValidateLoginWidgetRequest carries the Login Widget result serialized as a URL // ValidateLoginWidgetRequest carries the Login Widget result serialized as a URL
@@ -80,9 +71,8 @@ message ValidateLoginWidgetResponse {
// NotifyRequest addresses a push event to one recipient. kind is the backend push // NotifyRequest addresses a push event to one recipient. kind is the backend push
// catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers // catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers
// scrabblefb.* body for that kind; language (en/ru) is the recipient's service // scrabblefb.* body for that kind; language (en/ru) is the recipient's interface
// language (from their last ValidateInitData) — it both selects the delivering bot // language, used to render the message template (the single bot needs no routing).
// and the message template.
message NotifyRequest { message NotifyRequest {
string external_id = 1; string external_id = 1;
string kind = 2; string kind = 2;
@@ -96,21 +86,15 @@ message NotifyResponse {
bool delivered = 1; bool delivered = 1;
} }
// SendToUserRequest is an admin text message to one user by external_id. language // SendToUserRequest is an admin text message to one user by external_id.
// (en/ru) selects which bot delivers it — an operator choice in the admin console,
// unrelated to the user's service language.
message SendToUserRequest { message SendToUserRequest {
string external_id = 1; string external_id = 1;
string text = 2; string text = 2;
string language = 3;
} }
// SendToGameChannelRequest is an admin text message to a game channel. language // SendToGameChannelRequest is an admin text message to the bot's game channel.
// (en/ru) selects which bot's configured channel receives it — an operator choice
// in the admin console.
message SendToGameChannelRequest { message SendToGameChannelRequest {
string text = 1; string text = 1;
string language = 2;
} }
// SendResponse reports whether the message was sent. // SendResponse reports whether the message was sent.
+8 -12
View File
@@ -58,13 +58,11 @@ type TelegramClient interface {
// localized message with a Mini App deep-link button from the FlatBuffers // localized message with a Mini App deep-link button from the FlatBuffers
// payload; unrenderable kinds are skipped (delivered=false). // payload; unrenderable kinds are skipped (delivered=false).
Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error) Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error)
// SendToUser sends an arbitrary text message to one user through the bot the // SendToUser sends an arbitrary text message to one user (admin use). delivered is
// request selects by language (admin use). delivered is false // false when the user has not started the bot.
// when the user has not started that bot.
SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error) SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error)
// SendToGameChannel posts an arbitrary text message to the game channel of the // SendToGameChannel posts an arbitrary text message to the bot's game channel
// bot the request selects by language (admin use); the channel // (admin use); the channel id lives only in the connector configuration.
// ids live only in the connector configuration.
SendToGameChannel(ctx context.Context, in *SendToGameChannelRequest, opts ...grpc.CallOption) (*SendResponse, error) SendToGameChannel(ctx context.Context, in *SendToGameChannelRequest, opts ...grpc.CallOption) (*SendResponse, error)
} }
@@ -147,13 +145,11 @@ type TelegramServer interface {
// localized message with a Mini App deep-link button from the FlatBuffers // localized message with a Mini App deep-link button from the FlatBuffers
// payload; unrenderable kinds are skipped (delivered=false). // payload; unrenderable kinds are skipped (delivered=false).
Notify(context.Context, *NotifyRequest) (*NotifyResponse, error) Notify(context.Context, *NotifyRequest) (*NotifyResponse, error)
// SendToUser sends an arbitrary text message to one user through the bot the // SendToUser sends an arbitrary text message to one user (admin use). delivered is
// request selects by language (admin use). delivered is false // false when the user has not started the bot.
// when the user has not started that bot.
SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error) SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error)
// SendToGameChannel posts an arbitrary text message to the game channel of the // SendToGameChannel posts an arbitrary text message to the bot's game channel
// bot the request selects by language (admin use); the channel // (admin use); the channel id lives only in the connector configuration.
// ids live only in the connector configuration.
SendToGameChannel(context.Context, *SendToGameChannelRequest) (*SendResponse, error) SendToGameChannel(context.Context, *SendToGameChannelRequest) (*SendResponse, error)
mustEmbedUnimplementedTelegramServer() mustEmbedUnimplementedTelegramServer()
} }
+21 -25
View File
@@ -1,22 +1,20 @@
# scrabble/platform/telegram — Telegram connector # scrabble/platform/telegram — Telegram connector
The Telegram platform side-service. It is the **only** component that holds the bot The Telegram platform side-service. It is the **only** component that holds the bot
tokens: it runs a Bot API long-poll loop **per service language** (Mini App launch + token: it runs a Bot API long-poll loop (Mini App launch + deep-links) and serves
deep-links) and serves the connector gRPC API that the gateway and backend call over the connector gRPC API that the gateway and backend call over the trusted internal
the trusted internal network. See network. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
[`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
## Service languages (dual bots) ## Single bot
The connector hosts **one bot per service language** (`en`, `ru`) — each its own The connector hosts **one unified bot** — one token plus one optional game channel,
token + game channel, configured by the `*_EN` / `*_RU` env vars; at least one is configured by `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GAME_CHANNEL_ID`. `ValidateInitData`
required. The **same Telegram user id spans both bots**. `ValidateInitData` tries validates `initData` against that single token (it does not validate ⇒ invalid) and
each bot's token in turn (none validates ⇒ invalid) and reports which bot validated: returns only the Telegram user identity — there is no per-bot service language and no
its **`service_language`** (persisted by the backend to route the user's later push) supported-languages set. Every message the bot sends is rendered in the **recipient's
and its **`supported_languages`** set (which the UI gates the New Game variant choice interface language**: the user-facing `Notify` renders in the request's `language` (the
by — `en` → English, `ru` → Russian + Эрудит). The user-facing `Notify` routes by the recipient's interface language); the admin `SendToUser` / `SendToGameChannel` render in
recipient's persisted service language; the admin `SendToUser` / `SendToGameChannel` an **operator-chosen** `language`. No call routes between bots — there is only one.
route by an **operator-chosen** `language` (unrelated to login).
## Responsibilities ## Responsibilities
@@ -26,17 +24,17 @@ route by an **operator-chosen** `language` (unrelated to login).
backend internal API — so the bot token never leaves this process. backend internal API — so the bot token never leaves this process.
- **Out-of-app push.** `Notify` renders a backend push event (your_turn, nudge, - **Out-of-app push.** `Notify` renders a backend push event (your_turn, nudge,
match_found, and the invitation / friend_request notify sub-kinds) into a match_found, and the invitation / friend_request notify sub-kinds) into a
localized message with a Mini App launch button and sends it **through the bot for localized message with a Mini App launch button and sends it through the **single
the request's `language`** (the recipient's service language). The gateway calls it bot**, rendered in the request's `language` (the recipient's interface language).
**only** for a recipient with no live in-app stream and the The gateway calls it **only** for a recipient with no live in-app stream and the
`notifications_in_app_only` flag off, so the platform push never duplicates in-app `notifications_in_app_only` flag off, so the platform push never duplicates in-app
delivery. delivery.
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App - **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App
launch button; a deep-link payload routes the launch to a game / invitation / launch button; a deep-link payload routes the launch to a game / invitation /
friend code. friend code.
- **Admin messaging.** `SendToUser` and `SendToGameChannel` send - **Admin messaging.** `SendToUser` and `SendToGameChannel` send
arbitrary text to one user or a game channel through the bot the request selects by arbitrary text to one user or a game channel through the single bot, rendered in the
`language` (an operator choice in the admin console). `language` the operator chooses in the admin console.
The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a
recipient by the identity `external_id` (as in the backend `identities` table), so a recipient by the identity `external_id` (as in the backend `identities` table), so a
@@ -71,11 +69,9 @@ The bot turns a `/start <payload>` or a notification target into a launch-button
| Env var | Default | Meaning | | Env var | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `TELEGRAM_BOT_TOKEN_EN` | — | English bot's API token + initData HMAC secret | | `TELEGRAM_BOT_TOKEN` | — (required) | The bot's API token + initData HMAC secret |
| `TELEGRAM_BOT_TOKEN_RU` | — | Russian bot's API token + initData HMAC secret (≥ 1 of EN/RU required) | | `TELEGRAM_GAME_CHANNEL_ID` | — | The bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_GAME_CHANNEL_ID_EN` | — | English bot's game channel chat id for `SendToGameChannel` | | `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) |
| `TELEGRAM_GAME_CHANNEL_ID_RU` | — | Russian bot's game channel chat id |
| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin, shared by all bots (BotFather-registered) |
| `TELEGRAM_GRPC_ADDR` | `:9091` | connector gRPC listen address | | `TELEGRAM_GRPC_ADDR` | `:9091` | connector gRPC listen address |
| `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Bot API host override (mock / self-hosted) | | `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Bot API host override (mock / self-hosted) |
| `TELEGRAM_TEST_ENV` | `false` | route to the Bot API **test environment** (`/bot<token>/test/METHOD`) | | `TELEGRAM_TEST_ENV` | `false` | route to the Bot API **test environment** (`/bot<token>/test/METHOD`) |
@@ -93,7 +89,7 @@ builds `<host>/bot<token>/<method>`).
```sh ```sh
go build ./platform/telegram/... go build ./platform/telegram/...
go test ./platform/telegram/... # unit tests use an httptest fake Bot API go test ./platform/telegram/... # unit tests use an httptest fake Bot API
go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN_EN or _RU go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN
``` ```
## Deploy ## Deploy
+18 -35
View File
@@ -67,36 +67,22 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Warn("telemetry: start runtime metrics", zap.Error(err)) logger.Warn("telemetry: start runtime metrics", zap.Error(err))
} }
// One bot per configured service language; ValidateInitData tries each token and // The single bot validates launch data and delivers push/admin messages.
// the push/admin methods route by language. b, err := bot.New(bot.Config{
var bots []*bot.Bot Token: cfg.Token,
var runtimes []connector.BotRuntime APIBaseURL: cfg.APIBaseURL,
var langs []string TestEnv: cfg.TestEnv,
for _, lang := range config.Languages { MiniAppURL: cfg.MiniAppURL,
bc, ok := cfg.Bots[lang] }, logger)
if !ok { if err != nil {
continue return err
}
b, err := bot.New(bot.Config{
Token: bc.Token,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
}, logger)
if err != nil {
return err
}
bots = append(bots, b)
runtimes = append(runtimes, connector.BotRuntime{
Language: lang,
Sender: b,
ChannelID: bc.GameChannelID,
InitValidator: initdata.NewHMACValidator(bc.Token),
WidgetValidator: loginwidget.NewHMACValidator(bc.Token),
})
langs = append(langs, lang)
} }
srv := connector.NewServer(runtimes, logger) srv := connector.NewServer(connector.BotRuntime{
Sender: b,
ChannelID: cfg.GameChannelID,
InitValidator: initdata.NewHMACValidator(cfg.Token),
WidgetValidator: loginwidget.NewHMACValidator(cfg.Token),
}, logger)
grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler())) grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
telegramv1.RegisterTelegramServer(grpcServer, srv) telegramv1.RegisterTelegramServer(grpcServer, srv)
@@ -106,11 +92,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err return err
} }
// The long-poll loops and the gRPC server run together; cancelling the context // The long-poll loop and the gRPC server run together; cancelling the context
// stops every bot loop and gracefully drains the gRPC server. // stops the bot loop and gracefully drains the gRPC server.
for _, b := range bots { go b.Run(ctx)
go b.Run(ctx)
}
go func() { go func() {
<-ctx.Done() <-ctx.Done()
grpcServer.GracefulStop() grpcServer.GracefulStop()
@@ -119,7 +103,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("telegram connector starting", logger.Info("telegram connector starting",
zap.String("grpc_addr", cfg.GRPCAddr), zap.String("grpc_addr", cfg.GRPCAddr),
zap.String("miniapp_url", cfg.MiniAppURL), zap.String("miniapp_url", cfg.MiniAppURL),
zap.Strings("languages", langs),
zap.Bool("test_env", cfg.TestEnv)) zap.Bool("test_env", cfg.TestEnv))
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
return err return err
+18 -41
View File
@@ -10,40 +10,26 @@ import (
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
// Languages is the set of service languages a bot may be tagged with. Each is a
// separate bot (own token + game channel) serving that audience; the same Telegram
// user id spans them all (ARCHITECTURE.md §12).
var Languages = []string{"en", "ru"}
// BotConfig is one language-tagged bot's settings.
type BotConfig struct {
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN_<LANG>). It both
// authenticates the Bot API client and is the HMAC secret for Mini App initData
// validation against this bot.
Token string
// GameChannelID is the chat id of this bot's game channel for SendToGameChannel
// (TELEGRAM_GAME_CHANNEL_ID_<LANG>, optional; 0 disables channel posts).
GameChannelID int64
}
// Config is the Telegram connector's runtime configuration, read from the // Config is the Telegram connector's runtime configuration, read from the
// environment. The bot tokens live only in this process (ARCHITECTURE.md §12). // environment. The bot token lives only in this process (ARCHITECTURE.md §12).
type Config struct { type Config struct {
// Bots maps a service language (one of Languages) to that language's bot // Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). It both
// settings. A language is present when its TELEGRAM_BOT_TOKEN_<LANG> is set; at // authenticates the Bot API client and is the HMAC secret for Mini App initData
// least one bot is required. // and Login Widget validation.
Bots map[string]BotConfig Token string
// GameChannelID is the chat id of the bot's game channel for SendToGameChannel
// (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
GameChannelID int64
// GRPCAddr is the listen address of the connector gRPC server that gateway and // GRPCAddr is the listen address of the connector gRPC server that gateway and
// backend call (TELEGRAM_GRPC_ADDR, default :9091). // backend call (TELEGRAM_GRPC_ADDR, default :9091).
GRPCAddr string GRPCAddr string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it
// is the base of every launch button, to which a deep-link adds a startapp // is the base of every launch button, to which a deep-link adds a startapp
// query parameter (TELEGRAM_MINIAPP_URL, required). It is shared by all bots // query parameter (TELEGRAM_MINIAPP_URL, required).
// (one gateway origin); initData is signed per bot token.
MiniAppURL string MiniAppURL string
// APIBaseURL overrides the Bot API host (TELEGRAM_API_BASE_URL, optional; // APIBaseURL overrides the Bot API host (TELEGRAM_API_BASE_URL, optional;
// default https://api.telegram.org) — used for a local mock or a self-hosted // default https://api.telegram.org) — used for a local mock or a self-hosted
// Bot API server. Shared by all bots. // Bot API server.
APIBaseURL string APIBaseURL string
// TestEnv routes the Bot API client to Telegram's test environment // TestEnv routes the Bot API client to Telegram's test environment
// (.../bot<token>/test/METHOD) (TELEGRAM_TEST_ENV=true, default false). // (.../bot<token>/test/METHOD) (TELEGRAM_TEST_ENV=true, default false).
@@ -58,36 +44,27 @@ type Config struct {
// and validating the required fields. // and validating the required fields.
func Load() (Config, error) { func Load() (Config, error) {
cfg := Config{ cfg := Config{
Bots: map[string]BotConfig{}, Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
GRPCAddr: envOr("TELEGRAM_GRPC_ADDR", ":9091"), GRPCAddr: envOr("TELEGRAM_GRPC_ADDR", ":9091"),
MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"), MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"),
APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"), APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"),
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true", TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
} }
for _, lang := range Languages { if v := strings.TrimSpace(os.Getenv("TELEGRAM_GAME_CHANNEL_ID")); v != "" {
suffix := strings.ToUpper(lang) id, err := strconv.ParseInt(v, 10, 64)
token := os.Getenv("TELEGRAM_BOT_TOKEN_" + suffix) if err != nil {
if token == "" { return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID %q: %w", v, err)
continue
} }
bot := BotConfig{Token: token} cfg.GameChannelID = id
if v := strings.TrimSpace(os.Getenv("TELEGRAM_GAME_CHANNEL_ID_" + suffix)); v != "" {
id, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID_%s %q: %w", suffix, v, err)
}
bot.GameChannelID = id
}
cfg.Bots[lang] = bot
} }
tel := pkgtel.DefaultConfig("scrabble-telegram") tel := pkgtel.DefaultConfig("scrabble-telegram")
tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName) tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter) tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter) tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
cfg.Telemetry = tel cfg.Telemetry = tel
if len(cfg.Bots) == 0 { if cfg.Token == "" {
return Config{}, fmt.Errorf("config: at least one TELEGRAM_BOT_TOKEN_<LANG> (LANG in %v) is required", Languages) return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
} }
if cfg.MiniAppURL == "" { if cfg.MiniAppURL == "" {
return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required") return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required")
@@ -6,33 +6,38 @@ import (
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
// setRequired sets the required connector variables (one bot + the Mini App URL) // setRequired sets the required connector variables (the bot token + the Mini App
// so Load reaches the telemetry checks. // URL) so Load reaches the telemetry checks.
func setRequired(t *testing.T) { func setRequired(t *testing.T) {
t.Helper() t.Helper()
t.Setenv("TELEGRAM_BOT_TOKEN_EN", "test-token") t.Setenv("TELEGRAM_BOT_TOKEN", "test-token")
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app") t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
} }
// TestLoadBots verifies the per-language bot parsing: a present token enables a // TestLoadBot verifies the bot parsing: the token is read and the game channel id is
// language, its channel id is optional, and the result is keyed by language. // parsed when present.
func TestLoadBots(t *testing.T) { func TestLoadBot(t *testing.T) {
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app") t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
t.Setenv("TELEGRAM_BOT_TOKEN_EN", "en-token") t.Setenv("TELEGRAM_BOT_TOKEN", "bot-token")
t.Setenv("TELEGRAM_GAME_CHANNEL_ID_EN", "-100111") t.Setenv("TELEGRAM_GAME_CHANNEL_ID", "-100111")
t.Setenv("TELEGRAM_BOT_TOKEN_RU", "ru-token")
c, err := Load() c, err := Load()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("Load: %v", err)
} }
if len(c.Bots) != 2 { if c.Token != "bot-token" || c.GameChannelID != -100111 {
t.Fatalf("Bots = %d, want 2", len(c.Bots)) t.Errorf("config = token %q / channel %d, want bot-token / -100111", c.Token, c.GameChannelID)
} }
if c.Bots["en"].Token != "en-token" || c.Bots["en"].GameChannelID != -100111 { }
t.Errorf("en bot = %+v", c.Bots["en"])
// TestLoadOptionalChannel verifies the game channel id defaults to 0 when unset.
func TestLoadOptionalChannel(t *testing.T) {
setRequired(t)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
} }
if c.Bots["ru"].Token != "ru-token" || c.Bots["ru"].GameChannelID != 0 { if c.GameChannelID != 0 {
t.Errorf("ru bot = %+v, want token ru-token / channel 0", c.Bots["ru"]) t.Errorf("GameChannelID = %d, want 0", c.GameChannelID)
} }
} }
+49 -103
View File
@@ -4,15 +4,14 @@
// methods address a recipient by the identity external_id, so a future platform // methods address a recipient by the identity external_id, so a future platform
// connector can implement the same service. // connector can implement the same service.
// //
// The connector hosts one bot per configured service language (en/ru); the same // The connector hosts a single bot. ValidateInitData/ValidateLoginWidget verify
// Telegram user id spans them all. ValidateInitData tries each bot's token in turn // launch data against its token; Notify renders the message in the recipient's
// and reports which bot validated (its service language); the push and admin // interface language (the single bot needs no routing); the admin methods deliver
// methods route to the bot the request selects by language. // through that bot.
package connector package connector
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"strconv" "strconv"
@@ -34,103 +33,70 @@ type Sender interface {
SendText(ctx context.Context, chatID int64, text string) error SendText(ctx context.Context, chatID int64, text string) error
} }
// BotRuntime is one configured language-tagged bot: its sender, game channel id, // BotRuntime is the configured bot: its sender, game channel id, and the two HMAC
// and the two HMAC validators bound to its token. // validators bound to its token.
type BotRuntime struct { type BotRuntime struct {
// Language is the bot's service language (en/ru). // Sender delivers messages through the bot.
Language string
// Sender delivers messages through this bot.
Sender Sender Sender Sender
// ChannelID is this bot's game channel (0 disables channel posts). // ChannelID is the bot's game channel (0 disables channel posts).
ChannelID int64 ChannelID int64
// InitValidator verifies Mini App initData signed by this bot's token. // InitValidator verifies Mini App initData signed by the bot's token.
InitValidator initdata.Validator InitValidator initdata.Validator
// WidgetValidator verifies Login Widget data signed by this bot's token. // WidgetValidator verifies Login Widget data signed by the bot's token.
WidgetValidator loginwidget.Validator WidgetValidator loginwidget.Validator
} }
// Server implements telegramv1.TelegramServer over one or more language-tagged bots. // Server implements telegramv1.TelegramServer over the single configured bot.
type Server struct { type Server struct {
telegramv1.UnimplementedTelegramServer telegramv1.UnimplementedTelegramServer
bots map[string]BotRuntime // keyed by service language bot BotRuntime
order []string // stable iteration order for validation log *zap.Logger
log *zap.Logger
} }
// NewServer builds the gRPC service from the configured bots (at least one). // NewServer builds the gRPC service from the configured bot.
func NewServer(bots []BotRuntime, log *zap.Logger) *Server { func NewServer(bot BotRuntime, log *zap.Logger) *Server {
if log == nil { if log == nil {
log = zap.NewNop() log = zap.NewNop()
} }
m := make(map[string]BotRuntime, len(bots)) return &Server{bot: bot, log: log}
order := make([]string, 0, len(bots))
for _, b := range bots {
m[b.Language] = b
order = append(order, b.Language)
}
return &Server{bots: m, order: order, log: log}
} }
// ValidateInitData verifies Mini App launch data against each bot's token in turn // ValidateInitData verifies Mini App launch data against the bot's token and returns
// and returns the user identity plus the validating bot's service language (which // the user identity.
// routes the user's later push) and its set of offered game languages.
func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) { func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) {
var lastErr error u, err := s.bot.InitValidator.Validate(req.GetInitData())
for _, lang := range s.order { if err != nil {
u, err := s.bots[lang].InitValidator.Validate(req.GetInitData()) return nil, status.Error(codes.InvalidArgument, err.Error())
if err != nil {
lastErr = err
continue
}
return &telegramv1.ValidateInitDataResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
ServiceLanguage: lang,
SupportedLanguages: []string{lang},
}, nil
} }
if lastErr == nil { return &telegramv1.ValidateInitDataResponse{
lastErr = errors.New("no bot configured") ExternalId: u.ExternalID,
} Username: u.Username,
return nil, status.Error(codes.InvalidArgument, lastErr.Error()) FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
}, nil
} }
// ValidateLoginWidget verifies Login Widget authorization data against each bot's // ValidateLoginWidget verifies Login Widget authorization data against the bot's
// token in turn and returns the user identity, for attaching a Telegram identity to // token and returns the user identity, for attaching a Telegram identity to an
// an existing account. // existing account.
func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) { func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) {
var lastErr error u, err := s.bot.WidgetValidator.Validate(req.GetData())
for _, lang := range s.order { if err != nil {
u, err := s.bots[lang].WidgetValidator.Validate(req.GetData()) return nil, status.Error(codes.InvalidArgument, err.Error())
if err != nil {
lastErr = err
continue
}
return &telegramv1.ValidateLoginWidgetResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
}, nil
} }
if lastErr == nil { return &telegramv1.ValidateLoginWidgetResponse{
lastErr = errors.New("no bot configured") ExternalId: u.ExternalID,
} Username: u.Username,
return nil, status.Error(codes.InvalidArgument, lastErr.Error()) FirstName: u.FirstName,
}, nil
} }
// Notify renders and delivers an out-of-app notification through the bot selected by // Notify renders and delivers an out-of-app notification through the bot. The message
// the recipient's service language. It reports delivered=false (without an error) // is rendered in the recipient's interface language (req language). It reports
// when no bot serves that language, the kind is not pushed out-of-app, or the bot // delivered=false (without an error) when the kind is not pushed out-of-app or the
// could not deliver (e.g. the user never started it), so the gateway treats a // bot could not deliver (e.g. the user never started it), so the gateway treats a
// fallback miss as best-effort. // fallback miss as best-effort.
func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*telegramv1.NotifyResponse, error) { func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*telegramv1.NotifyResponse, error) {
bot, ok := s.bots[req.GetLanguage()]
if !ok {
s.log.Warn("notify: no bot for language", zap.String("language", req.GetLanguage()), zap.String("kind", req.GetKind()))
return &telegramv1.NotifyResponse{Delivered: false}, nil
}
msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage()) msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage())
if !ok { if !ok {
return &telegramv1.NotifyResponse{Delivered: false}, nil return &telegramv1.NotifyResponse{Delivered: false}, nil
@@ -139,58 +105,38 @@ func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*te
if err != nil { if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error()) return nil, status.Error(codes.InvalidArgument, err.Error())
} }
if err := bot.Sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil { if err := s.bot.Sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil {
s.log.Warn("notify delivery failed", zap.String("kind", req.GetKind()), zap.Error(err)) s.log.Warn("notify delivery failed", zap.String("kind", req.GetKind()), zap.Error(err))
return &telegramv1.NotifyResponse{Delivered: false}, nil return &telegramv1.NotifyResponse{Delivered: false}, nil
} }
return &telegramv1.NotifyResponse{Delivered: true}, nil return &telegramv1.NotifyResponse{Delivered: true}, nil
} }
// SendToUser sends an arbitrary admin message to one user through the bot selected // SendToUser sends an arbitrary admin message to one user through the bot.
// by language (an operator choice in the admin console).
func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) { func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) {
bot, err := s.botFor(req.GetLanguage())
if err != nil {
return nil, err
}
chat, err := parseChatID(req.GetExternalId()) chat, err := parseChatID(req.GetExternalId())
if err != nil { if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error()) return nil, status.Error(codes.InvalidArgument, err.Error())
} }
if err := bot.Sender.SendText(ctx, chat, req.GetText()); err != nil { if err := s.bot.Sender.SendText(ctx, chat, req.GetText()); err != nil {
s.log.Warn("send to user failed", zap.Error(err)) s.log.Warn("send to user failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, nil return &telegramv1.SendResponse{Delivered: false}, nil
} }
return &telegramv1.SendResponse{Delivered: true}, nil return &telegramv1.SendResponse{Delivered: true}, nil
} }
// SendToGameChannel posts an arbitrary admin message to the game channel of the bot // SendToGameChannel posts an arbitrary admin message to the bot's game channel.
// selected by language (an operator choice in the admin console).
func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) { func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) {
bot, err := s.botFor(req.GetLanguage()) if s.bot.ChannelID == 0 {
if err != nil { return nil, status.Error(codes.FailedPrecondition, "game channel is not configured")
return nil, err
} }
if bot.ChannelID == 0 { if err := s.bot.Sender.SendText(ctx, s.bot.ChannelID, req.GetText()); err != nil {
return nil, status.Error(codes.FailedPrecondition, fmt.Sprintf("game channel is not configured for language %q", req.GetLanguage()))
}
if err := bot.Sender.SendText(ctx, bot.ChannelID, req.GetText()); err != nil {
s.log.Warn("send to channel failed", zap.Error(err)) s.log.Warn("send to channel failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, nil return &telegramv1.SendResponse{Delivered: false}, nil
} }
return &telegramv1.SendResponse{Delivered: true}, nil return &telegramv1.SendResponse{Delivered: true}, nil
} }
// botFor returns the bot tagged with language, or a FailedPrecondition error when no
// bot serves it (admin broadcasts choose the language explicitly).
func (s *Server) botFor(language string) (BotRuntime, error) {
bot, ok := s.bots[language]
if !ok {
return BotRuntime{}, status.Error(codes.FailedPrecondition, fmt.Sprintf("no bot configured for language %q", language))
}
return bot, nil
}
// parseChatID converts a Telegram identity external_id into a numeric chat id. // parseChatID converts a Telegram identity external_id into a numeric chat id.
func parseChatID(externalID string) (int64, error) { func parseChatID(externalID string) (int64, error) {
id, err := strconv.ParseInt(externalID, 10, 64) id, err := strconv.ParseInt(externalID, 10, 64)
@@ -56,9 +56,9 @@ func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) erro
return f.err return f.err
} }
// botRT assembles one language-tagged bot runtime for a test. // botRT assembles the bot runtime for a test.
func botRT(lang string, sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime { func botRT(sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime {
return BotRuntime{Language: lang, Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv} return BotRuntime{Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv}
} }
func yourTurnPayload(gameID string) []byte { func yourTurnPayload(gameID string) []byte {
@@ -72,7 +72,7 @@ func yourTurnPayload(gameID string) []byte {
func TestValidateInitData(t *testing.T) { func TestValidateInitData(t *testing.T) {
want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "en-GB"} want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "en-GB"}
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{})}, nil) srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{}), nil)
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"}) resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
if err != nil { if err != nil {
t.Fatalf("validate: %v", err) t.Fatalf("validate: %v", err)
@@ -80,36 +80,16 @@ func TestValidateInitData(t *testing.T) {
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "en-GB" { if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "en-GB" {
t.Errorf("resp = %+v, want %+v", resp, want) t.Errorf("resp = %+v, want %+v", resp, want)
} }
if resp.GetServiceLanguage() != "en" || len(resp.GetSupportedLanguages()) != 1 || resp.GetSupportedLanguages()[0] != "en" {
t.Errorf("service_language=%q supported=%v, want en / [en]", resp.GetServiceLanguage(), resp.GetSupportedLanguages())
}
bad := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{})}, nil) bad := NewServer(botRT(&fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}), nil)
if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument { if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err)) t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
} }
} }
// TestValidateInitDataPicksValidatingBot proves the second bot's token validates and
// its service language is reported when the first bot rejects the data.
func TestValidateInitDataPicksValidatingBot(t *testing.T) {
want := initdata.User{ExternalID: "7", Username: "ru_user", FirstName: "Иван", LanguageCode: "ru"}
srv := NewServer([]BotRuntime{
botRT("en", &fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}),
botRT("ru", &fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{}),
}, nil)
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if resp.GetExternalId() != "7" || resp.GetServiceLanguage() != "ru" || resp.GetSupportedLanguages()[0] != "ru" {
t.Errorf("resp = %+v, want external 7 / service ru", resp)
}
}
func TestValidateLoginWidget(t *testing.T) { func TestValidateLoginWidget(t *testing.T) {
want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"} want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"}
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{user: want})}, nil) srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{user: want}), nil)
resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"}) resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"})
if err != nil { if err != nil {
t.Fatalf("validate: %v", err) t.Fatalf("validate: %v", err)
@@ -118,7 +98,7 @@ func TestValidateLoginWidget(t *testing.T) {
t.Errorf("resp = %+v, want %+v", resp, want) t.Errorf("resp = %+v, want %+v", resp, want)
} }
bad := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget})}, nil) bad := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget}), nil)
if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument { if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err)) t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
} }
@@ -127,7 +107,7 @@ func TestValidateLoginWidget(t *testing.T) {
func TestNotifyDelivers(t *testing.T) { func TestNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{} sender := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil) srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en", ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en",
}) })
@@ -145,44 +125,9 @@ func TestNotifyDelivers(t *testing.T) {
} }
} }
// TestNotifyRoutesByLanguage proves the push goes through the bot for the recipient's
// service language, not the other bot.
func TestNotifyRoutesByLanguage(t *testing.T) {
en, ru := &fakeSender{}, &fakeSender{}
srv := NewServer([]BotRuntime{
botRT("en", en, 0, stubValidator{}, stubWidgetValidator{}),
botRT("ru", ru, 0, stubValidator{}, stubWidgetValidator{}),
}, nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload("7c9e6679-7425-40de-944b-e07fc1f90ae7"), Language: "ru",
})
if err != nil || !resp.GetDelivered() {
t.Fatalf("notify: %v delivered=%v", err, resp.GetDelivered())
}
if len(ru.notify) != 1 || len(en.notify) != 0 {
t.Errorf("routing wrong: ru=%d en=%d, want 1/0", len(ru.notify), len(en.notify))
}
}
// TestNotifyUnknownLanguage proves a push for a language with no bot is a best-effort
// miss (delivered=false, no delivery attempt), not an error.
func TestNotifyUnknownLanguage(t *testing.T) {
en := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", en, 0, stubValidator{}, stubWidgetValidator{})}, nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "ru",
})
if err != nil {
t.Fatalf("notify: %v", err)
}
if resp.GetDelivered() || len(en.notify) != 0 {
t.Errorf("delivered=%v en calls=%d, want false / 0", resp.GetDelivered(), len(en.notify))
}
}
func TestNotifySkipsUnrenderedKind(t *testing.T) { func TestNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil) srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "opponent_moved", Language: "en", ExternalId: "12345", Kind: "opponent_moved", Language: "en",
}) })
@@ -198,7 +143,7 @@ func TestNotifySkipsUnrenderedKind(t *testing.T) {
} }
func TestNotifyInvalidExternalID(t *testing.T) { func TestNotifyInvalidExternalID(t *testing.T) {
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil) srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
_, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ _, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en", ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en",
}) })
@@ -209,8 +154,8 @@ func TestNotifyInvalidExternalID(t *testing.T) {
func TestSendToUser(t *testing.T) { func TestSendToUser(t *testing.T) {
sender := &fakeSender{} sender := &fakeSender{}
srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil) srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi", Language: "en"}) resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"})
if err != nil { if err != nil {
t.Fatalf("send to user: %v", err) t.Fatalf("send to user: %v", err)
} }
@@ -219,36 +164,23 @@ func TestSendToUser(t *testing.T) {
} }
} }
// TestSendToUserUnknownLanguage proves the admin broadcast errors when no bot serves
// the operator-chosen language.
func TestSendToUserUnknownLanguage(t *testing.T) {
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil)
_, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi", Language: "ru"})
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
}
}
func TestSendToGameChannel(t *testing.T) { func TestSendToGameChannel(t *testing.T) {
t.Run("unconfigured", func(t *testing.T) { t.Run("unconfigured", func(t *testing.T) {
srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil) srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
_, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x", Language: "en"}) _, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"})
if status.Code(err) != codes.FailedPrecondition { if status.Code(err) != codes.FailedPrecondition {
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err)) t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
} }
}) })
t.Run("configured routes by language", func(t *testing.T) { t.Run("configured", func(t *testing.T) {
en, ru := &fakeSender{}, &fakeSender{} sender := &fakeSender{}
srv := NewServer([]BotRuntime{ srv := NewServer(botRT(sender, 555, stubValidator{}, stubWidgetValidator{}), nil)
botRT("en", en, 111, stubValidator{}, stubWidgetValidator{}), resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"})
botRT("ru", ru, 555, stubValidator{}, stubWidgetValidator{}),
}, nil)
resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news", Language: "ru"})
if err != nil { if err != nil {
t.Fatalf("send to channel: %v", err) t.Fatalf("send to channel: %v", err)
} }
if !resp.GetDelivered() || len(ru.text) != 1 || ru.text[0].chatID != 555 || len(en.text) != 0 { if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 555 {
t.Errorf("send to channel: ru=%+v en=%+v", ru.text, en.text) t.Errorf("send to channel: %+v", sender.text)
} }
}) })
} }
+3 -6
View File
@@ -28,12 +28,9 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
`GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime `GATEWAY_URL` overrides the dev proxy target; `VITE_GATEWAY_URL` sets the runtime
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID` gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK_EN` / domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
`VITE_TELEGRAM_LINK_RU` are the per-bot friend-invite Mini App links (full URL friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
`https://t.me/<bot>/<app>`; the share picks the one matching the bot the player `VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
signed in through, falling back to `VITE_TELEGRAM_LINK`).
`VITE_TELEGRAM_GAME_CHANNEL_NAME_EN` / `VITE_TELEGRAM_GAME_CHANNEL_NAME_RU`
are the per-language "Play in Telegram" links shown on the landing page.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`). `/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
+1 -1
View File
@@ -16,7 +16,7 @@
let prefs: Partial<Prefs> = {}; let prefs: Partial<Prefs> = {};
const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock const about = $derived(aboutContent(i18n.locale, 24)); // 24h = the auto-match move clock
const tgLink = $derived(telegramChannelLink(i18n.locale)); const tgLink = $derived(telegramChannelLink());
const locales: { code: Locale; label: string }[] = [ const locales: { code: Locale; label: string }[] = [
{ code: 'en', label: '🇬🇧 English' }, { code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' }, { code: 'ru', label: '🇷🇺 Русский' },
+2 -3
View File
@@ -5,9 +5,8 @@
import { telegramOpenLink } from '../lib/telegram'; import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to // The single bot's @username, for the deep link.
// the interface locale, so an ru player is sent to the ru bot and an en player to the en one. const username = $derived(botUsername());
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// Split the message around the {bot} token so the bot handle renders as an inline link. // Split the message around the {bot} token so the bot handle renders as an inline link.
const parts = $derived(t('friends.staleInvite').split('{bot}')); const parts = $derived(t('friends.staleInvite').split('{bot}'));
+2 -3
View File
@@ -5,9 +5,8 @@
import { telegramOpenLink } from '../lib/telegram'; import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
// Point at the bot the player signed in through (its service language), falling back to the // The single bot's @username, for the deep link.
// interface locale, so an ru player is sent to the ru bot and an en player to the en one. const username = $derived(botUsername());
const username = $derived(botUsername(app.session?.serviceLanguage || app.locale));
// Greet the arriving player by their own display name. // Greet the arriving player by their own display name.
const name = $derived(app.profile?.displayName || app.session?.displayName || ''); const name = $derived(app.profile?.displayName || app.session?.displayName || '');
// Interpolate the name, then split the rest around the {bot} token so the bot handle renders // Interpolate the name, then split the rest around the {bot} token so the bot handle renders
+29 -1
View File
@@ -95,8 +95,20 @@ banner(obj?:BannerInfo):BannerInfo|null {
return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null; return offset ? (obj || new BannerInfo()).__init(this.bb!.__indirect(this.bb_pos + offset), this.bb!) : null;
} }
variantPreferences(index: number):string
variantPreferences(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
variantPreferences(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 28);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
variantPreferencesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 28);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startProfile(builder:flatbuffers.Builder) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(12); builder.startObject(13);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -147,6 +159,22 @@ static addBanner(builder:flatbuffers.Builder, bannerOffset:flatbuffers.Offset) {
builder.addFieldOffset(11, bannerOffset, 0); builder.addFieldOffset(11, bannerOffset, 0);
} }
static addVariantPreferences(builder:flatbuffers.Builder, variantPreferencesOffset:flatbuffers.Offset) {
builder.addFieldOffset(12, variantPreferencesOffset, 0);
}
static createVariantPreferencesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
+2 -43
View File
@@ -46,27 +46,8 @@ displayName(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;
} }
supportedLanguages(index: number):string
supportedLanguages(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
supportedLanguages(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
supportedLanguagesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 12);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
serviceLanguage():string|null
serviceLanguage(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
serviceLanguage(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 startSession(builder:flatbuffers.Builder) { static startSession(builder:flatbuffers.Builder) {
builder.startObject(6); builder.startObject(4);
} }
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) { static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
@@ -85,39 +66,17 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
builder.addFieldOffset(3, displayNameOffset, 0); builder.addFieldOffset(3, displayNameOffset, 0);
} }
static addSupportedLanguages(builder:flatbuffers.Builder, supportedLanguagesOffset:flatbuffers.Offset) {
builder.addFieldOffset(4, supportedLanguagesOffset, 0);
}
static createSupportedLanguagesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startSupportedLanguagesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static addServiceLanguage(builder:flatbuffers.Builder, serviceLanguageOffset:flatbuffers.Offset) {
builder.addFieldOffset(5, serviceLanguageOffset, 0);
}
static endSession(builder:flatbuffers.Builder):flatbuffers.Offset { static endSession(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset:flatbuffers.Offset, serviceLanguageOffset:flatbuffers.Offset):flatbuffers.Offset { static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
Session.startSession(builder); Session.startSession(builder);
Session.addToken(builder, tokenOffset); Session.addToken(builder, tokenOffset);
Session.addUserId(builder, userIdOffset); Session.addUserId(builder, userIdOffset);
Session.addIsGuest(builder, isGuest); Session.addIsGuest(builder, isGuest);
Session.addDisplayName(builder, displayNameOffset); Session.addDisplayName(builder, displayNameOffset);
Session.addSupportedLanguages(builder, supportedLanguagesOffset);
Session.addServiceLanguage(builder, serviceLanguageOffset);
return Session.endSession(builder); return Session.endSession(builder);
} }
} }
@@ -70,8 +70,20 @@ notificationsInAppOnly():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true; return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
} }
variantPreferences(index: number):string
variantPreferences(index: number,optionalEncoding:flatbuffers.Encoding):string|Uint8Array
variantPreferences(index: number,optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__string(this.bb!.__vector(this.bb_pos + offset) + index * 4, optionalEncoding) : null;
}
variantPreferencesLength():number {
const offset = this.bb!.__offset(this.bb_pos, 20);
return offset ? this.bb!.__vector_len(this.bb_pos + offset) : 0;
}
static startUpdateProfileRequest(builder:flatbuffers.Builder) { static startUpdateProfileRequest(builder:flatbuffers.Builder) {
builder.startObject(8); builder.startObject(9);
} }
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
@@ -106,12 +118,28 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp
builder.addFieldInt8(7, +notificationsInAppOnly, +true); builder.addFieldInt8(7, +notificationsInAppOnly, +true);
} }
static addVariantPreferences(builder:flatbuffers.Builder, variantPreferencesOffset:flatbuffers.Offset) {
builder.addFieldOffset(8, variantPreferencesOffset, 0);
}
static createVariantPreferencesVector(builder:flatbuffers.Builder, data:flatbuffers.Offset[]):flatbuffers.Offset {
builder.startVector(4, data.length, 4);
for (let i = data.length - 1; i >= 0; i--) {
builder.addOffset(data[i]!);
}
return builder.endVector();
}
static startVariantPreferencesVector(builder:flatbuffers.Builder, numElems:number) {
builder.startVector(4, numElems, 4);
}
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean):flatbuffers.Offset { static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean, variantPreferencesOffset:flatbuffers.Offset):flatbuffers.Offset {
UpdateProfileRequest.startUpdateProfileRequest(builder); UpdateProfileRequest.startUpdateProfileRequest(builder);
UpdateProfileRequest.addDisplayName(builder, displayNameOffset); UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset); UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
@@ -121,6 +149,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
UpdateProfileRequest.addBlockChat(builder, blockChat); UpdateProfileRequest.addBlockChat(builder, blockChat);
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests); UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly); UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
UpdateProfileRequest.addVariantPreferences(builder, variantPreferencesOffset);
return UpdateProfileRequest.endUpdateProfileRequest(builder); return UpdateProfileRequest.endUpdateProfileRequest(builder);
} }
} }
+1
View File
@@ -706,6 +706,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
blockChat: p.blockChat, blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests, blockFriendRequests: p.blockFriendRequests,
notificationsInAppOnly: p.notificationsInAppOnly, notificationsInAppOnly: p.notificationsInAppOnly,
variantPreferences: p.variantPreferences,
}); });
} catch { } catch {
// The client locale already changed; the server sync is best-effort. // The client locale already changed; the server sync is best-effort.
+31 -5
View File
@@ -26,6 +26,7 @@ import {
encodeStateRequest, encodeStateRequest,
encodeSubmitPlay, encodeSubmitPlay,
encodeTarget, encodeTarget,
encodeUpdateProfile,
} from './codec'; } from './codec';
describe('codec', () => { describe('codec', () => {
@@ -188,21 +189,17 @@ describe('codec', () => {
const token = b.createString('tok'); const token = b.createString('tok');
const uid = b.createString('u1'); const uid = b.createString('u1');
const name = b.createString('Me'); const name = b.createString('Me');
const langs = fb.Session.createSupportedLanguagesVector(b, [b.createString('en'), b.createString('ru')]);
fb.Session.startSession(b); fb.Session.startSession(b);
fb.Session.addToken(b, token); fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid); fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, true); fb.Session.addIsGuest(b, true);
fb.Session.addDisplayName(b, name); fb.Session.addDisplayName(b, name);
fb.Session.addSupportedLanguages(b, langs);
b.finish(fb.Session.endSession(b)); b.finish(fb.Session.endSession(b));
expect(decodeSession(b.asUint8Array())).toEqual({ expect(decodeSession(b.asUint8Array())).toEqual({
token: 'tok', token: 'tok',
userId: 'u1', userId: 'u1',
isGuest: true, isGuest: true,
displayName: 'Me', displayName: 'Me',
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
}); });
}); });
@@ -411,7 +408,7 @@ describe('codec', () => {
b.finish(fb.LinkResult.endLinkResult(b)); b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array()); const r = decodeLinkResult(b.asUint8Array());
expect(r.status).toBe('merged'); expect(r.status).toBe('merged');
expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [], serviceLanguage: '' }); expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya' });
}); });
it('decodes an Invitation with inviter and invitees', () => { it('decodes an Invitation with inviter and invitees', () => {
@@ -568,6 +565,35 @@ describe('codec', () => {
b.finish(fb.Profile.endProfile(b)); b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined(); expect(decodeProfile(b.asUint8Array()).banner).toBeUndefined();
}); });
it('decodes the profile variant preferences', () => {
const b = new Builder(64);
const uid = b.createString('u-1');
const prefs = fb.Profile.createVariantPreferencesVector(b, [b.createString('erudit_ru'), b.createString('scrabble_en')]);
fb.Profile.startProfile(b);
fb.Profile.addUserId(b, uid);
fb.Profile.addVariantPreferences(b, prefs);
b.finish(fb.Profile.endProfile(b));
expect(decodeProfile(b.asUint8Array()).variantPreferences).toEqual(['erudit_ru', 'scrabble_en']);
});
it('encodes the update-profile variant preferences', () => {
const buf = encodeUpdateProfile({
displayName: 'Kaya',
preferredLanguage: 'ru',
timeZone: 'UTC',
awayStart: '00:00',
awayEnd: '07:00',
blockChat: false,
blockFriendRequests: false,
notificationsInAppOnly: true,
variantPreferences: ['erudit_ru', 'scrabble_ru'],
});
const t = fb.UpdateProfileRequest.getRootAsUpdateProfileRequest(new ByteBuffer(buf));
const out: string[] = [];
for (let i = 0; i < t.variantPreferencesLength(); i++) out.push(t.variantPreferences(i));
expect(out).toEqual(['erudit_ru', 'scrabble_ru']);
});
}); });
// The live play loop exchanges alphabet indices, mapped through the per-variant // The live play loop exchanges alphabet indices, mapped through the per-variant
+15 -5
View File
@@ -294,17 +294,13 @@ function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
} }
// sessionFromTable projects a FlatBuffers Session table (a root or a nested one) to // sessionFromTable projects a FlatBuffers Session table (a root or a nested one) to
// the Session model, including the supported-languages set the UI gates variants by. // the Session model.
function sessionFromTable(t: fb.Session): Session { function sessionFromTable(t: fb.Session): Session {
const supportedLanguages: string[] = [];
for (let i = 0; i < t.supportedLanguagesLength(); i++) supportedLanguages.push(s(t.supportedLanguages(i)));
return { return {
token: s(t.token()), token: s(t.token()),
userId: s(t.userId()), userId: s(t.userId()),
isGuest: t.isGuest(), isGuest: t.isGuest(),
displayName: s(t.displayName()), displayName: s(t.displayName()),
supportedLanguages,
serviceLanguage: s(t.serviceLanguage()),
}; };
} }
@@ -326,10 +322,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockFriendRequests: p.blockFriendRequests(), blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(), isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(), notificationsInAppOnly: p.notificationsInAppOnly(),
variantPreferences: decodeVariantPreferences(p),
banner: decodeBanner(p), banner: decodeBanner(p),
}; };
} }
// decodeVariantPreferences reads the Profile.variant_preferences vector (the variants
// the player enabled in Settings).
function decodeVariantPreferences(p: fb.Profile): Variant[] {
const out: Variant[] = [];
for (let i = 0; i < p.variantPreferencesLength(); i++) out.push(s(p.variantPreferences(i)) as Variant);
return out;
}
// decodeBanner projects the optional advertising-banner block of a Profile, or // decodeBanner projects the optional advertising-banner block of a Profile, or
// undefined when the viewer is not eligible (the field is absent). // undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined { function decodeBanner(p: fb.Profile): Banner | undefined {
@@ -642,6 +647,10 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
const tz = b.createString(p.timeZone); const tz = b.createString(p.timeZone);
const as = b.createString(p.awayStart); const as = b.createString(p.awayStart);
const ae = b.createString(p.awayEnd); const ae = b.createString(p.awayEnd);
const prefs = fb.UpdateProfileRequest.createVariantPreferencesVector(
b,
p.variantPreferences.map((v) => b.createString(v)),
);
fb.UpdateProfileRequest.startUpdateProfileRequest(b); fb.UpdateProfileRequest.startUpdateProfileRequest(b);
fb.UpdateProfileRequest.addDisplayName(b, name); fb.UpdateProfileRequest.addDisplayName(b, name);
fb.UpdateProfileRequest.addPreferredLanguage(b, lang); fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
@@ -651,6 +660,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat); fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests); fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly); fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
fb.UpdateProfileRequest.addVariantPreferences(b, prefs);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
} }
-20
View File
@@ -35,19 +35,6 @@ describe('shareLink', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
}); });
it('picks the per-bot base by language', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
expect(shareLink('f1', 'en')).toBe('https://t.me/enbot/app?startapp=f1');
expect(shareLink('f1', 'ru')).toBe('https://t.me/rubot/app?startapp=f1');
});
it('falls back to the language-agnostic base when the per-bot one is unset', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/fallback/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', '');
expect(shareLink('f1', 'ru')).toBe('https://t.me/fallback/app?startapp=f1');
});
}); });
describe('botUsername', () => { describe('botUsername', () => {
@@ -62,11 +49,4 @@ describe('botUsername', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
expect(botUsername()).toBe('bot'); expect(botUsername()).toBe('bot');
}); });
it('picks the per-bot handle by language', () => {
vi.stubEnv('VITE_TELEGRAM_LINK_EN', 'https://t.me/enbot/app');
vi.stubEnv('VITE_TELEGRAM_LINK_RU', 'https://t.me/rubot/app');
expect(botUsername('en')).toBe('enbot');
expect(botUsername('ru')).toBe('rubot');
});
}); });
+12 -18
View File
@@ -41,25 +41,20 @@ function envVar(name: string): string | undefined {
} }
/** /**
* telegramBase returns the Mini App link base (e.g. https://t.me/<bot>/<app>) for a * telegramBase returns the single bot's Mini App link base (e.g. https://t.me/<bot>/<app>)
* bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the * from VITE_TELEGRAM_LINK, or null when it is not configured.
* language-agnostic VITE_TELEGRAM_LINK. Returns null when none is configured, so a
* shared link points at the same bot the player signed in through.
*/ */
function telegramBase(lang: string): string | null { function telegramBase(): string | null {
const byLang = return envVar('VITE_TELEGRAM_LINK') || null;
lang === 'ru' ? envVar('VITE_TELEGRAM_LINK_RU') : lang === 'en' ? envVar('VITE_TELEGRAM_LINK_EN') : undefined;
return byLang || envVar('VITE_TELEGRAM_LINK') || null;
} }
/** /**
* botUsername extracts the bot's @username (without the @) from the configured Mini App * botUsername extracts the bot's @username (without the @) from the configured Mini App
* link for a bot language: the first path segment of https://t.me/<bot>/<app>. Returns * link: the first path segment of https://t.me/<bot>/<app>. Returns null when no base is
* null when no base is configured or the link carries no path, so callers can fall back * configured or the link carries no path.
* when they cannot point at a specific bot.
*/ */
export function botUsername(lang = ''): string | null { export function botUsername(): string | null {
const base = telegramBase(lang); const base = telegramBase();
if (!base) return null; if (!base) return null;
try { try {
const seg = new URL(base).pathname.split('/').filter(Boolean)[0]; const seg = new URL(base).pathname.split('/').filter(Boolean)[0];
@@ -70,12 +65,11 @@ export function botUsername(lang = ''): string | null {
} }
/** /**
* shareLink wraps a deep-link start parameter in a t.me Mini App link for the given * shareLink wraps a deep-link start parameter in a t.me Mini App link for the single
* bot language (the session's service language). Returns null when no base is * bot. Returns null when no base is configured, so callers can hide the share affordance.
* configured, so callers can hide the share affordance.
*/ */
export function shareLink(param: string, lang = ''): string | null { export function shareLink(param: string): string | null {
const base = telegramBase(lang); const base = telegramBase();
if (!base) return null; if (!base) return null;
const sep = base.includes('?') ? '&' : '?'; const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`; return `${base}${sep}startapp=${encodeURIComponent(param)}`;
+2
View File
@@ -136,6 +136,8 @@ export const en = {
'profile.blockChat': 'Disable chat', 'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests', 'profile.blockFriendRequests': 'Disable friend requests',
'profile.notificationsInAppOnly': 'Notifications in the app only', 'profile.notificationsInAppOnly': 'Notifications in the app only',
'profile.preferences': 'Preferences',
'profile.preferencesHint': 'The game variants you can be matched into. Pick at least one.',
'profile.email': 'Email', 'profile.email': 'Email',
'profile.bindEmail': 'Bind email', 'profile.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code', 'profile.emailCode': 'Confirmation code',
+2
View File
@@ -137,6 +137,8 @@ export const ru: Record<MessageKey, string> = {
'profile.blockChat': 'Отключить чат', 'profile.blockChat': 'Отключить чат',
'profile.blockFriendRequests': 'Отключить заявки в друзья', 'profile.blockFriendRequests': 'Отключить заявки в друзья',
'profile.notificationsInAppOnly': 'Уведомления только в приложении', 'profile.notificationsInAppOnly': 'Уведомления только в приложении',
'profile.preferences': 'Предпочтения',
'profile.preferencesHint': 'Варианты игр, в которые вас могут подобрать. Выберите хотя бы один.',
'profile.email': 'Эл. почта', 'profile.email': 'Эл. почта',
'profile.bindEmail': 'Привязать почту', 'profile.bindEmail': 'Привязать почту',
'profile.emailCode': 'Код подтверждения', 'profile.emailCode': 'Код подтверждения',
+6 -10
View File
@@ -4,17 +4,13 @@ import { telegramChannelLink } from './landing';
describe('telegramChannelLink', () => { describe('telegramChannelLink', () => {
afterEach(() => vi.unstubAllEnvs()); afterEach(() => vi.unstubAllEnvs());
it('builds the per-language t.me link from the channel name', () => { it('builds the t.me link from the channel name', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game'); vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated expect(telegramChannelLink()).toBe('https://t.me/Erudit_Game');
expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
}); });
it('returns null when the locale channel is unset or blank', () => { it('returns null when the channel name is unset or blank', () => {
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', ''); vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', ' ');
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' '); expect(telegramChannelLink()).toBeNull();
expect(telegramChannelLink('en')).toBeNull();
expect(telegramChannelLink('ru')).toBeNull();
}); });
}); });
+8 -13
View File
@@ -1,20 +1,15 @@
// Pure helpers for the public landing page, kept out of the Svelte component so // Pure helpers for the public landing page, kept out of the Svelte component so the
// the per-language Telegram-channel link selection is unit-testable. // Telegram-channel link selection is unit-testable.
import type { Locale } from './i18n/index.svelte';
/** /**
* telegramChannelLink returns the t.me link for the locale's game channel, or null when it is * telegramChannelLink returns the t.me link for the single bot's game channel, or null
* not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN * when it is not configured. The channel username is a build-time var
* / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different * (VITE_TELEGRAM_GAME_CHANNEL_NAME) because the test and prod contours run different
* channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_* * channels; it is the same channel the connector posts to via TELEGRAM_GAME_CHANNEL_ID
* (the id to post, the name to link). A leading "@" is tolerated. * (the id to post, the name to link). A leading "@" is tolerated.
*/ */
export function telegramChannelLink(locale: Locale): string | null { export function telegramChannelLink(): string | null {
const raw = const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME;
locale === 'ru'
? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
: import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
const name = (raw as string | undefined)?.trim().replace(/^@/, ''); const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null; return name ? `https://t.me/${name}` : null;
} }
+2 -3
View File
@@ -23,9 +23,6 @@ export const SESSION: Session = {
userId: ME, userId: ME,
isGuest: true, isGuest: true,
displayName: 'You', displayName: 'You',
// Both languages by default, so the mock-driven UI offers every variant.
supportedLanguages: ['en', 'ru'],
serviceLanguage: '',
}; };
export const PROFILE: Profile = { export const PROFILE: Profile = {
@@ -40,6 +37,8 @@ export const PROFILE: Profile = {
blockFriendRequests: false, blockFriendRequests: false,
isGuest: false, isGuest: false,
notificationsInAppOnly: true, notificationsInAppOnly: true,
// Every variant enabled, so the mock-driven UI offers the full New Game picker.
variantPreferences: ['erudit_ru', 'scrabble_ru', 'scrabble_en'],
}; };
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+4 -8
View File
@@ -151,6 +151,9 @@ export interface Profile {
isGuest: boolean; isGuest: boolean;
/** Confine notifications to the in-app stream (no out-of-app platform push). */ /** Confine notifications to the in-app stream (no out-of-app platform push). */
notificationsInAppOnly: boolean; notificationsInAppOnly: boolean;
/** Variants the player allows themselves to be matched into (Erudit-first). The New
* Game picker is gated by this set, which is never empty. */
variantPreferences: Variant[];
/** The advertising-banner block, present only for a viewer eligible to see it. */ /** The advertising-banner block, present only for a viewer eligible to see it. */
banner?: Banner; banner?: Banner;
} }
@@ -201,6 +204,7 @@ export interface ProfileUpdate {
blockChat: boolean; blockChat: boolean;
blockFriendRequests: boolean; blockFriendRequests: boolean;
notificationsInAppOnly: boolean; notificationsInAppOnly: boolean;
variantPreferences: Variant[];
} }
/** A referenced account with its display name (friend, blocked user, invitee). */ /** A referenced account with its display name (friend, blocked user, invitee). */
@@ -327,14 +331,6 @@ export interface Session {
userId: string; userId: string;
isGuest: boolean; isGuest: boolean;
displayName: string; displayName: string;
// supportedLanguages is the set of game languages the service the user signed in
// through offers (subset of {en, ru}, at least one). New Game offers only the
// variants these languages support (en -> English; ru -> Russian + Эрудит). Empty
// means ungated (all variants).
supportedLanguages: string[];
// serviceLanguage is the en/ru tag of the Telegram bot this session was minted
// through (empty for a non-Telegram login); used to share a link to the same bot.
serviceLanguage: string;
} }
// LinkResult is the outcome of an account link/merge step. status is // LinkResult is the outcome of an account link/merge step. status is
+16 -9
View File
@@ -7,22 +7,29 @@ import {
multipleWordsForRequest, multipleWordsForRequest,
} from './variants'; } from './variants';
describe('ALL_VARIANTS', () => {
it('lists variants Erudit-first', () => {
expect(ALL_VARIANTS.map((v) => v.id)).toEqual(['erudit_ru', 'scrabble_ru', 'scrabble_en']);
});
});
describe('availableVariants', () => { describe('availableVariants', () => {
it('is ungated (all variants) for an empty or absent set', () => { it('is ungated (all variants) for an empty or absent preference set', () => {
expect(availableVariants([])).toEqual(ALL_VARIANTS); expect(availableVariants([])).toEqual(ALL_VARIANTS);
expect(availableVariants(undefined)).toEqual(ALL_VARIANTS); expect(availableVariants(undefined)).toEqual(ALL_VARIANTS);
}); });
it('offers only English for an en-only service', () => { it('offers only the preferred variants', () => {
expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']); expect(availableVariants(['scrabble_en']).map((v) => v.id)).toEqual(['scrabble_en']);
expect(availableVariants(['erudit_ru', 'scrabble_en']).map((v) => v.id)).toEqual(['erudit_ru', 'scrabble_en']);
}); });
it('offers Russian and Эрудит for a ru-only service', () => { it('keeps the Erudit-first catalogue order regardless of preference order', () => {
expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']); expect(availableVariants(['scrabble_en', 'erudit_ru', 'scrabble_ru']).map((v) => v.id)).toEqual([
}); 'erudit_ru',
'scrabble_ru',
it('offers every variant for a bilingual service', () => { 'scrabble_en',
expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']); ]);
}); });
}); });
+11 -10
View File
@@ -11,14 +11,15 @@ export interface VariantOption {
label: MessageKey; label: MessageKey;
} }
// ALL_VARIANTS lists every variant in display order. The labels are display names keyed by // ALL_VARIANTS lists every variant in display order (Erudit first — the default
// preference and the product's primary variant). The labels are display names keyed by
// the game's alphabet, not the interface language: the English-alphabet game is always // the game's alphabet, not the interface language: the English-alphabet game is always
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the // "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит". // two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит".
export const ALL_VARIANTS: VariantOption[] = [ export const ALL_VARIANTS: VariantOption[] = [
{ id: 'scrabble_en', label: 'new.english' },
{ id: 'scrabble_ru', label: 'new.russian' },
{ id: 'erudit_ru', label: 'new.erudit' }, { id: 'erudit_ru', label: 'new.erudit' },
{ id: 'scrabble_ru', label: 'new.russian' },
{ id: 'scrabble_en', label: 'new.english' },
]; ];
// variantNameKey returns the i18n key for a variant's display name (used by the in-game // variantNameKey returns the i18n key for a variant's display name (used by the in-game
@@ -47,13 +48,13 @@ 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' };
// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty // availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
// or absent set is ungated (a web/legacy session without a declared set), returning // they enabled in Settings). An empty or absent set is ungated (returns every variant)
// every variant. // — a safety fallback; a real profile always carries at least one preference.
export function availableVariants(supportedLanguages: string[] | undefined): VariantOption[] { export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
const langs = supportedLanguages ?? []; const prefs = preferences ?? [];
if (langs.length === 0) return ALL_VARIANTS; if (prefs.length === 0) return ALL_VARIANTS;
return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id])); return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
} }
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle // supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
+5 -9
View File
@@ -4,8 +4,7 @@
import { connection } from '../lib/connection.svelte'; import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { localeFrom, t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { translate } from '../lib/i18n/catalog';
import { friendCodeParam, shareLink } from '../lib/deeplink'; import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram'; import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model'; import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
@@ -95,10 +94,8 @@
// shareInvite shares the friend-code deep link: inside Telegram via the native // shareInvite shares the friend-code deep link: inside Telegram via the native
// "share to chat" picker; on the web via the system share sheet; failing both, it // "share to chat" picker; on the web via the system share sheet; failing both, it
// copies the link to the clipboard. // copies the link to the clipboard.
async function shareInvite(url: string, lang: string) { async function shareInvite(url: string) {
// The caption is in the bot's language (Эрудит for ru, Scrabble for en), not the const text = t('friends.inviteText');
// interface language — the recipient lands in that bot.
const text = translate(localeFrom(lang), 'friends.inviteText');
if (shareTelegramLink(url, text)) return; if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) { if (typeof navigator !== 'undefined' && navigator.share) {
try { try {
@@ -134,8 +131,7 @@
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button> <button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
</div> </div>
{#if code} {#if code}
{@const lang = app.session?.serviceLanguage || app.locale} {@const tg = shareLink(friendCodeParam(code.code))}
{@const tg = shareLink(friendCodeParam(code.code), lang)}
<div class="code" data-testid="friend-code"> <div class="code" data-testid="friend-code">
<div class="coderow"> <div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button> <button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -145,7 +141,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
{#if tg} {#if tg}
<button type="button" class="link tgshare" onclick={() => shareInvite(tg, lang)}>{t('friends.shareTelegram')}</button> <button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if} {/if}
</div> </div>
{:else} {:else}
+3 -3
View File
@@ -18,9 +18,9 @@
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h). // The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24; const AUTO_MATCH_HOURS = 24;
// The offered variants are gated by the languages the sign-in service supports; // The offered variants are gated by the player's profile preferences (the variants
// the auto-match list and the friend-invite picker both use this. // they enabled in Settings); the auto-match list and the friend-invite picker both use this.
const variants = $derived(availableVariants(app.session?.supportedLanguages)); const variants = $derived(availableVariants(app.profile?.variantPreferences));
// "Multiple words per turn" off is the single-word rule; it is offered for Russian games // "Multiple words per turn" off is the single-word rule; it is offered for Russian games
// only (English is always standard and shows no toggle). Shared by both flows. // only (English is always standard and shows no toggle). Shared by both flows.
let multipleWords = $state(false); let multipleWords = $state(false);
+56 -1
View File
@@ -16,6 +16,8 @@
validDisplayName, validDisplayName,
validEmail, validEmail,
} from '../lib/profileValidation'; } from '../lib/profileValidation';
import { ALL_VARIANTS, VARIANT_RULES } from '../lib/variants';
import type { Variant } from '../lib/model';
let dn = $state(''); let dn = $state('');
let tz = $state('+00:00'); let tz = $state('+00:00');
@@ -28,6 +30,7 @@
let blockChat = $state(false); let blockChat = $state(false);
let blockFriendRequests = $state(false); let blockFriendRequests = $state(false);
let notificationsInAppOnly = $state(true); let notificationsInAppOnly = $state(true);
let variantPrefs = $state<Variant[]>([]);
let emailInput = $state(''); let emailInput = $state('');
let codeInput = $state(''); let codeInput = $state('');
let emailSent = $state(false); let emailSent = $state(false);
@@ -60,6 +63,7 @@
blockChat = p.blockChat; blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests; blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly; notificationsInAppOnly = p.notificationsInAppOnly;
variantPrefs = [...p.variantPreferences];
} }
onMount(populate); onMount(populate);
@@ -67,9 +71,19 @@
const awayEnd = $derived(`${endH}:${endM}`); const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn)); const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd)); const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
const formValid = $derived(nameOk && awayOk); const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
const emailOk = $derived(validEmail(emailInput)); const emailOk = $derived(validEmail(emailInput));
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
// the player must allow themselves at least one variant.
function toggleVariant(id: Variant) {
if (variantPrefs.includes(id)) {
if (variantPrefs.length > 1) variantPrefs = variantPrefs.filter((v) => v !== id);
} else {
variantPrefs = [...variantPrefs, id];
}
}
async function save() { async function save() {
if (!formValid) return; if (!formValid) return;
try { try {
@@ -82,6 +96,7 @@
blockChat, blockChat,
blockFriendRequests, blockFriendRequests,
notificationsInAppOnly, notificationsInAppOnly,
variantPreferences: variantPrefs,
}); });
showToast(t('profile.saved')); showToast(t('profile.saved'));
} catch (e) { } catch (e) {
@@ -207,6 +222,22 @@
<input type="checkbox" bind:checked={notificationsInAppOnly} /> <input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span> <span>{t('profile.notificationsInAppOnly')}</span>
</label> </label>
<fieldset class="prefs">
<legend>{t('profile.preferences')}</legend>
<p class="muted">{t('profile.preferencesHint')}</p>
{#each ALL_VARIANTS as v (v.id)}
<label class="check pref">
<input
type="checkbox"
checked={variantPrefs.includes(v.id)}
disabled={variantPrefs.length === 1 && variantPrefs.includes(v.id)}
onchange={() => toggleVariant(v.id)}
/>
<span class="pname">{t(v.label)}</span>
<span class="prule">{t(VARIANT_RULES[v.id])}</span>
</label>
{/each}
</fieldset>
<div class="formacts"> <div class="formacts">
<button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button> <button type="submit" class="btn" disabled={!formValid || !connection.online}>{t('common.save')}</button>
</div> </div>
@@ -342,6 +373,30 @@
gap: 10px !important; gap: 10px !important;
color: var(--text) !important; color: var(--text) !important;
} }
.prefs {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
margin: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.prefs legend {
color: var(--text-muted);
font-size: 0.9rem;
padding: 0 4px;
}
.pref {
align-items: baseline;
}
.pname {
font-weight: 600;
}
.prule {
color: var(--text-muted);
font-size: 0.8rem;
}
.formacts { .formacts {
display: flex; display: flex;
gap: 10px; gap: 10px;