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
+32
View File
@@ -41,6 +41,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no webhook** (rejected: one URL per token, adds inbound + a static address). The **unified test contour** runs the split (the bot keeps its VPN sidecar and dials the gateway by its internal name; certs from `deploy/gen-certs.sh`). The **prod** wiring — the bot on a separate host (no VPN), the gateway bot-link port published, `PROD_` certs with scheduled rotation, an SSH deploy of both hosts together — is the **deferred final stage** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) |
| AG | Anti-abuse IP ban + honeypot/honeytoken (prod-only): a fail2ban-style in-memory `ratelimit.Banlist` keyed by client IP, fed by sustained rate-limiter rejections (the IP-keyed public/email/admin classes — the user class stays the soft-flag's concern), a **honeypot** decoy path (the contour caddy tags `/.env`, `/.git`, `/wp-*`, … with `X-Scrabble-Honeypot` and routes them to the gateway), and a **honeytoken** (`GATEWAY_HONEYTOKEN`, a planted bearer). The `abuseGuard` edge middleware refuses a banned IP with **429** before any work — closing the R3 gap that the static SPA/landing was outside the token bucket. Off by default — it keys by the real client IP the shared-NAT test contour does not expose (detection still logs there); enabled in prod via `GATEWAY_ABUSE_BAN_ENABLED`. Operators see + lift bans on the console **Throttled** page; the gateway syncs its active set to the backend (`/api/v1/internal/bans/sync`, `internal/banview`) every 30 s and applies operator unbans. | owner ad-hoc | **done** (code + test contour; ban enabled in prod → Stage 18) |
| CM | Channel-chat moderation + promo bot: a second standalone bot in the bot container answers `/start` with a localized message + a **URL** button into the **main** bot's Mini App (`?startapp`; a `web_app` button would sign initData with the promo token, which the main validator rejects). The **main** bot gates write access in a channel's linked discussion chat — granting on join when the Telegram user is registered and neither admin-suspended nor holding a new **`chat_muted`** role, and revoking/granting on the matching moderation change for a member currently in the chat (a `getChatMember` guard, since bots cannot list members). Eligibility = `registered AND NOT suspended AND NOT chat_muted` (the game suspension dominates), 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, so a temporary-block-expiry sweeper may over-emit). No schema change — `chat_muted` reuses `account_roles`. | 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)
@@ -579,3 +580,34 @@ Then Stage 18.
(`game_limit_test.go`: count rule + HTTP gate 409 + accept bypass), server unit (error mapping), gateway
transcode round-trip, UI codec + lobbycache unit, e2e (`gamelimit.spec.ts`). Bake-back: `docs/FUNCTIONAL.md`
(+`_ru`), `docs/ARCHITECTURE.md` §8, `docs/UI_DESIGN.md`, `backend/README.md`.
- **CM — Channel-chat moderation + promo bot** (owner ad-hoc, not on the raw TODO list):
- **Locked decisions (interview):** the promo bot is a **goroutine in `cmd/bot`** (its own token, no
bot-link); the moderated chat's default-no-send is configured by a **human** in the group settings (the
bot only grants, never `setChatPermissions`); a non-eligible joiner is **left muted silently**; a
temporary-suspension expiry is handled by a **backend sweeper** that emits the re-evaluate event; and a new
**`chat_muted` role** is a chat-only mute with the **game suspension dominating**
(`eligible = registered AND NOT suspended AND NOT chat_muted`).
- **Bot API reality (verified against the docs):** a cross-bot Mini App launch must be a **URL button** to the
main bot's `t.me/<bot>?startapp` link — a `web_app` button signs initData with the *sending* bot's token,
which the main validator rejects — so the promo button reuses the UI's `VITE_TELEGRAM_LINK`. `chat_member`
updates arrive **only** when the bot is a chat **admin** with the "Ban users" right (the client label for the
Bot API `can_restrict_members`) and `chat_member` is in `allowed_updates`; bots cannot list members but can
`getChatMember` a single user, which is the membership guard on the block/unblock path.
- **Wire:** `pkg/proto/botlink/v1` gains a `ChatGateCommand` in the `Command` oneof and a unary
`ResolveChatEligibility`; the backend gains `notify.KindChatAccessChanged` (no payload, infra-only — never an
out-of-app message) and an internal `POST /api/v1/internal/chat-access` resolver; the gateway resolves the
join (by external_id) and the event (by user_id) through it and pushes the chat-gate command fire-and-forget
(at-most-once, recovered by the next moderation action or a re-join).
- **No schema change → no contour DB wipe:** `chat_muted` is a new `account.KnownRoles` entry (the
`account_roles` table is data-driven). The suspension-expiry sweeper is a new `account.SuspensionSweeper`
(a 1-minute window, idempotent) started in `cmd/backend`, alongside the guest reaper.
- **Deploy:** new `TEST_`/`PROD_` `TELEGRAM_PROMO_BOT_TOKEN` (secret), `TELEGRAM_BOT_USERNAME` and
`TELEGRAM_CHAT_ID` (variables); the promo link reuses the existing `*_VITE_TELEGRAM_LINK` variable as
`TELEGRAM_BOT_LINK`. The bot must be promoted to admin in the real discussion group, and the group default
set to no-send, as part of the Stage 18 prod cutover (the test contour exercises the code path).
- **Bake-back:** `docs/ARCHITECTURE.md`, `docs/FUNCTIONAL.md` (+`_ru`), `platform/telegram/README.md`,
`backend/README.md`, Go Doc comments. Tests: backend resolver truth table + publish on block/unblock/role +
the sweeper window (unit + integration); gateway hub `ResolveChatEligibility` + the chat-gate command; bot
`chat_member` grant + `ApplyChatGate` getChatMember-guard; promo `/start` localization + URL button; config
parsing.