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

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

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

The migration squash is deferred to a follow-up PR.
This commit is contained in:
Ilia Denisov
2026-06-20 14:08:27 +02:00
parent 1933849dba
commit 57c778f9b2
99 changed files with 1006 additions and 1256 deletions
+50 -36
View File
@@ -46,10 +46,9 @@ Three executables plus per-platform side-services:
mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/telegram`** — the Telegram side-service (the "connector", module
`scrabble/platform/telegram`). It is the only component holding the bot tokens — **one
bot per service language** (`en`/`ru`), each its own token + game channel, the same
Telegram user id spanning both (§3). It
runs a Bot API long-poll loop per bot (Mini App launch + `/start` deep-links) and serves
`scrabble/platform/telegram`). It is the only component holding the bot token — **one
unified bot** (one token + one optional game channel, §3). It
runs a Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation
and out-of-app push) and `backend` (operator broadcasts) call over the
trusted internal network. Its generic delivery methods are **platform-agnostic**
@@ -138,24 +137,27 @@ arrive from a platform rather than completing a mandatory registration).
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4).
- **Service language & variant gating.** The connector hosts **one bot per
service language** (`en`/`ru`), each its own token + game channel; the same Telegram
user id spans both. `ValidateInitData` tries each token in turn and returns the
validating bot's **service language** and its **supported-languages set**. The set
rides the **`Session`** (FlatBuffers, session-scoped, not persisted): the UI offers
only the variants those languages support on New Game (`en` → English; `ru` → Russian
+ Эрудит). **Starting** a new game is the only gated action — opening and playing
existing games of any language is unrestricted, and the backend does not enforce the
gate (it is a product affordance, not a trust boundary). The service language is
**persisted** per account (`accounts.service_language`, updated on every Telegram
login — last-login-wins) and routes the user's out-of-app push back through the right
bot (§10) — **except a game event, which routes by the game's own language** (its variant →
en/ru), so a game's notification always comes from the game's bot rather than the
recipient's latest login bot. It also rides the **Session wire** to the client, which uses it
to build the friend-invite **share link** (and its caption) for the **same bot** the player
is in. The service language is distinct from `preferred_language` (the
interface language) and from a game's variant language. Non-Telegram logins (web / email / guest) carry the
gateway's default set (`GATEWAY_DEFAULT_SUPPORTED_LANGUAGES`, all variants by default).
- **Single bot.** The connector hosts **one unified bot** (one token + one optional
game channel). `ValidateInitData` validates `initData` against that single token and
returns only the Telegram user identity — there is no per-bot "service language" and no
supported-languages set on the wire. The bot's chat messages and out-of-app push are
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
any bot-scoped language, and the friend-invite **share link** (and its caption) point at
that one bot. First Telegram contact seeds the new account's `preferred_language` from the
launch `language_code` (§4); the interface language is otherwise edited in Settings.
- **Variant preferences (New Game gating).** Which variants a player may be matched into is a
per-user **profile** setting — `variant_preferences`, a set of `engine.Variant` labels
(`scrabble_en`, `scrabble_ru`, `erudit_ru`) edited on the Settings/Profile screen. New
accounts default to **Erudit only** (a DB column default); at least one variant must stay
selected. The picker is ordered **Erudit-first** everywhere. The preference gates the New
Game picker on every create path the player **initiates** — auto-match, vs-AI and a friend
invitation the player **creates** and the backend **enforces** it on those paths (a chosen
variant outside the caller's preferences is rejected with HTTP 400). An **invited** friend
may still **accept** an invitation in **any** variant (accepting is never gated), and opening
or playing existing games of any variant is unrestricted. This replaces the former
login-language variant gating; it is a per-account product affordance plus a server-side
create-path check, distinct from `preferred_language` (the interface language) and from a
game's variant language.
- The client holds `session_id` in memory for the app session (browser/OS
storage is optional and may be unavailable; losing it means re-login).
- The gateway caches `session → user_id` and injects `X-User-ID`. Session
@@ -176,6 +178,18 @@ arrive from a platform rather than completing a mandatory registration).
Platform and email users are auto-provisioned **durable** accounts with an
identity.
> **Decision (2026-06-20) — single bot, preference-based variant gating.** The former
> two-bot model (one bot per service language, with `accounts.service_language`, a
> `supported_languages` set on the `Session` wire and game-language push routing) was
> collapsed into **one unified bot**: it renders chat and out-of-app push in the
> recipient's interface language (`preferred_language`), with no per-bot routing. New
> Game variant gating moved off the login language onto a per-user profile setting
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
> paths; an invited friend may still accept any variant). The per-bot env vars and
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile.
## 4. Accounts, identities, linking & merge
- One internal account may carry several **platform identities**
@@ -525,10 +539,10 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
code** the to-be-added player issues (a `friend_codes` row: 6-digit numeric,
SHA-256-hashed, **12 h** TTL, one live code per issuer, single-use, redeem
rate-limited) is redeemed by the other player to become friends immediately. It is shared as
a Telegram `startapp` deep-link to the issuer's own bot (by service language, with a matching
caption), redeemed by the recipient's Mini App on launch; a **spent or expired** code is not
a Telegram `startapp` deep-link to the single bot (with a matching caption),
redeemed by the recipient's Mini App on launch; a **spent or expired** code is not
surfaced as an error there but lands the visitor in the lobby with a gentle pointer to the
right bot, since the shared link outlives the single-use code.
bot, since the shared link outlives the single-use code.
Alternatively a **request → accept** is sent to someone you **share a game with**
(active or finished); the recipient may accept, ignore (the pending row lazily
expires after **30 days** and may be re-sent), or **decline** — a decline is
@@ -602,7 +616,9 @@ in either direction (the enqueue excludes the caller's `BlockedWith` set);
sections (the finished section keeps its activity order). On each clear the publish-to-read
latency is recorded; the read time itself is not retained.
- **Profile**: `preferred_language` (en/ru, edited in Settings), display name, email
(confirm-code binding, see §4), **timezone**, the daily **away window** and the
(confirm-code binding, see §4), **timezone**, the daily **away window**, the
**variant preferences** (`variant_preferences`, the matchable-variant set that gates New
Game — §3, defaulting to Erudit only, at least one enforced) and the
block toggles — all editable through `account.UpdateProfile`, which validates them:
a display name is Unicode letters joined by single ` `/`.`/`_`
separators (no leading/trailing/adjacent separators, ≤ 32 runes); the timezone is a
@@ -772,20 +788,18 @@ invitations) the client re-polls on the `notify` event and on lobby open / focus
missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the **service language** — the bot they last signed in through, falling
back to the interface language and the `notifications_in_app_only` flag). A **game** event,
however, carries the **game's own language** on the push, and the gateway routes by
that instead of the service language — so a game's notification always comes from the game's bot,
not the recipient's latest-login bot. It then asks the **Telegram connector** to deliver a
localized message with a Mini App deep-link button — only when the recipient has a Telegram
`external_id`, the recipient's **interface language** (`preferred_language`) as the render
language, and the `notifications_in_app_only` flag). It then asks the **Telegram connector**
to deliver — through the **single bot** — a
localized message with a Mini App deep-link button, only when the recipient has a Telegram
identity and has not confined notifications to the app, so the two channels never duplicate. The
connector routes by that language to the matching bot and renders the message in it. The out-of-app set is
connector renders the message in that language; there is no per-bot routing. The out-of-app set is
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so in-app-only sub-kinds like
**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a
block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) instead pick the bot by an
**operator-chosen** language in the console, unrelated to the recipient's login. Session-revocation events and
(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
the console, sent through the same single bot. Session-revocation events and
cursor-based stream resume stay deferred (single-instance MVP).
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
@@ -798,7 +812,7 @@ remainder up to 100% and is undeletable. Eligibility — who sees a banner at al
it unconditionally); guests qualify. The eligible viewer's banner block rides the **`profile.get`**
response (the one bootstrap every client fetches on open, authed or guest — no separate request,
nothing distinct for an advanced user to filter): the backend resolves each message to the viewer's
**service language** (the bot they signed in through, falling back to the interface language) and
**interface language** (`preferred_language`) and
computes the active set — window-filtered campaigns, the default's effective weight
(`max(0, 100 Σ active timed weights)`, dropped at 0), GCD-reduced. The **client** rotates that set
with a smooth weighted round-robin (deterministic, fair: each campaign gets its weight share per
+17 -12
View File
@@ -31,13 +31,9 @@ ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
App** launch authenticates from the platform's signed `initData`, themes the UI to
the Telegram colours, and — on first contact — seeds the new account's interface
language from the Telegram client. The sign-in service also declares the **game
languages** it offers (a set of en/ru, at least one), which gate the New Game variant
choice in the lobby. Telegram runs a separate bot per language (an English bot and a
Russian bot, the same player spanning both); the bot a player signed in through sets their
offered languages, and their non-game notifications come from it. A **game's** notifications
(your turn, game over, a nudge), though, always come from **that game's** bot — by the game's
language, not whichever bot the player signed in through last. Guests are session-only with restricted features
language from the Telegram client. Telegram runs a **single bot**: every player uses
the same bot, and all of its chat and out-of-app notifications are written in the
player's own **interface language** (en/ru). Guests are session-only with restricted features
(auto-match only; no friends, stats or history); an abandoned guest that never
joined a game and has been idle past the retention window is garbage-collected. While the app is open the client
keeps a live stream and receives in-app updates in real time — the opponent's move,
@@ -85,12 +81,12 @@ in the other players' lists, and there is no undo. A finished **AI game (🤖) y
resigning or by letting it lapse to the 7-day timeout — drops from your *finished* list
automatically, with no swipe needed; a normally finished AI game stays until you remove it, and
no other game type is auto-removed. The game types offered on **New Game** are
limited to the languages the player's sign-in service supports (English → Scrabble;
Russian → Scrabble + Erudite; a bilingual service shows all three, and the web client is
unrestricted). Variants are shown by their **display name** — both Scrabble variants read
limited to the player's chosen **variant preferences** (see *Profile & settings*) —
Erudite-first, defaulting to Erudite only. Variants are shown by their **display name** — both Scrabble variants read
"Scrabble"/"Скрэббл" and Erudit reads "Erudite"/"Эрудит" (by the interface language), and
the same name titles the in-game screen. This gates only **starting** a new game — both auto-match and a friend
invitation — so a player still sees and plays existing games of any language.
the same name titles the in-game screen. This gates only **starting** a new game you initiate —
auto-match, a vs-AI game and a friend invitation **you create** — so a player still sees and plays
existing games of any variant, and being **invited** to a game lets you accept it in any variant.
**Quick game** lets you choose your opponent — an **AI** (the default) or a **random player**.
With **AI** you start at once against a 🤖 that joins and replies immediately: there is no waiting,
@@ -237,6 +233,15 @@ block toggles. The profile form is edited inline (no separate edit mode). Linkin
an email or Telegram and merging accounts are covered under "Accounts, linking &
merge".
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
least one** selected. This list is exactly what **New Game** offers when you start a game
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
invited friend may accept an invitation in **any** variant, and you can always open and play
existing games of any variant.
### Feedback
A registered player reaches the operators from Settings → Info: a **Feedback** screen with a
message (up to 1024 characters) and an optional single attachment (one file, up to ~1 MB — images,
+18 -13
View File
@@ -32,13 +32,9 @@ top-1 подсказку, безлимитную проверку слова с
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
языку Telegram-клиента. Сервис входа также объявляет **языки игры**, которые он
предлагает (набор из en/ru, минимум один), и они ограничивают выбор типа партии в
лобби. Telegram держит отдельного бота на язык (английский и русский, один игрок
охватывает обоих); бот, через которого игрок вошёл, задаёт его доступные языки, и от него
приходят его **внеигровые** уведомления. А уведомления по **партии** (ваш ход, конец партии,
nudge) приходят от бота **этой партии** — по языку партии, а не по тому боту, через которого
игрок входил последним. Гость — только сессия, с урезанными функциями (только
языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
@@ -87,13 +83,13 @@ nudge) приходят от бота **этой партии** — по язы
из *завершённых* автоматически, без свайпа; нормально доигранная игра с ИИ остаётся, пока ты не
уберёшь её, и никакие другие типы партий автоматически не убираются. Типы партий
на экране **Новая игра**
ограничены языками, которые поддерживает сервис входа игрока (английский → Scrabble;
русский → Scrabble + Erudite; двуязычный сервис показывает все три, а веб-клиент не
ограничен). Варианты показываются под **отображаемым именем** — оба варианта Scrabble
ограничены выбранными игроком **предпочтениями вариантов** (см. «Профиль и
настройки») — сначала Эрудит, по умолчанию только Эрудит. Варианты показываются под **отображаемым именем** — оба варианта Scrabble
читаются как «Scrabble»/«Скрэббл», а Erudit — «Erudite»/«Эрудит» (по языку интерфейса),
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** новой игры — и авто-подбор, и
приглашение друга, — поэтому игрок по-прежнему видит и играет существующие игры на
любом языке.
и это же имя выносится в заголовок экрана игры. Это ограничивает только **старт** игры, которую ты
инициируешь, — авто-подбор, игру с ИИ и приглашение друга, которое ты **создаёшь**, — поэтому игрок
по-прежнему видит и играет существующие игры любого варианта, а при **приглашении** в партию её
можно принять в любом варианте.
**Быстрая игра** даёт выбрать соперника — **ИИ** (по умолчанию) или **случайного игрока**. С **ИИ**
вы сразу начинаете против 🤖, который присоединяется и отвечает мгновенно: ожидания нет, чат и nudge
@@ -243,6 +239,15 @@ UTC), суточного окна отсутствия (away; сетка по 10
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
а открывать и играть существующие игры любого варианта можно всегда.
### Обратная связь
Зарегистрированный игрок обращается к операторам из Settings → Info: экран **«Обратная связь»** с
сообщением (до 1024 символов) и необязательным вложением (один файл, до ~1 МБ — изображения, PDF,
+3 -3
View File
@@ -313,10 +313,10 @@ enabled on the first, uncached load) and flip in place when an event refreshes t
- **Friend code**: the issued code sits next to a 📋 copy control; tapping the code or
the icon copies it. **Share via Telegram** wraps the code in a `t.me/<bot>/<app>?startapp=`
deep-link and opens Telegram's native share-to-chat sheet (Web Share / clipboard fallback
outside Telegram); the link and its caption are for the **same bot the player signed in
through** (its service language). Redeeming your **own** invite shows a friendly note, not an
outside Telegram); the link and its caption are for the **single bot**. Redeeming your **own**
invite shows a friendly note, not an
error; opening an **outdated** invite link (a used or expired single-use code) lands in the
lobby with a calm modal pointing at the right bot (`@<username>`), not a red error on the
lobby with a calm modal pointing at the bot (`@<username>`), not a red error on the
Friends tab. Flex text inputs carry `min-width:0` so they shrink instead of overflowing in
Safari.
- **History / GCG**: the in-game slide-down history lays each move out in a per-seat grid