feat(telegram,game): single bot + per-user variant preferences
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 13s
CI / ui (pull_request) Successful in 54s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 57s
Collapse the two per-language Telegram bots into one unified bot and
replace language-based variant gating with explicit per-user variant
preferences.
- Telegram: one bot; drop service_language and the supported_languages
set everywhere (DB, account, auth, FlatBuffers Session wire, gateway,
connector proto). The single bot renders chat and out-of-app push in
the recipient's preferred_language; remove the game-language push
routing override (notify Intent.Language / push Event.language).
- Preferences: new accounts.variant_preferences (text[], DB default
{erudit_ru}, CHECK non-empty + subset of the three variants). Gates
the New Game picker, vs-AI and the friend invitation the player
creates, enforced server-side (HTTP 400 otherwise); an invited friend
may still accept any variant. Edited on the Settings screen; variants
are Erudit-first everywhere.
- Admin: drop the per-bot language selectors (broadcast / send-to-user)
and the feedback channel_lang column/field.
- Env/CI: collapse TELEGRAM_BOT_TOKEN_{EN,RU}, TELEGRAM_GAME_CHANNEL_ID_{EN,RU},
VITE_TELEGRAM_LINK{,_EN,_RU} and VITE_TELEGRAM_GAME_CHANNEL_NAME_{EN,RU}
to single unsuffixed names; drop GATEWAY_DEFAULT_SUPPORTED_LANGUAGES.
- Docs updated (ARCHITECTURE, FUNCTIONAL + _ru, platform/telegram, gateway,
backend, ui, UI_DESIGN, PRERELEASE).
The migration squash is deferred to a follow-up PR.
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user