Files
scrabble-game/platform/telegram
Ilia Denisov bb71e7b1c7
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
feat(bot): report Telegram bot Bot API health to the gateway over the bot-link
The bot runs on its own host and exports no telemetry (otelcol is unreachable
from there), so it was a monitoring blind spot. Observe its Bot API health
centrally by wrapping the HTTP client — one place, no per-call-site
instrumentation — and relay it up the existing bot-link as a periodic Health
message the gateway turns into its own metrics.

- proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to
  the FromBot oneof (additive, backward-compatible).
- bot: platform/telegram/internal/health wraps the Bot API HTTP client — it
  stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even
  when idle), classifies transport/5xx failures (getUpdates vs other) and 429s,
  and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other
  than 429 is a normal per-request outcome and is not counted.
- bot-link client: flush the reporter as a Health message every 30s over a
  single-sender loop (a gRPC stream forbids concurrent Send).
- gateway: fold each report into bot_tg_errors_total{kind} and the
  bot_tg_last_ok_unix liveness gauge.
- grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained
  429s) routed to the operator email — which does not go through the bot — plus
  a Telegram-bot dashboard.
- docs (ARCHITECTURE, compose comments); unit tests (observer classification,
  Retry-After, snapshot/commit, hub last-ok monotonicity).
2026-07-11 12:48:06 +02:00
..

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 §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) 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.
  • 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 the user gets no automatic reply.
  • 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.

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

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.