Merge pull request 'Stage 15: dual Telegram bots & language-gated variants' (#16) from feature/stage-15-language-service-split into master
This commit was merged in pull request #16.
This commit is contained in:
@@ -48,9 +48,9 @@ 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 | Deploy infra & test contour (Dockerfiles, gateway static UI, compose, observability) | todo |
|
||||
| 16 | Prod contour deploy (SSH export/import, manual after merge) | todo |
|
||||
| 17 | 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 |
|
||||
|
||||
Scaffolding is incremental: `go.work` lists only existing modules; each stage
|
||||
adds the modules it needs.
|
||||
@@ -264,7 +264,22 @@ both — discharging **TODO-1** and **TODO-2**.
|
||||
`BACKEND_DICT_DIR/<version>/`, `engine.OpenWithVersions`, per-game `dict_version` pin; a version is
|
||||
safe to retire once no active game pins it).
|
||||
|
||||
### Stage 15 — Deploy infra & test contour
|
||||
### 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 +
|
||||
gateway **Dockerfiles** (multi-stage distroless, mirroring the Stage 9 connector image); the gateway
|
||||
gains **static UI serving** — **embedded** via `go:embed` (a node build stage in the gateway image),
|
||||
@@ -282,7 +297,7 @@ h2c wrap — `/` + `/telegram/` mounts; a committed `dist` placeholder so `go bu
|
||||
build); Postgres healthcheck/volume; whether the connector-scoped compose is retired for the root one;
|
||||
collector/Tempo/Prometheus retention.
|
||||
|
||||
### Stage 16 — Prod contour deploy
|
||||
### Stage 17 — Prod contour deploy
|
||||
Scope: the **production contour** on a remote host over SSH. Deploy by **container export/import**
|
||||
(`docker save` → `scp`/ssh → `docker load` → `docker compose up` on the remote), the SSH key + host IP
|
||||
in Gitea secrets; **strictly manual** (`workflow_dispatch`) after a feature branch is merged to
|
||||
@@ -292,20 +307,6 @@ convention.
|
||||
Open details (re-interview): export/import vs a registry trade-off; prod domain/TLS at the remote
|
||||
caddy; prod VPN; rollback.
|
||||
|
||||
### Stage 17 — 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.
|
||||
|
||||
## Refinements logged during implementation
|
||||
|
||||
- **Stage 0**: solver `replace` deferred to Stage 2 (nothing imports it yet;
|
||||
@@ -1006,6 +1007,35 @@ sequential validation + language-keyed routing model is sound.
|
||||
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 `<select>`), so a `language` field was added to those two RPCs
|
||||
sourced from the form, not from `service_language`.
|
||||
- **Gating = UI-only, creation-only** (interview): the backend does not enforce (a valid game is
|
||||
harmless, not a trust boundary); only the New Game pickers (auto-match + friend invite) filter — there
|
||||
is no variant picker on accept/open/play, so those are inherently ungated. Non-Telegram logins
|
||||
(web/email/guest) carry the gateway default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, default all).
|
||||
- **Wire/connector**: `ValidateInitDataResponse` gained `service_language` + `supported_languages`; the
|
||||
fbs `Session` gained `supported_languages:[string]`; `SendToUser`/`SendToGameChannel` gained
|
||||
`language` (committed Go + TS regenerated via `make -C pkg gen` + `pnpm -C ui codegen`). The connector
|
||||
config moved to **per-language** bots (`TELEGRAM_BOT_TOKEN_EN/_RU`, `TELEGRAM_GAME_CHANNEL_ID_EN/_RU`;
|
||||
`TELEGRAM_MINIAPP_URL` shared; ≥ 1 token required — a breaking config change, no prod yet); the
|
||||
server hosts a bot map and routes by language. The push template language now follows the routing bot
|
||||
(was `preferred_language`) — a documented change. The deploy compose/Dockerfile env was updated to the
|
||||
per-language vars (the full deploy stack is Stage 16). No CI workflow change (the Go and UI workflows
|
||||
already span the touched modules).
|
||||
|
||||
## Deferred TODOs (cross-stage)
|
||||
|
||||
- ~~**TODO-1 — publish & version the solver.**~~ **Done in Stage 14.** `scrabble-solver` is
|
||||
|
||||
+6
-2
@@ -80,8 +80,12 @@ the gateway fronts it with Basic-Auth and a same-origin guard protects its POSTs
|
||||
`resolved_at`/`applied_in_version` + the `status` CHECK) feeding a dictionary-change
|
||||
pipeline, dictionary **hot-reload** from `BACKEND_DICT_DIR/<version>/`
|
||||
(`engine.OpenWithVersions` / `Registry.LoadAvailable`), and operator **broadcasts** via a
|
||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`). The
|
||||
shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||
broadcast picks the delivering bot by an operator-chosen language. **Stage 15** adds
|
||||
migration `00010` (`accounts.service_language`): the language tag of the bot a Telegram
|
||||
user last signed in through, written on every login and returned by
|
||||
`/internal/push-target` (falling back to `preferred_language`) so out-of-app push routes
|
||||
to the right bot. The shared wire contracts live in the sibling [`../pkg`](../pkg) module.
|
||||
|
||||
Stage 11 adds **account linking & merge** (`/api/v1/user/link/*`). `internal/link`
|
||||
orchestrates it: an email confirm-code or a gateway-validated Telegram identity is
|
||||
|
||||
@@ -56,6 +56,12 @@ type Account struct {
|
||||
HintBalance int
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
// ServiceLanguage is the language tag (en/ru) of the bot the account last
|
||||
// authenticated through (its last Telegram ValidateInitData); it routes the
|
||||
// account's out-of-app push back through the right bot. Empty when the account
|
||||
// has never signed in through a tagged bot. Distinct from PreferredLanguage (the
|
||||
// interface language) and from a game's variant language.
|
||||
ServiceLanguage string
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
@@ -374,16 +380,41 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// SetServiceLanguage records the service language (en/ru) of the bot a Telegram
|
||||
// user authenticated through. It is called on every Telegram login — new and
|
||||
// existing accounts — so it tracks the bot the user last came through (last-login-
|
||||
// wins), and the out-of-app push routes by it. It is a no-op for an empty language
|
||||
// (a non-Telegram login carries none) and does not bump updated_at (an infra
|
||||
// routing field, not a user profile edit).
|
||||
func (s *Store) SetServiceLanguage(ctx context.Context, id uuid.UUID, language string) error {
|
||||
if language == "" {
|
||||
return nil
|
||||
}
|
||||
stmt := table.Accounts.
|
||||
UPDATE(table.Accounts.ServiceLanguage).
|
||||
SET(postgres.String(language)).
|
||||
WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id)))
|
||||
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
|
||||
return fmt.Errorf("account: set service language %s: %w", id, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
var mergedInto uuid.UUID
|
||||
if row.MergedInto != nil {
|
||||
mergedInto = *row.MergedInto
|
||||
}
|
||||
var serviceLanguage string
|
||||
if row.ServiceLanguage != nil {
|
||||
serviceLanguage = *row.ServiceLanguage
|
||||
}
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
ServiceLanguage: serviceLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/broadcast">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
||||
<div><button type="submit">Post to channel</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
{{if .ConnectorEnabled}}
|
||||
<form class="form col" method="post" action="/_gm/users/{{.ID}}/message">
|
||||
<label>Message <textarea name="text" required></textarea></label>
|
||||
<label>Bot language <select name="language"><option value="en">en</option><option value="ru">ru</option></select></label>
|
||||
<div><button type="submit">Send to user</button></div>
|
||||
</form>
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -29,4 +29,5 @@ type Accounts struct {
|
||||
PaidAccount bool
|
||||
MergedInto *uuid.UUID
|
||||
MergedAt *time.Time
|
||||
ServiceLanguage *string
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
+25
-5
@@ -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).
|
||||
|
||||
+14
-6
@@ -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
|
||||
|
||||
+13
-4
@@ -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)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 <payload>` 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<token>/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 `<host>/bot<token>/<method>`).
|
||||
```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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_<LANG>). It both
|
||||
// authenticates the Bot API client and is the HMAC secret for Mini App initData
|
||||
// validation against this bot.
|
||||
Token string
|
||||
// GameChannelID is the chat id of this bot's game channel for SendToGameChannel
|
||||
// (TELEGRAM_GAME_CHANNEL_ID_<LANG>, optional; 0 disables channel posts).
|
||||
GameChannelID int64
|
||||
}
|
||||
|
||||
// Config is the Telegram connector's runtime configuration, read from the
|
||||
// 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_<LANG> 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<token>/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> (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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+16
-5
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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<Variant, 'en' | 'ru'> = { 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]));
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user