feat(telegram): promo bot + channel-chat moderation gate
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 57s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

Add a second standalone promo bot to the bot container (answers /start with a
localized message + a URL button into the main bot's Mini App) and gate write
access in a channel's linked discussion chat: grant on join when the Telegram
user is registered and neither admin-suspended nor holding a new chat_muted
role, and revoke/grant on the matching moderation change for a member currently
in the chat.

Eligibility (registered AND NOT suspended AND NOT chat_muted; the game
suspension dominates) is resolved once in the backend and reached two ways: the
bot's join-time unary ResolveChatEligibility over the existing mTLS bot-link,
and a backend chat_access_changed event -> gateway -> ChatGate command
(idempotent; a temporary-block-expiry sweeper may over-emit). The bot guards the
block/unblock path with getChatMember, since bots cannot list members.

A web_app button cannot open another bot's Mini App (it signs initData with the
sending bot's token), so the promo button is a t.me ?startapp URL reusing the
UI's VITE_TELEGRAM_LINK. The bot must be a chat admin with the restrict-members
right and chat_member in its allowed updates.

No schema change: chat_muted reuses the data-driven account_roles table.
This commit is contained in:
Ilia Denisov
2026-06-21 14:46:51 +02:00
parent 41d21f3f6f
commit e71e40eef5
42 changed files with 2082 additions and 68 deletions
+23 -2
View File
@@ -824,7 +824,13 @@ the bot renders the message and skips the rest — so in-app-only sub-kinds like
block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
the console; the backend calls them on the **gateway's bot-link relay**, which forwards them
to the bot and **awaits its delivery ack** (so the console still reports delivered/not).
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
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),
@@ -987,9 +993,24 @@ revoked token would fail session resolution at the gateway *before* the gate, se
login instead of the blocked screen). A block instantly **forfeits** every active game the player
is in (the opponent wins, exactly as a resignation — the engine resigns off-turn) and cancels
their open matchmaking games; a temporary block lapses automatically once its expiry passes (no
sweeper the gate recomputes against `now`). No operator identity is recorded (shared
sweeper for the gate — it recomputes against `now`). No operator identity is recorded (shared
Basic-Auth).
**Moderated discussion chat.** A channel's linked discussion group is gated by the Telegram bot
(`TELEGRAM_CHAT_ID`): the group defaults to no-send, and a user may write only while they are
**registered and neither admin-suspended nor holding the chat-only `chat_muted` role**
(`eligible = registered AND NOT suspended AND NOT chat_muted` — the game suspension dominates). A
single backend resolver behind `POST /api/v1/internal/chat-access` answers both directions: the
bot's join-time `ResolveChatEligibility` (over the mTLS bot-link) grants write access to an
eligible joiner, and a `chat_access_changed` event — emitted on a block/unblock, a `chat_muted`
grant/revoke, or a temporary block lapsing (a dedicated `account.SuspensionSweeper`, since no
request fires then) — drives a `ChatGate` command the gateway pushes to the bot. The bot applies it
only to a member currently in the chat (a per-user `getChatMember` probe, since bots cannot list
members); the signal is idempotent and is never an in-app or out-of-app message. `chat_muted` is an
`account_roles` entry (an operator toggle in the console), so it needs no schema change. The bot
must be an administrator in the group with the **restrict-members** right and `chat_member` in its
allowed updates.
**Short numeric codes** (email confirm-codes and friend codes) are stored
only as SHA-256 hashes and are short-lived and single-use. The unauthenticated
email path carries a tight per-IP sub-limit (5 / 10 min); the **friend-code redeem**
+12 -1
View File
@@ -33,7 +33,10 @@ App** launch authenticates from the platform's signed `initData`, themes the UI
the Telegram colours, and — on first contact — seeds the new account's interface
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
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
main one — its only job is to answer `/start` with a short message and a button that opens the
**main** bot's app, where the player picks their game variant; it is an onboarding entry point that
touches nothing else. 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,
@@ -320,6 +323,14 @@ plus the reason when one was given, and the app stops all background traffic wit
temporary block lifts itself when it expires; the operator can also **unblock** from the user card
at any time (games already lost stay lost).
Where the bot manages a channel's **linked discussion chat**, only a **registered** player who is
**not blocked** may write there: the bot grants the right to write when such a player joins, while an
unregistered or blocked one stays muted (the promo bot points newcomers at the game so they register).
An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
without a full account block; an account block mutes them in the chat regardless. Muting/unmuting and
blocking/unblocking take effect for a player already in the chat; one who is not in it is unaffected
until they next join.
From the user card the operator can also **top up a player's hint wallet**: an additive grant
(1100 hints per action) that raises the balance shown on the card. Grants are **raise-only**
the console can never lower a wallet (a player only loses hints by spending them in a game), so an
+12 -1
View File
@@ -34,7 +34,10 @@ Mini App** авторизует по подписанным `initData` плат
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
языку Telegram-клиента. Telegram держит **единого бота**: все игроки пользуются одним
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
интерфейса** самого игрока (en/ru). Гость — только сессия, с урезанными функциями (только
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
открывающей приложение **основного** бота, где игрок выбирает нужный вариант игры; это точка входа
для онбординга, не затрагивающая больше ничего. Гость — только сессия, с урезанными функциями (только
авто-подбор; без друзей, статистики и истории); заброшенный гость, не вошедший ни
в одну игру и простаивавший дольше окна удержания, удаляется сборщиком. Пока приложение открыто, клиент
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
@@ -329,6 +332,14 @@ high-rate флага. С карточки пользователя операт
истечении срока; оператор также может **разблокировать** с карточки пользователя в любой момент
(уже проигранные партии не возвращаются).
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, писать в нём может только
**зарегистрированный** игрок, который **не заблокирован**: при входе такого игрока бот выдаёт право
писать, а незарегистрированному или заблокированному — оставляет немым (промо-бот направляет
новичков в игру, чтобы они зарегистрировались). Оператор также может **замьютить игрока только в
чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
аккаунта всё равно мьютит его в чате. Мьют/размьют и блокировка/разблокировка срабатывают для
игрока, уже находящегося в чате; того, кого в чате нет, это не затрагивает до его следующего входа.
С карточки пользователя оператор также может **пополнить кошелёк подсказок** игрока: аддитивное
начисление (1–100 подсказок за раз), которое **только увеличивает** баланс на карточке. Начисления
**только в плюс** — понизить кошелёк из консоли нельзя (игрок теряет подсказки только тратя их в