feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s

Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.

- Telegram: one bot; drop service_language and the supported_languages
  set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
  connector proto). The single bot renders chat and out-of-app push in
  the recipient's preferred_language; remove the game-language push
  routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
  {erudit_ru}, CHECK non-empty + subset of the three variants). Gates
  the New Game picker, vs-AI and the friend invitation the player
  creates, enforced server-side (HTTP 400 otherwise); an invited friend
  may still accept any variant. Edited on the Settings screen; variants
  are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
  and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
  VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
  to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
  backend, ui, UI_DESIGN, PRERELEASE).

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