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
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).
211 lines
14 KiB
Markdown
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.
|