diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml
index 63f9196..c41b40c 100644
--- a/.gitea/workflows/ci.yaml
+++ b/.gitea/workflows/ci.yaml
@@ -254,25 +254,19 @@ jobs:
AWG_CONF: ${{ secrets.TEST_AWG_CONF }}
GM_BASICAUTH_HASH: ${{ secrets.TEST_GM_BASICAUTH_HASH }}
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
- TELEGRAM_BOT_TOKEN_EN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_EN }}
- TELEGRAM_BOT_TOKEN_RU: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN_RU }}
+ TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
- TELEGRAM_GAME_CHANNEL_ID_EN: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_EN }}
- TELEGRAM_GAME_CHANNEL_ID_RU: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID_RU }}
+ TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
# The test contour always uses Telegram's test environment — pinned here,
# not an operator variable. The prod workflow leaves it false.
TELEGRAM_TEST_ENV: "true"
VITE_TELEGRAM_BOT_ID: ${{ vars.TEST_VITE_TELEGRAM_BOT_ID }}
VITE_TELEGRAM_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
- VITE_TELEGRAM_LINK_EN: ${{ vars.TEST_VITE_TELEGRAM_LINK_EN }}
- 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_TELEGRAM_GAME_CHANNEL_NAME: ${{ vars.TEST_VITE_TELEGRAM_GAME_CHANNEL_NAME }}
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.
POSTGRES_DB: ${{ vars.TEST_POSTGRES_DB }}
POSTGRES_USER: ${{ vars.TEST_POSTGRES_USER }}
diff --git a/PLAN.md b/PLAN.md
index 92030a0..10836dd 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -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`)
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)*
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
diff --git a/PRERELEASE.md b/PRERELEASE.md
index 83c1ff7..0c9894f 100644
--- a/PRERELEASE.md
+++ b/PRERELEASE.md
@@ -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** |
| 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** |
+| 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) |
## Key findings (these reshaped the raw list — read before starting a phase)
diff --git a/backend/README.md b/backend/README.md
index 22fd1af..57c66ff 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -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
`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
-broadcast picks the delivering bot by an operator-chosen language. `accounts.service_language`
-holds the language tag of the bot a Telegram
-user last signed in through, written on every login and returned by
-`/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` +
+broadcast renders through the single bot in an operator-chosen language. There is one bot,
+so `/internal/push-target` returns the recipient's `preferred_language` as the render
+language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_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
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
contracts live in the sibling [`../pkg`](../pkg) module.
diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go
index a0a7a28..14e143a 100644
--- a/backend/internal/account/account.go
+++ b/backend/internal/account/account.go
@@ -55,12 +55,12 @@ type Account struct {
HintBalance int
BlockChat bool
BlockFriendRequests bool
- // ServiceLanguage is the language tag (en/ru) of the bot the account last
- // authenticated through (its last Telegram ValidateInitData); it routes the
- // account's out-of-app push back through the right bot. Empty when the account
- // has never signed in through a tagged bot. Distinct from PreferredLanguage (the
- // interface language) and from a game's variant language.
- ServiceLanguage string
+ // VariantPreferences is the set of game variants (engine.Variant stable labels:
+ // "scrabble_en", "scrabble_ru", "erudit_ru") the player is willing to be matched
+ // into. It gates the New Game picker, the matchmaker and the friend-invite the
+ // player creates; an invited friend may still accept any variant. A new account
+ // defaults to Erudit only. Never empty — enforced on update and by a DB check.
+ VariantPreferences []string
// IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history.
IsGuest bool
@@ -491,36 +491,12 @@ func (s *Store) ClearHighRateFlag(ctx context.Context, id uuid.UUID) error {
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.
func modelToAccount(row model.Accounts) Account {
var mergedInto uuid.UUID
if row.MergedInto != nil {
mergedInto = *row.MergedInto
}
- var serviceLanguage string
- if row.ServiceLanguage != nil {
- serviceLanguage = *row.ServiceLanguage
- }
var flaggedHighRateAt time.Time
if row.FlaggedHighRateAt != nil {
flaggedHighRateAt = *row.FlaggedHighRateAt
@@ -529,7 +505,7 @@ func modelToAccount(row model.Accounts) Account {
ID: row.AccountID,
DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage,
- ServiceLanguage: serviceLanguage,
+ VariantPreferences: []string(row.VariantPreferences),
TimeZone: row.TimeZone,
AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd,
diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go
index d2fa936..fbee7a7 100644
--- a/backend/internal/account/profile.go
+++ b/backend/internal/account/profile.go
@@ -14,6 +14,7 @@ import (
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
+ "github.com/lib/pq"
"scrabble/backend/internal/postgres/jet/backend/model"
"scrabble/backend/internal/postgres/jet/backend/table"
@@ -58,6 +59,46 @@ type ProfileUpdate struct {
BlockChat bool
BlockFriendRequests 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
@@ -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 {
return Account{}, err
}
+ prefs, err := validateVariantPreferences(p.VariantPreferences)
+ if err != nil {
+ return Account{}, err
+ }
stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd,
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
- table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
+ table.Accounts.NotificationsInAppOnly, table.Accounts.VariantPreferences,
+ table.Accounts.UpdatedAt,
).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
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))).
RETURNING(table.Accounts.AllColumns)
diff --git a/backend/internal/account/profile_test.go b/backend/internal/account/profile_test.go
index b8774f7..5003dd9 100644
--- a/backend/internal/account/profile_test.go
+++ b/backend/internal/account/profile_test.go
@@ -3,6 +3,7 @@ package account
import (
"context"
"errors"
+ "slices"
"strings"
"testing"
"time"
@@ -16,7 +17,7 @@ import (
// offset/IANA timezone), not just their unit tests in validate_test.go.
func TestUpdateProfileValidation(t *testing.T) {
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) }
tests := []struct {
name string
@@ -28,6 +29,8 @@ func TestUpdateProfileValidation(t *testing.T) {
{"over-long name", func(p *ProfileUpdate) { p.DisplayName = strings.Repeat("x", maxDisplayName+1) }},
{"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) }},
+ {"empty variant preferences", func(p *ProfileUpdate) { p.VariantPreferences = nil }},
+ {"unknown variant preference", func(p *ProfileUpdate) { p.VariantPreferences = []string{"chess"} }},
}
for _, tc := range tests {
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)
+ }
+}
diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go
index f827e3f..2f46a28 100644
--- a/backend/internal/adminconsole/render_test.go
+++ b/backend/internal/adminconsole/render_test.go
@@ -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"},
{"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_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"},
{"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"},
diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml
index 8aa559a..f570231 100644
--- a/backend/internal/adminconsole/templates/pages/broadcast.gohtml
+++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml
@@ -5,7 +5,6 @@
{{if .ConnectorEnabled}}
{{else}}
connector not configured (set BACKEND_CONNECTOR_ADDR)
connector not configured (set BACKEND_CONNECTOR_ADDR)
{{end}}
diff --git a/backend/internal/adminconsole/views.go b/backend/internal/adminconsole/views.go
index 7142021..eb3aafb 100644
--- a/backend/internal/adminconsole/views.go
+++ b/backend/internal/adminconsole/views.go
@@ -531,11 +531,8 @@ type FeedbackDetailView struct {
SenderName string
Source string
Channel string
- // 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 is the sender's interface language (account preference).
InterfaceLanguage string
- BotLanguage string
IP string
Body string
HasAttachment bool
diff --git a/backend/internal/connector/client.go b/backend/internal/connector/client.go
index 0030275..5b8196d 100644
--- a/backend/internal/connector/client.go
+++ b/backend/internal/connector/client.go
@@ -1,11 +1,10 @@
// Package connector is the backend's gRPC client for the Telegram platform
// 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
-// selects the delivering bot by language (an operator choice, since the connector
-// hosts one bot per service language). The connector lives on the trusted internal
-// network, so the connection uses insecure (plaintext) transport credentials
-// (docs/ARCHITECTURE.md §12). It mirrors gateway/internal/connector, narrowed to
-// the two broadcast methods the admin surface needs.
+// a direct message to one user, or a post to the game channel, through the single
+// bot. The connector lives on the trusted internal network, so the connection uses
+// insecure (plaintext) transport credentials (docs/ARCHITECTURE.md §12). It mirrors
+// gateway/internal/connector, narrowed to the two broadcast methods the admin
+// surface needs.
package connector
import (
@@ -37,22 +36,21 @@ func New(addr string) (*Client, error) {
func (c *Client) Close() error { return c.conn.Close() }
// SendToUser sends an operator text message to one user, addressed by their
-// platform external_id, through the bot for the given language. delivered reports
-// whether the connector actually sent it (false when the user has not started that
-// bot).
-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, Language: language})
+// platform external_id, through the bot. delivered reports whether the connector
+// actually sent it (false when the user has not started the bot).
+func (c *Client) SendToUser(ctx context.Context, externalID, text string) (bool, error) {
+ resp, err := c.c.SendToUser(ctx, &telegramv1.SendToUserRequest{ExternalId: externalID, Text: text})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
-// SendToGameChannel posts an operator text message to the game channel of the bot
-// for the given language. delivered reports whether the connector sent it (false
-// when that bot has no channel configured).
-func (c *Client) SendToGameChannel(ctx context.Context, text, language string) (bool, error) {
- resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text, Language: language})
+// SendToGameChannel posts an operator text message to the bot's game channel.
+// delivered reports whether the connector sent it (false when the bot has no
+// channel configured).
+func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) {
+ resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text})
if err != nil {
return false, err
}
diff --git a/backend/internal/feedback/service.go b/backend/internal/feedback/service.go
index d0b22be..824cff9 100644
--- a/backend/internal/feedback/service.go
+++ b/backend/internal/feedback/service.go
@@ -112,14 +112,9 @@ func (svc *Service) Submit(ctx context.Context, accountID uuid.UUID, body string
attachmentName = "" // a name without bytes carries no attachment
}
ch := normalizeChannel(channel)
- // Snapshot the languages at submit time (acc is already loaded for the guest check):
- // the sender's interface language, and the connector bot language when the message
- // came through an external connector (currently Telegram).
- var channelLang string
- if ch == "telegram" {
- channelLang = acc.ServiceLanguage
- }
- _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, channelLang, parseIP(senderIP))
+ // Snapshot the sender's interface language at submit time (acc is already loaded
+ // for the guest check) so the operator later sees the state as it was.
+ _, err = svc.store.Insert(ctx, accountID, body, attachment, attachmentName, ch, acc.PreferredLanguage, parseIP(senderIP))
return err
}
diff --git a/backend/internal/feedback/store.go b/backend/internal/feedback/store.go
index 2e64e39..5281978 100644
--- a/backend/internal/feedback/store.go
+++ b/backend/internal/feedback/store.go
@@ -34,11 +34,10 @@ func NewStore(db *sql.DB) *Store {
// Insert stores one feedback message from accountID and returns its id. attachment
// is the raw file bytes (nil for none); attachmentName, ip and a non-default
-// channel are stored as given. lang (the sender's interface language) and channelLang
-// (the connector bot language, empty for a non-connector channel) are snapshots taken
-// now, so the operator later sees the state at submit time. created_at defaults to
-// now() in the database.
-func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang, channelLang string, ip *string) (uuid.UUID, error) {
+// channel are stored as given. lang (the sender's interface language) is a snapshot
+// taken now, so the operator later sees the state at submit time. created_at defaults
+// to now() in the database.
+func (s *Store) Insert(ctx context.Context, accountID uuid.UUID, body string, attachment []byte, attachmentName, channel, lang string, ip *string) (uuid.UUID, error) {
id, err := uuid.NewV7()
if err != nil {
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,
`INSERT INTO backend.feedback_messages
- (message_id, account_id, body, attachment, attachment_name, channel, lang, channel_lang, sender_ip)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
- id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), nullStr(channelLang), ip); err != nil {
+ (message_id, account_id, body, attachment, attachment_name, channel, lang, sender_ip)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
+ id, accountID, body, att, nullStr(attachmentName), channel, nullStr(lang), ip); err != nil {
return uuid.Nil, fmt.Errorf("feedback: insert: %w", err)
}
return id, nil
@@ -228,10 +227,8 @@ type AdminMessage struct {
Source string
Body string
Channel string
- // Lang is the sender's interface language and ChannelLang the connector bot language
- // (en/ru, empty for a non-connector channel) — both snapshotted at submit time.
+ // Lang is the sender's interface language, snapshotted at submit time.
Lang string
- ChannelLang string
SenderIP string
HasAttachment bool
AttachmentName string
@@ -346,7 +343,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
var m AdminMessage
var repliedAt sql.NullTime
q := `SELECT m.message_id, m.account_id, a.display_name, ` + feedbackSource + ` AS source, m.body, m.channel,
- COALESCE(m.lang, ''), COALESCE(m.channel_lang, ''),
+ COALESCE(m.lang, ''),
COALESCE(m.sender_ip, ''), (m.attachment IS NOT NULL), COALESCE(m.attachment_name, ''),
(m.read_at IS NOT NULL), (m.archived_at IS NOT NULL), (m.reply_body IS NOT NULL),
COALESCE(m.reply_body, ''), m.replied_at, m.created_at
@@ -355,7 +352,7 @@ func (s *Store) AdminGet(ctx context.Context, id uuid.UUID) (AdminMessage, error
WHERE m.message_id = $1`
err := s.db.QueryRowContext(ctx, q, id).Scan(
&m.ID, &m.AccountID, &m.SenderName, &m.Source, &m.Body, &m.Channel,
- &m.Lang, &m.ChannelLang,
+ &m.Lang,
&m.SenderIP, &m.HasAttachment, &m.AttachmentName,
&m.Read, &m.Archived, &m.Replied, &m.ReplyBody, &repliedAt, &m.CreatedAt)
if errors.Is(err, sql.ErrNoRows) {
diff --git a/backend/internal/game/emit_test.go b/backend/internal/game/emit_test.go
index db953de..9a05483 100644
--- a/backend/internal/game/emit_test.go
+++ b/backend/internal/game/emit_test.go
@@ -68,10 +68,6 @@ func TestEmitMoveNotifiesActor(t *testing.T) {
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)
}
- // 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,
@@ -106,7 +102,4 @@ func TestEmitMoveAnnouncesGameOver(t *testing.T) {
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())
}
- 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)
- }
}
diff --git a/backend/internal/game/service.go b/backend/internal/game/service.go
index 3da994c..1c0dbd0 100644
--- a/backend/internal/game/service.go
+++ b/backend/internal/game/service.go
@@ -554,16 +554,6 @@ func (svc *Service) GameVariant(ctx context.Context, gameID uuid.UUID) (engine.V
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
// 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) {
@@ -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))
}
- // 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 {
case StatusActive:
// 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)
yourTurn := notify.YourTurn(next, post.ID, deadline, opponent, action, word, scoreLine(post, post.ToMove), post.MoveCount)
- yourTurn.Language = lang
intents = append(intents, yourTurn)
}
case StatusFinished:
@@ -769,7 +755,6 @@ func (svc *Service) emitMove(ctx context.Context, post Game, rec engine.MoveReco
continue
}
over := notify.GameOver(s.AccountID, post.ID, seatResult(post.Seats, s.Seat), scoreLine(post, s.Seat), summary)
- over.Language = lang
intents = append(intents, over)
}
}
diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go
index c7f1815..b9327a7 100644
--- a/backend/internal/inttest/account_test.go
+++ b/backend/internal/inttest/account_test.go
@@ -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
// is unflagged, FlagHighRate stamps it exactly once (a second sustained episode
// never moves the timestamp), ClearHighRateFlag reverses it, and a re-flag after
@@ -299,6 +259,7 @@ func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
PreferredLanguage: "en",
TimeZone: "UTC",
NotificationsInAppOnly: false,
+ VariantPreferences: []string{"erudit_ru"},
})
if err != nil {
t.Fatalf("update profile: %v", err)
diff --git a/backend/internal/inttest/banner_console_test.go b/backend/internal/inttest/banner_console_test.go
index 8c33350..d858213 100644
--- a/backend/internal/inttest/banner_console_test.go
+++ b/backend/internal/inttest/banner_console_test.go
@@ -256,7 +256,8 @@ func TestBannerSurvivesProfileUpdate(t *testing.T) {
id := provisionAccount(t)
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()
req := httptest.NewRequest(http.MethodPut, "/api/v1/user/profile", strings.NewReader(body))
req.Header.Set("X-User-ID", id.String())
diff --git a/backend/internal/inttest/email_test.go b/backend/internal/inttest/email_test.go
index e30ccc6..97d8d5e 100644
--- a/backend/internal/inttest/email_test.go
+++ b/backend/internal/inttest/email_test.go
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"regexp"
+ "slices"
"testing"
"time"
@@ -134,12 +135,20 @@ func TestUpdateProfilePersists(t *testing.T) {
store := account.NewStore(testDB)
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{
DisplayName: "Kaya",
PreferredLanguage: "ru",
TimeZone: "Europe/Moscow",
BlockChat: true,
BlockFriendRequests: true,
+ VariantPreferences: []string{"scrabble_en", "erudit_ru"},
})
if err != nil {
t.Fatalf("update profile: %v", err)
@@ -157,6 +166,10 @@ func TestUpdateProfilePersists(t *testing.T) {
if reloaded.TimeZone != "Europe/Moscow" || !reloaded.BlockChat {
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
@@ -167,9 +180,10 @@ func TestUpdateProfileOffsetTimezone(t *testing.T) {
acc := provisionAccount(t)
updated, err := store.UpdateProfile(ctx, acc, account.ProfileUpdate{
- DisplayName: "Kaya",
- PreferredLanguage: "en",
- TimeZone: "+03:00",
+ DisplayName: "Kaya",
+ PreferredLanguage: "en",
+ TimeZone: "+03:00",
+ VariantPreferences: []string{"erudit_ru"},
})
if err != nil {
t.Fatalf("update with offset timezone: %v", err)
diff --git a/backend/internal/inttest/feedback_test.go b/backend/internal/inttest/feedback_test.go
index 6aadcfa..9d323a1 100644
--- a/backend/internal/inttest/feedback_test.go
+++ b/backend/internal/inttest/feedback_test.go
@@ -145,48 +145,33 @@ func TestFeedbackReplyHiddenAfterNewMessage(t *testing.T) {
}
}
-func TestFeedbackSnapshotsLanguages(t *testing.T) {
+func TestFeedbackSnapshotsLanguage(t *testing.T) {
ctx := context.Background()
svc := newFeedbackService()
acc := provisionAccount(t)
if _, err := testDB.ExecContext(ctx,
- `UPDATE backend.accounts SET preferred_language = 'en', service_language = 'ru' WHERE account_id = $1`, acc); err != nil {
- t.Fatalf("set languages: %v", err)
+ `UPDATE backend.accounts SET preferred_language = 'en' WHERE account_id = $1`, acc); err != nil {
+ 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 {
t.Fatalf("submit: %v", err)
}
id := latestFeedbackID(t, svc, acc)
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatalf("admin get: %v", err)
- } else if m.Lang != "en" || m.ChannelLang != "ru" {
- t.Fatalf("snapshot = lang %q / channel_lang %q, want en / ru", m.Lang, m.ChannelLang)
+ } else if m.Lang != "en" {
+ t.Fatalf("snapshot = lang %q, want en", m.Lang)
}
// Changing the account afterwards must not change the stored snapshot.
if _, err := testDB.ExecContext(ctx,
- `UPDATE backend.accounts SET preferred_language = 'ru', service_language = 'en' WHERE account_id = $1`, acc); err != nil {
- t.Fatalf("change languages: %v", err)
+ `UPDATE backend.accounts SET preferred_language = 'ru' WHERE account_id = $1`, acc); err != nil {
+ t.Fatalf("change language: %v", err)
}
if m, err := svc.AdminGet(ctx, id); err != nil {
t.Fatal(err)
- } else if m.Lang != "en" || m.ChannelLang != "ru" {
- t.Fatalf("snapshot drifted after account change = lang %q / channel_lang %q", m.Lang, m.ChannelLang)
- }
-
- // 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)
+ } else if m.Lang != "en" {
+ t.Fatalf("snapshot drifted after account change = lang %q, want en", m.Lang)
}
}
diff --git a/backend/internal/inttest/game_limit_test.go b/backend/internal/inttest/game_limit_test.go
index 56b1574..7541184 100644
--- a/backend/internal/inttest/game_limit_test.go
+++ b/backend/internal/inttest/game_limit_test.go
@@ -97,10 +97,12 @@ func TestGameLimitGate(t *testing.T) {
if !gamesListAtLimit(t, srv, human) {
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))
}
- 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" {
t.Fatalf("invitation at limit = (%d, %q), want (409, game_limit_reached)", rec.Code, errorCode(t, rec))
}
diff --git a/backend/internal/inttest/seat_display_name_test.go b/backend/internal/inttest/seat_display_name_test.go
index 4c51020..905eef6 100644
--- a/backend/internal/inttest/seat_display_name_test.go
+++ b/backend/internal/inttest/seat_display_name_test.go
@@ -37,7 +37,7 @@ func TestSeatNameFrozenAfterRename(t *testing.T) {
starter := provisionAccount(t)
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 {
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.
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 {
t.Fatalf("rename: %v", err)
}
diff --git a/backend/internal/inttest/social_test.go b/backend/internal/inttest/social_test.go
index f6f9e84..6639ceb 100644
--- a/backend/internal/inttest/social_test.go
+++ b/backend/internal/inttest/social_test.go
@@ -135,7 +135,7 @@ func TestFriendRequestRefusedByToggleAndBlock(t *testing.T) {
// Toggle: the addressee does not accept friend requests.
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)
}
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 {
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)
}
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
// (nudges excluded), the game / sender pins, the sender glob masks, and the source label.
func TestAdminListMessages(t *testing.T) {
diff --git a/backend/internal/inttest/variant_preference_test.go b/backend/internal/inttest/variant_preference_test.go
new file mode 100644
index 0000000..5e49a28
--- /dev/null
+++ b/backend/internal/inttest/variant_preference_test.go
@@ -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)
+}
diff --git a/backend/internal/lobby/matchmaker.go b/backend/internal/lobby/matchmaker.go
index 54e9b16..6c52e68 100644
--- a/backend/internal/lobby/matchmaker.go
+++ b/backend/internal/lobby/matchmaker.go
@@ -206,7 +206,6 @@ func (m *Matchmaker) announceOpponent(ctx context.Context, g game.Game, joinerID
return
}
intent := notify.OpponentJoined(starter, g.ID, state)
- intent.Language = g.Variant.Language()
m.pub.Publish(intent)
}
diff --git a/backend/internal/notify/notify.go b/backend/internal/notify/notify.go
index 9e74774..583987a 100644
--- a/backend/internal/notify/notify.go
+++ b/backend/internal/notify/notify.go
@@ -82,11 +82,6 @@ type Intent struct {
Kind string
Payload []byte
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
diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go
index ae26769..c69445d 100644
--- a/backend/internal/postgres/jet/backend/model/accounts.go
+++ b/backend/internal/postgres/jet/backend/model/accounts.go
@@ -9,6 +9,7 @@ package model
import (
"github.com/google/uuid"
+ "github.com/lib/pq"
"time"
)
@@ -29,6 +30,6 @@ type Accounts struct {
PaidAccount bool
MergedInto *uuid.UUID
MergedAt *time.Time
- ServiceLanguage *string
FlaggedHighRateAt *time.Time
+ VariantPreferences pq.StringArray
}
diff --git a/backend/internal/postgres/jet/backend/model/feedback_messages.go b/backend/internal/postgres/jet/backend/model/feedback_messages.go
index ef0dff8..32bbdaa 100644
--- a/backend/internal/postgres/jet/backend/model/feedback_messages.go
+++ b/backend/internal/postgres/jet/backend/model/feedback_messages.go
@@ -20,12 +20,11 @@ type FeedbackMessages struct {
AttachmentName *string
SenderIP *string
Channel string
- Lang *string
- ChannelLang *string
ReadAt *time.Time
ArchivedAt *time.Time
ReplyBody *string
RepliedAt *time.Time
ReplyReadAt *time.Time
CreatedAt time.Time
+ Lang *string
}
diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go
index 021e7a4..3cafd35 100644
--- a/backend/internal/postgres/jet/backend/table/accounts.go
+++ b/backend/internal/postgres/jet/backend/table/accounts.go
@@ -33,8 +33,8 @@ type accountsTable struct {
PaidAccount postgres.ColumnBool
MergedInto postgres.ColumnString
MergedAt postgres.ColumnTimestampz
- ServiceLanguage postgres.ColumnString
FlaggedHighRateAt postgres.ColumnTimestampz
+ VariantPreferences postgres.ColumnStringArray
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -92,11 +92,11 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccountColumn = postgres.BoolColumn("paid_account")
MergedIntoColumn = postgres.StringColumn("merged_into")
MergedAtColumn = postgres.TimestampzColumn("merged_at")
- ServiceLanguageColumn = postgres.StringColumn("service_language")
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}
- mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn, FlaggedHighRateAtColumn}
- defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn}
+ VariantPreferencesColumn = postgres.StringArrayColumn("variant_preferences")
+ allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, FlaggedHighRateAtColumn, VariantPreferencesColumn}
+ 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{
@@ -119,8 +119,8 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
PaidAccount: PaidAccountColumn,
MergedInto: MergedIntoColumn,
MergedAt: MergedAtColumn,
- ServiceLanguage: ServiceLanguageColumn,
FlaggedHighRateAt: FlaggedHighRateAtColumn,
+ VariantPreferences: VariantPreferencesColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
diff --git a/backend/internal/postgres/jet/backend/table/feedback_messages.go b/backend/internal/postgres/jet/backend/table/feedback_messages.go
index 7b16c19..238c640 100644
--- a/backend/internal/postgres/jet/backend/table/feedback_messages.go
+++ b/backend/internal/postgres/jet/backend/table/feedback_messages.go
@@ -24,14 +24,13 @@ type feedbackMessagesTable struct {
AttachmentName postgres.ColumnString
SenderIP postgres.ColumnString
Channel postgres.ColumnString
- Lang postgres.ColumnString
- ChannelLang postgres.ColumnString
ReadAt postgres.ColumnTimestampz
ArchivedAt postgres.ColumnTimestampz
ReplyBody postgres.ColumnString
RepliedAt postgres.ColumnTimestampz
ReplyReadAt postgres.ColumnTimestampz
CreatedAt postgres.ColumnTimestampz
+ Lang postgres.ColumnString
AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList
@@ -80,16 +79,15 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentNameColumn = postgres.StringColumn("attachment_name")
SenderIPColumn = postgres.StringColumn("sender_ip")
ChannelColumn = postgres.StringColumn("channel")
- LangColumn = postgres.StringColumn("lang")
- ChannelLangColumn = postgres.StringColumn("channel_lang")
ReadAtColumn = postgres.TimestampzColumn("read_at")
ArchivedAtColumn = postgres.TimestampzColumn("archived_at")
ReplyBodyColumn = postgres.StringColumn("reply_body")
RepliedAtColumn = postgres.TimestampzColumn("replied_at")
ReplyReadAtColumn = postgres.TimestampzColumn("reply_read_at")
CreatedAtColumn = postgres.TimestampzColumn("created_at")
- allColumns = postgres.ColumnList{MessageIDColumn, AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
- mutableColumns = postgres.ColumnList{AccountIDColumn, BodyColumn, AttachmentColumn, AttachmentNameColumn, SenderIPColumn, ChannelColumn, LangColumn, ChannelLangColumn, ReadAtColumn, ArchivedAtColumn, ReplyBodyColumn, RepliedAtColumn, ReplyReadAtColumn, CreatedAtColumn}
+ LangColumn = postgres.StringColumn("lang")
+ 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}
)
@@ -104,14 +102,13 @@ func newFeedbackMessagesTableImpl(schemaName, tableName, alias string) feedbackM
AttachmentName: AttachmentNameColumn,
SenderIP: SenderIPColumn,
Channel: ChannelColumn,
- Lang: LangColumn,
- ChannelLang: ChannelLangColumn,
ReadAt: ReadAtColumn,
ArchivedAt: ArchivedAtColumn,
ReplyBody: ReplyBodyColumn,
RepliedAt: RepliedAtColumn,
ReplyReadAt: ReplyReadAtColumn,
CreatedAt: CreatedAtColumn,
+ Lang: LangColumn,
AllColumns: allColumns,
MutableColumns: mutableColumns,
diff --git a/backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql b/backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql
new file mode 100644
index 0000000..421d49e
--- /dev/null
+++ b/backend/internal/postgres/migrations/00014_single_bot_variant_preferences.sql
@@ -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;
diff --git a/backend/internal/pushgrpc/server.go b/backend/internal/pushgrpc/server.go
index 5bb4661..8a3dcad 100644
--- a/backend/internal/pushgrpc/server.go
+++ b/backend/internal/pushgrpc/server.go
@@ -54,11 +54,10 @@ func (s *Service) Subscribe(req *pushv1.SubscribeRequest, stream grpc.ServerStre
return nil
}
ev := &pushv1.Event{
- UserId: in.UserID.String(),
- Kind: in.Kind,
- Payload: in.Payload,
- EventId: in.EventID,
- Language: in.Language,
+ UserId: in.UserID.String(),
+ Kind: in.Kind,
+ Payload: in.Payload,
+ EventId: in.EventID,
}
if err := stream.Send(ev); err != nil {
return err
diff --git a/backend/internal/server/banner.go b/backend/internal/server/banner.go
index af91626..c87d70d 100644
--- a/backend/internal/server/banner.go
+++ b/backend/internal/server/banner.go
@@ -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)
-// language they last signed in through, falling back to the interface language
-// and then English.
+// bannerLang resolves the message language for a viewer: their interface language
+// (Russian, or English by default).
func bannerLang(acc account.Account) string {
- if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
- return acc.ServiceLanguage
- }
if acc.PreferredLanguage == "ru" {
return "ru"
}
diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go
index 68ba1e7..9b97885 100644
--- a/backend/internal/server/dto.go
+++ b/backend/internal/server/dto.go
@@ -16,11 +16,10 @@ import (
// sessionResponse is the credential returned by every auth endpoint.
type sessionResponse struct {
- Token string `json:"token"`
- UserID string `json:"user_id"`
- IsGuest bool `json:"is_guest"`
- DisplayName string `json:"display_name"`
- ServiceLanguage string `json:"service_language"`
+ Token string `json:"token"`
+ UserID string `json:"user_id"`
+ IsGuest bool `json:"is_guest"`
+ DisplayName string `json:"display_name"`
}
// okResponse is a simple success acknowledgement.
@@ -49,6 +48,10 @@ type profileResponse struct {
BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"`
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
// see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go.
@@ -177,11 +180,10 @@ type errorBody struct {
// sessionResponseFor builds the credential payload for a minted session.
func sessionResponseFor(token string, acc account.Account) sessionResponse {
return sessionResponse{
- Token: token,
- UserID: acc.ID.String(),
- IsGuest: acc.IsGuest,
- DisplayName: acc.DisplayName,
- ServiceLanguage: acc.ServiceLanguage,
+ Token: token,
+ UserID: acc.ID.String(),
+ IsGuest: acc.IsGuest,
+ DisplayName: acc.DisplayName,
}
}
@@ -199,6 +201,7 @@ func profileResponseFor(acc account.Account) profileResponse {
BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
+ VariantPreferences: acc.VariantPreferences,
}
}
diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go
index 7df096c..ed3a3c7 100644
--- a/backend/internal/server/handlers_account.go
+++ b/backend/internal/server/handlers_account.go
@@ -18,14 +18,15 @@ import (
// updateProfileRequest is the full editable profile. away_start/away_end are
// "HH:MM" local-time bounds of the daily away window.
type updateProfileRequest struct {
- DisplayName string `json:"display_name"`
- PreferredLanguage string `json:"preferred_language"`
- TimeZone string `json:"time_zone"`
- AwayStart string `json:"away_start"`
- AwayEnd string `json:"away_end"`
- BlockChat bool `json:"block_chat"`
- BlockFriendRequests bool `json:"block_friend_requests"`
- NotificationsInAppOnly bool `json:"notifications_in_app_only"`
+ DisplayName string `json:"display_name"`
+ PreferredLanguage string `json:"preferred_language"`
+ TimeZone string `json:"time_zone"`
+ AwayStart string `json:"away_start"`
+ AwayEnd string `json:"away_end"`
+ BlockChat bool `json:"block_chat"`
+ BlockFriendRequests bool `json:"block_friend_requests"`
+ 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
@@ -92,6 +93,7 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
BlockChat: req.BlockChat,
BlockFriendRequests: req.BlockFriendRequests,
NotificationsInAppOnly: req.NotificationsInAppOnly,
+ VariantPreferences: req.VariantPreferences,
})
if err != nil {
s.abortErr(c, err)
diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go
index c7a1013..fac9fde 100644
--- a/backend/internal/server/handlers_admin_console.go
+++ b/backend/internal/server/handlers_admin_console.go
@@ -424,7 +424,6 @@ func (s *Server) consoleUserMessage(c *gin.Context) {
}
back := "/_gm/users/" + id.String()
text := trimForm(c, "text")
- language := trimForm(c, "language")
switch {
case text == "":
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)
return
}
- delivered, err := s.connector.SendToUser(ctx, ext, text, language)
+ delivered, err := s.connector.SendToUser(ctx, ext, text)
if err != nil {
s.consoleError(c, err)
return
}
body := "message 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)
}
@@ -833,21 +832,20 @@ func (s *Server) consoleBroadcast(c *gin.Context) {
// consolePostBroadcast posts an operator message to the connector's game channel.
func (s *Server) consolePostBroadcast(c *gin.Context) {
text := trimForm(c, "text")
- language := trimForm(c, "language")
switch {
case text == "":
s.renderConsoleMessage(c, "Nothing sent", "the message was empty", "/_gm/broadcast")
case s.connector == nil:
s.renderConsoleMessage(c, "Not configured", "the connector is not configured (set BACKEND_CONNECTOR_ADDR)", "/_gm/broadcast")
default:
- delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text, language)
+ delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text)
if err != nil {
s.consoleError(c, err)
return
}
body := "posted to the game channel"
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")
}
diff --git a/backend/internal/server/handlers_admin_feedback.go b/backend/internal/server/handlers_admin_feedback.go
index 4a04384..582e6fe 100644
--- a/backend/internal/server/handlers_admin_feedback.go
+++ b/backend/internal/server/handlers_admin_feedback.go
@@ -79,7 +79,7 @@ func (s *Server) consoleFeedbackDetail(c *gin.Context) {
}
view := adminconsole.FeedbackDetailView{
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,
HasAttachment: m.HasAttachment, AttachmentName: m.AttachmentName, IsImage: feedback.IsImage(m.AttachmentName),
Read: m.Read, Archived: m.Archived, Replied: m.Replied, ReplyBody: m.ReplyBody,
diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go
index 1fed516..59bc100 100644
--- a/backend/internal/server/handlers_auth.go
+++ b/backend/internal/server/handlers_auth.go
@@ -19,20 +19,16 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
-// 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 {
- ExternalID string `json:"external_id"`
- Username string `json:"username"`
- FirstName string `json:"first_name"`
- LanguageCode string `json:"language_code"`
- ServiceLanguage string `json:"service_language"`
+ ExternalID string `json:"external_id"`
+ Username string `json:"username"`
+ FirstName string `json:"first_name"`
+ LanguageCode string `json:"language_code"`
}
// 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
-// language from the supplied Telegram fields and recording the validating bot's
-// service language (updated every login) so out-of-app push routes to that bot.
+// language from the supplied Telegram fields (first contact only).
func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
@@ -44,11 +40,6 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
s.abortErr(c, err)
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)
}
@@ -59,10 +50,9 @@ type pushTargetRequest struct {
// 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
-// identity, e.g. a guest or email-only account), the language that both selects the
-// delivering bot and renders the message (the account's service language, the bot
-// it last signed in through, falling back to its preferred language), and whether
-// they confined notifications to the in-app stream.
+// identity, e.g. a guest or email-only account), the language the single bot renders
+// the message in (the account's interface language), and whether they confined
+// notifications to the in-app stream.
type pushTargetResponse struct {
ExternalID string `json:"external_id"`
Language string `json:"language"`
@@ -94,15 +84,9 @@ func (s *Server) handlePushTarget(c *gin.Context) {
s.abortErr(c, err)
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{
ExternalID: ext,
- Language: language,
+ Language: acc.PreferredLanguage,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
})
}
diff --git a/backend/internal/server/handlers_invitations.go b/backend/internal/server/handlers_invitations.go
index d6614ad..98a9ce3 100644
--- a/backend/internal/server/handlers_invitations.go
+++ b/backend/internal/server/handlers_invitations.go
@@ -104,6 +104,9 @@ func (s *Server) handleCreateInvitation(c *gin.Context) {
abortBadRequest(c, "unknown variant")
return
}
+ if !s.ensureVariantAllowed(c, uid, variant.String()) {
+ return
+ }
settings := lobby.InvitationSettings{
Variant: variant,
HintsAllowed: req.HintsAllowed,
diff --git a/backend/internal/server/handlers_user.go b/backend/internal/server/handlers_user.go
index e15e041..9faf71e 100644
--- a/backend/internal/server/handlers_user.go
+++ b/backend/internal/server/handlers_user.go
@@ -2,8 +2,10 @@ package server
import (
"net/http"
+ "slices"
"github.com/gin-gonic/gin"
+ "github.com/google/uuid"
"scrabble/backend/internal/engine"
)
@@ -134,6 +136,24 @@ func (s *Server) handleGameState(c *gin.Context) {
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
// starts an honest-AI game against a robot instead of the open/wait matchmaking path.
type enqueueRequest struct {
@@ -162,6 +182,9 @@ func (s *Server) handleEnqueue(c *gin.Context) {
abortBadRequest(c, "unknown variant")
return
}
+ if !s.ensureVariantAllowed(c, uid, variant.String()) {
+ return
+ }
if !s.ensureUnderGameLimit(c, uid) {
return
}
diff --git a/backend/internal/social/chat.go b/backend/internal/social/chat.go
index 27e8329..3c5bd7f 100644
--- a/backend/internal/social/chat.go
+++ b/backend/internal/social/chat.go
@@ -206,9 +206,6 @@ func (svc *Service) Nudge(ctx context.Context, gameID, senderID uuid.UUID) (Mess
// read ": …"; an unresolved name (best-effort) falls back to the plain phrase.
senderName, _ := svc.games.SeatName(ctx, gameID, senderID)
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)
}
return msg, nil
diff --git a/backend/internal/social/social.go b/backend/internal/social/social.go
index ac86a1b..f97740b 100644
--- a/backend/internal/social/social.go
+++ b/backend/internal/social/social.go
@@ -44,9 +44,6 @@ type GameReader interface {
// one-chat-message-per-turn limit (a message counts toward the current turn when it
// was posted at or after this time).
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
// both disabled (the game still reports status 'active').
VsAI(ctx context.Context, gameID uuid.UUID) (bool, error)
diff --git a/deploy/.env.example b/deploy/.env.example
index 236b4c1..7bfe49f 100644
--- a/deploy/.env.example
+++ b/deploy/.env.example
@@ -29,26 +29,18 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
# --- UI build args (baked into the gateway image) ---------------------------
VITE_TELEGRAM_BOT_ID=
-VITE_TELEGRAM_LINK= # fallback friend-invite Mini App link (per-bot below)
-VITE_TELEGRAM_LINK_EN= # friend-invite Mini App link, English bot (full URL, e.g. https://t.me//)
-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_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me//)
+VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
VITE_GATEWAY_URL=
-# --- Gateway ----------------------------------------------------------------
-GATEWAY_DEFAULT_SUPPORTED_LANGUAGES=en,ru
-
# --- Grafana ----------------------------------------------------------------
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
GRAFANA_ADMIN_PASSWORD=admin
# --- Telegram connector -----------------------------------------------------
AWG_CONF= # required; AmneziaWG sidecar config
-TELEGRAM_BOT_TOKEN_EN= # at least one of EN/RU required
-TELEGRAM_BOT_TOKEN_RU=
-TELEGRAM_GAME_CHANNEL_ID_EN=
-TELEGRAM_GAME_CHANNEL_ID_RU=
+TELEGRAM_BOT_TOKEN= # required
+TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL=
diff --git a/deploy/README.md b/deploy/README.md
index f93ed70..5031fac 100644
--- a/deploy/README.md
+++ b/deploy/README.md
@@ -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 ''`. |
| `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`
-(secrets). Compose cannot express "one of", so they default to empty, but the
-connector **fails at boot** if both are empty.
+**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret). It defaults to empty in
+compose, but the connector **fails at boot** when it is empty.
## 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. |
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https:///_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. |
-| `TELEGRAM_GAME_CHANNEL_ID_EN` | variable | _(empty)_ | English 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_GAME_CHANNEL_ID` | variable | _(empty)_ | The bot's game-channel id; empty/`0` disables channel posts. |
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot/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`. |
-| `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_LINK` | variable | _(empty)_ | UI build-arg: **fallback** friend-invite Mini App link (e.g. `https://t.me//`), used when the per-bot link below is unset. |
-| `VITE_TELEGRAM_LINK_EN` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link for the **English** bot (full URL, `https://t.me//` — `` 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_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_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me//` — `` is the Mini App short name from BotFather). |
+| `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_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
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 84b930c..1046b9d 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -121,10 +121,7 @@ services:
args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
- VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
- 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_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
@@ -135,7 +132,6 @@ services:
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
GATEWAY_CONNECTOR_ADDR: telegram:9091
- GATEWAY_DEFAULT_SUPPORTED_LANGUAGES: ${GATEWAY_DEFAULT_SUPPORTED_LANGUAGES:-en,ru}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp
@@ -171,10 +167,7 @@ services:
args:
VITE_TELEGRAM_BOT_ID: ${VITE_TELEGRAM_BOT_ID:-}
VITE_TELEGRAM_LINK: ${VITE_TELEGRAM_LINK:-}
- VITE_TELEGRAM_LINK_EN: ${VITE_TELEGRAM_LINK_EN:-}
- 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_TELEGRAM_GAME_CHANNEL_NAME: ${VITE_TELEGRAM_GAME_CHANNEL_NAME:-}
VITE_GATEWAY_URL: ${VITE_GATEWAY_URL:-}
VITE_APP_VERSION: ${APP_VERSION:-dev}
restart: unless-stopped
@@ -209,12 +202,11 @@ services:
depends_on: [vpn]
network_mode: "service:vpn"
environment:
- # The bot tokens live ONLY in this container (ARCHITECTURE.md §12). At least
- # one token is required (the connector validates this at boot).
- TELEGRAM_BOT_TOKEN_EN: ${TELEGRAM_BOT_TOKEN_EN:-}
- TELEGRAM_BOT_TOKEN_RU: ${TELEGRAM_BOT_TOKEN_RU:-}
- TELEGRAM_GAME_CHANNEL_ID_EN: ${TELEGRAM_GAME_CHANNEL_ID_EN:-}
- TELEGRAM_GAME_CHANNEL_ID_RU: ${TELEGRAM_GAME_CHANNEL_ID_RU:-}
+ # The bot token lives ONLY in this container (ARCHITECTURE.md §12). The connector
+ # requires it at boot; an empty value leaves the Telegram side down while the rest
+ # of the contour still comes up.
+ TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
+ TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
TELEGRAM_GRPC_ADDR: ":9091"
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 4579c32..3871bcc 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -46,10 +46,9 @@ Three executables plus per-platform side-services:
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/telegram`** — the Telegram side-service (the "connector", module
- `scrabble/platform/telegram`). It is the only component holding the bot tokens — **one
- bot per service language** (`en`/`ru`), each its own token + game channel, the same
- Telegram user id spanning both (§3). It
- runs a Bot API long-poll loop per bot (Mini App launch + `/start` deep-links) and serves
+ `scrabble/platform/telegram`). It is the only component holding the bot token — **one
+ unified bot** (one token + one optional game channel, §3). It
+ runs a Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
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
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
Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4).
-- **Service language & variant gating.** The connector hosts **one bot per
- service language** (`en`/`ru`), each its own token + game channel; the same Telegram
- user id spans both. `ValidateInitData` tries each token in turn and returns the
- validating bot's **service language** and its **supported-languages set**. The set
- rides the **`Session`** (FlatBuffers, session-scoped, not persisted): the UI offers
- only the variants those languages support on New Game (`en` → English; `ru` → Russian
- + Эрудит). **Starting** a new game is the only gated action — opening and playing
- existing games of any language is unrestricted, and the backend does not enforce the
- gate (it is a product affordance, not a trust boundary). The service language is
- **persisted** per account (`accounts.service_language`, updated on every Telegram
- login — last-login-wins) and routes the user's out-of-app push back through the right
- bot (§10) — **except a game event, which routes by the game's own language** (its variant →
- en/ru), so a game's notification always comes from the game's bot rather than the
- recipient's latest login bot. It also rides the **Session wire** to the client, which uses it
- to build the friend-invite **share link** (and its caption) for the **same bot** the player
- is in. The service language is distinct from `preferred_language` (the
- interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the
- gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
+- **Single bot.** The connector hosts **one unified bot** (one token + one optional
+ game channel). `ValidateInitData` validates `initData` against that single token and
+ returns only the Telegram user identity — there is no per-bot "service language" and no
+ supported-languages set on the wire. The bot's chat messages and out-of-app push are
+ rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
+ any bot-scoped language, and the friend-invite **share link** (and its caption) point at
+ that one bot. First Telegram contact seeds the new account's `preferred_language` from the
+ launch `language_code` (§4); the interface language is otherwise edited in Settings.
+- **Variant preferences (New Game gating).** Which variants a player may be matched into is a
+ per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
+ (`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
+ accounts default to **Erudit only** (a DB column default); at least one variant must stay
+ selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New
+ Game picker on every create path the player **initiates** — auto-match, vs-AI and a friend
+ invitation the player **creates** — and the backend **enforces** it on those paths (a chosen
+ variant outside the caller's preferences is rejected with HTTP 400). An **invited** friend
+ may still **accept** an invitation in **any** variant (accepting is never gated), and opening
+ 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
storage is optional and may be unavailable; losing it means re-login).
- 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
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
- 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,
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
- a Telegram `startapp` deep-link to the issuer's own bot (by service language, with a matching
- caption), redeemed by the recipient's Mini App on launch; a **spent or expired** code is not
+ a Telegram `startapp` deep-link to the single bot (with a matching caption),
+ 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
- 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**
(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
@@ -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
latency is recorded; the read time itself is not retained.
- **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:
a display name is Unicode letters joined by single ` `/`.`/`_`
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
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
-`external_id`, the **service language** — the bot they last signed in through, falling
-back to the interface language — and the `notifications_in_app_only` flag). A **game** event,
-however, carries the **game's own language** on the push, and the gateway routes by
-that instead of the service language — so a game's notification always comes from the game's bot,
-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
+`external_id`, the recipient's **interface language** (`preferred_language`) as the render
+language, and the `notifications_in_app_only` flag). It then asks the **Telegram connector**
+to deliver — through the **single bot** — 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
-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;
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
block-state sync to the blocker) never become a platform push. Operator broadcasts
-(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
-**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
+(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
+the console, sent through the same single bot. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
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`**
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
-**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
(`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
diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md
index 97ee0d5..622daff 100644
--- a/docs/FUNCTIONAL.md
+++ b/docs/FUNCTIONAL.md
@@ -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
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
-language from the Telegram client. The sign-in service also declares the **game
-languages** it offers (a set of en/ru, at least one), which gate the New Game variant
-choice in the lobby. Telegram runs a separate bot per language (an English bot and a
-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
+language from the Telegram client. Telegram runs a **single bot**: every player uses
+the same bot, and all of its chat and out-of-app notifications are written in the
+player's own **interface language** (en/ru). Guests are session-only with restricted features
(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
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
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
-limited to the languages the player's sign-in service supports (English → Scrabble;
-Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
-unrestricted). Variants are shown by their **display name** — both Scrabble variants read
+limited to the player's chosen **variant preferences** (see *Profile & settings*) —
+Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read
"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
-invitation — so a player still sees and plays existing games of any language.
+the same name titles the in-game screen. This gates only **starting** a new game you initiate —
+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**.
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 &
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
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,
diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md
index b16ac48..bf7c672 100644
--- a/docs/FUNCTIONAL_ru.md
+++ b/docs/FUNCTIONAL_ru.md
@@ -32,13 +32,9 @@ top-1 подсказку, безлимитную проверку слова с
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
-языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он
-предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в
-лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок
-охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки, и от него
-приходят его **внеигровые** уведомления. А уведомления по **партии** (ваш ход, конец партии,
-nudge) приходят от бота **этой партии** — по языку партии, а не по тому боту, через которого
-игрок входил последним. Гость — только сессия, с урезанными функциями (только
+языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним
+и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
+интерфейса** самого игрока (en/ru). Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
@@ -87,13 +83,13 @@ nudge) приходят от бота **этой партии** — по язы
из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
на экране **Новая игра**
-ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
-русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
-ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
+ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
+настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
-и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
-приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
-любом языке.
+и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
+инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
+по-прежнему видит и играет существующие игры любого варианта, а при **приглашении** в партию её
+можно принять в любом варианте.
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
@@ -243,6 +239,15 @@ UTC), суточного окна отсутствия (away; сетка по 10
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
+**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
+игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
+которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
+оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
+запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
+включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
+**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
+а открывать и играть существующие игры любого варианта можно всегда.
+
### Обратная связь
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md
index 45cc441..7714494 100644
--- a/docs/UI_DESIGN.md
+++ b/docs/UI_DESIGN.md
@@ -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
the icon copies it. **Share via Telegram** wraps the code in a `t.me//?startapp=`
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
- through** (its service language). Redeeming your **own** invite shows a friendly note, not an
+ outside Telegram); the link and its caption are for the **single bot**. Redeeming your **own**
+ invite shows a friendly note, not an
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 (`@`), not a red error on the
+ lobby with a calm modal pointing at the bot (`@`), not a red error on the
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
Safari.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid
diff --git a/gateway/README.md b/gateway/README.md
index 3a4ff56..cb47331 100644
--- a/gateway/README.md
+++ b/gateway/README.md
@@ -77,7 +77,6 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_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_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) |
diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go
index 1dbc951..7364889 100644
--- a/gateway/cmd/gateway/main.go
+++ b/gateway/cmd/gateway/main.go
@@ -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)")
}
- registry := transcode.NewRegistry(backend, validator, cfg.DefaultSupportedLanguages...)
+ registry := transcode.NewRegistry(backend, validator)
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
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
// so a slow connector never stalls the in-app firehose.
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) {
@@ -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
// connector to deliver the event. It is best-effort: every failure is logged and
// 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)
if err != nil {
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) {
return
}
- // A game event carries its own language, so the push comes from the game's bot rather than
- // the recipient's last-login bot; other events fall back to the service language.
- lang := target.Language
- if gameLang != "" {
- lang = gameLang
- }
- if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, lang); err != nil {
+ // The single bot renders the message in the recipient's interface language.
+ if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil {
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err))
}
}
diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go
index f9465e5..3a5e656 100644
--- a/gateway/internal/backendclient/api.go
+++ b/gateway/internal/backendclient/api.go
@@ -14,26 +14,26 @@ import (
// SessionResp is the credential minted by an auth operation.
type SessionResp struct {
- Token string `json:"token"`
- UserID string `json:"user_id"`
- IsGuest bool `json:"is_guest"`
- DisplayName string `json:"display_name"`
- ServiceLanguage string `json:"service_language"`
+ Token string `json:"token"`
+ UserID string `json:"user_id"`
+ IsGuest bool `json:"is_guest"`
+ DisplayName string `json:"display_name"`
}
// ProfileResp is an account's own profile.
type ProfileResp struct {
- UserID string `json:"user_id"`
- DisplayName string `json:"display_name"`
- PreferredLanguage string `json:"preferred_language"`
- TimeZone string `json:"time_zone"`
- AwayStart string `json:"away_start"`
- AwayEnd string `json:"away_end"`
- HintBalance int `json:"hint_balance"`
- BlockChat bool `json:"block_chat"`
- BlockFriendRequests bool `json:"block_friend_requests"`
- IsGuest bool `json:"is_guest"`
- NotificationsInAppOnly bool `json:"notifications_in_app_only"`
+ UserID string `json:"user_id"`
+ DisplayName string `json:"display_name"`
+ PreferredLanguage string `json:"preferred_language"`
+ TimeZone string `json:"time_zone"`
+ AwayStart string `json:"away_start"`
+ AwayEnd string `json:"away_end"`
+ HintBalance int `json:"hint_balance"`
+ BlockChat bool `json:"block_chat"`
+ BlockFriendRequests bool `json:"block_friend_requests"`
+ IsGuest bool `json:"is_guest"`
+ 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
// see the banner. The gateway forwards it verbatim into the Profile payload.
Banner *BannerResp `json:"banner,omitempty"`
@@ -184,18 +184,15 @@ type ChatResp struct {
}
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
-// brand-new account's display name and language from the validated launch fields and
-// recording the validating bot's serviceLanguage (which routes the account's later
-// out-of-app push).
-func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, serviceLanguage string) (SessionResp, error) {
+// brand-new account's display name and language from the validated launch fields.
+func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{
- "external_id": externalID,
- "language_code": languageCode,
- "username": username,
- "first_name": firstName,
- "service_language": serviceLanguage,
+ "external_id": externalID,
+ "language_code": languageCode,
+ "username": username,
+ "first_name": firstName,
}, &out)
return out, err
}
diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go
index 7d774a4..52c00f1 100644
--- a/gateway/internal/backendclient/api_social.go
+++ b/gateway/internal/backendclient/api_social.go
@@ -287,6 +287,7 @@ func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp
"block_chat": p.BlockChat,
"block_friend_requests": p.BlockFriendRequests,
"notifications_in_app_only": p.NotificationsInAppOnly,
+ "variant_preferences": p.VariantPreferences,
}
err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out)
return out, err
diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go
index 482284a..2c4c726 100644
--- a/gateway/internal/config/config.go
+++ b/gateway/internal/config/config.go
@@ -6,7 +6,6 @@ import (
"fmt"
"os"
"strconv"
- "strings"
"time"
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.
// Empty disables the telegram auth path and the out-of-app push channel.
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
// caps the number of cached sessions.
SessionTTL time.Duration
@@ -85,13 +79,6 @@ const (
// yet small enough to stop a cheap memory-amplification upload.
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.
func DefaultRateLimit() RateLimitConfig {
return RateLimitConfig{
@@ -141,9 +128,6 @@ func Load() (Config, error) {
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
return Config{}, err
}
- if c.DefaultSupportedLanguages, err = envLanguages("GATEWAY_DEFAULT_SUPPORTED_LANGUAGES", defaultSupportedLanguages); err != nil {
- return Config{}, err
- }
if err := c.validate(); err != nil {
return Config{}, err
}
@@ -190,31 +174,6 @@ func envOr(key, fallback string) string {
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
// when it is unset and an error when it is set but malformed.
func envInt(key string, fallback int) (int, error) {
diff --git a/gateway/internal/connector/client.go b/gateway/internal/connector/client.go
index bbb0677..8f8f93e 100644
--- a/gateway/internal/connector/client.go
+++ b/gateway/internal/connector/client.go
@@ -27,18 +27,13 @@ var ErrInvalidInitData = errors.New("connector: invalid telegram init data")
// rejects the Login Widget data (a gRPC InvalidArgument).
var ErrInvalidLoginWidget = errors.New("connector: invalid telegram login widget data")
-// User is a validated Mini App identity. ServiceLanguage is the validating bot's
-// language tag (en/ru), persisted to route the user's out-of-app push back through
-// 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).
+// User is a validated Mini App identity. LanguageCode seeds a brand-new account's
+// preferred (interface) language; it is empty for a Login Widget validation.
type User struct {
- ExternalID string
- Username string
- FirstName string
- LanguageCode string
- ServiceLanguage string
- SupportedLanguages []string
+ ExternalID string
+ Username string
+ FirstName string
+ LanguageCode string
}
// 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{
- ExternalID: resp.GetExternalId(),
- Username: resp.GetUsername(),
- FirstName: resp.GetFirstName(),
- LanguageCode: resp.GetLanguageCode(),
- ServiceLanguage: resp.GetServiceLanguage(),
- SupportedLanguages: resp.GetSupportedLanguages(),
+ ExternalID: resp.GetExternalId(),
+ Username: resp.GetUsername(),
+ FirstName: resp.GetFirstName(),
+ LanguageCode: resp.GetLanguageCode(),
}, nil
}
diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go
index 2b44f2e..7ef473f 100644
--- a/gateway/internal/transcode/encode.go
+++ b/gateway/internal/transcode/encode.go
@@ -13,38 +13,17 @@ import (
// created before the table that references it, and no two tables/vectors are
// under construction at once.
-// buildSupportedLanguagesVector creates the Session.supported_languages [string]
-// vector from langs. FlatBuffers is built bottom-up, so the caller must invoke this
-// (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 {
+// encodeSession builds a Session payload.
+func encodeSession(s backendclient.SessionResp) []byte {
b := flatbuffers.NewBuilder(128)
token := b.CreateString(s.Token)
uid := b.CreateString(s.UserID)
name := b.CreateString(s.DisplayName)
- svcLang := b.CreateString(s.ServiceLanguage)
- langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, s.IsGuest)
fb.SessionAddDisplayName(b, name)
- fb.SessionAddSupportedLanguages(b, langs)
- fb.SessionAddServiceLanguage(b, svcLang)
b.Finish(fb.SessionEnd(b))
return b.FinishedBytes()
}
@@ -74,6 +53,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
if p.Banner != nil {
banner = encodeBanner(b, *p.Banner)
}
+ prefs := buildStringVector(b, p.VariantPreferences, fb.ProfileStartVariantPreferencesVector)
fb.ProfileStart(b)
fb.ProfileAddUserId(b, uid)
fb.ProfileAddDisplayName(b, name)
@@ -86,6 +66,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddAwayStart(b, awayStart)
fb.ProfileAddAwayEnd(b, awayEnd)
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
+ fb.ProfileAddVariantPreferences(b, prefs)
if p.Banner != nil {
fb.ProfileAddBanner(b, banner)
}
@@ -147,10 +128,8 @@ func encodeBlockStatus(s backendclient.BlockStatusResp) []byte {
// encodeLinkResult builds a LinkResult payload. A switched-session token
// (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
-// gating set for that switched session — the link flows run on the web, so it is the
-// gateway's default (non-platform) set.
-func encodeLinkResult(r backendclient.LinkResultResp, supportedLangs []string) []byte {
+// for the client to adopt; it is omitted otherwise.
+func encodeLinkResult(r backendclient.LinkResultResp) []byte {
b := flatbuffers.NewBuilder(256)
status := b.CreateString(r.Status)
secID := b.CreateString(r.SecondaryUserID)
@@ -161,13 +140,11 @@ func encodeLinkResult(r backendclient.LinkResultResp, supportedLangs []string) [
token := b.CreateString(r.Token)
uid := b.CreateString(r.Profile.UserID)
name := b.CreateString(r.Profile.DisplayName)
- langs := buildSupportedLanguagesVector(b, supportedLangs)
fb.SessionStart(b)
fb.SessionAddToken(b, token)
fb.SessionAddUserId(b, uid)
fb.SessionAddIsGuest(b, r.Profile.IsGuest)
fb.SessionAddDisplayName(b, name)
- fb.SessionAddSupportedLanguages(b, langs)
sess = fb.SessionEnd(b)
}
fb.LinkResultStart(b)
diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go
index b6c4f03..f50d4df 100644
--- a/gateway/internal/transcode/transcode.go
+++ b/gateway/internal/transcode/transcode.go
@@ -89,20 +89,14 @@ type TelegramValidator interface {
// NewRegistry builds the slice's message-type catalog over the backend client.
// The Telegram auth op is registered only when a validator is supplied (the
// connector is configured); otherwise auth.telegram is simply unknown.
-// defaultLanguages is the New Game variant gating set placed on the Session for
-// 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"}
- }
+func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
r := &Registry{ops: make(map[string]Op)}
if tg != nil {
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[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[MsgBlockStatus] = Op{Handler: blockStatusHandler(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[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
registerSocialOps(r, backend)
- registerLinkOps(r, backend, tg, defaultLanguages)
+ registerLinkOps(r, backend, tg)
return r
}
@@ -164,21 +158,21 @@ func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Ha
if err != nil {
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 {
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) {
sess, err := backend.GuestAuth(ctx)
if err != nil {
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) {
in := fb.GetRootAsEmailLoginRequest(req.Payload, 0)
sess, err := backend.EmailLogin(ctx, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
- return encodeSession(sess, supportedLangs), nil
+ return encodeSession(sess), nil
}
}
diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go
index 007ae02..546c279 100644
--- a/gateway/internal/transcode/transcode_link.go
+++ b/gateway/internal/transcode/transcode_link.go
@@ -22,15 +22,13 @@ const (
// 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.
-// supportedLangs is the variant gating set for a switched link session (the link
-// flows run on the web, so the gateway default set).
-func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) {
+func registerLinkOps(r *Registry, backend *backendclient.Client, tg TelegramValidator) {
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[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true}
+ r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend), Auth: true, Email: true}
+ r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend), Auth: true, Email: true}
if tg != nil {
- r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false, supportedLangs), Auth: true}
- r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true, supportedLangs), Auth: true}
+ r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), 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) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailConfirm(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
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) {
in := fb.GetRootAsLinkEmailConfirm(req.Payload, 0)
res, err := backend.LinkEmailMerge(ctx, req.UserID, string(in.Email()), string(in.Code()))
if err != nil {
return nil, err
}
- return encodeLinkResult(res, supportedLangs), nil
+ return encodeLinkResult(res), nil
}
}
// linkTelegramHandler validates Login Widget data via the connector and then calls
// 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) {
in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0)
user, err := tg.ValidateLoginWidget(ctx, string(in.Data()))
@@ -84,6 +82,6 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me
if err != nil {
return nil, err
}
- return encodeLinkResult(res, supportedLangs), nil
+ return encodeLinkResult(res), nil
}
}
diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go
index 72d1473..e0125cd 100644
--- a/gateway/internal/transcode/transcode_social.go
+++ b/gateway/internal/transcode/transcode_social.go
@@ -246,6 +246,10 @@ func invitationCancelHandler(backend *backendclient.Client) Handler {
func profileUpdateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0)
+ prefs := make([]string, in.VariantPreferencesLength())
+ for i := range prefs {
+ prefs[i] = string(in.VariantPreferences(i))
+ }
p := backendclient.ProfileResp{
DisplayName: string(in.DisplayName()),
PreferredLanguage: string(in.PreferredLanguage()),
@@ -255,6 +259,7 @@ func profileUpdateHandler(backend *backendclient.Client) Handler {
BlockChat: in.BlockChat(),
BlockFriendRequests: in.BlockFriendRequests(),
NotificationsInAppOnly: in.NotificationsInAppOnly(),
+ VariantPreferences: prefs,
}
out, err := backend.UpdateProfile(ctx, req.UserID, p)
if err != nil {
diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go
index 4849056..2d02015 100644
--- a/gateway/internal/transcode/transcode_telegram_test.go
+++ b/gateway/internal/transcode/transcode_telegram_test.go
@@ -43,11 +43,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Errorf("unexpected path %q", r.URL.Path)
}
_ = 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()
- 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)
op, ok := reg.Lookup(transcode.MsgAuthTelegram)
if !ok {
@@ -62,48 +62,9 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if string(sess.Token()) != "tok-tg" || string(sess.UserId()) != "u-tg" {
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
- // New Game variant choice (here: ru -> Russian + Эрудит only).
- if got := sessionLanguages(sess); len(got) != 1 || got[0] != "ru" {
- 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)
+ // The validated launch fields are forwarded so the backend can seed a new account.
+ if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" {
+ t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody)
}
}
diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs
index c396b82..c0f7972 100644
--- a/pkg/fbs/scrabble.fbs
+++ b/pkg/fbs/scrabble.fbs
@@ -123,20 +123,11 @@ table EmailLoginRequest {
}
// 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 {
token:string;
user_id:string;
is_guest:bool;
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).
@@ -190,6 +181,9 @@ table Profile {
away_end:string;
notifications_in_app_only:bool = true;
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
@@ -434,6 +428,7 @@ table UpdateProfileRequest {
block_chat:bool;
block_friend_requests:bool;
notifications_in_app_only:bool = true;
+ variant_preferences:[string];
}
// --- account linking & merge (authenticated) ---
diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go
index 180e6bf..209e23e 100644
--- a/pkg/fbs/scrabblefb/Profile.go
+++ b/pkg/fbs/scrabblefb/Profile.go
@@ -162,8 +162,25 @@ func (rcv *Profile) Banner(obj *BannerInfo) *BannerInfo {
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) {
- builder.StartObject(12)
+ builder.StartObject(13)
}
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
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) {
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 {
return builder.EndObject()
}
diff --git a/pkg/fbs/scrabblefb/Session.go b/pkg/fbs/scrabblefb/Session.go
index 98a12fd..de425d8 100644
--- a/pkg/fbs/scrabblefb/Session.go
+++ b/pkg/fbs/scrabblefb/Session.go
@@ -77,33 +77,8 @@ func (rcv *Session) DisplayName() []byte {
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) {
- builder.StartObject(6)
+ builder.StartObject(4)
}
func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) {
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) {
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 {
return builder.EndObject()
}
diff --git a/pkg/fbs/scrabblefb/UpdateProfileRequest.go b/pkg/fbs/scrabblefb/UpdateProfileRequest.go
index 980219a..4a676ed 100644
--- a/pkg/fbs/scrabblefb/UpdateProfileRequest.go
+++ b/pkg/fbs/scrabblefb/UpdateProfileRequest.go
@@ -117,8 +117,25 @@ func (rcv *UpdateProfileRequest) MutateNotificationsInAppOnly(n bool) bool {
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) {
- builder.StartObject(8)
+ builder.StartObject(9)
}
func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0)
@@ -144,6 +161,12 @@ func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, bl
func UpdateProfileRequestAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
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 {
return builder.EndObject()
}
diff --git a/pkg/proto/push/v1/push.pb.go b/pkg/proto/push/v1/push.pb.go
index 97b1996..45d49a3 100644
--- a/pkg/proto/push/v1/push.pb.go
+++ b/pkg/proto/push/v1/push.pb.go
@@ -79,16 +79,11 @@ func (x *SubscribeRequest) GetGatewayId() string {
// FlatBuffers-encoded body for that kind. event_id is a correlation id the
// gateway carries through to the client envelope.
type Event struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- 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"`
- 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"`
- // 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"`
+ state protoimpl.MessageState `protogen:"open.v1"`
+ 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"`
+ 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"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -151,13 +146,6 @@ func (x *Event) GetEventId() string {
return ""
}
-func (x *Event) GetLanguage() string {
- if x != nil {
- return x.Language
- }
- return ""
-}
-
var File_push_v1_push_proto protoreflect.FileDescriptor
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" +
"\x10SubscribeRequest\x12\x1d\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" +
"\auser_id\x18\x01 \x01(\tR\x06userId\x12\x12\n" +
"\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" +
"\apayload\x18\x03 \x01(\fR\apayload\x12\x19\n" +
- "\bevent_id\x18\x04 \x01(\tR\aeventId\x12\x1a\n" +
- "\blanguage\x18\x05 \x01(\tR\blanguage2R\n" +
+ "\bevent_id\x18\x04 \x01(\tR\aeventId2R\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"
diff --git a/pkg/proto/push/v1/push.proto b/pkg/proto/push/v1/push.proto
index ae0bf18..40fbbb2 100644
--- a/pkg/proto/push/v1/push.proto
+++ b/pkg/proto/push/v1/push.proto
@@ -33,9 +33,4 @@ message Event {
string kind = 2;
bytes payload = 3;
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;
}
diff --git a/pkg/proto/telegram/v1/telegram.pb.go b/pkg/proto/telegram/v1/telegram.pb.go
index ac2a884..9af8fce 100644
--- a/pkg/proto/telegram/v1/telegram.pb.go
+++ b/pkg/proto/telegram/v1/telegram.pb.go
@@ -79,22 +79,15 @@ func (x *ValidateInitDataRequest) GetInitData() string {
// 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
-// preferred (interface) language. service_language (en/ru) is the language tag of
-// 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.
+// preferred (interface) language.
type ValidateInitDataResponse struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- 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"`
- 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"`
- ServiceLanguage string `protobuf:"bytes,5,opt,name=service_language,json=serviceLanguage,proto3" json:"service_language,omitempty"`
- SupportedLanguages []string `protobuf:"bytes,6,rep,name=supported_languages,json=supportedLanguages,proto3" json:"supported_languages,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ state protoimpl.MessageState `protogen:"open.v1"`
+ 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"`
+ 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"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *ValidateInitDataResponse) Reset() {
@@ -155,20 +148,6 @@ func (x *ValidateInitDataResponse) GetLanguageCode() string {
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
// query string (the widget fields plus the hash, e.g. "auth_date=...&id=...&hash=...").
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
// 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
-// language (from their last ValidateInitData) — it both selects the delivering bot
-// and the message template.
+// scrabblefb.* body for that kind; language (en/ru) is the recipient's interface
+// language, used to render the message template (the single bot needs no routing).
type NotifyRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
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
}
-// SendToUserRequest is an admin text message to one user by external_id. language
-// (en/ru) selects which bot delivers it — an operator choice in the admin console,
-// unrelated to the user's service language.
+// SendToUserRequest is an admin text message to one user by external_id.
type SendToUserRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
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"`
- Language string `protobuf:"bytes,3,opt,name=language,proto3" json:"language,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -453,20 +428,10 @@ func (x *SendToUserRequest) GetText() string {
return ""
}
-func (x *SendToUserRequest) GetLanguage() string {
- 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.
+// SendToGameChannelRequest is an admin text message to the bot's game channel.
type SendToGameChannelRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
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
sizeCache protoimpl.SizeCache
}
@@ -508,13 +473,6 @@ func (x *SendToGameChannelRequest) GetText() string {
return ""
}
-func (x *SendToGameChannelRequest) GetLanguage() string {
- if x != nil {
- return x.Language
- }
- return ""
-}
-
// SendResponse reports whether the message was sent.
type SendResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -566,16 +524,14 @@ const file_telegram_v1_telegram_proto_rawDesc = "" +
"\n" +
"\x1atelegram/v1/telegram.proto\x12\x14scrabble.telegram.v1\"6\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" +
"\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x1a\n" +
"\busername\x18\x02 \x01(\tR\busername\x12\x1d\n" +
"\n" +
"first_name\x18\x03 \x01(\tR\tfirstName\x12#\n" +
- "\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\x12)\n" +
- "\x10service_language\x18\x05 \x01(\tR\x0fserviceLanguage\x12/\n" +
- "\x13supported_languages\x18\x06 \x03(\tR\x12supportedLanguages\"0\n" +
+ "\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\"0\n" +
"\x1aValidateLoginWidgetRequest\x12\x12\n" +
"\x04data\x18\x01 \x01(\tR\x04data\"y\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" +
"\blanguage\x18\x04 \x01(\tR\blanguage\".\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" +
"\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x12\n" +
- "\x04text\x18\x02 \x01(\tR\x04text\x12\x1a\n" +
- "\blanguage\x18\x03 \x01(\tR\blanguage\"J\n" +
+ "\x04text\x18\x02 \x01(\tR\x04text\".\n" +
"\x18SendToGameChannelRequest\x12\x12\n" +
- "\x04text\x18\x01 \x01(\tR\x04text\x12\x1a\n" +
- "\blanguage\x18\x02 \x01(\tR\blanguage\",\n" +
+ "\x04text\x18\x01 \x01(\tR\x04text\",\n" +
"\fSendResponse\x12\x1c\n" +
"\tdelivered\x18\x01 \x01(\bR\tdelivered2\x92\x04\n" +
"\bTelegram\x12q\n" +
diff --git a/pkg/proto/telegram/v1/telegram.proto b/pkg/proto/telegram/v1/telegram.proto
index f70e50f..cc39abd 100644
--- a/pkg/proto/telegram/v1/telegram.proto
+++ b/pkg/proto/telegram/v1/telegram.proto
@@ -31,13 +31,11 @@ service Telegram {
// localized message with a Mini App deep-link button from the FlatBuffers
// payload; unrenderable kinds are skipped (delivered=false).
rpc Notify(NotifyRequest) returns (NotifyResponse);
- // SendToUser sends an arbitrary text message to one user through the bot the
- // request selects by language (admin use). delivered is false
- // when the user has not started that bot.
+ // SendToUser sends an arbitrary text message to one user (admin use). delivered is
+ // false when the user has not started the bot.
rpc SendToUser(SendToUserRequest) returns (SendResponse);
- // SendToGameChannel posts an arbitrary text message to the game channel of the
- // bot the request selects by language (admin use); the channel
- // ids live only in the connector configuration.
+ // SendToGameChannel posts an arbitrary text message to the bot's game channel
+ // (admin use); the channel id lives only in the connector configuration.
rpc SendToGameChannel(SendToGameChannelRequest) returns (SendResponse);
}
@@ -48,19 +46,12 @@ message ValidateInitDataRequest {
// 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
-// preferred (interface) language. service_language (en/ru) is the language tag of
-// 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.
+// preferred (interface) language.
message ValidateInitDataResponse {
string external_id = 1;
string username = 2;
string first_name = 3;
string language_code = 4;
- string service_language = 5;
- repeated string supported_languages = 6;
}
// 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
// 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
-// language (from their last ValidateInitData) — it both selects the delivering bot
-// and the message template.
+// scrabblefb.* body for that kind; language (en/ru) is the recipient's interface
+// language, used to render the message template (the single bot needs no routing).
message NotifyRequest {
string external_id = 1;
string kind = 2;
@@ -96,21 +86,15 @@ message NotifyResponse {
bool delivered = 1;
}
-// SendToUserRequest is an admin text message to one user by external_id. language
-// (en/ru) selects which bot delivers it — an operator choice in the admin console,
-// unrelated to the user's service language.
+// SendToUserRequest is an admin text message to one user by external_id.
message SendToUserRequest {
string external_id = 1;
string text = 2;
- string language = 3;
}
-// 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.
+// SendToGameChannelRequest is an admin text message to the bot's game channel.
message SendToGameChannelRequest {
string text = 1;
- string language = 2;
}
// SendResponse reports whether the message was sent.
diff --git a/pkg/proto/telegram/v1/telegram_grpc.pb.go b/pkg/proto/telegram/v1/telegram_grpc.pb.go
index 8099e7d..0bcfb01 100644
--- a/pkg/proto/telegram/v1/telegram_grpc.pb.go
+++ b/pkg/proto/telegram/v1/telegram_grpc.pb.go
@@ -58,13 +58,11 @@ type TelegramClient interface {
// localized message with a Mini App deep-link button from the FlatBuffers
// payload; unrenderable kinds are skipped (delivered=false).
Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error)
- // SendToUser sends an arbitrary text message to one user through the bot the
- // request selects by language (admin use). delivered is false
- // when the user has not started that bot.
+ // SendToUser sends an arbitrary text message to one user (admin use). delivered is
+ // false when the user has not started the bot.
SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error)
- // SendToGameChannel posts an arbitrary text message to the game channel of the
- // bot the request selects by language (admin use); the channel
- // ids live only in the connector configuration.
+ // SendToGameChannel posts an arbitrary text message to the bot's game channel
+ // (admin use); the channel id lives only in the connector configuration.
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
// payload; unrenderable kinds are skipped (delivered=false).
Notify(context.Context, *NotifyRequest) (*NotifyResponse, error)
- // SendToUser sends an arbitrary text message to one user through the bot the
- // request selects by language (admin use). delivered is false
- // when the user has not started that bot.
+ // SendToUser sends an arbitrary text message to one user (admin use). delivered is
+ // false when the user has not started the bot.
SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error)
- // SendToGameChannel posts an arbitrary text message to the game channel of the
- // bot the request selects by language (admin use); the channel
- // ids live only in the connector configuration.
+ // SendToGameChannel posts an arbitrary text message to the bot's game channel
+ // (admin use); the channel id lives only in the connector configuration.
SendToGameChannel(context.Context, *SendToGameChannelRequest) (*SendResponse, error)
mustEmbedUnimplementedTelegramServer()
}
diff --git a/platform/telegram/README.md b/platform/telegram/README.md
index 07f83d5..f315464 100644
--- a/platform/telegram/README.md
+++ b/platform/telegram/README.md
@@ -1,22 +1,20 @@
# scrabble/platform/telegram — Telegram connector
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 +
-deep-links) and serves the connector gRPC API that the gateway and backend call over
-the trusted internal network. See
-[`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
+token: it runs a Bot API long-poll loop (Mini App launch + deep-links) and serves
+the connector gRPC API that the gateway and backend call over the trusted internal
+network. See [`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
-token + game channel, configured by the `*_EN` / `*_RU` env vars; at least one is
-required. The **same Telegram user id spans both bots**. `ValidateInitData` tries
-each bot's token in turn (none validates ⇒ invalid) and reports which bot validated:
-its **`service_language`** (persisted by the backend to route the user's later push)
-and its **`supported_languages`** set (which the UI gates the New Game variant choice
-by — `en` → English, `ru` → Russian + Эрудит). The user-facing `Notify` routes by the
-recipient's persisted service language; the admin `SendToUser` / `SendToGameChannel`
-route by an **operator-chosen** `language` (unrelated to login).
+The connector hosts **one unified bot** — one token plus one optional game channel,
+configured by `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GAME_CHANNEL_ID`. `ValidateInitData`
+validates `initData` against that single token (it does not validate ⇒ invalid) and
+returns only the Telegram user identity — there is no per-bot service language and no
+supported-languages set. Every message the bot sends is rendered in the **recipient's
+interface language**: the user-facing `Notify` renders in the request's `language` (the
+recipient's interface language); the admin `SendToUser` / `SendToGameChannel` render in
+an **operator-chosen** `language`. No call routes between bots — there is only one.
## 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.
- **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
- localized message with a Mini App launch button and sends it **through the bot for
- the request's `language`** (the recipient's service language). The gateway calls it
- **only** for a recipient with no live in-app stream and the
+ localized message with a Mini App launch button and sends it through the **single
+ bot**, rendered in the request's `language` (the recipient's interface language).
+ 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
delivery.
- **Bot chat.** `/start ` (and the chat menu button) reply with a Mini App
launch button; a deep-link payload routes the launch to a game / invitation /
friend code.
- **Admin messaging.** `SendToUser` and `SendToGameChannel` send
- arbitrary text to one user or a game channel through the bot the request selects by
- `language` (an operator choice in the admin console).
+ arbitrary text to one user or a game channel through the single bot, rendered in the
+ `language` the operator chooses in the admin console.
The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a
recipient by the identity `external_id` (as in the backend `identities` table), so a
@@ -71,11 +69,9 @@ The bot turns a `/start ` or a notification target into a launch-button
| Env var | Default | Meaning |
| --- | --- | --- |
-| `TELEGRAM_BOT_TOKEN_EN` | — | English 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_EN` | — | English bot's game channel chat id for `SendToGameChannel` |
-| `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_BOT_TOKEN` | — (required) | The bot's API token + initData HMAC secret |
+| `TELEGRAM_GAME_CHANNEL_ID` | — | The bot's game channel chat id for `SendToGameChannel` |
+| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) |
| `TELEGRAM_GRPC_ADDR` | `:9091` | connector gRPC listen address |
| `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/test/METHOD`) |
@@ -93,7 +89,7 @@ builds `/bot/`).
```sh
go build ./platform/telegram/...
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
diff --git a/platform/telegram/cmd/telegram/main.go b/platform/telegram/cmd/telegram/main.go
index 4e50229..8d5f375 100644
--- a/platform/telegram/cmd/telegram/main.go
+++ b/platform/telegram/cmd/telegram/main.go
@@ -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))
}
- // One bot per configured service language; ValidateInitData tries each token and
- // the push/admin methods route by language.
- var bots []*bot.Bot
- var runtimes []connector.BotRuntime
- var langs []string
- for _, lang := range config.Languages {
- bc, ok := cfg.Bots[lang]
- if !ok {
- continue
- }
- 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)
+ // The single bot validates launch data and delivers push/admin messages.
+ b, err := bot.New(bot.Config{
+ Token: cfg.Token,
+ APIBaseURL: cfg.APIBaseURL,
+ TestEnv: cfg.TestEnv,
+ MiniAppURL: cfg.MiniAppURL,
+ }, logger)
+ if err != nil {
+ return err
}
- 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()))
telegramv1.RegisterTelegramServer(grpcServer, srv)
@@ -106,11 +92,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err
}
- // The long-poll loops and the gRPC server run together; cancelling the context
- // stops every bot loop and gracefully drains the gRPC server.
- for _, b := range bots {
- go b.Run(ctx)
- }
+ // The long-poll loop and the gRPC server run together; cancelling the context
+ // stops the bot loop and gracefully drains the gRPC server.
+ go b.Run(ctx)
go func() {
<-ctx.Done()
grpcServer.GracefulStop()
@@ -119,7 +103,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("telegram connector starting",
zap.String("grpc_addr", cfg.GRPCAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
- zap.Strings("languages", langs),
zap.Bool("test_env", cfg.TestEnv))
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
return err
diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go
index ebbbd5c..2c0b46e 100644
--- a/platform/telegram/internal/config/config.go
+++ b/platform/telegram/internal/config/config.go
@@ -10,40 +10,26 @@ import (
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_). 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_, optional; 0 disables channel posts).
- GameChannelID int64
-}
-
// 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 {
- // Bots maps a service language (one of Languages) to that language's bot
- // settings. A language is present when its TELEGRAM_BOT_TOKEN_ is set; at
- // least one bot is required.
- Bots map[string]BotConfig
+ // Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). It both
+ // authenticates the Bot API client and is the HMAC secret for Mini App initData
+ // and Login Widget validation.
+ 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
// backend call (TELEGRAM_GRPC_ADDR, default :9091).
GRPCAddr string
// 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
- // query parameter (TELEGRAM_MINIAPP_URL, required). It is shared by all bots
- // (one gateway origin); initData is signed per bot token.
+ // query parameter (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
// 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
- // Bot API server. Shared by all bots.
+ // Bot API server.
APIBaseURL string
// TestEnv routes the Bot API client to Telegram's test environment
// (.../bot/test/METHOD) (TELEGRAM_TEST_ENV=true, default false).
@@ -58,36 +44,27 @@ type Config struct {
// and validating the required fields.
func Load() (Config, error) {
cfg := Config{
- Bots: map[string]BotConfig{},
+ Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
GRPCAddr: envOr("TELEGRAM_GRPC_ADDR", ":9091"),
MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"),
APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"),
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
}
- for _, lang := range Languages {
- suffix := strings.ToUpper(lang)
- token := os.Getenv("TELEGRAM_BOT_TOKEN_" + suffix)
- if token == "" {
- continue
+ if v := strings.TrimSpace(os.Getenv("TELEGRAM_GAME_CHANNEL_ID")); v != "" {
+ id, err := strconv.ParseInt(v, 10, 64)
+ if err != nil {
+ return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID %q: %w", v, err)
}
- bot := BotConfig{Token: token}
- 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
+ cfg.GameChannelID = id
}
tel := pkgtel.DefaultConfig("scrabble-telegram")
tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
cfg.Telemetry = tel
- if len(cfg.Bots) == 0 {
- return Config{}, fmt.Errorf("config: at least one TELEGRAM_BOT_TOKEN_ (LANG in %v) is required", Languages)
+ if cfg.Token == "" {
+ return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
}
if cfg.MiniAppURL == "" {
return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required")
diff --git a/platform/telegram/internal/config/config_test.go b/platform/telegram/internal/config/config_test.go
index f2785a0..635e6f1 100644
--- a/platform/telegram/internal/config/config_test.go
+++ b/platform/telegram/internal/config/config_test.go
@@ -6,33 +6,38 @@ import (
pkgtel "scrabble/pkg/telemetry"
)
-// setRequired sets the required connector variables (one bot + the Mini App URL)
-// so Load reaches the telemetry checks.
+// setRequired sets the required connector variables (the bot token + the Mini App
+// URL) so Load reaches the telemetry checks.
func setRequired(t *testing.T) {
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")
}
-// TestLoadBots verifies the per-language bot parsing: a present token enables a
-// language, its channel id is optional, and the result is keyed by language.
-func TestLoadBots(t *testing.T) {
+// TestLoadBot verifies the bot parsing: the token is read and the game channel id is
+// parsed when present.
+func TestLoadBot(t *testing.T) {
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
- t.Setenv("TELEGRAM_BOT_TOKEN_EN", "en-token")
- t.Setenv("TELEGRAM_GAME_CHANNEL_ID_EN", "-100111")
- t.Setenv("TELEGRAM_BOT_TOKEN_RU", "ru-token")
+ t.Setenv("TELEGRAM_BOT_TOKEN", "bot-token")
+ t.Setenv("TELEGRAM_GAME_CHANNEL_ID", "-100111")
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
- if len(c.Bots) != 2 {
- t.Fatalf("Bots = %d, want 2", len(c.Bots))
+ if c.Token != "bot-token" || c.GameChannelID != -100111 {
+ 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 {
- t.Errorf("ru bot = %+v, want token ru-token / channel 0", c.Bots["ru"])
+ if c.GameChannelID != 0 {
+ t.Errorf("GameChannelID = %d, want 0", c.GameChannelID)
}
}
diff --git a/platform/telegram/internal/connector/server.go b/platform/telegram/internal/connector/server.go
index 3755392..a2e032c 100644
--- a/platform/telegram/internal/connector/server.go
+++ b/platform/telegram/internal/connector/server.go
@@ -4,15 +4,14 @@
// methods address a recipient by the identity external_id, so a future platform
// connector can implement the same service.
//
-// The connector hosts one bot per configured service language (en/ru); the same
-// Telegram user id spans them all. ValidateInitData tries each bot's token in turn
-// and reports which bot validated (its service language); the push and admin
-// methods route to the bot the request selects by language.
+// The connector hosts a single bot. ValidateInitData/ValidateLoginWidget verify
+// launch data against its token; Notify renders the message in the recipient's
+// interface language (the single bot needs no routing); the admin methods deliver
+// through that bot.
package connector
import (
"context"
- "errors"
"fmt"
"strconv"
@@ -34,103 +33,70 @@ type Sender interface {
SendText(ctx context.Context, chatID int64, text string) error
}
-// BotRuntime is one configured language-tagged bot: its sender, game channel id,
-// and the two HMAC validators bound to its token.
+// BotRuntime is the configured bot: its sender, game channel id, and the two HMAC
+// validators bound to its token.
type BotRuntime struct {
- // Language is the bot's service language (en/ru).
- Language string
- // Sender delivers messages through this bot.
+ // Sender delivers messages through the bot.
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
- // 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
- // 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
}
-// Server implements telegramv1.TelegramServer over one or more language-tagged bots.
+// Server implements telegramv1.TelegramServer over the single configured bot.
type Server struct {
telegramv1.UnimplementedTelegramServer
- bots map[string]BotRuntime // keyed by service language
- order []string // stable iteration order for validation
- log *zap.Logger
+ bot BotRuntime
+ log *zap.Logger
}
-// NewServer builds the gRPC service from the configured bots (at least one).
-func NewServer(bots []BotRuntime, log *zap.Logger) *Server {
+// NewServer builds the gRPC service from the configured bot.
+func NewServer(bot BotRuntime, log *zap.Logger) *Server {
if log == nil {
log = zap.NewNop()
}
- m := make(map[string]BotRuntime, len(bots))
- 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}
+ return &Server{bot: bot, log: log}
}
-// ValidateInitData verifies Mini App launch data against each bot's token in turn
-// and returns the user identity plus the validating bot's service language (which
-// routes the user's later push) and its set of offered game languages.
+// ValidateInitData verifies Mini App launch data against the bot's token and returns
+// the user identity.
func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) {
- var lastErr error
- for _, lang := range s.order {
- u, err := s.bots[lang].InitValidator.Validate(req.GetInitData())
- 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
+ u, err := s.bot.InitValidator.Validate(req.GetInitData())
+ if err != nil {
+ return nil, status.Error(codes.InvalidArgument, err.Error())
}
- if lastErr == nil {
- lastErr = errors.New("no bot configured")
- }
- return nil, status.Error(codes.InvalidArgument, lastErr.Error())
+ return &telegramv1.ValidateInitDataResponse{
+ ExternalId: u.ExternalID,
+ Username: u.Username,
+ FirstName: u.FirstName,
+ LanguageCode: u.LanguageCode,
+ }, nil
}
-// ValidateLoginWidget verifies Login Widget authorization data against each bot's
-// token in turn and returns the user identity, for attaching a Telegram identity to
-// an existing account.
+// ValidateLoginWidget verifies Login Widget authorization data against the bot's
+// token and returns the user identity, for attaching a Telegram identity to an
+// existing account.
func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) {
- var lastErr error
- for _, lang := range s.order {
- u, err := s.bots[lang].WidgetValidator.Validate(req.GetData())
- if err != nil {
- lastErr = err
- continue
- }
- return &telegramv1.ValidateLoginWidgetResponse{
- ExternalId: u.ExternalID,
- Username: u.Username,
- FirstName: u.FirstName,
- }, nil
+ u, err := s.bot.WidgetValidator.Validate(req.GetData())
+ if err != nil {
+ return nil, status.Error(codes.InvalidArgument, err.Error())
}
- if lastErr == nil {
- lastErr = errors.New("no bot configured")
- }
- return nil, status.Error(codes.InvalidArgument, lastErr.Error())
+ return &telegramv1.ValidateLoginWidgetResponse{
+ ExternalId: u.ExternalID,
+ Username: u.Username,
+ FirstName: u.FirstName,
+ }, nil
}
-// Notify renders and delivers an out-of-app notification through the bot selected by
-// the recipient's service language. It reports delivered=false (without an error)
-// when no bot serves that language, the kind is not pushed out-of-app, or the bot
-// could not deliver (e.g. the user never started it), so the gateway treats a
+// Notify renders and delivers an out-of-app notification through the bot. The message
+// is rendered in the recipient's interface language (req language). It reports
+// delivered=false (without an error) when the kind is not pushed out-of-app or the
+// bot could not deliver (e.g. the user never started it), so the gateway treats a
// fallback miss as best-effort.
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())
if !ok {
return &telegramv1.NotifyResponse{Delivered: false}, nil
@@ -139,58 +105,38 @@ func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*te
if err != nil {
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))
return &telegramv1.NotifyResponse{Delivered: false}, nil
}
return &telegramv1.NotifyResponse{Delivered: true}, nil
}
-// SendToUser sends an arbitrary admin message to one user through the bot selected
-// by language (an operator choice in the admin console).
+// SendToUser sends an arbitrary admin message to one user through the bot.
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())
if err != nil {
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))
return &telegramv1.SendResponse{Delivered: false}, nil
}
return &telegramv1.SendResponse{Delivered: true}, nil
}
-// SendToGameChannel posts an arbitrary admin message to the game channel of the bot
-// selected by language (an operator choice in the admin console).
+// SendToGameChannel posts an arbitrary admin message to the bot's game channel.
func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) {
- bot, err := s.botFor(req.GetLanguage())
- if err != nil {
- return nil, err
+ if s.bot.ChannelID == 0 {
+ return nil, status.Error(codes.FailedPrecondition, "game channel is not configured")
}
- if bot.ChannelID == 0 {
- 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 {
+ if err := s.bot.Sender.SendText(ctx, s.bot.ChannelID, req.GetText()); err != nil {
s.log.Warn("send to channel failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, 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.
func parseChatID(externalID string) (int64, error) {
id, err := strconv.ParseInt(externalID, 10, 64)
diff --git a/platform/telegram/internal/connector/server_test.go b/platform/telegram/internal/connector/server_test.go
index 34a0f00..2242f1d 100644
--- a/platform/telegram/internal/connector/server_test.go
+++ b/platform/telegram/internal/connector/server_test.go
@@ -56,9 +56,9 @@ func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) erro
return f.err
}
-// botRT assembles one language-tagged bot runtime for a test.
-func botRT(lang string, sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime {
- return BotRuntime{Language: lang, Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv}
+// botRT assembles the bot runtime for a test.
+func botRT(sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime {
+ return BotRuntime{Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv}
}
func yourTurnPayload(gameID string) []byte {
@@ -72,7 +72,7 @@ func yourTurnPayload(gameID string) []byte {
func TestValidateInitData(t *testing.T) {
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"})
if err != nil {
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" {
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 {
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) {
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"})
if err != nil {
t.Fatalf("validate: %v", err)
@@ -118,7 +98,7 @@ func TestValidateLoginWidget(t *testing.T) {
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 {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
@@ -127,7 +107,7 @@ func TestValidateLoginWidget(t *testing.T) {
func TestNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
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{
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) {
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{
ExternalId: "12345", Kind: "opponent_moved", Language: "en",
})
@@ -198,7 +143,7 @@ func TestNotifySkipsUnrenderedKind(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{
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) {
sender := &fakeSender{}
- srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil)
- resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi", Language: "en"})
+ srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
+ resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"})
if err != nil {
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) {
t.Run("unconfigured", func(t *testing.T) {
- srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil)
- _, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x", Language: "en"})
+ srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
+ _, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"})
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
}
})
- t.Run("configured routes by language", func(t *testing.T) {
- en, ru := &fakeSender{}, &fakeSender{}
- srv := NewServer([]BotRuntime{
- botRT("en", en, 111, stubValidator{}, stubWidgetValidator{}),
- botRT("ru", ru, 555, stubValidator{}, stubWidgetValidator{}),
- }, nil)
- resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news", Language: "ru"})
+ t.Run("configured", func(t *testing.T) {
+ sender := &fakeSender{}
+ srv := NewServer(botRT(sender, 555, stubValidator{}, stubWidgetValidator{}), nil)
+ resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"})
if err != nil {
t.Fatalf("send to channel: %v", err)
}
- if !resp.GetDelivered() || len(ru.text) != 1 || ru.text[0].chatID != 555 || len(en.text) != 0 {
- t.Errorf("send to channel: ru=%+v en=%+v", ru.text, en.text)
+ if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 555 {
+ t.Errorf("send to channel: %+v", sender.text)
}
})
}
diff --git a/ui/README.md b/ui/README.md
index 4d5f057..87727a6 100644
--- a/ui/README.md
+++ b/ui/README.md
@@ -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 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
-domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK_EN` /
-`VITE_TELEGRAM_LINK_RU` are the per-bot friend-invite Mini App links (full URL
-`https://t.me//`; the share picks the one matching the bot the player
-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.
+domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
+friend-invite Mini App link for the single bot (full URL `https://t.me//`).
+`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
The build has **two entries**: the game SPA (`index.html`, served at `/app/` and
`/telegram/`) and a lightweight landing page (`landing.html`, served at `/`).
diff --git a/ui/src/Landing.svelte b/ui/src/Landing.svelte
index bea284e..e135e8b 100644
--- a/ui/src/Landing.svelte
+++ b/ui/src/Landing.svelte
@@ -16,7 +16,7 @@
let prefs: Partial = {};
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 }[] = [
{ code: 'en', label: '🇬🇧 English' },
{ code: 'ru', label: '🇷🇺 Русский' },
diff --git a/ui/src/components/StaleInviteModal.svelte b/ui/src/components/StaleInviteModal.svelte
index 6c8bead..f8a266b 100644
--- a/ui/src/components/StaleInviteModal.svelte
+++ b/ui/src/components/StaleInviteModal.svelte
@@ -5,9 +5,8 @@
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
- // Point at the bot the player signed in through (its service language), falling back to
- // 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(app.session?.serviceLanguage || app.locale));
+ // The single bot's @username, for the deep link.
+ const username = $derived(botUsername());
// Split the message around the {bot} token so the bot handle renders as an inline link.
const parts = $derived(t('friends.staleInvite').split('{bot}'));
diff --git a/ui/src/components/WelcomeRedeemModal.svelte b/ui/src/components/WelcomeRedeemModal.svelte
index acdf4a5..7783c5c 100644
--- a/ui/src/components/WelcomeRedeemModal.svelte
+++ b/ui/src/components/WelcomeRedeemModal.svelte
@@ -5,9 +5,8 @@
import { telegramOpenLink } from '../lib/telegram';
import Modal from './Modal.svelte';
- // Point at the bot the player signed in through (its service language), falling back to 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(app.session?.serviceLanguage || app.locale));
+ // The single bot's @username, for the deep link.
+ const username = $derived(botUsername());
// Greet the arriving player by their own display name.
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
diff --git a/ui/src/gen/fbs/scrabblefb/profile.ts b/ui/src/gen/fbs/scrabblefb/profile.ts
index 8f13d8e..89ef96b 100644
--- a/ui/src/gen/fbs/scrabblefb/profile.ts
+++ b/ui/src/gen/fbs/scrabblefb/profile.ts
@@ -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;
}
+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) {
- builder.startObject(12);
+ builder.startObject(13);
}
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);
}
+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 {
const offset = builder.endObject();
return offset;
diff --git a/ui/src/gen/fbs/scrabblefb/session.ts b/ui/src/gen/fbs/scrabblefb/session.ts
index 4b752fe..6882465 100644
--- a/ui/src/gen/fbs/scrabblefb/session.ts
+++ b/ui/src/gen/fbs/scrabblefb/session.ts
@@ -46,27 +46,8 @@ displayName(optionalEncoding?:any):string|Uint8Array|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) {
- builder.startObject(6);
+ builder.startObject(4);
}
static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) {
@@ -85,39 +66,17 @@ static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers
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 {
const offset = builder.endObject();
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.addToken(builder, tokenOffset);
Session.addUserId(builder, userIdOffset);
Session.addIsGuest(builder, isGuest);
Session.addDisplayName(builder, displayNameOffset);
- Session.addSupportedLanguages(builder, supportedLanguagesOffset);
- Session.addServiceLanguage(builder, serviceLanguageOffset);
return Session.endSession(builder);
}
}
diff --git a/ui/src/gen/fbs/scrabblefb/update-profile-request.ts b/ui/src/gen/fbs/scrabblefb/update-profile-request.ts
index a060271..34e0bf5 100644
--- a/ui/src/gen/fbs/scrabblefb/update-profile-request.ts
+++ b/ui/src/gen/fbs/scrabblefb/update-profile-request.ts
@@ -70,8 +70,20 @@ notificationsInAppOnly():boolean {
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) {
- builder.startObject(8);
+ builder.startObject(9);
}
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
@@ -106,12 +118,28 @@ static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInApp
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 {
const offset = builder.endObject();
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.addDisplayName(builder, displayNameOffset);
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
@@ -121,6 +149,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
UpdateProfileRequest.addBlockChat(builder, blockChat);
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
+ UpdateProfileRequest.addVariantPreferences(builder, variantPreferencesOffset);
return UpdateProfileRequest.endUpdateProfileRequest(builder);
}
}
diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts
index e91f4c5..40ec7e8 100644
--- a/ui/src/lib/app.svelte.ts
+++ b/ui/src/lib/app.svelte.ts
@@ -706,6 +706,7 @@ async function persistLanguageToServer(locale: Locale): Promise {
blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests,
notificationsInAppOnly: p.notificationsInAppOnly,
+ variantPreferences: p.variantPreferences,
});
} catch {
// The client locale already changed; the server sync is best-effort.
diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts
index 9bfe3fd..0af414b 100644
--- a/ui/src/lib/codec.test.ts
+++ b/ui/src/lib/codec.test.ts
@@ -26,6 +26,7 @@ import {
encodeStateRequest,
encodeSubmitPlay,
encodeTarget,
+ encodeUpdateProfile,
} from './codec';
describe('codec', () => {
@@ -188,21 +189,17 @@ describe('codec', () => {
const token = b.createString('tok');
const uid = b.createString('u1');
const name = b.createString('Me');
- const langs = fb.Session.createSupportedLanguagesVector(b, [b.createString('en'), b.createString('ru')]);
fb.Session.startSession(b);
fb.Session.addToken(b, token);
fb.Session.addUserId(b, uid);
fb.Session.addIsGuest(b, true);
fb.Session.addDisplayName(b, name);
- fb.Session.addSupportedLanguages(b, langs);
b.finish(fb.Session.endSession(b));
expect(decodeSession(b.asUint8Array())).toEqual({
token: 'tok',
userId: 'u1',
isGuest: true,
displayName: 'Me',
- supportedLanguages: ['en', 'ru'],
- serviceLanguage: '',
});
});
@@ -411,7 +408,7 @@ describe('codec', () => {
b.finish(fb.LinkResult.endLinkResult(b));
const r = decodeLinkResult(b.asUint8Array());
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', () => {
@@ -568,6 +565,35 @@ describe('codec', () => {
b.finish(fb.Profile.endProfile(b));
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
diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts
index 2708a16..80b3684 100644
--- a/ui/src/lib/codec.ts
+++ b/ui/src/lib/codec.ts
@@ -294,17 +294,13 @@ function decodeChatMsg(m: fb.ChatMessage): ChatMessage {
}
// 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 {
- const supportedLanguages: string[] = [];
- for (let i = 0; i < t.supportedLanguagesLength(); i++) supportedLanguages.push(s(t.supportedLanguages(i)));
return {
token: s(t.token()),
userId: s(t.userId()),
isGuest: t.isGuest(),
displayName: s(t.displayName()),
- supportedLanguages,
- serviceLanguage: s(t.serviceLanguage()),
};
}
@@ -326,10 +322,19 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
+ variantPreferences: decodeVariantPreferences(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
// undefined when the viewer is not eligible (the field is absent).
function decodeBanner(p: fb.Profile): Banner | undefined {
@@ -642,6 +647,10 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
const tz = b.createString(p.timeZone);
const as = b.createString(p.awayStart);
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.addDisplayName(b, name);
fb.UpdateProfileRequest.addPreferredLanguage(b, lang);
@@ -651,6 +660,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
+ fb.UpdateProfileRequest.addVariantPreferences(b, prefs);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
}
diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts
index 46d3df5..81a4fed 100644
--- a/ui/src/lib/deeplink.test.ts
+++ b/ui/src/lib/deeplink.test.ts
@@ -35,19 +35,6 @@ describe('shareLink', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
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', () => {
@@ -62,11 +49,4 @@ describe('botUsername', () => {
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
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');
- });
});
diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts
index 33f6d34..199eaa8 100644
--- a/ui/src/lib/deeplink.ts
+++ b/ui/src/lib/deeplink.ts
@@ -41,25 +41,20 @@ function envVar(name: string): string | undefined {
}
/**
- * telegramBase returns the Mini App link base (e.g. https://t.me//) for a
- * bot language: VITE_TELEGRAM_LINK_EN / _RU when lang is en/ru, else the
- * 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.
+ * telegramBase returns the single bot's Mini App link base (e.g. https://t.me//)
+ * from VITE_TELEGRAM_LINK, or null when it is not configured.
*/
-function telegramBase(lang: string): string | null {
- const byLang =
- lang === 'ru' ? envVar('VITE_TELEGRAM_LINK_RU') : lang === 'en' ? envVar('VITE_TELEGRAM_LINK_EN') : undefined;
- return byLang || envVar('VITE_TELEGRAM_LINK') || null;
+function telegramBase(): string | null {
+ return envVar('VITE_TELEGRAM_LINK') || null;
}
/**
* 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//. Returns
- * null when no base is configured or the link carries no path, so callers can fall back
- * when they cannot point at a specific bot.
+ * link: the first path segment of https://t.me//. Returns null when no base is
+ * configured or the link carries no path.
*/
-export function botUsername(lang = ''): string | null {
- const base = telegramBase(lang);
+export function botUsername(): string | null {
+ const base = telegramBase();
if (!base) return null;
try {
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
- * bot language (the session's service language). Returns null when no base is
- * configured, so callers can hide the share affordance.
+ * shareLink wraps a deep-link start parameter in a t.me Mini App link for the single
+ * bot. Returns null when no base is configured, so callers can hide the share affordance.
*/
-export function shareLink(param: string, lang = ''): string | null {
- const base = telegramBase(lang);
+export function shareLink(param: string): string | null {
+ const base = telegramBase();
if (!base) return null;
const sep = base.includes('?') ? '&' : '?';
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts
index 0b45a14..2e957fb 100644
--- a/ui/src/lib/i18n/en.ts
+++ b/ui/src/lib/i18n/en.ts
@@ -136,6 +136,8 @@ export const en = {
'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests',
'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.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code',
diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts
index 661bf7b..53a4082 100644
--- a/ui/src/lib/i18n/ru.ts
+++ b/ui/src/lib/i18n/ru.ts
@@ -137,6 +137,8 @@ export const ru: Record = {
'profile.blockChat': 'Отключить чат',
'profile.blockFriendRequests': 'Отключить заявки в друзья',
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
+ 'profile.preferences': 'Предпочтения',
+ 'profile.preferencesHint': 'Варианты игр, в которые вас могут подобрать. Выберите хотя бы один.',
'profile.email': 'Эл. почта',
'profile.bindEmail': 'Привязать почту',
'profile.emailCode': 'Код подтверждения',
diff --git a/ui/src/lib/landing.test.ts b/ui/src/lib/landing.test.ts
index 25bfad8..71a9c56 100644
--- a/ui/src/lib/landing.test.ts
+++ b/ui/src/lib/landing.test.ts
@@ -4,17 +4,13 @@ import { telegramChannelLink } from './landing';
describe('telegramChannelLink', () => {
afterEach(() => vi.unstubAllEnvs());
- it('builds the per-language t.me link from the channel name', () => {
- vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', 'Scrabble_Game');
- vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', '@Erudit_Game'); // a leading @ is tolerated
- expect(telegramChannelLink('en')).toBe('https://t.me/Scrabble_Game');
- expect(telegramChannelLink('ru')).toBe('https://t.me/Erudit_Game');
+ it('builds the t.me link from the channel name', () => {
+ vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated
+ expect(telegramChannelLink()).toBe('https://t.me/Erudit_Game');
});
- it('returns null when the locale channel is unset or blank', () => {
- vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_EN', '');
- vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME_RU', ' ');
- expect(telegramChannelLink('en')).toBeNull();
- expect(telegramChannelLink('ru')).toBeNull();
+ it('returns null when the channel name is unset or blank', () => {
+ vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', ' ');
+ expect(telegramChannelLink()).toBeNull();
});
});
diff --git a/ui/src/lib/landing.ts b/ui/src/lib/landing.ts
index 3fdf236..7d5ed43 100644
--- a/ui/src/lib/landing.ts
+++ b/ui/src/lib/landing.ts
@@ -1,20 +1,15 @@
-// Pure helpers for the public landing page, kept out of the Svelte component so
-// the per-language Telegram-channel link selection is unit-testable.
-
-import type { Locale } from './i18n/index.svelte';
+// Pure helpers for the public landing page, kept out of the Svelte component so the
+// Telegram-channel link selection is unit-testable.
/**
- * telegramChannelLink returns the t.me link for the locale's game channel, or null when it is
- * not configured. The channel usernames are build-time vars (VITE_TELEGRAM_GAME_CHANNEL_NAME_EN
- * / VITE_TELEGRAM_GAME_CHANNEL_NAME_RU) because the test and prod contours run different
- * channels; they are the same channels the connector posts to via TELEGRAM_GAME_CHANNEL_ID_*
+ * telegramChannelLink returns the t.me link for the single bot's game channel, or null
+ * when it is not configured. The channel username is a build-time var
+ * (VITE_TELEGRAM_GAME_CHANNEL_NAME) because the test and prod contours run different
+ * 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.
*/
-export function telegramChannelLink(locale: Locale): string | null {
- const raw =
- locale === 'ru'
- ? import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_RU
- : import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME_EN;
+export function telegramChannelLink(): string | null {
+ const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME;
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
return name ? `https://t.me/${name}` : null;
}
diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts
index 718b74f..08777a0 100644
--- a/ui/src/lib/mock/data.ts
+++ b/ui/src/lib/mock/data.ts
@@ -23,9 +23,6 @@ export const SESSION: Session = {
userId: ME,
isGuest: true,
displayName: 'You',
- // Both languages by default, so the mock-driven UI offers every variant.
- supportedLanguages: ['en', 'ru'],
- serviceLanguage: '',
};
export const PROFILE: Profile = {
@@ -40,6 +37,8 @@ export const PROFILE: Profile = {
blockFriendRequests: false,
isGuest: false,
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
diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts
index f2be57e..ec1fe64 100644
--- a/ui/src/lib/model.ts
+++ b/ui/src/lib/model.ts
@@ -151,6 +151,9 @@ export interface Profile {
isGuest: boolean;
/** Confine notifications to the in-app stream (no out-of-app platform push). */
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. */
banner?: Banner;
}
@@ -201,6 +204,7 @@ export interface ProfileUpdate {
blockChat: boolean;
blockFriendRequests: boolean;
notificationsInAppOnly: boolean;
+ variantPreferences: Variant[];
}
/** A referenced account with its display name (friend, blocked user, invitee). */
@@ -327,14 +331,6 @@ export interface Session {
userId: string;
isGuest: boolean;
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
diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts
index 8c2671b..e5e5712 100644
--- a/ui/src/lib/variants.test.ts
+++ b/ui/src/lib/variants.test.ts
@@ -7,22 +7,29 @@ import {
multipleWordsForRequest,
} 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', () => {
- 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(undefined)).toEqual(ALL_VARIANTS);
});
- it('offers only English for an en-only service', () => {
- expect(availableVariants(['en']).map((v) => v.id)).toEqual(['scrabble_en']);
+ it('offers only the preferred variants', () => {
+ 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', () => {
- expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['scrabble_ru', 'erudit_ru']);
- });
-
- it('offers every variant for a bilingual service', () => {
- expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['scrabble_en', 'scrabble_ru', 'erudit_ru']);
+ it('keeps the Erudit-first catalogue order regardless of preference order', () => {
+ expect(availableVariants(['scrabble_en', 'erudit_ru', 'scrabble_ru']).map((v) => v.id)).toEqual([
+ 'erudit_ru',
+ 'scrabble_ru',
+ 'scrabble_en',
+ ]);
});
});
diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts
index d93addc..f317a50 100644
--- a/ui/src/lib/variants.ts
+++ b/ui/src/lib/variants.ts
@@ -11,14 +11,15 @@ export interface VariantOption {
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
// "Scrabble" and the Russian-alphabet Scrabble always "Скрэббл" (both unlocalized, so the
// two never collide whatever the UI language); Erudit is localized "Erudite"/"Эрудит".
export const ALL_VARIANTS: VariantOption[] = [
- { id: 'scrabble_en', label: 'new.english' },
- { id: 'scrabble_ru', label: 'new.russian' },
{ 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
@@ -47,13 +48,13 @@ export const VARIANT_FLAG: Record = {
// ru -> Russian + Эрудит.
export const VARIANT_LANGUAGE: Record = { scrabble_en: 'en', scrabble_ru: 'ru', erudit_ru: 'ru' };
-// availableVariants gates ALL_VARIANTS by the session's supported languages. An empty
-// or absent set is ungated (a web/legacy session without a declared set), returning
-// every variant.
-export function availableVariants(supportedLanguages: string[] | undefined): VariantOption[] {
- const langs = supportedLanguages ?? [];
- if (langs.length === 0) return ALL_VARIANTS;
- return ALL_VARIANTS.filter((v) => langs.includes(VARIANT_LANGUAGE[v.id]));
+// availableVariants gates ALL_VARIANTS by the player's variant preferences (the set
+// they enabled in Settings). An empty or absent set is ungated (returns every variant)
+// — a safety fallback; a real profile always carries at least one preference.
+export function availableVariants(preferences: Variant[] | undefined): VariantOption[] {
+ const prefs = preferences ?? [];
+ if (prefs.length === 0) return ALL_VARIANTS;
+ return ALL_VARIANTS.filter((v) => prefs.includes(v.id));
}
// supportsMultipleWordsToggle reports whether the New Game "multiple words per turn" toggle
diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte
index ceb5e7e..4471ef4 100644
--- a/ui/src/screens/Friends.svelte
+++ b/ui/src/screens/Friends.svelte
@@ -4,8 +4,7 @@
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { GatewayError } from '../lib/client';
- import { localeFrom, t } from '../lib/i18n/index.svelte';
- import { translate } from '../lib/i18n/catalog';
+ import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
@@ -95,10 +94,8 @@
// 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
// copies the link to the clipboard.
- async function shareInvite(url: string, lang: string) {
- // The caption is in the bot's language (Эрудит for ru, Scrabble for en), not the
- // interface language — the recipient lands in that bot.
- const text = translate(localeFrom(lang), 'friends.inviteText');
+ async function shareInvite(url: string) {
+ const text = t('friends.inviteText');
if (shareTelegramLink(url, text)) return;
if (typeof navigator !== 'undefined' && navigator.share) {
try {
@@ -134,8 +131,7 @@
{#if code}
- {@const lang = app.session?.serviceLanguage || app.locale}
- {@const tg = shareLink(friendCodeParam(code.code), lang)}
+ {@const tg = shareLink(friendCodeParam(code.code))}
{:else}
diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte
index 8ea84d6..0064751 100644
--- a/ui/src/screens/NewGame.svelte
+++ b/ui/src/screens/NewGame.svelte
@@ -18,9 +18,9 @@
// The auto-match move clock (mirrors backend game.DefaultTurnTimeout = 24h).
const AUTO_MATCH_HOURS = 24;
- // The offered variants are gated by the languages the sign-in service supports;
- // the auto-match list and the friend-invite picker both use this.
- const variants = $derived(availableVariants(app.session?.supportedLanguages));
+ // The offered variants are gated by the player's profile preferences (the variants
+ // they enabled in Settings); the auto-match list and the friend-invite picker both use this.
+ const variants = $derived(availableVariants(app.profile?.variantPreferences));
// "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.
let multipleWords = $state(false);
diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte
index b71b03a..12561fa 100644
--- a/ui/src/screens/Profile.svelte
+++ b/ui/src/screens/Profile.svelte
@@ -16,6 +16,8 @@
validDisplayName,
validEmail,
} from '../lib/profileValidation';
+ import { ALL_VARIANTS, VARIANT_RULES } from '../lib/variants';
+ import type { Variant } from '../lib/model';
let dn = $state('');
let tz = $state('+00:00');
@@ -28,6 +30,7 @@
let blockChat = $state(false);
let blockFriendRequests = $state(false);
let notificationsInAppOnly = $state(true);
+ let variantPrefs = $state([]);
let emailInput = $state('');
let codeInput = $state('');
let emailSent = $state(false);
@@ -60,6 +63,7 @@
blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
+ variantPrefs = [...p.variantPreferences];
}
onMount(populate);
@@ -67,9 +71,19 @@
const awayEnd = $derived(`${endH}:${endM}`);
const nameOk = $derived(validDisplayName(dn));
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
- const formValid = $derived(nameOk && awayOk);
+ const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
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() {
if (!formValid) return;
try {
@@ -82,6 +96,7 @@
blockChat,
blockFriendRequests,
notificationsInAppOnly,
+ variantPreferences: variantPrefs,
});
showToast(t('profile.saved'));
} catch (e) {
@@ -207,6 +222,22 @@
{t('profile.notificationsInAppOnly')}
+