diff --git a/PLAN.md b/PLAN.md index a197a14..2660855 100644 --- a/PLAN.md +++ b/PLAN.md @@ -48,7 +48,7 @@ independent (see ARCHITECTURE §9.1). | 12 | Observability & performance (telemetry, metrics, guest GC) | **done** | | 13 | Alphabet on the wire (UI alphabet-agnostic) | **done** | | 14 | Solver & dictionary split (publish solver + scrabble-dictionary repo/artifact) | **done** | -| 15 | Dual Telegram bots & language-gated variants | todo | +| 15 | Dual Telegram bots & language-gated variants | **done** | | 16 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | todo | | 17 | Prod contour deploy (SSH export/import, manual after merge) | todo | @@ -264,19 +264,20 @@ both — discharging **TODO-1** and **TODO-2**. `BACKEND_DICT_DIR//`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is safe to retire once no active game pins it). -### Stage 15 — Dual Telegram bots & language-gated variants *(feature; own interview)* -Scope (owner's idea, to design in detail at its own start): run **two bots in the one connector -container** — one for the English audience, one for Russian — each with its own token + game-channel id -+ service-language tag (the same Telegram user id spans both). `initData` validation tries each bot's -token in turn (none succeeds ⇒ invalid). The connector returns the **service language `en`/`ru`**; -`Notify`/`SendToUser` take a language key so the right bot delivers. The UI **gates the game-type -(variant) choice** by service language (en → English; ru → Russian + Эрудит). -Open details (own interview): which bot sends a notification for an **existing** game (game language vs -the player's service language) given one user id spans both bots; behaviour for **non-Telegram** -players (web/email/guest — ungated, or by interface language); the proto/wire changes -(`ValidateInitData` service-language field, a bot/language selector on the push RPCs); per-bot config + -tests. Engineering feedback already captured at the Stage 14 interview: the two-bots-in-one-container + -sequential validation + language-keyed routing model is sound. +### Stage 15 — Dual Telegram bots & language-gated variants *(done)* +Re-framed at its start to be **service-agnostic**: the sign-in service returns, with the user identity, a +**set of supported game languages** (subset of `{en, ru}`, ≥ 1) that gates the New Game variant choice. +Built: the connector hosts **two bots in one container** (one per service language, 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`** + **`supported_languages`** set. The set rides the +`Session` (FlatBuffers, session-scoped, not persisted) and the UI offers only the matching variants on New +Game (en → English; ru → Russian + Эрудит) — gating **only** the start of a new game (auto-match + friend +invite); existing games of any language are unrestricted and the backend does not enforce. The service +language is persisted (`accounts.service_language`, migration `00010`, written on every login — +last-login-wins) and routes the user-facing out-of-app push (`Notify`) back through the right bot (falls +back to `preferred_language`). Non-Telegram logins (web/email/guest) carry the gateway default set +(`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants). Admin broadcasts (`SendToUser`/`SendToGameChannel`) +pick the bot by an **operator-chosen** language in the console — unrelated to `ValidateInitData`. ### Stage 16 — Deploy infra & test contour Scope: the deploy machinery + the **test contour** (the bulk of the original Stage 14). Backend + @@ -1006,6 +1007,35 @@ caddy; prod VPN; rollback. auto-release step's `${{ github.* }}` contexts failed the Gitea workflow compile, so releases are published manually for now (a logged follow-up). +- **Stage 15** (interview + implementation): + - **Re-framed service-agnostic** (interview): the owner kept the two-bots-in-one-container model but + generalised the language signal — the sign-in service returns a **set** of supported game languages + (subset of `{en, ru}`, ≥ 1) on the validate response, and the **UI gates** the New Game variant choice + by it. Two distinct scopes, deliberately not conflated: the **gating set** is per-session (rides the + `Session` fbs, never persisted — so the same `telegram_id` logged in through the en- and ru-bot gates + differently, which is correct), and the **routing language** is per-account. + - **Push routing resolved** (interview, the original "which bot delivers" open detail): only the + **user-facing `Notify`** carries the `en`/`ru` language from the user's **last `ValidateInitData`**, + persisted as `accounts.service_language` (migration `00010`, written every login — new and existing — + last-login-wins, read by `/internal/push-target` with a `preferred_language` fallback). It is NOT the + game's variant language. **Correction mid-interview:** the admin broadcasts `SendToUser` / + `SendToGameChannel` are admin-panel-only and unrelated to `ValidateInitData`; they pick the bot by an + **operator-chosen** language (a console ` +
{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} diff --git a/backend/internal/adminconsole/templates/pages/user_detail.gohtml b/backend/internal/adminconsole/templates/pages/user_detail.gohtml index 4e6c6bd..0d932c5 100644 --- a/backend/internal/adminconsole/templates/pages/user_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/user_detail.gohtml @@ -43,6 +43,7 @@ {{if .ConnectorEnabled}}
+
{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} diff --git a/backend/internal/connector/client.go b/backend/internal/connector/client.go index f34f5fb..0030275 100644 --- a/backend/internal/connector/client.go +++ b/backend/internal/connector/client.go @@ -1,8 +1,9 @@ // 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 the connector's configured game -// channel. The connector lives on the trusted internal network, so the -// connection uses insecure (plaintext) transport credentials +// 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. package connector @@ -36,21 +37,22 @@ 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. 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}) +// 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}) if err != nil { return false, err } return resp.GetDelivered(), nil } -// SendToGameChannel posts an operator text message to the connector's configured -// game channel. delivered reports whether the connector sent it (false when no -// channel is configured). -func (c *Client) SendToGameChannel(ctx context.Context, text string) (bool, error) { - resp, err := c.c.SendToGameChannel(ctx, &telegramv1.SendToGameChannelRequest{Text: text}) +// 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}) if err != nil { return false, err } diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index bcf3bad..1be5922 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -155,6 +155,46 @@ 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) + } +} + // TestIdentityExternalID covers the reverse identity lookup the push-target route // uses: it returns the external_id for the matching kind and ErrNotFound otherwise, // including for a guest that carries no identity. diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index 243617b..2501d3a 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -29,4 +29,5 @@ type Accounts struct { PaidAccount bool MergedInto *uuid.UUID MergedAt *time.Time + ServiceLanguage *string } diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index ed3d730..906a396 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -33,6 +33,7 @@ type accountsTable struct { PaidAccount postgres.ColumnBool MergedInto postgres.ColumnString MergedAt postgres.ColumnTimestampz + ServiceLanguage postgres.ColumnString AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -90,8 +91,9 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { PaidAccountColumn = postgres.BoolColumn("paid_account") MergedIntoColumn = postgres.StringColumn("merged_into") MergedAtColumn = postgres.TimestampzColumn("merged_at") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn} + ServiceLanguageColumn = postgres.StringColumn("service_language") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn, MergedIntoColumn, MergedAtColumn, ServiceLanguageColumn} defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn, PaidAccountColumn} ) @@ -115,6 +117,7 @@ func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { PaidAccount: PaidAccountColumn, MergedInto: MergedIntoColumn, MergedAt: MergedAtColumn, + ServiceLanguage: ServiceLanguageColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00010_service_language.sql b/backend/internal/postgres/migrations/00010_service_language.sql new file mode 100644 index 0000000..a230a6a --- /dev/null +++ b/backend/internal/postgres/migrations/00010_service_language.sql @@ -0,0 +1,21 @@ +-- +goose Up +-- Stage 15 dual Telegram bots: service_language records the language tag of the bot +-- a Telegram user last authenticated through (their last ValidateInitData). It is +-- updated on every Telegram login — new and existing accounts — and routes the +-- user's out-of-app push back through the right bot. It is distinct from +-- preferred_language (the interface language) and from a game's variant language. +-- Nullable: an account that has never signed in through a tagged bot (legacy, +-- email-only or guest) has no value, and push routing falls back to +-- preferred_language. Adds a column, so the generated jet code is regenerated +-- (cmd/jetgen). +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts + ADD COLUMN service_language text + CHECK (service_language IN ('en', 'ru')); + +-- +goose Down +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts + DROP COLUMN service_language; diff --git a/backend/internal/server/handlers_admin_console.go b/backend/internal/server/handlers_admin_console.go index 1b6b977..d8e2265 100644 --- a/backend/internal/server/handlers_admin_console.go +++ b/backend/internal/server/handlers_admin_console.go @@ -146,6 +146,7 @@ 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) @@ -157,14 +158,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) + delivered, err := s.connector.SendToUser(ctx, ext, text, language) if err != nil { s.consoleError(c, err) return } body := "message delivered" if !delivered { - body = "not delivered (the user may not have started the bot)" + body = "not delivered (the user may not have started that bot)" } s.renderConsoleMessage(c, "Sent", body, back) } @@ -340,20 +341,21 @@ 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) + delivered, err := s.connector.SendToGameChannel(c.Request.Context(), text, language) if err != nil { s.consoleError(c, err) return } body := "posted to the game channel" if !delivered { - body = "not delivered (no game channel configured on the connector)" + body = "not delivered (that bot has no game channel configured)" } s.renderConsoleMessage(c, "Broadcast", body, "/_gm/broadcast") } diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index fd6cd73..9e93957 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -19,16 +19,20 @@ 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"` + ExternalID string `json:"external_id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LanguageCode string `json:"language_code"` + ServiceLanguage string `json:"service_language"` } // 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. +// 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. func (s *Server) handleTelegramAuth(c *gin.Context) { var req telegramAuthRequest if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { @@ -40,6 +44,10 @@ 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 + } s.mintSession(c, acc) } @@ -50,8 +58,10 @@ 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 preferred language for the -// message template, and whether they confined notifications to the in-app stream. +// 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. type pushTargetResponse struct { ExternalID string `json:"external_id"` Language string `json:"language"` @@ -83,9 +93,15 @@ 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: acc.PreferredLanguage, + Language: language, NotificationsInAppOnly: acc.NotificationsInAppOnly, }) } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 027be1f..c079e6e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -45,8 +45,10 @@ 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 token: it - runs the Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves + `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 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** @@ -119,6 +121,20 @@ 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 (Stage 15).** 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); it 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). - 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 @@ -447,12 +463,16 @@ open and on focus as well as re-polling on the `notify` event — covering a pus missed while the app was hidden. **Out-of-app platform push** (Stage 9) 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`, language, and the `notifications_in_app_only` flag) and asks the +`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) and asks the **Telegram connector** to deliver a localized message with a Mini App deep-link button — only when the recipient has a Telegram identity and has not confined -notifications to the app, so the two channels never duplicate. The out-of-app set is +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 your-turn, nudge, match-found and the invitation / friend-request notify sub-kinds; -the connector renders the message and skips the rest. Session-revocation events and +the connector renders the message and skips the rest. 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 cursor-based stream resume stay deferred (single-instance MVP). A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md). diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 1b3c6cb..923fcff 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -22,13 +22,17 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. -### Identity & sessions *(Stage 1 / 6 / 9)* +### Identity & sessions *(Stage 1 / 6 / 9 / 15)* A player arrives from a platform (Telegram first), via email login, or as an 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. Guests are session-only with restricted features +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 both +sets their offered languages and is the bot their out-of-app notifications come from. 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, @@ -49,10 +53,14 @@ when a guest links an identity that already has a durable account, where the dur account is kept and the guest's games move into it. A merge is blocked only while the two accounts share a game still in progress. -### Lobby & matchmaking *(Stage 4)* -Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a -per-variant pool and is paired with the next waiting human; after 10 s with no -human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are +### Lobby & matchmaking *(Stage 4 / 15)* +Bottom tab menu: **my games**, **profile**. The game types offered on **New Game** are +limited to the languages the player's sign-in service supports (English → English; +Russian → Russian + Эрудит; a bilingual service shows all three, and the web client is +unrestricted). 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. Auto-match +(always 2 players) joins a per-variant pool and is paired with the next waiting human; +after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are formed by inviting players from the friend list (an invitation, like a friend code, is shareable as a Telegram deep link that opens it directly): the inviter chooses the settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index d20e7be..92db5c5 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -23,13 +23,17 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. -### Личность и сессии *(Stage 1 / 6 / 9)* +### Личность и сессии *(Stage 1 / 6 / 9 / 15)* Игрок приходит с платформы (сначала Telegram), через email-вход или как эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по -языку Telegram-клиента. Гость — только сессия, с урезанными функциями (только +языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он +предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в +лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок +охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки и +является тем ботом, от которого приходят его внеприложенческие уведомления. Гость — только сессия, с урезанными функциями (только авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, @@ -50,8 +54,13 @@ Mini App** авторизует по подписанным `initData` плат тогда сохраняется постоянный аккаунт, а игры гостя переходят в него. Слияние запрещено, только пока у аккаунтов есть общая незавершённая игра. -### Лобби и подбор *(Stage 4)* -Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока) +### Лобби и подбор *(Stage 4 / 15)* +Нижнее tab-меню: **мои игры**, **профиль**. Типы партий на экране **Новая игра** +ограничены языками, которые поддерживает сервис входа игрока (английский → English; +русский → Russian + Эрудит; двуязычный сервис показывает все три, а веб-клиент не +ограничен). Это ограничивает только **старт** новой игры — и авто-подбор, и +приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на +любом языке. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4) формируются приглашением игроков из списка друзей (приглашение, как и код друга, diff --git a/gateway/README.md b/gateway/README.md index 21f53b9..706d582 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -69,6 +69,7 @@ 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` | `15s` | live-stream keep-alive | diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index b96634d..50d2dc4 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -117,7 +117,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) + registry := transcode.NewRegistry(backend, validator, cfg.DefaultSupportedLanguages...) edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 168882f..5eb17e1 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -146,15 +146,18 @@ 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. -func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) { +// 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) { 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, + "external_id": externalID, + "language_code": languageCode, + "username": username, + "first_name": firstName, + "service_language": serviceLanguage, }, &out) return out, err } diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 6ea46e4..0f442a5 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" pkgtel "scrabble/pkg/telemetry" @@ -32,6 +33,11 @@ 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 @@ -71,6 +77,13 @@ const ( defaultServiceName = "scrabble-gateway" ) +// 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{ @@ -113,6 +126,9 @@ func Load() (Config, error) { if c.PushHeartbeatInterval, err = envDuration("GATEWAY_PUSH_HEARTBEAT_INTERVAL", defaultPushHeartbeatInterval); 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 } @@ -156,6 +172,31 @@ 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 1f3ce78..1104ca7 100644 --- a/gateway/internal/connector/client.go +++ b/gateway/internal/connector/client.go @@ -27,12 +27,18 @@ 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. +// 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). type User struct { - ExternalID string - Username string - FirstName string - LanguageCode string + ExternalID string + Username string + FirstName string + LanguageCode string + ServiceLanguage string + SupportedLanguages []string } // Client wraps the connector's Telegram gRPC service. @@ -67,10 +73,12 @@ 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(), + ExternalID: resp.GetExternalId(), + Username: resp.GetUsername(), + FirstName: resp.GetFirstName(), + LanguageCode: resp.GetLanguageCode(), + ServiceLanguage: resp.GetServiceLanguage(), + SupportedLanguages: resp.GetSupportedLanguages(), }, nil } diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index f2e5489..24d7281 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -12,17 +12,36 @@ import ( // created before the table that references it, and no two tables/vectors are // under construction at once. -// encodeSession builds a Session payload. -func encodeSession(s backendclient.SessionResp) []byte { +// 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 { b := flatbuffers.NewBuilder(128) token := b.CreateString(s.Token) uid := b.CreateString(s.UserID) name := b.CreateString(s.DisplayName) + 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) b.Finish(fb.SessionEnd(b)) return b.FinishedBytes() } @@ -63,8 +82,10 @@ func encodeProfile(p backendclient.ProfileResp) []byte { // encodeLinkResult builds a LinkResult payload (Stage 11). 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. -func encodeLinkResult(r backendclient.LinkResultResp) []byte { +// 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 { b := flatbuffers.NewBuilder(256) status := b.CreateString(r.Status) secID := b.CreateString(r.SecondaryUserID) @@ -75,11 +96,13 @@ func encodeLinkResult(r backendclient.LinkResultResp) []byte { 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 7188458..6cf0ca4 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -75,14 +75,20 @@ 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. -func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry { +// 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"} + } 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)} + r.ops[MsgAuthGuest] = Op{Handler: authGuestHandler(backend, defaultLanguages)} r.ops[MsgAuthEmailReq] = Op{Handler: authEmailRequestHandler(backend), Email: true} - r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend), Email: true} + r.ops[MsgAuthEmailLogin] = Op{Handler: authEmailLoginHandler(backend, defaultLanguages), Email: true} r.ops[MsgProfileGet] = Op{Handler: profileHandler(backend), Auth: true} r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true} r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true} @@ -101,7 +107,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry r.ops[MsgChatList] = Op{Handler: chatListHandler(backend), Auth: true} r.ops[MsgChatNudge] = Op{Handler: nudgeHandler(backend), Auth: true} registerStage8(r, backend) - registerStage11(r, backend, tg) + registerStage11(r, backend, tg, defaultLanguages) return r } @@ -135,21 +141,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) + sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, user.ServiceLanguage) if err != nil { return nil, err } - return encodeSession(sess), nil + return encodeSession(sess, user.SupportedLanguages), nil } } -func authGuestHandler(backend *backendclient.Client) Handler { +func authGuestHandler(backend *backendclient.Client, supportedLangs []string) Handler { return func(ctx context.Context, _ Request) ([]byte, error) { sess, err := backend.GuestAuth(ctx) if err != nil { return nil, err } - return encodeSession(sess), nil + return encodeSession(sess, supportedLangs), nil } } @@ -163,14 +169,14 @@ func authEmailRequestHandler(backend *backendclient.Client) Handler { } } -func authEmailLoginHandler(backend *backendclient.Client) Handler { +func authEmailLoginHandler(backend *backendclient.Client, supportedLangs []string) 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), nil + return encodeSession(sess, supportedLangs), nil } } diff --git a/gateway/internal/transcode/transcode_link.go b/gateway/internal/transcode/transcode_link.go index e1714ac..1bf0fbf 100644 --- a/gateway/internal/transcode/transcode_link.go +++ b/gateway/internal/transcode/transcode_link.go @@ -22,13 +22,15 @@ const ( // registerStage11 adds the linking & merge operations. The telegram ops need the // connector's Login Widget validator, so they are registered only when tg is set. -func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator) { +// supportedLangs is the variant gating set for a switched link session (the link +// flows run on the web, so the gateway default set). +func registerStage11(r *Registry, backend *backendclient.Client, tg TelegramValidator, supportedLangs []string) { r.ops[MsgLinkEmailRequest] = Op{Handler: linkEmailRequestHandler(backend), 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} + r.ops[MsgLinkEmailConfirm] = Op{Handler: linkEmailConfirmHandler(backend, supportedLangs), Auth: true, Email: true} + r.ops[MsgLinkEmailMerge] = Op{Handler: linkEmailMergeHandler(backend, supportedLangs), Auth: true, Email: true} if tg != nil { - r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false), Auth: true} - r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true), Auth: true} + r.ops[MsgLinkTelegram] = Op{Handler: linkTelegramHandler(backend, tg, false, supportedLangs), Auth: true} + r.ops[MsgLinkTelegramMerge] = Op{Handler: linkTelegramHandler(backend, tg, true, supportedLangs), Auth: true} } } @@ -42,31 +44,31 @@ func linkEmailRequestHandler(backend *backendclient.Client) Handler { } } -func linkEmailConfirmHandler(backend *backendclient.Client) Handler { +func linkEmailConfirmHandler(backend *backendclient.Client, supportedLangs []string) 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), nil + return encodeLinkResult(res, supportedLangs), nil } } -func linkEmailMergeHandler(backend *backendclient.Client) Handler { +func linkEmailMergeHandler(backend *backendclient.Client, supportedLangs []string) 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), nil + return encodeLinkResult(res, supportedLangs), 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) Handler { +func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, merge bool, supportedLangs []string) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsLinkTelegramRequest(req.Payload, 0) user, err := tg.ValidateLoginWidget(ctx, string(in.Data())) @@ -82,6 +84,6 @@ func linkTelegramHandler(backend *backendclient.Client, tg TelegramValidator, me if err != nil { return nil, err } - return encodeLinkResult(res), nil + return encodeLinkResult(res, supportedLangs), nil } } diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go index 2d02015..ff26edd 100644 --- a/gateway/internal/transcode/transcode_telegram_test.go +++ b/gateway/internal/transcode/transcode_telegram_test.go @@ -47,7 +47,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) { }) defer cleanup() - v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru"}} + v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru", ServiceLanguage: "ru", SupportedLanguages: []string{"ru"}}} reg := transcode.NewRegistry(backend, v) op, ok := reg.Lookup(transcode.MsgAuthTelegram) if !ok { @@ -62,9 +62,43 @@ 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 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) + // 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 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) } } diff --git a/go.work.sum b/go.work.sum index 37e8829..2829bbd 100644 --- a/go.work.sum +++ b/go.work.sum @@ -7,6 +7,7 @@ github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cY github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= @@ -54,6 +55,7 @@ github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= @@ -82,6 +84,7 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aI golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index d451a44..23403f8 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -109,11 +109,16 @@ 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]; } // Ack is a simple success acknowledgement (e.g. an email-code request). diff --git a/pkg/fbs/scrabblefb/Session.go b/pkg/fbs/scrabblefb/Session.go index de425d8..d1b2fc8 100644 --- a/pkg/fbs/scrabblefb/Session.go +++ b/pkg/fbs/scrabblefb/Session.go @@ -77,8 +77,25 @@ 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 SessionStart(builder *flatbuffers.Builder) { - builder.StartObject(4) + builder.StartObject(5) } func SessionAddToken(builder *flatbuffers.Builder, token flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(token), 0) @@ -92,6 +109,12 @@ 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 SessionEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/proto/telegram/v1/telegram.pb.go b/pkg/proto/telegram/v1/telegram.pb.go index 7f348f5..ac2a884 100644 --- a/pkg/proto/telegram/v1/telegram.pb.go +++ b/pkg/proto/telegram/v1/telegram.pb.go @@ -79,15 +79,22 @@ 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 language. +// 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. 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"` - 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"` + 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 } func (x *ValidateInitDataResponse) Reset() { @@ -148,6 +155,20 @@ 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 { @@ -259,7 +280,9 @@ 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) selects the message template. +// 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. 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"` @@ -374,11 +397,14 @@ func (x *NotifyResponse) GetDelivered() bool { return false } -// SendToUserRequest is an admin text message to one user by external_id. +// 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. 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 } @@ -427,10 +453,20 @@ func (x *SendToUserRequest) GetText() string { return "" } -// SendToGameChannelRequest is an admin text message to the configured game channel. +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. 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 } @@ -472,6 +508,13 @@ 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"` @@ -523,14 +566,16 @@ 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\"\x9b\x01\n" + + "\tinit_data\x18\x01 \x01(\tR\binitData\"\xf7\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\"0\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" + "\x1aValidateLoginWidgetRequest\x12\x12\n" + "\x04data\x18\x01 \x01(\tR\x04data\"y\n" + "\x1bValidateLoginWidgetResponse\x12\x1f\n" + @@ -546,13 +591,15 @@ 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\"H\n" + + "\tdelivered\x18\x01 \x01(\bR\tdelivered\"d\n" + "\x11SendToUserRequest\x12\x1f\n" + "\vexternal_id\x18\x01 \x01(\tR\n" + "externalId\x12\x12\n" + - "\x04text\x18\x02 \x01(\tR\x04text\".\n" + + "\x04text\x18\x02 \x01(\tR\x04text\x12\x1a\n" + + "\blanguage\x18\x03 \x01(\tR\blanguage\"J\n" + "\x18SendToGameChannelRequest\x12\x12\n" + - "\x04text\x18\x01 \x01(\tR\x04text\",\n" + + "\x04text\x18\x01 \x01(\tR\x04text\x12\x1a\n" + + "\blanguage\x18\x02 \x01(\tR\blanguage\",\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 204e977..87a567c 100644 --- a/pkg/proto/telegram/v1/telegram.proto +++ b/pkg/proto/telegram/v1/telegram.proto @@ -31,12 +31,13 @@ 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 (admin use, wired in - // Stage 10). delivered is false when the user has not started the bot. + // SendToUser sends an arbitrary text message to one user through the bot the + // request selects by language (admin use, wired in Stage 10). delivered is false + // when the user has not started that bot. rpc SendToUser(SendToUserRequest) returns (SendResponse); - // SendToGameChannel posts an arbitrary text message to the bot's configured - // game channel (admin use, wired in Stage 10); the channel id lives only in the - // connector configuration. + // SendToGameChannel posts an arbitrary text message to the game channel of the + // bot the request selects by language (admin use, wired in Stage 10); the channel + // ids live only in the connector configuration. rpc SendToGameChannel(SendToGameChannelRequest) returns (SendResponse); } @@ -47,12 +48,19 @@ 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 language. +// 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. 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 @@ -72,7 +80,9 @@ 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) selects the message template. +// 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. message NotifyRequest { string external_id = 1; string kind = 2; @@ -86,15 +96,21 @@ message NotifyResponse { bool delivered = 1; } -// SendToUserRequest is an admin text message to one user by external_id. +// 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. message SendToUserRequest { string external_id = 1; string text = 2; + string language = 3; } -// SendToGameChannelRequest is an admin text message to the configured game channel. +// 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. 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 081e9ea..f0bb97c 100644 --- a/pkg/proto/telegram/v1/telegram_grpc.pb.go +++ b/pkg/proto/telegram/v1/telegram_grpc.pb.go @@ -58,12 +58,13 @@ 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 (admin use, wired in - // Stage 10). delivered is false when the user has not started the bot. + // SendToUser sends an arbitrary text message to one user through the bot the + // request selects by language (admin use, wired in Stage 10). delivered is false + // when the user has not started that bot. SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error) - // SendToGameChannel posts an arbitrary text message to the bot's configured - // game channel (admin use, wired in Stage 10); the channel id lives only in the - // connector configuration. + // SendToGameChannel posts an arbitrary text message to the game channel of the + // bot the request selects by language (admin use, wired in Stage 10); the channel + // ids live only in the connector configuration. SendToGameChannel(ctx context.Context, in *SendToGameChannelRequest, opts ...grpc.CallOption) (*SendResponse, error) } @@ -146,12 +147,13 @@ 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 (admin use, wired in - // Stage 10). delivered is false when the user has not started the bot. + // SendToUser sends an arbitrary text message to one user through the bot the + // request selects by language (admin use, wired in Stage 10). delivered is false + // when the user has not started that bot. SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error) - // SendToGameChannel posts an arbitrary text message to the bot's configured - // game channel (admin use, wired in Stage 10); the channel id lives only in the - // connector configuration. + // SendToGameChannel posts an arbitrary text message to the game channel of the + // bot the request selects by language (admin use, wired in Stage 10); the channel + // ids live 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 8195537..f8dd987 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -1,9 +1,22 @@ # scrabble/platform/telegram — Telegram connector The Telegram platform side-service. It is the **only** component that holds the bot -token: it runs the 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. +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. + +## Service languages (dual bots) + +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). ## Responsibilities @@ -13,7 +26,8 @@ network. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/ 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. The gateway calls it + 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 `notifications_in_app_only` flag off, so the platform push never duplicates in-app delivery. @@ -21,7 +35,8 @@ network. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/ launch button; a deep-link payload routes the launch to a game / invitation / friend code. - **Admin messaging** (wired in Stage 10). `SendToUser` and `SendToGameChannel` send - arbitrary text to one user or the configured game channel. + arbitrary text to one user or a game channel through the bot the request selects by + `language` (an operator choice 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 @@ -56,12 +71,14 @@ The bot turns a `/start ` or a notification target into a launch-button | Env var | Default | Meaning | | --- | --- | --- | -| `TELEGRAM_BOT_TOKEN` | — (required) | Bot API token + the initData HMAC secret | -| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) | +| `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_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`) | -| `TELEGRAM_GAME_CHANNEL_ID` | — | game channel chat id for `SendToGameChannel` | | `TELEGRAM_LOG_LEVEL` | `info` | zap log level | | `TELEGRAM_SERVICE_NAME` | `scrabble-telegram` | OpenTelemetry `service.name` | | `TELEGRAM_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) | @@ -76,7 +93,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 +go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN_EN or _RU ``` ## Deploy diff --git a/platform/telegram/cmd/telegram/main.go b/platform/telegram/cmd/telegram/main.go index ef48286..4e50229 100644 --- a/platform/telegram/cmd/telegram/main.go +++ b/platform/telegram/cmd/telegram/main.go @@ -67,19 +67,36 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { logger.Warn("telemetry: start runtime metrics", zap.Error(err)) } - b, err := bot.New(bot.Config{ - Token: cfg.BotToken, - APIBaseURL: cfg.APIBaseURL, - TestEnv: cfg.TestEnv, - MiniAppURL: cfg.MiniAppURL, - }, logger) - if err != nil { - return 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) } - srv := connector.NewServer( - initdata.NewHMACValidator(cfg.BotToken), - loginwidget.NewHMACValidator(cfg.BotToken), - b, cfg.GameChannelID, logger) + srv := connector.NewServer(runtimes, logger) grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler())) telegramv1.RegisterTelegramServer(grpcServer, srv) @@ -89,9 +106,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { return err } - // 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) + // 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) + } go func() { <-ctx.Done() grpcServer.GracefulStop() @@ -100,6 +119,7 @@ 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/deploy/docker-compose.yml b/platform/telegram/deploy/docker-compose.yml index 44288a0..b280eae 100644 --- a/platform/telegram/deploy/docker-compose.yml +++ b/platform/telegram/deploy/docker-compose.yml @@ -44,14 +44,18 @@ services: - vpn network_mode: "service:vpn" environment: - # The bot token lives ONLY in this container (ARCHITECTURE.md §12). - TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN} + # The bot tokens live ONLY in this container (ARCHITECTURE.md §12). One bot per + # service language (en/ru); at least one token is required (the connector + # validates this at boot — compose cannot express "one of"). + 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:-} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_GRPC_ADDR: ${TELEGRAM_GRPC_ADDR:-:9091} # Set to true when deploying into Telegram's test environment. TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false} TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-} - TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} networks: edge: diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go index 3aca704..ebbbd5c 100644 --- a/platform/telegram/internal/config/config.go +++ b/platform/telegram/internal/config/config.go @@ -10,30 +10,44 @@ 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 token lives only in this process (ARCHITECTURE.md §12). +// environment. The bot tokens live only in this process (ARCHITECTURE.md §12). type Config struct { - // BotToken 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 validation. - BotToken string + // 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 // 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). + // query parameter (TELEGRAM_MINIAPP_URL, required). It is shared by all bots + // (one gateway origin); initData is signed per bot token. 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. + // Bot API server. Shared by all bots. APIBaseURL string // TestEnv routes the Bot API client to Telegram's test environment // (.../bot/test/METHOD) (TELEGRAM_TEST_ENV=true, default false). TestEnv bool - // GameChannelID is the chat id of the bot's game channel for SendToGameChannel - // (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts). - GameChannelID int64 // LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info). LogLevel string // Telemetry configures the OpenTelemetry providers (shared bootstrap). @@ -44,31 +58,40 @@ type Config struct { // and validating the required fields. func Load() (Config, error) { cfg := Config{ - BotToken: os.Getenv("TELEGRAM_BOT_TOKEN"), + Bots: map[string]BotConfig{}, 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 + } + 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 + } 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 cfg.BotToken == "" { - return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required") + if len(cfg.Bots) == 0 { + return Config{}, fmt.Errorf("config: at least one TELEGRAM_BOT_TOKEN_ (LANG in %v) is required", Languages) } if cfg.MiniAppURL == "" { return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required") } - 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) - } - cfg.GameChannelID = id - } if err := cfg.Telemetry.Validate(); err != nil { return Config{}, fmt.Errorf("config: %w", err) } diff --git a/platform/telegram/internal/config/config_test.go b/platform/telegram/internal/config/config_test.go index 6a733bb..f2785a0 100644 --- a/platform/telegram/internal/config/config_test.go +++ b/platform/telegram/internal/config/config_test.go @@ -6,14 +6,44 @@ import ( pkgtel "scrabble/pkg/telemetry" ) -// setRequired sets the two required connector variables so Load reaches the -// telemetry checks. +// setRequired sets the required connector variables (one bot + the Mini App URL) +// so Load reaches the telemetry checks. func setRequired(t *testing.T) { t.Helper() - t.Setenv("TELEGRAM_BOT_TOKEN", "test-token") + t.Setenv("TELEGRAM_BOT_TOKEN_EN", "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) { + 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") + 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.Bots["en"].Token != "en-token" || c.Bots["en"].GameChannelID != -100111 { + t.Errorf("en bot = %+v", c.Bots["en"]) + } + 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"]) + } +} + +// TestLoadRequiresBot verifies Load fails when no bot token is configured. +func TestLoadRequiresBot(t *testing.T) { + t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app") + if _, err := Load(); err == nil { + t.Fatal("Load: expected an error when no bot token is set, got nil") + } +} + // TestLoadTelemetryDefaults verifies the connector telemetry defaults: the // "scrabble-telegram" service name and both exporters off. func TestLoadTelemetryDefaults(t *testing.T) { diff --git a/platform/telegram/internal/connector/server.go b/platform/telegram/internal/connector/server.go index 825236b..0b00d5b 100644 --- a/platform/telegram/internal/connector/server.go +++ b/platform/telegram/internal/connector/server.go @@ -1,12 +1,18 @@ // Package connector implements the Telegram gRPC service (pkg/proto/telegram/v1): // the gateway calls ValidateInitData (Mini App auth) and Notify (out-of-app push); -// the admin surface (Stage 10) will call SendToUser and SendToGameChannel. The -// generic methods address a recipient by the identity external_id, so a future -// platform connector can implement the same service. +// the admin surface (Stage 10) calls SendToUser and SendToGameChannel. The generic +// 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. package connector import ( "context" + "errors" "fmt" "strconv" @@ -28,59 +34,103 @@ type Sender interface { SendText(ctx context.Context, chatID int64, text string) error } -// Server implements telegramv1.TelegramServer. -type Server struct { - telegramv1.UnimplementedTelegramServer - validator initdata.Validator - widgetValidator loginwidget.Validator - sender Sender - channelID int64 - log *zap.Logger +// BotRuntime is one configured language-tagged 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 Sender + // ChannelID is this bot's game channel (0 disables channel posts). + ChannelID int64 + // InitValidator verifies Mini App initData signed by this bot's token. + InitValidator initdata.Validator + // WidgetValidator verifies Login Widget data signed by this bot's token. + WidgetValidator loginwidget.Validator } -// NewServer builds the gRPC service from the Mini App initData validator, the Login -// Widget validator (Stage 11), a sender (the bot), and the configured game channel -// id (0 disables channel posts). -func NewServer(validator initdata.Validator, widgetValidator loginwidget.Validator, sender Sender, channelID int64, log *zap.Logger) *Server { +// Server implements telegramv1.TelegramServer over one or more language-tagged bots. +type Server struct { + telegramv1.UnimplementedTelegramServer + bots map[string]BotRuntime // keyed by service language + order []string // stable iteration order for validation + log *zap.Logger +} + +// NewServer builds the gRPC service from the configured bots (at least one). +func NewServer(bots []BotRuntime, log *zap.Logger) *Server { if log == nil { log = zap.NewNop() } - return &Server{validator: validator, widgetValidator: widgetValidator, sender: sender, channelID: channelID, log: log} + 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} } -// ValidateInitData verifies Mini App launch data and returns the user identity. +// 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. func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) { - u, err := s.validator.Validate(req.GetInitData()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.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 } - return &telegramv1.ValidateInitDataResponse{ - ExternalId: u.ExternalID, - Username: u.Username, - FirstName: u.FirstName, - LanguageCode: u.LanguageCode, - }, nil + if lastErr == nil { + lastErr = errors.New("no bot configured") + } + return nil, status.Error(codes.InvalidArgument, lastErr.Error()) } -// ValidateLoginWidget verifies Login Widget authorization data and returns the user -// identity, for attaching a Telegram identity to an existing account (Stage 11). +// 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 (Stage 11). func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) { - u, err := s.widgetValidator.Validate(req.GetData()) - if err != nil { - return nil, status.Error(codes.InvalidArgument, err.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 } - return &telegramv1.ValidateLoginWidgetResponse{ - ExternalId: u.ExternalID, - Username: u.Username, - FirstName: u.FirstName, - }, nil + if lastErr == nil { + lastErr = errors.New("no bot configured") + } + return nil, status.Error(codes.InvalidArgument, lastErr.Error()) } -// Notify renders and delivers an out-of-app notification. It reports -// delivered=false (without an error) for a kind that is not pushed out-of-app or a -// delivery the bot could not complete (e.g. the user never started the bot), so the -// gateway treats a fallback miss as best-effort. +// 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 +// 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 @@ -89,38 +139,58 @@ func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*te if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } - if err := s.sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil { + if err := 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. +// SendToUser sends an arbitrary admin message to one user through the bot selected +// by language (an operator choice in the admin console). 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 := s.sender.SendText(ctx, chat, req.GetText()); err != nil { + if err := 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 admin message to the configured game channel. +// SendToGameChannel posts an arbitrary admin message to the game channel of the bot +// selected by language (an operator choice in the admin console). func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) { - if s.channelID == 0 { - return nil, status.Error(codes.FailedPrecondition, "game channel is not configured") + bot, err := s.botFor(req.GetLanguage()) + if err != nil { + return nil, err } - if err := s.sender.SendText(ctx, s.channelID, req.GetText()); err != nil { + 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 { 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 4d02b22..34a0f00 100644 --- a/platform/telegram/internal/connector/server_test.go +++ b/platform/telegram/internal/connector/server_test.go @@ -56,6 +56,11 @@ 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} +} + func yourTurnPayload(gameID string) []byte { b := flatbuffers.NewBuilder(0) gid := b.CreateString(gameID) @@ -66,25 +71,45 @@ func yourTurnPayload(gameID string) []byte { } func TestValidateInitData(t *testing.T) { - want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "ru"} - srv := NewServer(stubValidator{user: want}, stubWidgetValidator{}, &fakeSender{}, 0, nil) + want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "en-GB"} + srv := NewServer([]BotRuntime{botRT("en", &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() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "ru" { + 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(stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}, &fakeSender{}, 0, nil) + bad := NewServer([]BotRuntime{botRT("en", &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(stubValidator{}, stubWidgetValidator{user: want}, &fakeSender{}, 0, nil) + srv := NewServer([]BotRuntime{botRT("en", &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) @@ -93,7 +118,7 @@ func TestValidateLoginWidget(t *testing.T) { t.Errorf("resp = %+v, want %+v", resp, want) } - bad := NewServer(stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget}, &fakeSender{}, 0, nil) + bad := NewServer([]BotRuntime{botRT("en", &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)) } @@ -102,7 +127,7 @@ func TestValidateLoginWidget(t *testing.T) { func TestNotifyDelivers(t *testing.T) { const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" sender := &fakeSender{} - srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil) + srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil) resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en", }) @@ -120,9 +145,44 @@ 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(stubValidator{}, stubWidgetValidator{}, sender, 0, nil) + srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil) resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ ExternalId: "12345", Kind: "opponent_moved", Language: "en", }) @@ -138,7 +198,7 @@ func TestNotifySkipsUnrenderedKind(t *testing.T) { } func TestNotifyInvalidExternalID(t *testing.T) { - srv := NewServer(stubValidator{}, stubWidgetValidator{}, &fakeSender{}, 0, nil) + srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil) _, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{ ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en", }) @@ -149,8 +209,8 @@ func TestNotifyInvalidExternalID(t *testing.T) { func TestSendToUser(t *testing.T) { sender := &fakeSender{} - srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 0, nil) - resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}) + srv := NewServer([]BotRuntime{botRT("en", sender, 0, stubValidator{}, stubWidgetValidator{})}, nil) + resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi", Language: "en"}) if err != nil { t.Fatalf("send to user: %v", err) } @@ -159,23 +219,36 @@ 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(stubValidator{}, stubWidgetValidator{}, &fakeSender{}, 0, nil) - _, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"}) + srv := NewServer([]BotRuntime{botRT("en", &fakeSender{}, 0, stubValidator{}, stubWidgetValidator{})}, nil) + _, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x", Language: "en"}) if status.Code(err) != codes.FailedPrecondition { t.Errorf("err code = %v, want FailedPrecondition", status.Code(err)) } }) - t.Run("configured", func(t *testing.T) { - sender := &fakeSender{} - srv := NewServer(stubValidator{}, stubWidgetValidator{}, sender, 555, nil) - resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"}) + 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"}) if err != nil { t.Fatalf("send to channel: %v", err) } - if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 555 { - t.Errorf("send to channel calls = %+v", sender.text) + 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) } }) } diff --git a/ui/src/gen/fbs/scrabblefb/session.ts b/ui/src/gen/fbs/scrabblefb/session.ts index 6882465..bb33637 100644 --- a/ui/src/gen/fbs/scrabblefb/session.ts +++ b/ui/src/gen/fbs/scrabblefb/session.ts @@ -46,8 +46,20 @@ 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; +} + static startSession(builder:flatbuffers.Builder) { - builder.startObject(4); + builder.startObject(5); } static addToken(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset) { @@ -66,17 +78,34 @@ 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 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):flatbuffers.Offset { +static createSession(builder:flatbuffers.Builder, tokenOffset:flatbuffers.Offset, userIdOffset:flatbuffers.Offset, isGuest:boolean, displayNameOffset:flatbuffers.Offset, supportedLanguagesOffset: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); return Session.endSession(builder); } } diff --git a/ui/src/lib/codec.test.ts b/ui/src/lib/codec.test.ts index 02b9ec4..788d04d 100644 --- a/ui/src/lib/codec.test.ts +++ b/ui/src/lib/codec.test.ts @@ -47,17 +47,20 @@ 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'], }); }); @@ -181,7 +184,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' }); + expect(r.session).toEqual({ token: 'tok-9', userId: 'a-1', isGuest: false, displayName: 'Kaya', supportedLanguages: [] }); }); it('decodes an Invitation with inviter and invitees', () => { diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 0f46ca0..cff9d81 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -274,9 +274,22 @@ 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. +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, + }; +} + export function decodeSession(buf: Uint8Array): Session { - const t = fb.Session.getRootAsSession(new ByteBuffer(buf)); - return { token: s(t.token()), userId: s(t.userId()), isGuest: t.isGuest(), displayName: s(t.displayName()) }; + return sessionFromTable(fb.Session.getRootAsSession(new ByteBuffer(buf))); } export function decodeProfile(buf: Uint8Array): Profile { @@ -525,9 +538,7 @@ export function decodeLinkResult(buf: Uint8Array): LinkResult { secondaryDisplayName: s(r.secondaryDisplayName()), secondaryGames: r.secondaryGames(), secondaryFriends: r.secondaryFriends(), - session: sess - ? { token: s(sess.token()), userId: s(sess.userId()), isGuest: sess.isGuest(), displayName: s(sess.displayName()) } - : null, + session: sess ? sessionFromTable(sess) : null, }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index dacc485..c0d0522 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -23,6 +23,8 @@ 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'], }; export const PROFILE: Profile = { diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 1a0df53..aa7ad1c 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -186,6 +186,11 @@ 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[]; } // LinkResult is the outcome of an account link/merge step (Stage 11). status is diff --git a/ui/src/lib/variants.test.ts b/ui/src/lib/variants.test.ts new file mode 100644 index 0000000..cb87252 --- /dev/null +++ b/ui/src/lib/variants.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; + +import { ALL_VARIANTS, availableVariants } from './variants'; + +describe('availableVariants', () => { + it('is ungated (all variants) for an empty or absent 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(['english']); + }); + + it('offers Russian and Эрудит for a ru-only service', () => { + expect(availableVariants(['ru']).map((v) => v.id)).toEqual(['russian', 'erudit']); + }); + + it('offers every variant for a bilingual service', () => { + expect(availableVariants(['en', 'ru']).map((v) => v.id)).toEqual(['english', 'russian', 'erudit']); + }); +}); diff --git a/ui/src/lib/variants.ts b/ui/src/lib/variants.ts new file mode 100644 index 0000000..db20b60 --- /dev/null +++ b/ui/src/lib/variants.ts @@ -0,0 +1,32 @@ +// Game variants offered on New Game, and the Stage 15 gating of that choice by the +// languages the sign-in service supports. Kept out of the .svelte screen so the +// gating is unit-testable (the project's node-env Vitest layer). + +import type { MessageKey } from './i18n/index.svelte'; +import type { Variant } from './model'; + +// VariantOption is a selectable game variant with its i18n label key. +export interface VariantOption { + id: Variant; + label: MessageKey; +} + +// ALL_VARIANTS lists every variant in display order. +export const ALL_VARIANTS: VariantOption[] = [ + { id: 'english', label: 'new.english' }, + { id: 'russian', label: 'new.russian' }, + { id: 'erudit', label: 'new.erudit' }, +]; + +// VARIANT_LANGUAGE maps each variant to its game language. en -> English; +// ru -> Russian + Эрудит. +export const VARIANT_LANGUAGE: Record = { english: 'en', russian: 'ru', erudit: '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])); +} diff --git a/ui/src/screens/NewGame.svelte b/ui/src/screens/NewGame.svelte index caaca8e..85ff550 100644 --- a/ui/src/screens/NewGame.svelte +++ b/ui/src/screens/NewGame.svelte @@ -6,12 +6,11 @@ import { navigate } from '../lib/router.svelte'; import { t, type MessageKey } from '../lib/i18n/index.svelte'; import type { AccountRef, Variant } from '../lib/model'; + import { availableVariants } from '../lib/variants'; - const variants: { id: Variant; label: MessageKey }[] = [ - { id: 'english', label: 'new.english' }, - { id: 'russian', label: 'new.russian' }, - { id: 'erudit', label: 'new.erudit' }, - ]; + // The offered variants are gated by the languages the sign-in service supports + // (Stage 15); the auto-match list and the friend-invite picker both use this. + const variants = $derived(availableVariants(app.session?.supportedLanguages)); const timeouts = [ { secs: 300, key: 'time.minutes' as MessageKey, n: 5 }, { secs: 1800, key: 'time.minutes' as MessageKey, n: 30 },