Files
scrabble-game/platform/telegram/README.md
T
Ilia Denisov e0360c98e2
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
feat(telegram): add /support command with support-desk info reply
Intercept /support in the main bot with a dedicated handler (registered
like /start), replying with a fixed support-desk info message: the
operators' working hours and what to include (a description and, when
possible, screenshots). The reply is Russian or English by the sender's
reported Telegram language, and the command is listed in the bot's
command menu (localized).

Being a dedicated handler, it intercepts /support before the support
relay, so the command line itself is not forwarded into an operator
topic while the user's following description still is.

Update the telegram README and FUNCTIONAL.md (+ _ru mirror).
2026-07-13 09:25:20 +02:00

211 lines
14 KiB
Markdown

# scrabble/platform/telegram — Telegram validator + bot
The Telegram platform side-service, split into **two binaries** that share the bot
token, so Telegram egress lives off the main host while game login does not depend on
it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/§13.
- **`cmd/validator`** (home) — verifies Mini App `initData` and Login Widget data by
HMAC (the bot token is the secret) and **never calls the Bot API**. It serves the
validation gRPC API the gateway calls during Telegram auth, on the trusted internal
network with no VPN. Because it needs no Telegram reachability, **game login stays up
even when the bot or the bot-link is down**.
- **`cmd/bot`** (remote) — runs the Bot API long-poll (Mini App launch, `/start`
deep-links, the `/support` info reply) and `sendMessage`, the only component reaching
the Telegram Bot API. It
holds **no inbound port**: it dials the gateway over a reverse **mTLS bot-link** and
executes the send commands the gateway pushes, so its egress can run on a host with
native Telegram access (a VPN sidecar in the test contour, a separate host in prod).
Both hold the same token. One bot owns the exclusive `getUpdates` long-poll
(`TELEGRAM_OWNS_UPDATES`, exactly one per token, else Telegram returns 409); the
design leaves seams (a gateway bot registry, the `owns_updates` flag, per-command ids)
for running several bots later, not built yet.
## Validator
`ValidateInitData` validates `initData` against the token and returns only the
Telegram user identity (no per-bot service language, no supported-languages set);
`ValidateLoginWidget` verifies Telegram **Login Widget** web sign-in data — HMAC under
`SHA-256(bot_token)`, distinct from initData (`internal/loginwidget`) — for attaching a
Telegram identity to an account from a browser. Both map a rejection to gRPC
`InvalidArgument`.
## Bot
- **Send commands.** The gateway pushes `Notify` (out-of-app push: your_turn,
game_over, nudge, match_found, and the invitation / friend_request notify sub-kinds),
`SendToUser` and `SendToGameChannel` (operator broadcasts) down the bot-link. The bot
renders the message in the command's `language` (the recipient's interface language;
operator-chosen for broadcasts) with a Mini App launch button and sends it. It replies
with an `Ack` per command (`delivered` mirrors the former connector semantics —
false when the kind is not rendered out-of-app or the user never started the bot).
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a localized
welcome and a Mini App launch button; a deep-link payload routes the launch to a game /
invitation / friend code. The welcome is **Russian or English** by the sender's reported
Telegram language (`Message.from.language_code`, which the Bot API carries on the message
itself — no separate user-update event — English fallback) and links the game channel and
discussion chat by their public `@username`, **resolved once at startup** from
`TELEGRAM_GAME_CHANNEL_ID` / `TELEGRAM_CHAT_ID` via `getChat` (a chat that is unset,
private, or unreadable degrades that link to a generic noun — "the channel" / "our
chat" — rather than a dangling "@"). This is otherwise **self-contained**
— the bot never calls back into the game, so `/start` onboarding works even when the game
is down.
- **Support command.** `/support` replies with a fixed support-desk info message — the
operators' working hours and what to include (a description and, when possible,
screenshots). Like `/start` it answers only in a private chat and is **Russian or
English** by the sender's reported language, and it is listed in the bot's command menu
(localized, Russian/English). It is a dedicated command handler, so it intercepts
`/support` before the support relay below — the command line itself is not forwarded to
operators, while the user's following description still is.
- **Moderated-chat gating.** When `TELEGRAM_CHAT_ID` names a channel's linked discussion
group, the bot gates who may write there. The group **allows sending by default** (a
human setting) and the bot only **restricts** — Telegram intersects the chat default with
each user's permission, so a per-user grant cannot exceed a deny-by-default group, and the
gate must mute the ineligible rather than grant the eligible. On a `chat_member` event the
bot asks the gateway (`ResolveChatEligibility`) and **mutes** a member who is not registered
or is admin-suspended or `chat_muted`, **un-mutes** an eligible one it had muted, and leaves
an already-allowed eligible member untouched (it acts only when the state differs, so it is
idempotent and skips its own change). When an operator blocks/unblocks an account, toggles
its `chat_muted` role, or a user first registers, the gateway pushes a `ChatGate` command and
the bot applies it — but only to a member currently in the chat (it probes one user with
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
name is a tappable profile mention via a `text_mention` entity — which also stops a name starting
with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle
and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content
— text, media, voice, files — carries over). Any **administrator** of the support chat who writes
in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
(the loop guard). **Block** drops a user's incoming messages (the topic stays); **Clear** deletes
the relayed messages, keeping the info card; a topic the operators delete is reopened on the next
message. State (user→topic map, block list, relayed ids) is a JSON file under
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
The relay is bot-local (no backend, no bot-link) and a forwarded message gets no automatic
reply (the `/support` command above aside).
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
would sign the initData), which the main bot's validator rejects. It is fully
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
alongside the default Erudit). The message body also renders the bot's `@username` as that
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
the seeded Mini App rather than the bot profile.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits.
- **Payments (Telegram Stars).** When `TELEGRAM_STARS_OUTBOX_DIR` is set (default `/data`), the
bot handles the Stars rail. Only the bot reaches Telegram, so it mints the invoice on a
`CreateInvoice` bot-link command (`createInvoiceLink`, XTR — the link goes back to the Mini App
for `WebApp.openInvoice`); it gates each `pre_checkout_query` through the bot-link
(`ValidatePreCheckout`, backed by the backend intake — declining an already-paid reusable
invoice before the charge); and it records each `successful_payment` in a durable **SQLite
outbox** (`internal/outbox`, `stars.db` on the writable volume) before forwarding it over the
bot-link (`ForwardPayment`). The outbox is re-driven on startup and every 30 s, so a gateway or
backend outage never loses a paid order; crediting is idempotent on `telegram_payment_charge_id`.
The rail stays inert until a chip pack carries an XTR price (seeded in the admin).
The send commands address a recipient by the identity `external_id` (as in the backend
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData
parsing is Telegram-specific.
## gRPC contracts
- `pkg/proto/telegram/v1`, service `Telegram` — served by the **validator**
(`ValidateInitData`, `ValidateLoginWidget`). Its `Notify` / `SendToUser` /
`SendToGameChannel` request shapes are reused as bot-link command payloads; the
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
relay.
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
Ack result), plus unary `ResolveChatEligibility` (the bot's join-time query),
`ValidatePreCheckout` and `ForwardPayment` (the Stars rail) over the same mTLS channel.
Generated Go is committed under `pkg`.
## Deep-link scheme
Shared verbatim with the UI (`ui/src/lib/deeplink.ts`). A Mini App start parameter is a
one-character kind prefix plus a value:
| Parameter | Destination |
| --- | --- |
| `g<game uuid>` | open that game |
| `i<invitation uuid>` | open that invitation |
| `f<6-digit code>` | redeem that friend code |
| empty / unknown | the lobby |
The bot turns a `/start <payload>` or a notification target into a launch-button URL
`<MiniAppURL>?startapp=<payload>`.
## Configuration
Shared:
| Env var | Default | Meaning |
| --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | — (required) | the bot's API token + initData HMAC secret |
| `TELEGRAM_LOG_LEVEL` | `info` | zap log level |
| `TELEGRAM_SERVICE_NAME` | per binary | OpenTelemetry `service.name` (validator: `scrabble-telegram-validator`, bot: `scrabble-telegram-bot`) |
| `TELEGRAM_OTEL_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `TELEGRAM_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
Validator (`cmd/validator`):
| Env var | Default | Meaning |
| --- | --- | --- |
| `TELEGRAM_VALIDATOR_GRPC_ADDR` | `:9091` | validator gRPC listen address (the gateway dials it) |
Bot (`cmd/bot`):
| Env var | Default | Meaning |
| --- | --- | --- |
| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) |
| `TELEGRAM_GATEWAY_ADDR` | — (required) | the gateway bot-link endpoint to dial |
| `TELEGRAM_BOTLINK_SERVER_NAME` | — (required) | the gateway certificate's expected SNI / CN |
| `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert |
| `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
| `TELEGRAM_STARS_OUTBOX_DIR` | `/data` | directory for the Telegram Stars payment outbox (`stars.db`, a writable volume); empty disables the Stars rail |
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
| `TELEGRAM_BOTLINK_RECONNECT_DELAY` | `2s` | pause before re-dialing after the stream ends |
| `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Bot API host override (mock / self-hosted) |
| `TELEGRAM_TEST_ENV` | `false` | route to the Bot API **test environment** (`/bot<token>/test/METHOD`) |
## Build, test, run
```sh
go build ./platform/telegram/...
go test ./platform/telegram/... # unit tests use an httptest fake Bot API + an in-process bot-link
go run ./platform/telegram/cmd/validator # needs TELEGRAM_BOT_TOKEN
go run ./platform/telegram/cmd/bot # needs the bot env above + mTLS cert files
```
## Deploy
`platform/telegram/Dockerfile` builds both binaries on distroless nonroot with two
targets, `validator` and `bot`. In the test contour (`deploy/docker-compose.yml`) the
**validator** runs on the internal network (no VPN); the **bot** keeps a VPN sidecar
for Telegram egress and dials the gateway bot-link by its internal name. The bot-link
mTLS material is generated by `deploy/gen-certs.sh`. In prod the bot runs on a separate
host with native Telegram access and dials the gateway's published bot-link port with
`PROD_` certificates in production.
A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS Mini
App origin, and the bot container; the unit tests cover the wire format, templates,
deep-links, the validator handlers and the bot-link client/executor without a live bot.