Promote development → master (initial production release: pre-release line + Stage 18) #104
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) ---
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" +
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 `/`).
|
||||
|
||||
@@ -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: '🇷🇺 Русский' },
|
||||
|
||||
@@ -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}'));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)}`;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Код подтверждения',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user