Stage 15: dual Telegram bots & language-gated variants
Tests · Go / test (push) Successful in 9s
Tests · Integration / integration (push) Successful in 10s
Tests · UI / test (push) Successful in 20s
Tests · Go / test (pull_request) Successful in 8s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s

Service-agnostic refinement of the owner's idea: the sign-in service returns a
set of supported game languages with the user identity, and the lobby gates the
New Game variant choice by it (en -> English; ru -> Russian + Эрудит).

- Connector hosts two bots in one container (one per service language, each its
  own token + game channel; the same telegram_id spans both). ValidateInitData
  tries each token and returns the validating bot's service_language +
  supported_languages. Per-language config (TELEGRAM_BOT_TOKEN_EN/_RU, channels).
- supported_languages rides the Session (fbs, session-scoped, not persisted); the
  UI offers only the matching variants on New Game — gating only the START of a
  new game (auto-match + friend invite), not accept/open/play; backend does not
  enforce.
- service_language persisted (accounts.service_language, migration 00010, written
  every login, last-login-wins) and routes the user-facing Notify push back
  through the right bot (push-target coalesces with preferred_language).
- Admin SendToUser/SendToGameChannel gain an operator-chosen language selector in
  the console (unrelated to ValidateInitData).
- Non-Telegram logins carry the gateway default set
  (GATEWAY_DEFAULT_SUPPORTED_LANGUAGES, all variants).

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