diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 63f9196..c41b40c 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 }} diff --git a/PLAN.md b/PLAN.md index 92030a0..10836dd 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 diff --git a/PRERELEASE.md b/PRERELEASE.md index 83c1ff7..0c9894f 100644 --- a/PRERELEASE.md +++ b/PRERELEASE.md @@ -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) diff --git a/backend/README.md b/backend/README.md index 22fd1af..57c66ff 100644 --- a/backend/README.md +++ b/backend/README.md @@ -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. diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index a0a7a28..14e143a 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -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, diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index d2fa936..fbee7a7 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -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) diff --git a/backend/internal/account/profile_test.go b/backend/internal/account/profile_test.go index b8774f7..5003dd9 100644 --- a/backend/internal/account/profile_test.go +++ b/backend/internal/account/profile_test.go @@ -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) + } +} diff --git a/backend/internal/adminconsole/render_test.go b/backend/internal/adminconsole/render_test.go index f827e3f..2f46a28 100644 --- a/backend/internal/adminconsole/render_test.go +++ b/backend/internal/adminconsole/render_test.go @@ -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"}, diff --git a/backend/internal/adminconsole/templates/pages/broadcast.gohtml b/backend/internal/adminconsole/templates/pages/broadcast.gohtml index 8aa559a..f570231 100644 --- a/backend/internal/adminconsole/templates/pages/broadcast.gohtml +++ b/backend/internal/adminconsole/templates/pages/broadcast.gohtml @@ -5,7 +5,6 @@ {{if .ConnectorEnabled}}
-
{{else}}

connector not configured (set BACKEND_CONNECTOR_ADDR)

{{end}} diff --git a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml index 3ba3e4d..6d217b9 100644 --- a/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml +++ b/backend/internal/adminconsole/templates/pages/feedback_detail.gohtml @@ -5,7 +5,7 @@

Message