Files
scrabble-game/platform/telegram
Ilia Denisov 6aeb529f13
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
feat(telegram): split connector into home validator + remote bot
Move all Telegram egress off the main host. The single connector held the
bot token, long-polled Telegram and answered the gateway/backend over the
trusted internal network, so the whole component (including login validation)
shared fate with its VPN sidecar. Split it into two binaries that share the
token:

- cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only,
  never calls the Bot API. The gateway dials it for Telegram auth, so game
  login is now independent of Telegram reachability.
- cmd/bot (remote): Bot API long-poll + sendMessage, the only component
  reaching Telegram. It holds no inbound port — it dials the gateway over a
  new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send
  commands the gateway pushes.

The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget
(at-most-once, dropped if no bot is connected); the backend admin broadcasts
reach a gateway-served relay that forwards them and awaits the bot's ack
(SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one
inter-service link that leaves the trusted segment; validator<->gateway and
the relay stay plaintext internal. The bot is Telegram-rate-limited.

One bot now; the gateway bot registry, an owns_updates flag and per-command
ids leave seams for N later. 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; bot-link certs from deploy/gen-certs.sh,
generated in CI). 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 (PRERELEASE.md TX,
Stage 18).

Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway +
backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
2026-06-21 00:19:07 +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 Mini App launch button; a deep-link payload routes the launch to a game / invitation / friend code. This is self-contained — the bot never calls back into the game, so /start onboarding works even when the game is down.
  • Rate limiting. Outbound sends are throttled (TELEGRAM_SEND_RATE_PER_SECOND, default 25) to respect the Bot API flood limits.

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). 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_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 (the deferred final stage — see PRERELEASE.md).

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.