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
|
||||
|
||||
+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
|
||||
|
||||
Reference in New Issue
Block a user