The project is live in production, so the staged-development scaffolding is removed. - Delete the staged trackers PLAN.md and PRERELEASE.md. - Rewrite CLAUDE.md: drop the per-stage workflow; codify the ongoing development principles (How we work) and the production model (Branching, CI & production): manual prod-deploy / prod-rollback, semver release tags, Ansible provisioning, expand-contract migrations. - De-stage the living docs (README, ARCHITECTURE, TESTING, deploy/ansible, loadtest, platform/telegram READMEs) and the docker-compose tuning comments: drop the Stage N / R1-R7 / pre-release labels, keep every number and rationale, and fix the now-dangling PLAN.md / PRERELEASE.md references to describe the current state. - Reword stale 'later stage' Go doc comments for subsystems that have shipped.
9.5 KiB
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 AppinitDataand 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 +/startdeep-links) andsendMessage, 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),SendToUserandSendToGameChannel(operator broadcasts) down the bot-link. The bot renders the message in the command'slanguage(the recipient's interface language; operator-chosen for broadcasts) with a Mini App launch button and sends it. It replies with anAckper command (deliveredmirrors 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/startonboarding works even when the game is down. - Moderated-chat gating. When
TELEGRAM_CHAT_IDnames 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 achat_memberevent the bot asks the gateway (ResolveChatEligibility) and mutes a member who is not registered or is admin-suspended orchat_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 itschat_mutedrole, or a user first registers, the gateway pushes aChatGatecommand and the bot applies it — but only to a member currently in the chat (it probes one user withgetChatMember, since bots cannot list members). The bot must be an administrator there with the "Ban users" right (the Bot APIcan_restrict_members), and it subscribes tochat_memberupdates, which Telegram delivers only to a chat admin. - Promo bot (optional). When
TELEGRAM_PROMO_BOT_TOKENis set, the container also runs a second, standalone bot whose only job is to answer/startwith 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— aweb_appbutton 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. - 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, serviceTelegram— served by the validator (ValidateInitData,ValidateLoginWidget). ItsNotify/SendToUser/SendToGameChannelrequest shapes are reused as bot-link command payloads; the gateway also implementsSendToUser/SendToGameChannelas the backend's admin relay.pkg/proto/botlink/v1, serviceBotLink— the reverse bidi stream the bot dials on the gateway (Hello/Command/Ack), now also carrying aChatGateCommand(set a user's chat write access) and a unaryResolveChatEligibility(the bot's join-time query) over the same mTLS channel. Generated Go is committed underpkg.
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_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_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.