feat(telegram): split connector into home validator + remote bot (mTLS bot-link) #96

Merged
developer merged 2 commits from feature/telegram-egress-botlink into development 2026-06-21 05:35:02 +00:00
42 changed files with 3073 additions and 714 deletions
Showing only changes of commit 6aeb529f13 - Show all commits
+32 -21
View File
@@ -288,6 +288,11 @@ jobs:
mkdir -p "$conf" mkdir -p "$conf"
cp -r caddy otelcol prometheus tempo grafana "$conf"/ cp -r caddy otelcol prometheus tempo grafana "$conf"/
export SCRABBLE_CONFIG_DIR="$conf" export SCRABBLE_CONFIG_DIR="$conf"
# Bot-link mTLS material for the test contour: a private CA + gateway/bot
# leaves (CN=gateway, the service name the bot dials). Prod supplies these
# from PROD_ secrets instead. Regenerated each deploy; both ends redeploy
# together so they always share the fresh CA (see deploy/gen-certs.sh).
bash "$GITHUB_WORKSPACE/deploy/gen-certs.sh" "$conf/certs"
# App version for the About screen: the git tag if present, else the short SHA # App version for the About screen: the git tag if present, else the short SHA
# (the test checkout is shallow/untagged, so this is the SHA here — fine). # (the test checkout is shallow/untagged, so this is the SHA here — fine).
export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)" export APP_VERSION="$(git -C "$GITHUB_WORKSPACE" describe --tags --always 2>/dev/null || echo dev)"
@@ -323,33 +328,39 @@ jobs:
docker logs --tail 50 scrabble-backend || true docker logs --tail 50 scrabble-backend || true
exit 1 exit 1
- name: Probe the Telegram connector liveness - name: Probe the Telegram validator and bot liveness
run: | run: |
set -u set -u
# The gateway probe cannot see a crash-looping connector (it long-polls and # The gateway/backend probes cannot see a crash-looping validator or bot
# egresses through the VPN sidecar, with no public ingress). Inspect the # (the validator answers only internal gRPC; the bot long-polls + egresses
# container directly: it must be running, not restarting, with a stable # through the VPN sidecar with no public ingress). Inspect the containers
# restart count. A grace period lets the VPN handshake settle (the connector # directly: each must be running, not restarting, with a stable restart
# may restart a few times first). # count. A grace period lets the VPN handshake and the bot-link dial settle.
sleep 20 sleep 20
for i in $(seq 1 20); do for name in scrabble-telegram-validator scrabble-telegram-bot; do
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)" ok=
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)" for i in $(seq 1 20); do
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)" restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
sleep 5 if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)" c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
if [ "$c1" = "$c2" ]; then sleep 5
echo "connector healthy: status=$status restarts=$c2" c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
exit 0 if [ "$c1" = "$c2" ]; then
echo "$name healthy: status=$status restarts=$c2"
ok=1
break
fi
echo "$name still restarting ($c1 -> $c2); waiting"
fi fi
echo "connector still restarting ($c1 -> $c2); waiting" sleep 3
done
if [ -z "$ok" ]; then
echo "$name not healthy; recent logs:"
docker logs --tail 80 "$name" || true
exit 1
fi fi
sleep 3
done done
echo "connector not healthy; recent logs:"
docker logs --tail 80 scrabble-telegram || true
exit 1
- name: Prune dangling images - name: Prune dangling images
if: always() if: always()
+4
View File
@@ -17,5 +17,9 @@
**/.env.local **/.env.local
**/.env.*.local **/.env.*.local
# Bot-link mTLS material: private keys never belong in the repo. The test contour
# generates them with deploy/gen-certs.sh; prod supplies them from PROD_ secrets.
deploy/certs/
# Claude Code harness runtime artifacts # Claude Code harness runtime artifacts
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
+3 -3
View File
@@ -125,9 +125,9 @@ backend/ # module scrabble/backend
internal/inttest/ # //go:build integration Postgres-backed tests internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ # added by their stages gateway/ ui/ pkg/ # added by their stages
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API platform/telegram/ # Telegram side-service, two binaries (Stage 9; split in phase TX): cmd/validator (HMAC, no VPN) + cmd/bot (Bot API; dials gateway over reverse mTLS bot-link)
loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2) loadtest/ # module scrabble/loadtest: the pre-release stress harness (R2)
backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile also has the `landing` target (R3) backend/Dockerfile gateway/Dockerfile platform/telegram/Dockerfile loadtest/Dockerfile # multi-stage distroless (Stage 16; loadtest R2); gateway/Dockerfile has the `landing` target (R3), platform/telegram/Dockerfile has `validator`+`bot` targets (TX)
deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter deploy/ # docker-compose (per-service limits, R7) + caddy + landing + otelcol (OTLP + docker_stats per-container metrics) + prometheus/tempo/grafana + postgres_exporter
``` ```
@@ -138,7 +138,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/... go vet ./backend/...
gofmt -l . # must print nothing gofmt -l . # must print nothing
go test -count=1 ./backend/... go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9) go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram validator + bot (Stage 9; split in TX)
go run ./backend/cmd/backend # /healthz, /readyz on :8080 go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
+15
View File
@@ -39,6 +39,7 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
| FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** | | FM | First-move tile draw (official rules): each seated player draws a tile, the one closest to "A" leads (a blank beats every letter), ties re-drawing until a single leader; **honest per-draw `crypto/rand` entropy**, not the bag seed, so the **record** (`game_setup_draws`, migration `00013`) — not a seed — is the only account of the outcome, kept for future **tournaments** (designed as a discrete per-tile "player N draws" step). Friend/AI draws at create; **auto-match draws at *open*** against a synthetic `uuid.Nil` opponent whose draw rows are back-filled on join, so the opener's seat is fixed up front and the existing open-game pre-move is preserved (no reseating, no play-gating). Admin `/_gm/games/:id` gains the recorded draw list + a simple **step-by-step board replay** (`ReplayTimeline`). | owner ad-hoc | **done** |
| SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** | | SB | Single Telegram bot + per-user variant preferences: the two per-language bots collapse into **one** (drop `accounts.service_language`, `supported_languages`, the `*_EN`/`*_RU` env vars and game-language push routing — the single bot renders in the recipient's `preferred_language`); New Game variant gating moves to a profile **`variant_preferences`** set (default Erudit only, Erudit-first, server-enforced on the caller's auto-match/vs-AI/invitation-create paths, an invited friend may accept any variant); env vars collapse to unsuffixed `TELEGRAM_BOT_TOKEN`/`TELEGRAM_GAME_CHANNEL_ID`/`VITE_TELEGRAM_LINK`/`VITE_TELEGRAM_GAME_CHANNEL_NAME` and `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` is removed; wire drops `service_language`/`supported_languages` (Session, ValidateInitDataResponse) + the push `language` routing field and adds `variant_preferences` to Profile/UpdateProfile. | owner ad-hoc | **done** |
| DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** | | DV | Dictionary version hygiene: CI + image/compose seed track the current release (`v1.2.1`); a **seed-drift guard** records the flat dir's seed in an authoritative `.seed_version` marker so a bumped build seed on a live volume is ignored (it can't relabel live bytes — which would mis-serve the dictionary + void games pinned to the prior label); `DICT_VERSION` is the fresh-volume seed only, a live contour migrates through the admin console | owner ad-hoc | **done** |
| TX | Telegram egress off the main host: split the connector into a home **validator** (Mini App / Login-Widget HMAC, no VPN, no Bot API — so game login no longer depends on Telegram being reachable) and a remote **bot** (Bot API long-poll + `sendMessage`) that holds **no inbound port** and dials the gateway over a reverse **mTLS bot-link** (`pkg/proto/botlink/v1`); the gateway funnels out-of-app push (fire-and-forget, at-most-once) and the backend admin broadcasts (a relay that awaits the bot's ack) down the link. The bot is Telegram-rate-limited; **one bot now**, with seams (a bot registry + `owns_updates` + command ids) for N later; **no 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; certs from `deploy/gen-certs.sh`). 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** (Stage 18). | owner ad-hoc | **done** (code + test contour; prod wiring → Stage 18) |
| → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) | | → | Stage 18 — prod contour deploy | — | see [`PLAN.md`](PLAN.md) |
## Key findings (these reshaped the raw list — read before starting a phase) ## Key findings (these reshaped the raw list — read before starting a phase)
@@ -90,6 +91,20 @@ the edge before prod. Each phase maps back to the owner's raw pre-release TODO l
now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The now **DB-backed open games** — the in-memory pool, `lobby.poll` and `lobby.cancel` are gone. The
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
empty seat. empty seat.
- **Telegram egress off-host (TX, owner ad-hoc):** the driver is **removing VPN/Telegram
traffic from the main host** (OPSEC / one fewer analysis vector), not only notification
resilience. Login is local HMAC, so it stays up regardless of the bot — confirmed in the
code and made structural by the split. **Unified topology in code** (validator + bot, the
bot dialing the gateway) in **both** contours, differing only in deployment; the test bot
keeps its VPN sidecar. Transport = a **reverse gRPC bidi stream, mTLS, bot-dials-gateway**
(no inbound/static IP on the bot), reusing the push-stream pattern; **webhook rejected**
(one URL per token, adds inbound + a static address). Delivery **at-most-once** (a dropped
nudge beats a duplicate). **One bot now**, seams (registry + `owns_updates` + command ids)
for N later. **Cert rotation** by a scheduled CI job from a long-lived CA. **Prod deploy by
SSH** (pull excluded), the bot rolled **together** with the main app (the bot-link protocol
kept back-compatible by one version as the non-atomic-two-host-deploy safety net). The bot
is monitored **from the gateway** (connection + ack metrics). The bot-host token-at-rest is
**accepted**.
## Phases ## Phases
+6 -5
View File
@@ -103,7 +103,7 @@ second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) str
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's gateway. The gateway-only `POST /api/v1/internal/push-target` (a user's
Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway Telegram `external_id`, language and `notifications_in_app_only` flag) lets the gateway
route out-of-app push to the Telegram connector; the Telegram login route out-of-app push to the Telegram bot over the gateway bot-link; the Telegram login
seeds a new account's language and display name from the launch fields, and the seeds a new account's language and display name from the launch fields, and the
`accounts.notifications_in_app_only` flag (default true). `accounts.notifications_in_app_only` flag (default true).
`accounts.is_guest` marks an ephemeral guest — a durable row `accounts.is_guest` marks an ephemeral guest — a durable row
@@ -116,8 +116,9 @@ pipeline, the online **dictionary update** (upload the `scrabble-dawg-vX.Y.Z.tar
archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` + archive, preview the per-variant word diff, then install + activate — `internal/dictadmin` +
`engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the `engine.DiffWords` / `Registry.LoadAvailable`, written to per-version subdirectories of the
`BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a `BACKEND_DICT_DIR` volume with the active version persisted in `dictionary_state`), and operator **broadcasts** via a
backend Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
broadcast renders through the single bot in an operator-chosen language. There is one bot, **bot-link relay** — each broadcast renders through the bot in an operator-chosen language
and the relay awaits the bot's delivery ack. There is one bot,
so `/internal/push-target` returns the recipient's `preferred_language` as the render so `/internal/push-target` returns the recipient's `preferred_language` as the render
language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` + language for out-of-app push; no per-bot routing remains. The console also manages the **advertising banner** (`/_gm/banners` +
`/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional `/_gm/banner-settings`, `internal/ads`): operator campaigns with a percent weight, an optional
@@ -170,7 +171,7 @@ internal/lobby/ # auto-match (DB-backed open games + robot substitution) +
internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver internal/robot/ # human-like robot opponent: account pool, seed-derived strategy, move driver
internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm internal/adminconsole/ # server-rendered admin console (Go templates + embedded CSS, view models), served at /_gm
internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet) internal/ads/ # advertising banner: campaigns + bilingual messages + display timings, weighted-rotation feed (ActiveSet)
internal/connector/ # backend gRPC client to the Telegram connector (operator broadcasts) internal/connector/ # backend gRPC client to the gateway bot-link relay (operator broadcasts)
internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag internal/ratewatch/ # gateway rate-limit reports: episode window for the console + the high-rate auto-flag
``` ```
@@ -201,7 +202,7 @@ internal/ratewatch/ # gateway rate-limit reports: episode window for the consol
| `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. | | `BACKEND_SMTP_USERNAME` | — | SMTP user; empty relays without authentication. |
| `BACKEND_SMTP_PASSWORD` | — | SMTP password. | | `BACKEND_SMTP_PASSWORD` | — | SMTP password. |
| `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. | | `BACKEND_SMTP_FROM` | `no-reply@localhost` | Envelope/From address for confirm-codes. |
| `BACKEND_CONNECTOR_ADDR` | — | Telegram connector gRPC address for admin-console operator broadcasts. Empty disables broadcasts. | | `BACKEND_CONNECTOR_ADDR` | — | the gateway bot-link relay gRPC address for admin-console operator broadcasts. Empty disables broadcasts. |
| `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. | | `BACKEND_GUEST_REAP_INTERVAL` | `1h` | How often the abandoned-guest reaper sweeps. |
| `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. | | `BACKEND_GUEST_RETENTION` | `720h` | Account age past which a guest with no game seat is deleted. |
| `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. | | `BACKEND_HIGHRATE_FLAG_THRESHOLD` | `1000` | Gateway-reported rejected calls within the window past which an account is soft-flagged. |
+7 -7
View File
@@ -1,10 +1,10 @@
// Package connector is the backend's gRPC client for the Telegram platform // Package connector is the backend's gRPC client for operator broadcasts: a direct
// connector side-service. The admin console uses it to send operator broadcasts: // message to one user, or a post to the game channel. It calls the gateway's
// a direct message to one user, or a post to the game channel, through the single // bot-link relay (which forwards the send to the remote bot over the reverse mTLS
// bot. The connector lives on the trusted internal network, so the connection uses // link and reports back whether it was delivered). The relay lives on the trusted
// insecure (plaintext) transport credentials (docs/ARCHITECTURE.md §12). It mirrors // internal network, so the connection uses insecure (plaintext) transport
// gateway/internal/connector, narrowed to the two broadcast methods the admin // credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
// surface needs. // narrowed to the two broadcast methods the admin surface needs.
package connector package connector
import ( import (
+6 -2
View File
@@ -38,8 +38,12 @@ VITE_GATEWAY_URL=
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
GRAFANA_ADMIN_PASSWORD=admin GRAFANA_ADMIN_PASSWORD=admin
# --- Telegram connector ----------------------------------------------------- # --- Telegram validator + bot -----------------------------------------------
AWG_CONF= # required; AmneziaWG sidecar config # The token is shared: the validator uses it as the HMAC secret, the bot for the
# Bot API. The bot-link wiring (validator/relay/mTLS addresses) is hard-wired in
# docker-compose.yml; the mTLS material is NOT here — run `deploy/gen-certs.sh`
# (writes deploy/certs/, gitignored) before `docker compose up`.
AWG_CONF= # required; AmneziaWG sidecar config (the bot's Telegram egress)
TELEGRAM_BOT_TOKEN= # required TELEGRAM_BOT_TOKEN= # required
TELEGRAM_GAME_CHANNEL_ID= TELEGRAM_GAME_CHANNEL_ID=
TELEGRAM_MINIAPP_URL= # required TELEGRAM_MINIAPP_URL= # required
+22 -16
View File
@@ -1,8 +1,8 @@
# deploy # deploy
The full Scrabble contour: `backend` + `gateway` + the static `landing` + Postgres + The full Scrabble contour: `backend` + `gateway` + the static `landing` + Postgres +
the Telegram connector (with a VPN sidecar) + the observability stack (OTel the Telegram `validator` + `bot` (the bot with a VPN sidecar) + the observability stack
Collector → Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single (OTel Collector → Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single
`/_gm` Basic-Auth (the admin console + Grafana). Topology and the decision record are in `/_gm` Basic-Auth (the admin console + Grafana). Topology and the decision record are in
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §13; this file is the [`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §13; this file is the
operational reference for **every environment variable**. operational reference for **every environment variable**.
@@ -16,7 +16,8 @@ operational reference for **every environment variable**.
| `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. | | `landing` | built (`gateway/Dockerfile`, target `landing`) | Static landing page at `/` (caddy:2-alpine + the shared Vite build, `deploy/landing/Caddyfile`); absorbs stray public paths. |
| `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. | | `backend` | built (`backend/Dockerfile`) | Domain service; bakes in the DAWG dictionaries; runs migrations at boot. |
| `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). | | `postgres` | `postgres:17-alpine` | Database (named volume, `pg_isready` healthcheck). |
| `vpn` + `telegram` | sidecar + built (`platform/telegram/Dockerfile`) | Telegram connector; egresses through the AmneziaWG sidecar; internal gRPC at `telegram:9091`. | | `validator` | built (`platform/telegram/Dockerfile`, target `validator`) | Telegram HMAC validator (no VPN, no Bot API); internal gRPC at `validator:9091`. Game login depends only on this. |
| `vpn` + `bot` | sidecar + built (`platform/telegram/Dockerfile`, target `bot`) | Telegram bot; egresses through the AmneziaWG sidecar; holds no inbound port — dials the gateway bot-link (mTLS) at `gateway:9443`. |
| `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. | | `otelcol` | `otel/opentelemetry-collector-contrib` | OTLP/gRPC `:4317` → Prometheus scrape (`:9464`) + Tempo. |
| `prometheus` | `prom/prometheus` | Metrics, 15d retention. | | `prometheus` | `prom/prometheus` | Metrics, 15d retention. |
| `tempo` | `grafana/tempo` | Traces, 72h retention. | | `tempo` | `grafana/tempo` | Traces, 72h retention. |
@@ -58,12 +59,13 @@ compose binds from this directory.
| Variable | Gitea kind | Purpose | | Variable | Gitea kind | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). | | `POSTGRES_PASSWORD` | secret | Postgres password (also embedded in `BACKEND_POSTGRES_DSN`). |
| `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the connector's only egress). **Must not contain a `DNS=` line** — it hijacks the shared netns's resolv.conf and breaks the connector resolving `otelcol` (telemetry export). Without it, Docker's resolver handles both `otelcol` and `api.telegram.org`. | | `AWG_CONF` | secret | AmneziaWG config for the VPN sidecar (the bot's only Telegram egress in the test contour). **Must not contain a `DNS=` line** — it hijacks the shared netns's resolv.conf and breaks the bot resolving `otelcol` / `gateway`. Without it, Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`. |
| `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. | | `GM_BASICAUTH_HASH` | secret | bcrypt hash gating `/_gm` (admin console + Grafana). Generate with `docker run --rm caddy:2-alpine caddy hash-password --plaintext '<pw>'`. |
| `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the connector hands out in deep links / buttons. | | `TELEGRAM_MINIAPP_URL` | variable | The Mini App URL the bot hands out in deep links / buttons. |
**Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret). It defaults to empty in **Plus the bot token**`TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
compose, but the connector **fails at boot** when it is empty. secret) and the bot (Bot API). It defaults to empty in compose, but both **fail at
boot** when it is empty.
## Optional variables (with defaults) ## Optional variables (with defaults)
@@ -72,7 +74,7 @@ compose, but the connector **fails at boot** when it is empty.
| `POSTGRES_DB` | variable | `scrabble` | Database name. | | `POSTGRES_DB` | variable | `scrabble` | Database name. |
| `POSTGRES_USER` | variable | `scrabble` | Database user. | | `POSTGRES_USER` | variable | `scrabble` | Database user. |
| `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. | | `DICT_VERSION` | variable | `v1.2.1` | `scrabble-dictionary` release tag baked into the backend image as the **seed for a fresh volume** (build-arg). A live contour changes dictionary through the admin console, not this; on a seeded volume a changed value is ignored (the recorded `.seed_version` marker wins — the seed-drift guard, ARCHITECTURE.md §5). Set per contour as `TEST_`/`PROD_DICT_VERSION`. |
| `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / connector (`debug\|info\|warn\|error`). | | `LOG_LEVEL` | variable | `info` | Shared log level for backend / gateway / validator / bot (`debug\|info\|warn\|error`). |
| `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. | | `CADDY_SITE_ADDRESS` | variable | `:80` | Caddy site address. Test: `:80` (host caddy terminates TLS). Prod: a domain, so caddy does its own ACME. |
| `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. | | `GM_BASICAUTH_USER` | variable | `gm` | Username for the `/_gm` Basic-Auth. |
| `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. | | `GRAFANA_ROOT_URL` | variable | `/_gm/grafana/` | Grafana root URL (sub-path serving). Set the full `https://<domain>/_gm/grafana/` behind a real domain. |
@@ -95,14 +97,18 @@ These are hard-wired in `docker-compose.yml` (no `${...}`), pointing the service
at each other on the `internal` network — listed here so they are not mistaken for at each other on the `internal` network — listed here so they are not mistaken for
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`), missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`), `GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), and all three `GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
services' `*_OTEL_*_EXPORTER=otlp` `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317` bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
(`_INSECURE=true`). The connector shares the VPN sidecar's netns: routing to the mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
collector's internal IP is fine (connected route), but its `AWG_CONF` must **not** mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
set a `DNS=` directive — that hijacks resolv.conf and breaks resolving `otelcol` deploy), and all services' `*_OTEL_*_EXPORTER=otlp`
("produced zero addresses"); without it the netns uses Docker's resolver, which `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentionally (`_INSECURE=true`). The bot shares the VPN sidecar's netns: routing to the
**unset** — caddy owns `/_gm` in the contour. collector's / gateway's internal IP is fine (connected route), but its `AWG_CONF` must
**not** set a `DNS=` directive — that hijacks resolv.conf and breaks resolving `otelcol`
/ `gateway` ("produced zero addresses"); without it the netns uses Docker's resolver,
which resolves `otelcol`, `gateway` and `api.telegram.org`. `GATEWAY_ADMIN_*` is
intentionally **unset** — caddy owns `/_gm` in the contour.
## Host-side setup (outside this repo) ## Host-side setup (outside this repo)
+81 -22
View File
@@ -1,6 +1,6 @@
# Full deploy descriptor for the Scrabble test contour: backend + gateway + # Full deploy descriptor for the Scrabble test contour: backend + gateway +
# Postgres + the Telegram connector (with its VPN sidecar) + the observability # Postgres + the Telegram validator + bot (the bot with its VPN sidecar) + the
# stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by # observability stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
# .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are # .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are
# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy # interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
# job (see deploy/.env.example for the unprefixed names). # job (see deploy/.env.example for the unprefixed names).
@@ -19,8 +19,10 @@
# the test contour; the host caddy terminates TLS and forwards. For prod # the test contour; the host caddy terminates TLS and forwards. For prod
# (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy # (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
# does its own ACME — the contour is then self-contained. # does its own ACME — the contour is then self-contained.
# - The connector egresses to api.telegram.org through the `vpn` sidecar # - The validator answers internal gRPC at `validator:9091` (no VPN, HMAC only).
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`. # The bot egresses to api.telegram.org through the `vpn` sidecar (network_mode:
# service:vpn) and dials the gateway bot-link (mTLS) at `gateway:9443`. The
# backend admin relay reaches the gateway at `gateway:9092` (plaintext).
name: scrabble name: scrabble
# Bound every container's json-file logs. R7 measured the backend emitting a # Bound every container's json-file logs. R7 measured the backend emitting a
@@ -82,7 +84,9 @@ services:
BACKEND_POSTGRES_MAX_OPEN_CONNS: "40" BACKEND_POSTGRES_MAX_OPEN_CONNS: "40"
BACKEND_HTTP_ADDR: ":8080" BACKEND_HTTP_ADDR: ":8080"
BACKEND_GRPC_ADDR: ":9090" BACKEND_GRPC_ADDR: ":9090"
BACKEND_CONNECTOR_ADDR: telegram:9091 # Admin broadcasts go to the gateway's bot-link relay, which forwards them to
# the remote bot and reports back whether they were delivered.
BACKEND_CONNECTOR_ADDR: gateway:9092
BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info} BACKEND_LOG_LEVEL: ${LOG_LEVEL:-info}
BACKEND_SERVICE_NAME: scrabble-backend BACKEND_SERVICE_NAME: scrabble-backend
BACKEND_OTEL_TRACES_EXPORTER: otlp BACKEND_OTEL_TRACES_EXPORTER: otlp
@@ -135,7 +139,17 @@ services:
GATEWAY_HTTP_ADDR: ":8081" GATEWAY_HTTP_ADDR: ":8081"
GATEWAY_BACKEND_HTTP_URL: http://backend:8080 GATEWAY_BACKEND_HTTP_URL: http://backend:8080
GATEWAY_BACKEND_GRPC_ADDR: backend:9090 GATEWAY_BACKEND_GRPC_ADDR: backend:9090
GATEWAY_CONNECTOR_ADDR: telegram:9091 # Telegram auth validates against the home validator (plaintext, internal).
GATEWAY_VALIDATOR_ADDR: validator:9091
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
# listeners stay on the internal network (the bot shares the VPN netns); in prod
# the bot is a separate host and :9443 is published with public certificates.
GATEWAY_BOTLINK_ADDR: ":9443"
GATEWAY_BOTLINK_RELAY_ADDR: ":9092"
GATEWAY_BOTLINK_TLS_CERT: /certs/gateway.crt
GATEWAY_BOTLINK_TLS_KEY: /certs/gateway.key
GATEWAY_BOTLINK_TLS_CA: /certs/ca.crt
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info} GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp GATEWAY_OTEL_TRACES_EXPORTER: otlp
@@ -146,6 +160,10 @@ services:
GOMAXPROCS: "3" GOMAXPROCS: "3"
# GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front # GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front
# caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly. # caddy owns the /_gm Basic-Auth and routes /_gm to the backend directly.
# The bot-link mTLS material (CA + gateway server leaf). Generated by
# deploy/gen-certs.sh for the test contour; supplied from PROD_ secrets in prod.
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
# R7 tuned: the gateway holds one h2c connection per player, so at 500 players it # R7 tuned: the gateway holds one h2c connection per player, so at 500 players it
# bursts into a 2-core cap (~2.49% transport_error on game.state); 3 cores absorbs # bursts into a 2-core cap (~2.49% transport_error on game.state); 3 cores absorbs
# the bursts. Per-connection overhead is the realistic prod cost — size for it. # the bursts. Per-connection overhead is the realistic prod cost — size for it.
@@ -182,7 +200,39 @@ services:
memory: 128M memory: 128M
networks: [internal] networks: [internal]
# --- Telegram connector (egress via the VPN sidecar) ----------------------- # --- Telegram validator (home; HMAC only, no VPN, no Telegram egress) -------
# The validator holds the bot token solely as the HMAC secret and never reaches
# the Bot API, so it runs on the main network with no VPN. Game login validates
# against it and stays up even when the remote bot or the bot-link is down.
validator:
container_name: scrabble-telegram-validator
image: scrabble-telegram-validator:latest
build:
context: ..
dockerfile: platform/telegram/Dockerfile
target: validator
restart: unless-stopped
logging: *default-logging
environment:
# The token is the HMAC secret only; the validator never calls the Bot API. An
# empty value crash-loops only the validator; the rest of the contour comes up.
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_VALIDATOR_GRPC_ADDR: ":9091"
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram-validator
TELEGRAM_OTEL_TRACES_EXPORTER: otlp
TELEGRAM_OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
OTEL_EXPORTER_OTLP_INSECURE: "true"
GOMAXPROCS: "1"
deploy:
resources:
limits:
cpus: "1.0"
memory: 128M
networks: [internal]
# --- Telegram bot (egress via the VPN sidecar in test; dials the gateway) ---
vpn: vpn:
container_name: scrabble-telegram-vpn container_name: scrabble-telegram-vpn
image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
@@ -195,40 +245,49 @@ services:
internal: internal:
aliases: [telegram] aliases: [telegram]
telegram: bot:
container_name: scrabble-telegram container_name: scrabble-telegram-bot
image: scrabble-telegram:latest image: scrabble-telegram-bot:latest
build: build:
context: .. context: ..
dockerfile: platform/telegram/Dockerfile dockerfile: platform/telegram/Dockerfile
target: bot
restart: unless-stopped restart: unless-stopped
logging: *default-logging logging: *default-logging
depends_on: [vpn] depends_on: [vpn]
network_mode: "service:vpn" network_mode: "service:vpn"
environment: environment:
# The bot token lives ONLY in this container (ARCHITECTURE.md §12). The connector # The bot token lives on the bot host (ARCHITECTURE.md §12). The bot requires it
# requires it at boot; an empty value leaves the Telegram side down while the rest # at boot; an empty value leaves the bot down while the rest of the contour comes up.
# of the contour still comes up.
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-} TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
TELEGRAM_GRPC_ADDR: ":9091"
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false} TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-} TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-}
TELEGRAM_OWNS_UPDATES: "true"
# The bot dials the gateway bot-link over mTLS. In test it reaches the gateway by
# its internal service name through the VPN netns (Docker resolver, off-tunnel,
# like otelcol); in prod it is a separate host dialing the gateway's public port.
TELEGRAM_GATEWAY_ADDR: gateway:9443
TELEGRAM_BOTLINK_SERVER_NAME: gateway
TELEGRAM_BOTLINK_TLS_CERT: /certs/bot.crt
TELEGRAM_BOTLINK_TLS_KEY: /certs/bot.key
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info} TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
# The connector shares the VPN sidecar's netns. Routing to the collector's # The bot shares the VPN sidecar's netns. Routing to internal IPs stays off the
# internal IP stays off the tunnel (connected route), but the sidecar's DNS # tunnel (connected route), but the sidecar's DNS hijacks name resolution:
# hijacks name resolution: AWG_CONF must NOT carry a `DNS=` directive, else # AWG_CONF must NOT carry a `DNS=` directive, else `otelcol` and `gateway` won't
# `otelcol` won't resolve ("produced zero addresses"). Without DNS= the netns # resolve. Without DNS= the netns uses Docker's resolver, which resolves the
# uses Docker's resolver, which resolves both otelcol and api.telegram.org # internal services and api.telegram.org (see deploy/README.md).
# (see deploy/README.md).
TELEGRAM_OTEL_TRACES_EXPORTER: otlp TELEGRAM_OTEL_TRACES_EXPORTER: otlp
TELEGRAM_OTEL_METRICS_EXPORTER: otlp TELEGRAM_OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317 OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
OTEL_EXPORTER_OTLP_INSECURE: "true" OTEL_EXPORTER_OTLP_INSECURE: "true"
# The connector is light (the stress run does not drive Telegram); one P suffices. # The bot is light (the stress run does not drive Telegram); one P suffices.
GOMAXPROCS: "1" GOMAXPROCS: "1"
volumes:
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
deploy: deploy:
resources: resources:
limits: limits:
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Generate the bot-link mTLS material for the TEST contour: a private CA, a gateway
# server leaf and a bot client leaf. The gateway requires the leaf + the bot the
# client leaf to bring up the reverse bot-link; the CA signs both so each peer trusts
# only the other.
#
# Production certificates come from PROD_ secrets, NOT this script. Private keys never
# leave the host/secrets; deploy/certs/ is gitignored. The script is idempotent: it
# reuses an existing CA + leaves unless --force is passed (rotation re-mints the
# leaves from the same long-lived CA).
#
# Usage:
# deploy/gen-certs.sh [--force] [dir]
# Env:
# BOTLINK_GATEWAY_NAME gateway certificate SAN/CN (default: gateway, the compose
# service name the bot dials in the test contour)
set -euo pipefail
force=0
dir=""
for arg in "$@"; do
case "$arg" in
--force) force=1 ;;
*) dir="$arg" ;;
esac
done
dir="${dir:-$(cd "$(dirname "$0")" && pwd)/certs}"
gw_name="${BOTLINK_GATEWAY_NAME:-gateway}"
mkdir -p "$dir"
cd "$dir"
if [[ -f gateway.crt && -f bot.crt && "$force" -ne 1 ]]; then
echo "gen-certs: certificates already present in $dir (use --force to rotate the leaves)"
exit 0
fi
# CA: long-lived (10y), reused across leaf rotations.
if [[ ! -f ca.crt || ! -f ca.key ]]; then
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout ca.key -out ca.crt -days 3650 -subj "/CN=scrabble-botlink-ca"
echo "gen-certs: created CA"
fi
# Gateway server leaf (SAN matches the name the bot dials).
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout gateway.key -out gateway.csr -subj "/CN=${gw_name}"
openssl x509 -req -in gateway.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out gateway.crt -days 825 \
-extfile <(printf "subjectAltName=DNS:%s,DNS:localhost\nextendedKeyUsage=serverAuth\nkeyUsage=critical,digitalSignature\n" "$gw_name")
# Bot client leaf.
openssl req -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 -nodes \
-keyout bot.key -out bot.csr -subj "/CN=scrabble-bot"
openssl x509 -req -in bot.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-out bot.crt -days 825 \
-extfile <(printf "extendedKeyUsage=clientAuth\nkeyUsage=critical,digitalSignature\n")
rm -f gateway.csr bot.csr ca.srl
chmod 600 ./*.key
echo "gen-certs: wrote ca.crt, gateway.crt/key (CN=${gw_name}), bot.crt/key to $dir"
+83 -48
View File
@@ -45,16 +45,23 @@ Three executables plus per-platform side-services:
and a client **board-style** setting (bonus-label and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md). [`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/telegram`** — the Telegram side-service (the "connector", module - **`platform/telegram`** — the Telegram side-service (module
`scrabble/platform/telegram`). It is the only component holding the bot token — **one `scrabble/platform/telegram`), split into two binaries that share the bot token
unified bot** (one token + one optional game channel, §3). It (**one bot**, one optional game channel, §3):
runs a Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves - the **validator** (`cmd/validator`) verifies Mini App initData and Login Widget
a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation data by HMAC (the bot token is the secret) and **never reaches the Bot API**, so
and out-of-app push) and `backend` (operator broadcasts) call over the it runs on the main host with no VPN. The gateway calls its gRPC API
trusted internal network. Its generic delivery methods are **platform-agnostic** (`pkg/proto/telegram/v1`) over the trusted internal network during Telegram auth,
(keyed by the identity `external_id`), so a future VK/MAX connector reuses them; only so **game login is independent of Telegram reachability** (§10).
initData validation is Telegram-specific. It runs in its own container, egressing to - the **bot** (`cmd/bot`) runs the Bot API long-poll (Mini App launch + `/start`
Telegram through a VPN sidecar. 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**
(`pkg/proto/botlink/v1`) and executes the send commands the gateway pushes
(out-of-app push, operator broadcasts), so its egress lives on a host with native
Telegram access off the main host — a VPN sidecar in the test contour, a separate
host in prod (§12). Its delivery commands are **platform-agnostic** (keyed by the
identity `external_id`), so a future VK/MAX bot reuses them; only initData
validation is Telegram-specific.
```mermaid ```mermaid
flowchart LR flowchart LR
@@ -64,9 +71,11 @@ flowchart LR
Gateway -- in-app stream --> Client Gateway -- in-app stream --> Client
Backend -- pgx --> Postgres[(Postgres)] Backend -- pgx --> Postgres[(Postgres)]
Backend -. embeds .- Solver[[scrabble-solver library]] Backend -. embeds .- Solver[[scrabble-solver library]]
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector] Gateway -- gRPC (validate initData) --> Validator[Telegram validator]
Backend -. operator broadcasts (gRPC) .-> Telegram Bot[Telegram bot] -. dials, reverse mTLS bot-link .-> Gateway
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram)) Gateway -- send commands (out-of-app push, broadcasts) --> Bot
Backend -. operator broadcasts (gRPC relay) .-> Gateway
Bot -- Bot API --> TgCloud((Telegram))
``` ```
The MVP runs `gateway` and `backend` as single-instance processes inside a The MVP runs `gateway` and `backend` as single-instance processes inside a
@@ -132,15 +141,18 @@ signing, no anti-replay crypto** (these were considered and dropped — players
arrive from a platform rather than completing a mandatory registration). arrive from a platform rather than completing a mandatory registration).
- The gateway validates the originating credential **once** — Telegram `initData` - The gateway validates the originating credential **once** — Telegram `initData`
(delegated to the connector's `ValidateInitData` RPC, which holds the bot token — (delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
bootstrap — then mints a **thin opaque server session token** (`session_id`). First bootstrap — then mints a **thin opaque server session token** (`session_id`). First
Telegram contact seeds the new account's language (from the launch `language_code`) Telegram contact seeds the new account's language (from the launch `language_code`)
and display name (§4). and display name (§4). The validator runs on the main host and never reaches the Bot
- **Single bot.** The connector hosts **one unified bot** (one token + one optional API, so login does not depend on Telegram or the remote bot being up (§10, §12).
game channel). `ValidateInitData` validates `initData` against that single token and - **Single bot.** The platform side-service runs **one bot** (one token + one optional
returns only the Telegram user identity — there is no per-bot "service language" and no game channel), split into a home **validator** and a remote **bot** that share the
supported-languages set on the wire. The bot's chat messages and out-of-app push are token. `ValidateInitData` (the validator) validates `initData` against that single
token and returns only the Telegram user identity — there is no per-bot "service
language" and no supported-languages set on the wire. The bot's chat messages and
out-of-app push are
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
any bot-scoped language, and the friend-invite **share link** (and its caption) point at any bot-scoped language, and the friend-invite **share link** (and its caption) point at
that one bot. First Telegram contact seeds the new account's `preferred_language` from the that one bot. First Telegram contact seeds the new account's `preferred_language` from the
@@ -207,7 +219,7 @@ arrive from a platform rather than completing a mandatory registration).
payment; no purchase flow yet) is carried on the account and ORed on a merge. payment; no purchase flow yet) is carried on the account and ORed on a merge.
- **Linking** is initiated from an authenticated profile and proves - **Linking** is initiated from an authenticated profile and proves
control of the identity before attaching it: **email** through the confirm-code control of the identity before attaching it: **email** through the confirm-code
flow, **Telegram** through the web **Login Widget** (validated by the connector, flow, **Telegram** through the web **Login Widget** (validated by the validator,
HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway HMAC under `SHA-256(bot_token)` — distinct from Mini App initData; the gateway
passes the trusted `external_id` to the backend, as for `auth.telegram`). The passes the trusted `external_id` to the backend, as for `auth.telegram`). The
request step **always** sends/accepts the proof (no pre-send "already taken" request step **always** sends/accepts the proof (no pre-send "already taken"
@@ -800,18 +812,20 @@ missed while the app was hidden. **Out-of-app platform push** is a fallback
the **gateway** routes from the same firehose: for an event whose recipient has **no the **gateway** routes from the same firehose: for an event whose recipient has **no
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
`external_id`, the recipient's **interface language** (`preferred_language`) as the render `external_id`, the recipient's **interface language** (`preferred_language`) as the render
language, and the `notifications_in_app_only` flag). It then asks the **Telegram connector** language, and the `notifications_in_app_only` flag). It then pushes a deliver command over
to deliver — through the **single bot** a the **bot-link** to the remote **bot****fire-and-forget, best-effort** (dropped, with a
localized message with a Mini App deep-link button, only when the recipient has a Telegram metric, when no bot is connected) — only when the recipient has a Telegram identity and has
identity and has not confined notifications to the app, so the two channels never duplicate. The not confined notifications to the app, so the two channels never duplicate. The bot renders a
connector renders the message in that language; there is no per-bot routing. The out-of-app set is localized message with a Mini App deep-link button in that language; there is no per-bot
routing. The out-of-app set is
your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds; your-turn, game-over, nudge and the **invitation** (a new invitation) / friend-request notify sub-kinds;
the connector renders the message and skips the rest — so in-app-only sub-kinds like the bot renders the message and skips the rest — so in-app-only sub-kinds like
**invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a **invitation-update** (a response/withdrawal lobby sync) and **user-blocked/-unblocked** (a
block-state sync to the blocker) never become a platform push. Operator broadcasts block-state sync to the blocker) never become a platform push. Operator broadcasts
(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in (`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
the console, sent through the same single bot. Session-revocation events and the console; the backend calls them on the **gateway's bot-link relay**, which forwards them
cursor-based stream resume stay deferred (single-instance MVP). to the bot and **awaits its delivery ack** (so the console still reports delivered/not).
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md), A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
@@ -845,14 +859,15 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
## 11. Observability ## 11. Observability
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and - Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
meter providers are wired in **all three services** (backend, gateway, the meter providers are wired in **all services** (backend, gateway, the Telegram
Telegram connector) through a shared `pkg/telemetry` bootstrap, env-gated per validator and bot) through a shared `pkg/telemetry` bootstrap, env-gated per
service by `{BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTER` with a service by `{BACKEND,GATEWAY,TELEGRAM}_OTEL_{TRACES,METRICS}_EXPORTER` with a
default of `none` (so no collector is required locally or in CI). `stdout` is default of `none` (so no collector is required locally or in CI). `stdout` is
available for debugging; **`otlp`** (gRPC, endpoint from the standard available for debugging; **`otlp`** (gRPC, endpoint from the standard
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is `OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
and the gateway↔connector calls. The OTLP **Collector** (OTLP/gRPC → Prometheus and the gateway↔validator and bot-link calls; the gateway also exports
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. The OTLP **Collector** (OTLP/gRPC → Prometheus
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana** metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth) (provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
are stood up with the deploy (`deploy/`); the default exporter stays are stood up with the deploy (`deploy/`); the default exporter stays
@@ -918,18 +933,21 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
| Concern | Enforced by | | Concern | Enforced by |
| --- | --- | | --- | --- |
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) | | Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11) |
| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector | | Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
| Session minting; email-code / guest validation | gateway (with backend) | | Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway | | Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) | | Manual account block (suspension) | backend: a per-request gate refuses a blocked account on every `/api/v1/user/*` route except the block-status probe with **403 `account_blocked`**; the operator blocks/unblocks from the admin console (§11) |
| User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) | | User feedback gate | backend rejects a guest or a `feedback_banned` account from submitting; the **gateway** also rejects a guest's `feedback.submit` (the `Op.NonGuest` flag + `is_guest` from session resolve) with **`guest_forbidden`** before any backend call; attachments are served `nosniff` with a download disposition for non-images (§15) |
| Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked | | Admin authentication | a single Basic-Auth gate on `/_gm/*`, forwarded **verbatim** to the backend's server-rendered admin console (and, in the deployed contour, routing `/_gm/grafana/*` to Grafana). In the deploy the **caddy** owns this gate (§13); a local non-caddy run uses the gateway's own `GATEWAY_ADMIN_*` proxy, which the per-IP admin limiter class guards ahead of its Basic-Auth — the caddy-fronted path has no limiter (stock caddy), an accepted gap. The backend trusts the proxy (no admin principal) and guards its state-changing POSTs with a **same-origin** check — the console's CSRF defence. No operator identity is tracked |
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) | | backend ↔ gateway ↔ validator trust | the network (only gateway may reach backend; the validator and the gateway's admin bot-link relay serve unauthenticated gRPC on the trusted internal segment) |
| remote bot ↔ gateway (bot-link) | **mutual TLS**: a private CA signs the gateway server cert and the bot client cert, and each verifies the other. The bot dials out (no inbound port, no static IP), so the channel is guarded solely by mTLS — the bot client key is as sensitive as the token (§13) |
This is an explicit, accepted MVP risk: compromise of the gateway↔backend This is an explicit, accepted MVP risk: compromise of the gateway↔backend
network segment defeats backend authentication. Mitigated by network isolation; network segment defeats backend authentication. Mitigated by network isolation;
mutual auth is a future hardening step. mutual auth is a future hardening step. The **bot-link** is the exception — it
already uses mutual TLS, because it is the one inter-service link that leaves the
trusted segment (the remote bot lives off the main host).
**Manual account block (suspension).** Beyond the soft, reversible high-rate flag (§11, never a **Manual account block (suspension).** Beyond the soft, reversible high-rate flag (§11, never a
gate), an operator can hard-block an account from the admin console — permanently or until a gate), an operator can hard-block an account from the admin console — permanently or until a
@@ -977,17 +995,23 @@ routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the **admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/` — goes to the landing container. The catch-all — notably the landing at `/` — goes to the landing container. The
**Telegram connector** runs as a separate container with **no public ingress** — it **Telegram validator** runs as a separate container with **no public ingress**,
long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC. answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
Telegram — through a VPN sidecar in the test contour, from a separate host in prod.
The gateway exposes the bot-link on a dedicated mTLS gRPC listener
(`GATEWAY_BOTLINK_ADDR`, internal-only in the test contour, published in prod) plus a
plaintext relay (`GATEWAY_BOTLINK_RELAY_ADDR`) the backend admin console calls.
The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`, The full contour (`deploy/docker-compose.yml`) runs one `gateway`, one `backend`,
one Postgres, the static `landing`, the connector (+ its VPN sidecar) and the **observability stack** one Postgres, the static `landing`, the Telegram `validator` and `bot` (+ the bot's VPN
sidecar) and the **observability stack**
OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces) and Grafana OTel Collector (OTLP/gRPC ingest → Prometheus metrics + Tempo traces) and Grafana
with provisioned datasources and dashboards. All three services export OTLP to the with provisioned datasources and dashboards. All services export OTLP to the
collector; the connector shares the VPN sidecar's netns, so its `AWG_CONF` must not collector; the bot shares the VPN sidecar's netns, so its `AWG_CONF` must not
carry a `DNS=` directive (that would hijack resolv.conf and stop it resolving carry a `DNS=` directive (that would hijack resolv.conf and stop it resolving
`otelcol`; without it the netns uses Docker's resolver, which resolves both `otelcol` / `gateway`; without it the netns uses Docker's resolver, which resolves
`otelcol` and `api.telegram.org`). Inter-service traffic uses a private `internal` `otelcol`, `gateway` and `api.telegram.org`). Inter-service traffic uses a private `internal`
network (project-scoped DNS); only caddy joins the shared external `edge` network network (project-scoped DNS); only caddy joins the shared external `edge` network
(alias `scrabble`). (alias `scrabble`).
@@ -1001,10 +1025,21 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP — private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
real peer, so the single config is correct and spoof-safe in both contours. real peer, so the single config is correct and spoof-safe in both contours. The
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
generated by `deploy/gen-certs.sh` before `compose up`; the bot keeps its VPN sidecar
for Telegram egress and dials the gateway by its internal name, so the bot-link stays
on the internal network.
- **Prod**: a manual SSH deploy after `development → master`. There is no - **Prod**: a manual SSH deploy after `development → master`. There is no
host caddy, so the contour ships its own caddy terminating TLS — set host caddy, so the contour ships its own caddy terminating TLS — set
`CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME. `CADDY_SITE_ADDRESS` to the domain and the caddy does its own ACME. The **bot runs
on a separate host** with native Telegram access (no VPN), deployed by SSH alongside
the main app (rolled together so the bot-link protocol versions never skew); the
gateway **publishes** the bot-link port and the certificates come from `PROD_`
secrets — a long-lived CA with leaves rotated by a scheduled job. The bot dials the
gateway's public bot-link endpoint and holds no inbound port; login is unaffected if
that host or the link is down. *(This prod wiring is the deferred final stage; the
code and the unified test contour land first — see `PRERELEASE.md`.)*
## 14. CI & branches ## 14. CI & branches
@@ -1022,11 +1057,11 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
branch-protection required check (`CI / gate`), so a path-skipped job never blocks branch-protection required check (`CI / gate`), so a path-skipped job never blocks
a merge. a merge.
- A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push - A gated **`deploy`** job auto-rolls the **test contour** on a PR into — or a push
to — `development` (`docker compose up -d --build` on the runner host), then probes to — `development` (it generates the bot-link certs, then `docker compose up -d
the gateway (`GET /`) **and the Telegram connector's liveness** (via --build` on the runner host), then probes the gateway (`GET /`) **and the Telegram
`docker inspect`: running, not restarting, stable restart count, with a validator's and bot's liveness** (via `docker inspect`: running, not restarting,
VPN-handshake grace period, since the connector has no public ingress and a stable restart count, with a VPN-handshake grace period, since neither has public
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod ingress and a crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
deploy is the manual workflow. Secrets/variables are prefixed deploy is the manual workflow. Secrets/variables are prefixed
`TEST_`/`PROD_` per contour. `TEST_`/`PROD_` per contour.
- The engine consumes `scrabble-solver` as a **published, versioned module** - The engine consumes `scrabble-solver` as a **published, versioned module**
+2 -2
View File
@@ -285,7 +285,7 @@ release archive, preview the words added and removed per variant against the act
dictionary, then install — which writes the version, loads it and makes it active; dictionary, then install — which writes the version, loads it and makes it active;
versions are immutable and games in progress keep their own), and the **pending versions are immutable and games in progress keep their own), and the **pending
wordlist changes** derived from accepted complaints (which feed the offline rebuild wordlist changes** derived from accepted complaints (which feed the offline rebuild
and are marked applied after an update). When a Telegram connector is configured an operator can also and are marked applied after an update). When the Telegram bot channel is configured an operator can also
**message a user** (by their Telegram identity) or **post to the game channel**. **message a user** (by their Telegram identity) or **post to the game channel**.
State-changing actions are protected by a same-origin check; the console tracks no State-changing actions are protected by a same-origin check; the console tracks no
operator identity. operator identity.
@@ -317,7 +317,7 @@ over-grant cannot be reversed there.
The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered The console works a **feedback** queue too (`/_gm/feedback`): the messages players sent, filtered
**unread / read / archived** with per-user search, each shown with its sender, source, channel **unread / read / archived** with per-user search, each shown with its sender, source, channel
(with the connector bot language — en/ru — for a Telegram message), the sender's interface (with the bot language — en/ru — for a Telegram message), the sender's interface
language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered language, IP and any attachment. The operator can mark a message read, **reply** to the player (delivered
in-app), archive it, delete it, or delete every message from that player — and, alongside a delete, in-app), archive it, delete it, or delete every message from that player — and, alongside a delete,
**bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it **bar the player from feedback** (a `feedback_banned` role, distinct from a full account block: it
+2 -2
View File
@@ -293,7 +293,7 @@ identity, их игры) и **игры** — сводка, места, запи
активной; версии неизменяемы, а идущие партии остаются на своей) и **список ожидающих активной; версии неизменяемы, а идущие партии остаются на своей) и **список ожидающих
правок**, выведенный из принятых жалоб (он питает офлайн-пересборку и отмечается правок**, выведенный из принятых жалоб (он питает офлайн-пересборку и отмечается
применённым после обновления). Если применённым после обновления). Если
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его подключён Telegram-бот, оператор также может **написать пользователю** (по его
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
защищены проверкой same-origin; личность оператора не отслеживается. защищены проверкой same-origin; личность оператора не отслеживается.
@@ -325,7 +325,7 @@ high-rate флага. С карточки пользователя операт
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником, **непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
каналом (и языком бота-коннектора — en/ru — для сообщения из Telegram), языком интерфейса отправителя, каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта: **запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
+14 -6
View File
@@ -45,10 +45,14 @@ operations are unauthenticated and return the minted token. A unary domain
outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge
failures become Connect error codes. failures become Connect error codes.
`auth.telegram` validates the Mini App `initData` by calling the **Telegram connector** `auth.telegram` validates the Mini App `initData` by calling the **Telegram validator**
(`GATEWAY_CONNECTOR_ADDR`), which holds the bot token; the gateway also routes (`GATEWAY_VALIDATOR_ADDR`), which holds the bot token (HMAC); out-of-app push for
out-of-app push to that connector for recipients with no live in-app stream recipients with no live in-app stream goes to the remote **bot** over the reverse **mTLS
(ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled. bot-link** (`GATEWAY_BOTLINK_ADDR`, fire-and-forget), and the backend admin broadcasts
arrive on the gateway's plaintext **relay** (`GATEWAY_BOTLINK_RELAY_ADDR`) which forwards
them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). When
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
is unset the bot channel (out-of-app push + admin relay) is disabled.
The message-type catalog: `auth.telegram`, `auth.guest`, The message-type catalog: `auth.telegram`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
@@ -63,7 +67,7 @@ refetch). The social/account/history ops —
transcode pattern (`transcode_social.go`). Account linking & merge transcode pattern (`transcode_social.go`). Account linking & merge
`link.email.request/confirm/merge` and `link.telegram.confirm/merge` `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
(`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the (`transcode_link.go`); the telegram ops validate the **Login Widget** payload via the
connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
**superseded** the former `email.bind.*` ops, which were removed. **superseded** the former `email.bind.*` ops, which were removed.
## Configuration ## Configuration
@@ -76,7 +80,11 @@ connector (`ValidateLoginWidget`) and forward the trusted `external_id`. These
| `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address | | `GATEWAY_BACKEND_GRPC_ADDR` | `localhost:9090` | backend push gRPC address |
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call | | `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` | | `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
| `GATEWAY_CONNECTOR_ADDR` | unset | Telegram connector gRPC address (enables initData validation + out-of-app push) | | `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
| `GATEWAY_BOTLINK_SEND_TIMEOUT` | `5s` | admin relay wait for the bot ack before reporting not-delivered |
| `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime | | `GATEWAY_SESSION_TTL` | `10m` | cached session lifetime |
| `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap | | `GATEWAY_SESSION_CACHE_MAX` | `50000` | cached session cap |
| `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) | | `GATEWAY_PUSH_HEARTBEAT_INTERVAL` | `10s` | live-stream keep-alive (an immediate heartbeat also fires on open, under the ~15s edge idle timeout) |
+89 -18
View File
@@ -9,16 +9,23 @@ package main
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"log" "log"
"net"
"net/http" "net/http"
"os/signal" "os/signal"
"syscall" "syscall"
"time" "time"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.uber.org/zap" "go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
"scrabble/gateway/internal/admin" "scrabble/gateway/internal/admin"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/botlink"
"scrabble/gateway/internal/config" "scrabble/gateway/internal/config"
"scrabble/gateway/internal/connector" "scrabble/gateway/internal/connector"
"scrabble/gateway/internal/connectsrv" "scrabble/gateway/internal/connectsrv"
@@ -26,9 +33,23 @@ import (
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session" "scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode" "scrabble/gateway/internal/transcode"
"scrabble/pkg/mtls"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
const (
// botLinkKeepaliveTime is how often the gateway pings an idle bot-link stream
// to hold the WAN connection open and detect a dead bot.
botLinkKeepaliveTime = 30 * time.Second
// botLinkKeepaliveTimeout bounds the wait for a keepalive ping reply.
botLinkKeepaliveTimeout = 10 * time.Second
// botLinkMinPingInterval is the smallest client ping interval the gateway
// tolerates before treating it as abuse.
botLinkMinPingInterval = 10 * time.Second
)
const ( const (
// shutdownTimeout bounds the graceful HTTP shutdown. // shutdownTimeout bounds the graceful HTTP shutdown.
shutdownTimeout = 10 * time.Second shutdownTimeout = 10 * time.Second
@@ -100,17 +121,47 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
tracker := ratelimit.NewTracker() tracker := ratelimit.NewTracker()
hub := push.NewHub(0) hub := push.NewHub(0)
var conn *connector.Client
var validator transcode.TelegramValidator var validator transcode.TelegramValidator
if cfg.ConnectorAddr != "" { if cfg.ValidatorAddr != "" {
conn, err = connector.New(cfg.ConnectorAddr) conn, cerr := connector.New(cfg.ValidatorAddr)
if err != nil { if cerr != nil {
return err return cerr
} }
defer func() { _ = conn.Close() }() defer func() { _ = conn.Close() }()
validator = conn validator = conn
} else { } else {
logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)") logger.Warn("telegram auth disabled (GATEWAY_VALIDATOR_ADDR unset)")
}
// The reverse bot-link: the remote Telegram bot dials this gateway over mTLS and
// the gateway pushes send commands down the stream. Out-of-app push is
// fire-and-forget; the backend admin relay (plaintext, internal) awaits the Ack.
var botHub *botlink.Hub
if cfg.BotLinkEnabled() {
botHub = botlink.NewHub(logger, tel.MeterProvider().Meter("scrabble/gateway/botlink"))
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
if terr != nil {
return terr
}
botSrv := grpc.NewServer(
grpc.Creds(credentials.NewTLS(tlsCfg)),
grpc.StatsHandler(otelgrpc.NewServerHandler()),
grpc.KeepaliveParams(keepalive.ServerParameters{Time: botLinkKeepaliveTime, Timeout: botLinkKeepaliveTimeout}),
grpc.KeepaliveEnforcementPolicy(keepalive.EnforcementPolicy{MinTime: botLinkMinPingInterval, PermitWithoutStream: true}),
)
botlinkv1.RegisterBotLinkServer(botSrv, botHub)
if serr := serveGRPC(ctx, "botlink", cfg.BotLink.Addr, botSrv, logger); serr != nil {
return serr
}
if cfg.BotLink.RelayAddr != "" {
relaySrv := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
telegramv1.RegisterTelegramServer(relaySrv, botlink.NewRelayServer(botHub, cfg.BotLink.SendTimeout))
if serr := serveGRPC(ctx, "botlink-relay", cfg.BotLink.RelayAddr, relaySrv, logger); serr != nil {
return serr
}
}
} else {
logger.Warn("telegram bot channel disabled (GATEWAY_BOTLINK_ADDR unset)")
} }
// The admin console (backend /_gm) is fronted on the public listener behind // The admin console (backend /_gm) is fronted on the public listener behind
@@ -142,8 +193,8 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}) })
// Bridge the backend push stream into the fan-out hub (and the out-of-app // Bridge the backend push stream into the fan-out hub (and the out-of-app
// channel via the connector). // channel via the bot-link).
go runPushPump(ctx, backend, hub, conn, logger) go runPushPump(ctx, backend, hub, botHub, logger)
// Periodically summarise rate-limiter rejections (Warn log + backend report). // Periodically summarise rate-limiter rejections (Warn log + backend report).
go runThrottleReporter(ctx, tracker, backend, logger) go runThrottleReporter(ctx, tracker, backend, logger)
@@ -195,6 +246,27 @@ func runServers(ctx context.Context, cancel context.CancelFunc, servers []*named
return first return first
} }
// serveGRPC starts a gRPC server on addr in the background and gracefully stops it
// when ctx is cancelled. It returns synchronously on a bind error so a
// misconfigured listener fails startup fast; a later Serve error is logged.
func serveGRPC(ctx context.Context, name, addr string, srv *grpc.Server, logger *zap.Logger) error {
lis, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("gateway: listen %s (%s): %w", addr, name, err)
}
go func() {
<-ctx.Done()
srv.GracefulStop()
}()
go func() {
logger.Info("listener starting", zap.String("server", name), zap.String("addr", addr))
if err := srv.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
logger.Error("grpc listener failed", zap.String("server", name), zap.Error(err))
}
}()
return nil
}
// runThrottleReporter drains the rate-limiter rejection tracker on a fixed // runThrottleReporter drains the rate-limiter rejection tracker on a fixed
// cadence, emits one Warn summary per throttled key and forwards the report to // cadence, emits one Warn summary per throttled key and forwards the report to
// the backend (which feeds the admin throttled view and the high-rate // the backend (which feeds the admin throttled view and the high-rate
@@ -230,7 +302,7 @@ func runThrottleReporter(ctx context.Context, tracker *ratelimit.Tracker, backen
// the hub and re-subscribing after the stream ends, until the context is done. For // the hub and re-subscribing after the stream ends, until the context is done. For
// the out-of-app push kinds it also routes events whose recipient has no live // the out-of-app push kinds it also routes events whose recipient has no live
// in-app stream to the platform connector (a nil connector disables that channel). // in-app stream to the platform connector (a nil connector disables that channel).
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, conn *connector.Client, logger *zap.Logger) { func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, bot *botlink.Hub, logger *zap.Logger) {
for ctx.Err() == nil { for ctx.Err() == nil {
stream, err := backend.SubscribePush(ctx, gatewayID) stream, err := backend.SubscribePush(ctx, gatewayID)
if err != nil { if err != nil {
@@ -255,10 +327,10 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
EventID: ev.GetEventId(), EventID: ev.GetEventId(),
}) })
// Out-of-app fallback: when the recipient has no live in-app stream, // Out-of-app fallback: when the recipient has no live in-app stream,
// deliver the event over the platform push channel. Done in a goroutine // deliver the event over the bot-link. Done in a goroutine so a slow
// so a slow connector never stalls the in-app firehose. // target lookup never stalls the in-app firehose.
if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) { if bot != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger) go deliverOutOfApp(ctx, backend, bot, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
} }
} }
if !sleep(ctx, pushReconnectDelay) { if !sleep(ctx, pushReconnectDelay) {
@@ -271,7 +343,7 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
// Telegram identity and have not confined notifications to the app, asks the // Telegram identity and have not confined notifications to the app, asks the
// connector to deliver the event. It is best-effort: every failure is logged and // connector to deliver the event. It is best-effort: every failure is logged and
// dropped (the in-app stream remains the primary channel). // dropped (the in-app stream remains the primary channel).
func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, logger *zap.Logger) { func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, bot *botlink.Hub, userID, kind string, payload []byte, logger *zap.Logger) {
target, err := backend.PushTarget(ctx, userID) target, err := backend.PushTarget(ctx, userID)
if err != nil { if err != nil {
logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err)) logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err))
@@ -280,10 +352,9 @@ func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *c
if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) { if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) {
return return
} }
// The single bot renders the message in the recipient's interface language. // Fire-and-forget down the bot-link; the bot renders the message in the
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil { // recipient's interface language and is dropped (best-effort) if no bot is up.
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err)) bot.Send(botlink.NotifyCommand(target.ExternalID, kind, payload, target.Language))
}
} }
// sleep waits for d or until ctx is cancelled, reporting whether it waited the // sleep waits for d or until ctx is cancelled, reporting whether it waited the
+38
View File
@@ -0,0 +1,38 @@
package botlink
import (
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// NotifyCommand builds an out-of-app push command rendered by the bot in the
// recipient's interface language.
func NotifyCommand(externalID, kind string, payload []byte, language string) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_Notify{Notify: &telegramv1.NotifyRequest{
ExternalId: externalID,
Kind: kind,
Payload: payload,
Language: language,
}},
}
}
// SendToUserCommand builds an admin text message addressed to one user.
func SendToUserCommand(externalID, text string) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{
ExternalId: externalID,
Text: text,
}},
}
}
// SendToGameChannelCommand builds an admin text message for the bot's game channel.
func SendToGameChannelCommand(text string) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{
Text: text,
}},
}
}
+259
View File
@@ -0,0 +1,259 @@
// Package botlink is the gateway side of the reverse Telegram bot channel: a remote
// bot dials the gateway and opens one long-lived mTLS gRPC stream
// (pkg/proto/botlink/v1), over which the gateway pushes send Commands and the bot
// returns an Ack per command. The Hub registers connected bots and routes commands:
// out-of-app push is fire-and-forget (Send), admin sends await the bot Ack
// (SendAwait). Delivery is best-effort, at-most-once — a command lost across a
// reconnect is not replayed. See docs/ARCHITECTURE.md.
package botlink
import (
"context"
"errors"
"strconv"
"sync"
"sync/atomic"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
// ErrNoBot is returned by SendAwait when no bot is currently connected.
var ErrNoBot = errors.New("botlink: no bot connected")
// outboundBuffer is the per-link command queue depth; a full queue drops commands
// (at-most-once under backpressure).
const outboundBuffer = 64
// Hub registers connected bots and routes send commands to them. A single bot is
// expected today; the registry already holds a set so adding more later needs no
// rewrite.
type Hub struct {
botlinkv1.UnimplementedBotLinkServer
log *zap.Logger
mu sync.Mutex
links map[*link]struct{}
pending map[string]chan *botlinkv1.Ack
seq atomic.Uint64
connected metric.Int64UpDownCounter
commands metric.Int64Counter
}
// link is one connected bot's outbound queue and identity.
type link struct {
instanceID string
ownsUpdates bool
out chan *botlinkv1.ToBot
}
// NewHub builds a Hub. A nil meter disables metrics; a nil logger is tolerated.
func NewHub(log *zap.Logger, meter metric.Meter) *Hub {
if log == nil {
log = zap.NewNop()
}
h := &Hub{
log: log,
links: make(map[*link]struct{}),
pending: make(map[string]chan *botlinkv1.Ack),
}
if meter != nil {
h.connected, _ = meter.Int64UpDownCounter("botlink_connected_bots",
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
h.commands, _ = meter.Int64Counter("botlink_commands_total",
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
}
return h
}
// Link implements botlinkv1.BotLinkServer: it registers the dialing bot, drains
// queued commands to it, and resolves Acks until the stream ends.
func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.ToBot]) error {
first, err := stream.Recv()
if err != nil {
return err
}
hello := first.GetHello()
if hello == nil {
return status.Error(codes.InvalidArgument, "first bot-link message must be Hello")
}
l := &link{
instanceID: hello.GetInstanceId(),
ownsUpdates: hello.GetOwnsUpdates(),
out: make(chan *botlinkv1.ToBot, outboundBuffer),
}
h.register(l)
defer h.unregister(l)
ctx := stream.Context()
// A dedicated goroutine owns stream.Send; the Recv loop below owns stream.Recv.
go func() {
for {
select {
case <-ctx.Done():
return
case msg := <-l.out:
if err := stream.Send(msg); err != nil {
return
}
}
}
}()
for {
msg, err := stream.Recv()
if err != nil {
return err
}
if ack := msg.GetAck(); ack != nil {
h.resolve(ack)
}
}
}
// register adds a connected bot.
func (h *Hub) register(l *link) {
h.mu.Lock()
h.links[l] = struct{}{}
n := len(h.links)
h.mu.Unlock()
if h.connected != nil {
h.connected.Add(context.Background(), 1)
}
h.log.Info("bot connected",
zap.String("instance_id", l.instanceID),
zap.Bool("owns_updates", l.ownsUpdates),
zap.Int("connected", n))
}
// unregister removes a bot. l.out is left for the GC; its sender goroutine has
// already exited via the stream context, and stale enqueues simply never send
// (at-most-once).
func (h *Hub) unregister(l *link) {
h.mu.Lock()
delete(h.links, l)
n := len(h.links)
h.mu.Unlock()
if h.connected != nil {
h.connected.Add(context.Background(), -1)
}
h.log.Info("bot disconnected", zap.String("instance_id", l.instanceID), zap.Int("connected", n))
}
// pick returns one connected bot.
func (h *Hub) pick() (*link, bool) {
h.mu.Lock()
defer h.mu.Unlock()
for l := range h.links {
return l, true
}
return nil, false
}
// Send enqueues a fire-and-forget command to a connected bot, dropping it (with a
// warning) when no bot is connected or the queue is full.
func (h *Hub) Send(cmd *botlinkv1.Command) {
l, ok := h.pick()
if !ok {
h.count("dropped")
h.log.Warn("bot-link send dropped: no bot connected")
return
}
cmd.CommandId = h.nextID()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
default:
h.count("dropped")
h.log.Warn("bot-link send dropped: outbound queue full", zap.String("instance_id", l.instanceID))
}
}
// SendAwait enqueues a command and waits for the bot's Ack or until ctx is done.
// It returns ErrNoBot when no bot is connected, the delivered flag on a clean Ack,
// or (false, nil) when ctx (the deadline) fires before an Ack arrives.
func (h *Hub) SendAwait(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
l, ok := h.pick()
if !ok {
h.count("dropped")
return false, ErrNoBot
}
id := h.nextID()
cmd.CommandId = id
ackc := make(chan *botlinkv1.Ack, 1)
h.mu.Lock()
h.pending[id] = ackc
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
case <-ctx.Done():
h.count("dropped")
return false, ctx.Err()
}
select {
case ack := <-ackc:
if e := ack.GetError(); e != "" {
h.count("error")
return false, errors.New(e)
}
h.count(deliveredLabel(ack.GetDelivered()))
return ack.GetDelivered(), nil
case <-ctx.Done():
h.count("error")
return false, nil
}
}
// resolve routes an Ack to its waiting SendAwait, if any.
func (h *Hub) resolve(ack *botlinkv1.Ack) {
h.mu.Lock()
c, ok := h.pending[ack.GetCommandId()]
h.mu.Unlock()
if ok {
select {
case c <- ack:
default:
}
}
}
// nextID returns a process-unique command id.
func (h *Hub) nextID() string {
return strconv.FormatUint(h.seq.Add(1), 10)
}
// count records one command result, when metrics are enabled.
func (h *Hub) count(result string) {
if h.commands == nil {
return
}
h.commands.Add(context.Background(), 1, metric.WithAttributes(resultAttr(result)))
}
// deliveredLabel maps the delivered flag to a metric result label.
func deliveredLabel(delivered bool) string {
if delivered {
return "delivered"
}
return "not_delivered"
}
// resultAttr is the metric attribute carrying a command result label.
func resultAttr(result string) attribute.KeyValue {
return attribute.String("result", result)
}
+171
View File
@@ -0,0 +1,171 @@
package botlink
import (
"context"
"errors"
"net"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// fakeBot is a test bot that dials the hub, registers, and acks commands with a
// fixed delivered flag (or never, when ack is false).
type fakeBot struct {
delivered bool
ack bool
received chan *botlinkv1.Command
}
// startHub registers a Hub on an in-memory gRPC server and returns the hub plus a
// dialer for fake bots.
func startHub(t *testing.T) (*Hub, func(t *testing.T) botlinkv1.BotLinkClient) {
t.Helper()
lis := bufconn.Listen(1 << 20)
hub := NewHub(nil, nil)
srv := grpc.NewServer()
botlinkv1.RegisterBotLinkServer(srv, hub)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
dial := func(t *testing.T) botlinkv1.BotLinkClient {
t.Helper()
conn, err := grpc.NewClient("passthrough:///bufnet",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
return botlinkv1.NewBotLinkClient(conn)
}
return hub, dial
}
// connect runs a fake bot against client until ctx is cancelled, returning once the
// hub has registered it.
func (f *fakeBot) connect(t *testing.T, ctx context.Context, hub *Hub, client botlinkv1.BotLinkClient) {
t.Helper()
f.received = make(chan *botlinkv1.Command, 8)
stream, err := client.Link(ctx)
if err != nil {
t.Fatalf("link: %v", err)
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Hello{Hello: &botlinkv1.Hello{InstanceId: "test"}}}); err != nil {
t.Fatalf("hello: %v", err)
}
go func() {
for {
msg, err := stream.Recv()
if err != nil {
return
}
cmd := msg.GetCommand()
f.received <- cmd
if !f.ack {
continue
}
_ = stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: &botlinkv1.Ack{
CommandId: cmd.GetCommandId(),
Delivered: f.delivered,
}}})
}
}()
waitConnected(t, hub, 1)
}
// waitConnected blocks until the hub reports n connected bots.
func waitConnected(t *testing.T, hub *Hub, n int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
hub.mu.Lock()
got := len(hub.links)
hub.mu.Unlock()
if got == n {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("hub did not reach %d connected bots", n)
}
func TestHubSendAwaitDelivered(t *testing.T) {
hub, dial := startHub(t)
ctx := t.Context()
bot := &fakeBot{delivered: true, ack: true}
bot.connect(t, ctx, hub, dial(t))
delivered, err := hub.SendAwait(ctx, SendToUserCommand("42", "hi"))
if err != nil {
t.Fatalf("SendAwait: %v", err)
}
if !delivered {
t.Fatal("delivered = false, want true")
}
select {
case cmd := <-bot.received:
if cmd.GetSendToUser().GetExternalId() != "42" {
t.Errorf("received external_id = %q, want 42", cmd.GetSendToUser().GetExternalId())
}
case <-time.After(time.Second):
t.Fatal("bot received no command")
}
}
func TestHubSendAwaitNoBot(t *testing.T) {
hub, _ := startHub(t)
_, err := hub.SendAwait(context.Background(), SendToUserCommand("42", "hi"))
if !errors.Is(err, ErrNoBot) {
t.Errorf("err = %v, want ErrNoBot", err)
}
}
func TestHubSendAwaitDeadline(t *testing.T) {
hub, dial := startHub(t)
ctx := t.Context()
bot := &fakeBot{ack: false} // receives but never acks
bot.connect(t, ctx, hub, dial(t))
awaitCtx, awaitCancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer awaitCancel()
delivered, err := hub.SendAwait(awaitCtx, SendToUserCommand("42", "hi"))
if err != nil {
t.Fatalf("SendAwait err = %v, want nil on deadline", err)
}
if delivered {
t.Error("delivered = true, want false on deadline")
}
}
func TestHubSendAsync(t *testing.T) {
hub, dial := startHub(t)
ctx := t.Context()
bot := &fakeBot{ack: false}
bot.connect(t, ctx, hub, dial(t))
hub.Send(NotifyCommand("42", "your_turn", nil, "en"))
select {
case cmd := <-bot.received:
if cmd.GetNotify().GetExternalId() != "42" {
t.Errorf("received external_id = %q, want 42", cmd.GetNotify().GetExternalId())
}
case <-time.After(time.Second):
t.Fatal("bot received no command")
}
}
func TestRelayServerNoBot(t *testing.T) {
hub, _ := startHub(t)
relay := NewRelayServer(hub, 200*time.Millisecond)
if _, err := relay.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "42", Text: "hi"}); err == nil {
t.Fatal("expected an error with no bot connected")
}
}
+160
View File
@@ -0,0 +1,160 @@
package botlink
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"scrabble/pkg/mtls"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
// mintCerts writes a CA, a server leaf (IP SAN 127.0.0.1) and a client leaf into a
// temp dir, returning their file paths. It exercises the real pkg/mtls loaders.
func mintCerts(t *testing.T) (caFile, srvCert, srvKey, cliCert, cliKey string) {
t.Helper()
dir := t.TempDir()
caKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
caTmpl := &x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IsCA: true,
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
}
caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
if err != nil {
t.Fatalf("ca: %v", err)
}
caCert, _ := x509.ParseCertificate(caDER)
leaf := func(cn string, eku x509.ExtKeyUsage, ips []net.IP) (certPath, keyPath string) {
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().UnixNano()),
Subject: pkix.Name{CommonName: cn},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{eku},
IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, tmpl, caCert, &key.PublicKey, caKey)
if err != nil {
t.Fatalf("leaf %s: %v", cn, err)
}
certPath = filepath.Join(dir, cn+".crt")
keyPath = filepath.Join(dir, cn+".key")
writePEM(t, certPath, "CERTIFICATE", der)
keyDER, _ := x509.MarshalPKCS8PrivateKey(key)
writePEM(t, keyPath, "PRIVATE KEY", keyDER)
return certPath, keyPath
}
caFile = filepath.Join(dir, "ca.crt")
writePEM(t, caFile, "CERTIFICATE", caDER)
srvCert, srvKey = leaf("server", x509.ExtKeyUsageServerAuth, []net.IP{net.ParseIP("127.0.0.1")})
cliCert, cliKey = leaf("client", x509.ExtKeyUsageClientAuth, nil)
return caFile, srvCert, srvKey, cliCert, cliKey
}
func writePEM(t *testing.T, path, typ string, der []byte) {
t.Helper()
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: typ, Bytes: der}), 0o600); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// startMTLSHub starts the Hub behind a real mTLS gRPC listener and returns its
// address and the CA/client cert paths.
func startMTLSHub(t *testing.T) (hub *Hub, addr, caFile, cliCert, cliKey string) {
t.Helper()
caFile, srvCert, srvKey, cliCert, cliKey := mintCerts(t)
tlsCfg, err := mtls.ServerConfig(srvCert, srvKey, caFile)
if err != nil {
t.Fatalf("server config: %v", err)
}
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
hub = NewHub(nil, nil)
srv := grpc.NewServer(grpc.Creds(credentials.NewTLS(tlsCfg)))
botlinkv1.RegisterBotLinkServer(srv, hub)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
return hub, lis.Addr().String(), caFile, cliCert, cliKey
}
// TestMTLSValidClientDelivers verifies a CA-signed bot connects and receives a
// pushed command.
func TestMTLSValidClientDelivers(t *testing.T) {
hub, addr, caFile, cliCert, cliKey := startMTLSHub(t)
tlsCfg, err := mtls.ClientConfig(cliCert, cliKey, caFile, "127.0.0.1")
if err != nil {
t.Fatalf("client config: %v", err)
}
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(credentials.NewTLS(tlsCfg)))
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
ctx := t.Context()
bot := &fakeBot{delivered: true, ack: true}
bot.connect(t, ctx, hub, botlinkv1.NewBotLinkClient(conn))
delivered, err := hub.SendAwait(ctx, SendToUserCommand("42", "hi"))
if err != nil || !delivered {
t.Fatalf("SendAwait = (%v, %v), want (true, nil)", delivered, err)
}
}
// TestMTLSRejectsClientWithoutCert verifies the gateway refuses a client that
// presents no CA-signed certificate — mTLS is the sole guard on the bot-link.
func TestMTLSRejectsClientWithoutCert(t *testing.T) {
hub, addr, caFile, _, _ := startMTLSHub(t)
caPEM, _ := os.ReadFile(caFile)
pool := x509.NewCertPool()
pool.AppendCertsFromPEM(caPEM)
// Trusts the server, but presents no client certificate.
noCert := credentials.NewTLS(&tls.Config{RootCAs: pool, ServerName: "127.0.0.1", MinVersion: tls.VersionTLS13})
conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(noCert))
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
stream, err := botlinkv1.NewBotLinkClient(conn).Link(t.Context())
if err == nil {
err = stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Hello{Hello: &botlinkv1.Hello{InstanceId: "rogue"}}})
if err == nil {
_, err = stream.Recv()
}
}
if err == nil {
t.Fatal("expected the handshake to be rejected without a client certificate")
}
hub.mu.Lock()
n := len(hub.links)
hub.mu.Unlock()
if n != 0 {
t.Errorf("connected bots = %d, want 0 (rogue must not register)", n)
}
}
+67
View File
@@ -0,0 +1,67 @@
package botlink
import (
"context"
"errors"
"time"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// RelayServer lets the backend's admin console reach the remote bot through the
// gateway: it implements the two admin delivery methods of the Telegram service by
// forwarding them onto the bot-link and awaiting the bot's Ack. The validation and
// out-of-app push methods are intentionally unimplemented here — login validation
// runs against the home validator and out-of-app push goes straight to the Hub.
type RelayServer struct {
telegramv1.UnimplementedTelegramServer
hub *Hub
deadline time.Duration
}
// NewRelayServer builds the relay over hub. deadline bounds the wait for the bot's
// Ack before reporting the send as not delivered.
func NewRelayServer(hub *Hub, deadline time.Duration) *RelayServer {
return &RelayServer{hub: hub, deadline: deadline}
}
// SendToUser forwards an admin message for one user to the bot and reports whether
// it was delivered. A missing bot maps to Unavailable (parity with an unreachable
// connector); a deadline before the Ack reports delivered=false.
func (r *RelayServer) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) {
delivered, err := r.await(ctx, SendToUserCommand(req.GetExternalId(), req.GetText()))
if err != nil {
return nil, err
}
return &telegramv1.SendResponse{Delivered: delivered}, nil
}
// SendToGameChannel forwards an admin message for the game channel to the bot and
// reports whether it was delivered.
func (r *RelayServer) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) {
delivered, err := r.await(ctx, SendToGameChannelCommand(req.GetText()))
if err != nil {
return nil, err
}
return &telegramv1.SendResponse{Delivered: delivered}, nil
}
// await sends cmd with the relay deadline and translates the Hub outcome into the
// gRPC contract the backend client expects.
func (r *RelayServer) await(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
cctx, cancel := context.WithTimeout(ctx, r.deadline)
defer cancel()
delivered, err := r.hub.SendAwait(cctx, cmd)
switch {
case errors.Is(err, ErrNoBot):
return false, status.Error(codes.Unavailable, "no telegram bot connected")
case err != nil:
return false, status.Error(codes.Internal, err.Error())
default:
return delivered, nil
}
}
+50 -5
View File
@@ -28,10 +28,13 @@ type Config struct {
// checks before proxying admin traffic to the backend. Empty disables admin. // checks before proxying admin traffic to the backend. Empty disables admin.
AdminUser string AdminUser string
AdminPassword string AdminPassword string
// ConnectorAddr is the gRPC address of the Telegram connector side-service. The // ValidatorAddr is the gRPC address of the Telegram validator side-service (home,
// gateway calls it to validate Mini App initData and to deliver out-of-app push. // plaintext, internal). The gateway calls it to validate Mini App initData and
// Empty disables the telegram auth path and the out-of-app push channel. // Login Widget data. Empty disables the telegram auth path.
ConnectorAddr string ValidatorAddr string
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
BotLink BotLinkConfig
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax // SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
// caps the number of cached sessions. // caps the number of cached sessions.
SessionTTL time.Duration SessionTTL time.Duration
@@ -47,6 +50,29 @@ type Config struct {
Telemetry pkgtel.Config Telemetry pkgtel.Config
} }
// BotLinkConfig configures the gateway's reverse bot-link: the mTLS listener the
// remote Telegram bot dials, the plaintext listener the backend admin relay calls,
// and the mTLS material. The main host is already public, so exposing the bot-link
// listener on a dedicated port adds no static IP; the channel is guarded solely by
// mTLS (the bot has no fixed address to allow-list).
type BotLinkConfig struct {
// Addr is the mTLS gRPC listener the bot dials (e.g. ":9443"). Empty disables
// the whole bot channel.
Addr string
// RelayAddr is the plaintext internal gRPC listener that serves the backend
// admin SendToUser/SendToGameChannel relay (e.g. ":9092"). Empty disables it.
RelayAddr string
// CertFile, KeyFile and CAFile are the gateway server certificate, its key and
// the CA bundle that signs the accepted bot client certificates. Required when
// Addr is set.
CertFile string
KeyFile string
CAFile string
// SendTimeout bounds the admin relay's wait for the bot Ack before reporting
// the send as not delivered.
SendTimeout time.Duration
}
// RateLimitConfig holds the token-bucket limits per class. Public and admin are // RateLimitConfig holds the token-bucket limits per class. Public and admin are
// keyed per client IP; the authenticated class is keyed per user id; the email // keyed per client IP; the authenticated class is keyed per user id; the email
// sub-limit guards the costly email-code path per IP. // sub-limit guards the costly email-code path per IP.
@@ -72,6 +98,7 @@ const (
defaultSessionCacheMax = 50000 defaultSessionCacheMax = 50000
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout
defaultServiceName = "scrabble-gateway" defaultServiceName = "scrabble-gateway"
defaultBotLinkSendTimeout = 5 * time.Second
) )
// DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES): // DefaultMaxBodyBytes is the default request-body cap (GATEWAY_MAX_BODY_BYTES):
@@ -104,9 +131,16 @@ func Load() (Config, error) {
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
ConnectorAddr: os.Getenv("GATEWAY_CONNECTOR_ADDR"), ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
SessionCacheMax: defaultSessionCacheMax, SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(), RateLimit: DefaultRateLimit(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
CertFile: os.Getenv("GATEWAY_BOTLINK_TLS_CERT"),
KeyFile: os.Getenv("GATEWAY_BOTLINK_TLS_KEY"),
CAFile: os.Getenv("GATEWAY_BOTLINK_TLS_CA"),
},
} }
tel := pkgtel.DefaultConfig(defaultServiceName) tel := pkgtel.DefaultConfig(defaultServiceName)
tel.ServiceName = envOr("GATEWAY_SERVICE_NAME", tel.ServiceName) tel.ServiceName = envOr("GATEWAY_SERVICE_NAME", tel.ServiceName)
@@ -128,12 +162,18 @@ func Load() (Config, error) {
if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil { if c.MaxBodyBytes, err = envInt("GATEWAY_MAX_BODY_BYTES", DefaultMaxBodyBytes); err != nil {
return Config{}, err return Config{}, err
} }
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
if err := c.validate(); err != nil { if err := c.validate(); err != nil {
return Config{}, err return Config{}, err
} }
return c, nil return c, nil
} }
// BotLinkEnabled reports whether the reverse bot-link channel is configured.
func (c Config) BotLinkEnabled() bool { return c.BotLink.Addr != "" }
// AdminEnabled reports whether the admin console proxy should be mounted (both // AdminEnabled reports whether the admin console proxy should be mounted (both
// Basic-Auth credentials are configured). // Basic-Auth credentials are configured).
func (c Config) AdminEnabled() bool { func (c Config) AdminEnabled() bool {
@@ -159,6 +199,11 @@ func (c Config) validate() error {
if c.MaxBodyBytes <= 0 { if c.MaxBodyBytes <= 0 {
return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive") return fmt.Errorf("config: GATEWAY_MAX_BODY_BYTES must be positive")
} }
if c.BotLink.Addr != "" {
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
}
}
if err := c.Telemetry.Validate(); err != nil { if err := c.Telemetry.Validate(); err != nil {
return fmt.Errorf("config: %w", err) return fmt.Errorf("config: %w", err)
} }
+6 -19
View File
@@ -1,7 +1,9 @@
// Package connector is the gateway's gRPC client for the Telegram connector // Package connector is the gateway's gRPC client for the Telegram validator
// side-service: it validates Mini App initData and delivers out-of-app push. The // side-service: it validates Mini App initData and Login Widget data. The validator
// connector lives on the trusted internal network, so the connection uses insecure // lives on the trusted internal network and holds the bot token only for HMAC, so
// (plaintext) transport credentials (ARCHITECTURE.md §12). // the connection uses insecure (plaintext) transport credentials (ARCHITECTURE.md
// §12). Out-of-app push no longer goes through this client; it is delivered to the
// remote bot over the reverse mTLS bot-link (gateway/internal/botlink).
package connector package connector
import ( import (
@@ -92,18 +94,3 @@ func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, er
FirstName: resp.GetFirstName(), FirstName: resp.GetFirstName(),
}, nil }, nil
} }
// Notify delivers an out-of-app notification for a push event; delivered reports
// whether a message was actually sent.
func (c *Client) Notify(ctx context.Context, externalID, kind string, payload []byte, language string) (bool, error) {
resp, err := c.c.Notify(ctx, &telegramv1.NotifyRequest{
ExternalId: externalID,
Kind: kind,
Payload: payload,
Language: language,
})
if err != nil {
return false, err
}
return resp.GetDelivered(), nil
}
+68
View File
@@ -0,0 +1,68 @@
// Package mtls builds mutual-TLS configurations for the one inter-service link
// that crosses an untrusted network: the reverse bot-link between a remote
// Telegram bot and the gateway (pkg/proto/botlink/v1). Both peers present a
// certificate signed by a shared private CA and verify the other against it, so the
// gateway accepts only our bot and the bot trusts only our gateway. Every other
// inter-service hop stays on the trusted internal network and uses plaintext
// (docs/ARCHITECTURE.md §12).
package mtls
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
)
// ServerConfig builds a TLS config for the gateway's bot-link listener. It loads
// the server certificate from certFile/keyFile, trusts client certificates signed
// by the CA in caFile, and requires every client to present a valid one.
func ServerConfig(certFile, keyFile, caFile string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("mtls: load server keypair: %w", err)
}
pool, err := loadCAPool(caFile)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
ClientCAs: pool,
ClientAuth: tls.RequireAndVerifyClientCert,
MinVersion: tls.VersionTLS13,
}, nil
}
// ClientConfig builds a TLS config for the bot dialing the gateway. It presents the
// client certificate from certFile/keyFile, verifies the gateway's certificate
// against the CA in caFile, and pins the expected serverName.
func ClientConfig(certFile, keyFile, caFile, serverName string) (*tls.Config, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("mtls: load client keypair: %w", err)
}
pool, err := loadCAPool(caFile)
if err != nil {
return nil, err
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
ServerName: serverName,
MinVersion: tls.VersionTLS13,
}, nil
}
// loadCAPool reads a PEM bundle and returns a certificate pool trusting it.
func loadCAPool(caFile string) (*x509.CertPool, error) {
pem, err := os.ReadFile(caFile)
if err != nil {
return nil, fmt.Errorf("mtls: read CA %s: %w", caFile, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("mtls: CA %s contains no certificates", caFile)
}
return pool, nil
}
+490
View File
@@ -0,0 +1,490 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc (unknown)
// source: botlink/v1/botlink.proto
// Package scrabble.botlink.v1 is the reverse control channel between a remote
// Telegram bot and the gateway. The bot dials the gateway and opens a single
// long-lived Link stream (mTLS); once the stream is open the gateway pushes send
// Commands down it and the bot returns one Ack per command. This keeps the bot
// egress (the Bot API token, getUpdates long-poll and sendMessage) off the main
// host with no inbound port on the bot. See docs/ARCHITECTURE.md.
package botlinkv1
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
v1 "scrabble/pkg/proto/telegram/v1"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command.
type FromBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Msg:
//
// *FromBot_Hello
// *FromBot_Ack
Msg isFromBot_Msg `protobuf_oneof:"msg"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *FromBot) Reset() {
*x = FromBot{}
mi := &file_botlink_v1_botlink_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *FromBot) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*FromBot) ProtoMessage() {}
func (x *FromBot) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use FromBot.ProtoReflect.Descriptor instead.
func (*FromBot) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{0}
}
func (x *FromBot) GetMsg() isFromBot_Msg {
if x != nil {
return x.Msg
}
return nil
}
func (x *FromBot) GetHello() *Hello {
if x != nil {
if x, ok := x.Msg.(*FromBot_Hello); ok {
return x.Hello
}
}
return nil
}
func (x *FromBot) GetAck() *Ack {
if x != nil {
if x, ok := x.Msg.(*FromBot_Ack); ok {
return x.Ack
}
}
return nil
}
type isFromBot_Msg interface {
isFromBot_Msg()
}
type FromBot_Hello struct {
Hello *Hello `protobuf:"bytes,1,opt,name=hello,proto3,oneof"`
}
type FromBot_Ack struct {
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
}
func (*FromBot_Hello) isFromBot_Msg() {}
func (*FromBot_Ack) isFromBot_Msg() {}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
type ToBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
Command *Command `protobuf:"bytes,1,opt,name=command,proto3" json:"command,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ToBot) Reset() {
*x = ToBot{}
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ToBot) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ToBot) ProtoMessage() {}
func (x *ToBot) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
func (*ToBot) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
}
func (x *ToBot) GetCommand() *Command {
if x != nil {
return x.Command
}
return nil
}
// Hello registers the bot on connect. instance_id identifies the bot process for
// gateway-side logging and metrics; owns_updates reports whether this bot runs the
// exclusive getUpdates long-poll (exactly one bot must, else Telegram returns 409).
type Hello struct {
state protoimpl.MessageState `protogen:"open.v1"`
InstanceId string `protobuf:"bytes,1,opt,name=instance_id,json=instanceId,proto3" json:"instance_id,omitempty"`
OwnsUpdates bool `protobuf:"varint,2,opt,name=owns_updates,json=ownsUpdates,proto3" json:"owns_updates,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Hello) Reset() {
*x = Hello{}
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Hello) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Hello) ProtoMessage() {}
func (x *Hello) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
func (*Hello) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
}
func (x *Hello) GetInstanceId() string {
if x != nil {
return x.InstanceId
}
return ""
}
func (x *Hello) GetOwnsUpdates() bool {
if x != nil {
return x.OwnsUpdates
}
return false
}
// Command is one send instruction addressed by command_id, which the bot echoes in
// its Ack. Exactly one payload is set; the payloads reuse the connector request
// shapes from scrabble.telegram.v1.
type Command struct {
state protoimpl.MessageState `protogen:"open.v1"`
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
// Types that are valid to be assigned to Payload:
//
// *Command_Notify
// *Command_SendToUser
// *Command_SendToChannel
Payload isCommand_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Command) Reset() {
*x = Command{}
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Command) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Command) ProtoMessage() {}
func (x *Command) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
func (*Command) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
}
func (x *Command) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *Command) GetPayload() isCommand_Payload {
if x != nil {
return x.Payload
}
return nil
}
func (x *Command) GetNotify() *v1.NotifyRequest {
if x != nil {
if x, ok := x.Payload.(*Command_Notify); ok {
return x.Notify
}
}
return nil
}
func (x *Command) GetSendToUser() *v1.SendToUserRequest {
if x != nil {
if x, ok := x.Payload.(*Command_SendToUser); ok {
return x.SendToUser
}
}
return nil
}
func (x *Command) GetSendToChannel() *v1.SendToGameChannelRequest {
if x != nil {
if x, ok := x.Payload.(*Command_SendToChannel); ok {
return x.SendToChannel
}
}
return nil
}
type isCommand_Payload interface {
isCommand_Payload()
}
type Command_Notify struct {
Notify *v1.NotifyRequest `protobuf:"bytes,2,opt,name=notify,proto3,oneof"`
}
type Command_SendToUser struct {
SendToUser *v1.SendToUserRequest `protobuf:"bytes,3,opt,name=send_to_user,json=sendToUser,proto3,oneof"`
}
type Command_SendToChannel struct {
SendToChannel *v1.SendToGameChannelRequest `protobuf:"bytes,4,opt,name=send_to_channel,json=sendToChannel,proto3,oneof"`
}
func (*Command_Notify) isCommand_Payload() {}
func (*Command_SendToUser) isCommand_Payload() {}
func (*Command_SendToChannel) isCommand_Payload() {}
// Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered.
type Ack struct {
state protoimpl.MessageState `protogen:"open.v1"`
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"`
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Ack) Reset() {
*x = Ack{}
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Ack) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Ack) ProtoMessage() {}
func (x *Ack) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
func (*Ack) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
}
func (x *Ack) GetCommandId() string {
if x != nil {
return x.CommandId
}
return ""
}
func (x *Ack) GetDelivered() bool {
if x != nil {
return x.Delivered
}
return false
}
func (x *Ack) GetError() string {
if x != nil {
return x.Error
}
return ""
}
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" +
"\aFromBot\x122\n" +
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" +
"\x03msg\"?\n" +
"\x05ToBot\x126\n" +
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
"\x05Hello\x12\x1f\n" +
"\vinstance_id\x18\x01 \x01(\tR\n" +
"instanceId\x12!\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\x99\x02\n" +
"\aCommand\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" +
"\x06notify\x18\x02 \x01(\v2#.scrabble.telegram.v1.NotifyRequestH\x00R\x06notify\x12K\n" +
"\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" +
"sendToUser\x12X\n" +
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannelB\t\n" +
"\apayload\"X\n" +
"\x03Ack\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error2O\n" +
"\aBotLink\x12D\n" +
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01B)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
var (
file_botlink_v1_botlink_proto_rawDescOnce sync.Once
file_botlink_v1_botlink_proto_rawDescData []byte
)
func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
file_botlink_v1_botlink_proto_rawDescOnce.Do(func() {
file_botlink_v1_botlink_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)))
})
return file_botlink_v1_botlink_proto_rawDescData
}
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
(*Command)(nil), // 3: scrabble.botlink.v1.Command
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
(*v1.NotifyRequest)(nil), // 5: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 6: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 7: scrabble.telegram.v1.SendToGameChannelRequest
}
var file_botlink_v1_botlink_proto_depIdxs = []int32{
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
5, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
6, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
7, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
0, // 6: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
1, // 7: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // [7:8] is the sub-list for method output_type
6, // [6:7] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_botlink_v1_botlink_proto_init() }
func file_botlink_v1_botlink_proto_init() {
if File_botlink_v1_botlink_proto != nil {
return
}
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
(*FromBot_Hello)(nil),
(*FromBot_Ack)(nil),
}
file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{
(*Command_Notify)(nil),
(*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0,
NumMessages: 5,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_botlink_v1_botlink_proto_goTypes,
DependencyIndexes: file_botlink_v1_botlink_proto_depIdxs,
MessageInfos: file_botlink_v1_botlink_proto_msgTypes,
}.Build()
File_botlink_v1_botlink_proto = out.File
file_botlink_v1_botlink_proto_goTypes = nil
file_botlink_v1_botlink_proto_depIdxs = nil
}
+67
View File
@@ -0,0 +1,67 @@
syntax = "proto3";
// Package scrabble.botlink.v1 is the reverse control channel between a remote
// Telegram bot and the gateway. The bot dials the gateway and opens a single
// long-lived Link stream (mTLS); once the stream is open the gateway pushes send
// Commands down it and the bot returns one Ack per command. This keeps the bot
// egress (the Bot API token, getUpdates long-poll and sendMessage) off the main
// host with no inbound port on the bot. See docs/ARCHITECTURE.md.
package scrabble.botlink.v1;
option go_package = "scrabble/pkg/proto/botlink/v1;botlinkv1";
import "telegram/v1/telegram.proto";
// BotLink is the reverse (bot-dials-gateway) control channel. The bot is the gRPC
// client; once the stream is open the gateway (server) sends Commands at will and
// the bot returns one Ack per command. Delivery is best-effort, at-most-once: a
// command lost across a reconnect is not replayed.
service BotLink {
// Link opens the single bot <-> gateway stream. The first client message is
// Hello; thereafter the client sends one Ack per received Command.
rpc Link(stream FromBot) returns (stream ToBot);
}
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command.
message FromBot {
oneof msg {
Hello hello = 1;
Ack ack = 2;
}
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
message ToBot {
Command command = 1;
}
// Hello registers the bot on connect. instance_id identifies the bot process for
// gateway-side logging and metrics; owns_updates reports whether this bot runs the
// exclusive getUpdates long-poll (exactly one bot must, else Telegram returns 409).
message Hello {
string instance_id = 1;
bool owns_updates = 2;
}
// Command is one send instruction addressed by command_id, which the bot echoes in
// its Ack. Exactly one payload is set; the payloads reuse the connector request
// shapes from scrabble.telegram.v1.
message Command {
string command_id = 1;
oneof payload {
scrabble.telegram.v1.NotifyRequest notify = 2;
scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
}
}
// Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered.
message Ack {
string command_id = 1;
bool delivered = 2;
string error = 3;
}
+136
View File
@@ -0,0 +1,136 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.5.1
// - protoc (unknown)
// source: botlink/v1/botlink.proto
// Package scrabble.botlink.v1 is the reverse control channel between a remote
// Telegram bot and the gateway. The bot dials the gateway and opens a single
// long-lived Link stream (mTLS); once the stream is open the gateway pushes send
// Commands down it and the bot returns one Ack per command. This keeps the bot
// egress (the Bot API token, getUpdates long-poll and sendMessage) off the main
// host with no inbound port on the bot. See docs/ARCHITECTURE.md.
package botlinkv1
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.64.0 or later.
const _ = grpc.SupportPackageIsVersion9
const (
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
)
// BotLinkClient is the client API for BotLink service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// BotLink is the reverse (bot-dials-gateway) control channel. The bot is the gRPC
// client; once the stream is open the gateway (server) sends Commands at will and
// the bot returns one Ack per command. Delivery is best-effort, at-most-once: a
// command lost across a reconnect is not replayed.
type BotLinkClient interface {
// Link opens the single bot <-> gateway stream. The first client message is
// Hello; thereafter the client sends one Ack per received Command.
Link(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FromBot, ToBot], error)
}
type botLinkClient struct {
cc grpc.ClientConnInterface
}
func NewBotLinkClient(cc grpc.ClientConnInterface) BotLinkClient {
return &botLinkClient{cc}
}
func (c *botLinkClient) Link(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[FromBot, ToBot], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &BotLink_ServiceDesc.Streams[0], BotLink_Link_FullMethodName, cOpts...)
if err != nil {
return nil, err
}
x := &grpc.GenericClientStream[FromBot, ToBot]{ClientStream: stream}
return x, nil
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type BotLink_LinkClient = grpc.BidiStreamingClient[FromBot, ToBot]
// BotLinkServer is the server API for BotLink service.
// All implementations must embed UnimplementedBotLinkServer
// for forward compatibility.
//
// BotLink is the reverse (bot-dials-gateway) control channel. The bot is the gRPC
// client; once the stream is open the gateway (server) sends Commands at will and
// the bot returns one Ack per command. Delivery is best-effort, at-most-once: a
// command lost across a reconnect is not replayed.
type BotLinkServer interface {
// Link opens the single bot <-> gateway stream. The first client message is
// Hello; thereafter the client sends one Ack per received Command.
Link(grpc.BidiStreamingServer[FromBot, ToBot]) error
mustEmbedUnimplementedBotLinkServer()
}
// UnimplementedBotLinkServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedBotLinkServer struct{}
func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot]) error {
return status.Errorf(codes.Unimplemented, "method Link not implemented")
}
func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {}
func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
// UnsafeBotLinkServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to BotLinkServer will
// result in compilation errors.
type UnsafeBotLinkServer interface {
mustEmbedUnimplementedBotLinkServer()
}
func RegisterBotLinkServer(s grpc.ServiceRegistrar, srv BotLinkServer) {
// If the following call pancis, it indicates UnimplementedBotLinkServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&BotLink_ServiceDesc, srv)
}
func _BotLink_Link_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(BotLinkServer).Link(&grpc.GenericServerStream[FromBot, ToBot]{ServerStream: stream})
}
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type BotLink_LinkServer = grpc.BidiStreamingServer[FromBot, ToBot]
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var BotLink_ServiceDesc = grpc.ServiceDesc{
ServiceName: "scrabble.botlink.v1.BotLink",
HandlerType: (*BotLinkServer)(nil),
Methods: []grpc.MethodDesc{},
Streams: []grpc.StreamDesc{
{
StreamName: "Link",
Handler: _BotLink_Link_Handler,
ServerStreams: true,
ClientStreams: true,
},
},
Metadata: "botlink/v1/botlink.proto",
}
+18 -8
View File
@@ -1,11 +1,14 @@
# Telegram connector image. # Telegram platform images: the home validator (HMAC of Mini App / Login Widget
# data, no Bot API, no VPN) and the remote bot (Bot API long-poll + sendMessage,
# dialing the gateway over the reverse mTLS bot-link). One build stage compiles both
# binaries; the `validator` and `bot` targets each ship one on distroless nonroot.
# #
# The connector imports only the shared scrabble/pkg module, so the build drops the # The platform imports only the shared scrabble/pkg module, so the build drops the
# other workspace modules (backend, gateway, loadtest), the loadtest-only # other workspace modules (backend, gateway, loadtest), the loadtest-only
# scrabble/gateway replace and the scrabble-solver replace from a copy of go.work: it # scrabble/gateway replace and the scrabble-solver replace from a copy of go.work: it
# needs neither their sources nor the solver sibling checkout. # needs neither their sources nor the solver sibling checkout.
# Build from the repository ROOT so go.work, pkg/ and platform/telegram/ are all in # Build from the repository ROOT so go.work, pkg/ and platform/telegram/ are all in
# the context (see deploy/docker-compose.yml, which sets context: ../../..). # the context (see deploy/docker-compose.yml, which sets context: ..).
FROM golang:1.26.3-alpine AS build FROM golang:1.26.3-alpine AS build
WORKDIR /src WORKDIR /src
@@ -13,11 +16,18 @@ COPY go.work go.work.sum ./
COPY pkg ./pkg COPY pkg ./pkg
COPY platform/telegram ./platform/telegram COPY platform/telegram ./platform/telegram
# Reduce the workspace to what the connector needs: only pkg + platform/telegram. # Reduce the workspace to what the platform needs: only pkg + platform/telegram.
RUN go work edit -dropuse=./backend -dropuse=./gateway -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0 -dropreplace=scrabble-solver RUN go work edit -dropuse=./backend -dropuse=./gateway -dropuse=./loadtest -dropreplace=scrabble/gateway@v0.0.0 -dropreplace=scrabble-solver
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/telegram ./platform/telegram/cmd/telegram RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/validator ./platform/telegram/cmd/validator
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/bot ./platform/telegram/cmd/bot
FROM gcr.io/distroless/static-debian12:nonroot # --- validator (home) --------------------------------------------------------
COPY --from=build /out/telegram /usr/local/bin/telegram FROM gcr.io/distroless/static-debian12:nonroot AS validator
ENTRYPOINT ["/usr/local/bin/telegram"] COPY --from=build /out/validator /usr/local/bin/validator
ENTRYPOINT ["/usr/local/bin/validator"]
# --- bot (remote) ------------------------------------------------------------
FROM gcr.io/distroless/static-debian12:nonroot AS bot
COPY --from=build /out/bot /usr/local/bin/bot
ENTRYPOINT ["/usr/local/bin/bot"]
+91 -65
View File
@@ -1,59 +1,68 @@
# scrabble/platform/telegram — Telegram connector # scrabble/platform/telegram — Telegram validator + bot
The Telegram platform side-service. It is the **only** component that holds the bot The Telegram platform side-service, split into **two binaries** that share the bot
token: it runs a Bot API long-poll loop (Mini App launch + deep-links) and serves token, so Telegram egress lives off the main host while game login does not depend on
the connector gRPC API that the gateway and backend call over the trusted internal it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/§13.
network. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
## Single bot - **`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).
The connector hosts **one unified bot** — one token plus one optional game channel, Both hold the same token. One bot owns the exclusive `getUpdates` long-poll
configured by `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GAME_CHANNEL_ID`. `ValidateInitData` (`TELEGRAM_OWNS_UPDATES`, exactly one per token, else Telegram returns 409); the
validates `initData` against that single token (it does not validate ⇒ invalid) and design leaves seams (a gateway bot registry, the `owns_updates` flag, per-command ids)
returns only the Telegram user identity — there is no per-bot service language and no for running several bots later, not built yet.
supported-languages set. Every message the bot sends is rendered in the **recipient's
interface language**: the user-facing `Notify` renders in the request's `language` (the
recipient's interface language); the admin `SendToUser` / `SendToGameChannel` render in
an **operator-chosen** `language`. No call routes between bots — there is only one.
## Responsibilities ## Validator
- **Mini App auth.** `ValidateInitData` verifies Telegram Web App `initData` (HMAC `ValidateInitData` validates `initData` against the token and returns only the
under the bot token) and returns the user identity. The gateway calls it during Telegram user identity (no per-bot service language, no supported-languages set);
the `auth.telegram` edge operation, then provisions the session through the `ValidateLoginWidget` verifies Telegram **Login Widget** web sign-in data — HMAC under
backend internal API — so the bot token never leaves this process. `SHA-256(bot_token)`, distinct from initData (`internal/loginwidget`) — for attaching a
- **Out-of-app push.** `Notify` renders a backend push event (your_turn, nudge, Telegram identity to an account from a browser. Both map a rejection to gRPC
match_found, and the invitation / friend_request notify sub-kinds) into a `InvalidArgument`.
localized message with a Mini App launch button and sends it through the **single
bot**, rendered in the request's `language` (the recipient's interface language). ## Bot
The gateway calls it **only** for a recipient with no live in-app stream and the
`notifications_in_app_only` flag off, so the platform push never duplicates in-app - **Send commands.** The gateway pushes `Notify` (out-of-app push: your_turn,
delivery. 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 - **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 / launch button; a deep-link payload routes the launch to a game / invitation / friend
friend code. code. This is **self-contained** — the bot never calls back into the game, so `/start`
- **Admin messaging.** `SendToUser` and `SendToGameChannel` send onboarding works even when the game is down.
arbitrary text to one user or a game channel through the single bot, rendered in the - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
`language` the operator chooses in the admin console. default 25) to respect the Bot API flood limits.
The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a The send commands address a recipient by the identity `external_id` (as in the backend
recipient by the identity `external_id` (as in the backend `identities` table), so a `identities` table), so a future VK / MAX bot reuses them; only the validator's initData
future VK / MAX connector can implement the same service; only `ValidateInitData` is parsing is Telegram-specific.
Telegram-specific.
## gRPC API ## gRPC contracts
`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, - `pkg/proto/telegram/v1`, service `Telegram` — served by the **validator**
`ValidateLoginWidget`, `Notify`, `SendToUser`, `SendToGameChannel`. Generated Go is (`ValidateInitData`, `ValidateLoginWidget`). Its `Notify` / `SendToUser` /
committed under `pkg`. `ValidateLoginWidget` verifies Telegram **Login `SendToGameChannel` request shapes are reused as bot-link command payloads; the
Widget** web sign-in data — HMAC under `SHA-256(bot_token)`, distinct from initData gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
(`internal/loginwidget`) — for attaching a Telegram identity to an account from a relay.
browser. - `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`.
## Deep-link scheme ## Deep-link scheme
Shared verbatim with the UI (`ui/src/lib/deeplink.ts`). A Mini App start parameter Shared verbatim with the UI (`ui/src/lib/deeplink.ts`). A Mini App start parameter is a
is a one-character kind prefix plus a value: one-character kind prefix plus a value:
| Parameter | Destination | | Parameter | Destination |
| --- | --- | | --- | --- |
@@ -67,40 +76,57 @@ The bot turns a `/start <payload>` or a notification target into a launch-button
## Configuration ## Configuration
Shared:
| Env var | Default | Meaning | | Env var | Default | Meaning |
| --- | --- | --- | | --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | — (required) | The bot's API token + initData HMAC secret | | `TELEGRAM_BOT_TOKEN` | — (required) | the bot's API token + initData HMAC secret |
| `TELEGRAM_GAME_CHANNEL_ID` | — | The bot's game channel chat id for `SendToGameChannel` |
| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) |
| `TELEGRAM_GRPC_ADDR` | `:9091` | connector gRPC listen address |
| `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`) |
| `TELEGRAM_LOG_LEVEL` | `info` | zap log level | | `TELEGRAM_LOG_LEVEL` | `info` | zap log level |
| `TELEGRAM_SERVICE_NAME` | `scrabble-telegram` | OpenTelemetry `service.name` | | `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_TRACES_EXPORTER` | `none` | `none`, `stdout` or `otlp` (gRPC; endpoint from `OTEL_EXPORTER_OTLP_*`) |
| `TELEGRAM_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` | | `TELEGRAM_OTEL_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
The **test environment** is selected by `TELEGRAM_TEST_ENV=true`, which suffixes the Validator (`cmd/validator`):
Bot API path with `/test` (the connector appends it to the token, since the client
builds `<host>/bot<token>/<method>`). | 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 ## Build, test, run
```sh ```sh
go build ./platform/telegram/... go build ./platform/telegram/...
go test ./platform/telegram/... # unit tests use an httptest fake Bot API go test ./platform/telegram/... # unit tests use an httptest fake Bot API + an in-process bot-link
go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN 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 ## Deploy
The connector runs in its **own container** with the bot token held only there and `platform/telegram/Dockerfile` builds both binaries on distroless nonroot with two
all egress through a VPN sidecar (`deploy/docker-compose.yml`, mirroring targets, `validator` and `bot`. In the test contour (`deploy/docker-compose.yml`) the
`../../15-puzzle`). It needs no public ingress — it long-polls Telegram and answers **validator** runs on the internal network (no VPN); the **bot** keeps a VPN sidecar
internal gRPC at `telegram:9091` on the shared `edge` network. The host reverse proxy for Telegram egress and dials the gateway bot-link by its internal name. The bot-link
routes public traffic to the **gateway** port only, which serves the Mini App under mTLS material is generated by `deploy/gen-certs.sh`. In prod the bot runs on a separate
`/telegram/`. The full multi-service deploy is `deploy/docker-compose.yml`. 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 A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS Mini
Mini App origin, and the connector container; the unit tests cover the wire format, App origin, and the bot container; the unit tests cover the wire format, templates,
templates, deep-links and the gRPC handlers without a live bot. deep-links, the validator handlers and the bot-link client/executor without a live bot.
+123
View File
@@ -0,0 +1,123 @@
// Command bot is the remote-side Telegram bot. It runs the Bot API long-poll
// (Mini App launch + /start deep-links) and dials the gateway over the reverse mTLS
// bot-link to receive and execute send commands (out-of-app push and admin sends).
// It holds the bot token and is the only component that reaches the Telegram Bot
// API, so it runs on a host with native Telegram access (no VPN) and needs no
// inbound port. See platform/telegram/README.md.
package main
import (
"context"
"log"
"os/signal"
"sync"
"syscall"
"time"
"go.uber.org/zap"
"google.golang.org/grpc/credentials"
"scrabble/pkg/mtls"
pkgtel "scrabble/pkg/telemetry"
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
func main() {
cfg, err := config.LoadBot()
if err != nil {
log.Fatalf("bot: load config: %v", err)
}
logger, err := newLogger(cfg.LogLevel)
if err != nil {
log.Fatalf("bot: build logger: %v", err)
}
defer func() { _ = logger.Sync() }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, cfg, logger); err != nil {
logger.Fatal("bot: terminated", zap.Error(err))
}
}
// run wires the bot long-poll and the bot-link client and runs both until the
// context is cancelled.
func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
tel, err := pkgtel.New(ctx, cfg.Telemetry)
if err != nil {
return err
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
defer cancel()
if err := tel.Shutdown(shutdownCtx); err != nil {
logger.Warn("telemetry shutdown", zap.Error(err))
}
}()
if err := tel.StartRuntimeMetrics(); err != nil {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond,
}, logger)
if err != nil {
return err
}
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
if err != nil {
return err
}
exec := botlink.NewExecutor(b, cfg.GameChannelID, logger)
client := botlink.NewClient(botlink.ClientConfig{
GatewayAddr: cfg.BotLink.GatewayAddr,
InstanceID: cfg.BotLink.InstanceID,
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
}, exec, logger)
logger.Info("telegram bot starting",
zap.String("gateway", cfg.BotLink.GatewayAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
zap.Bool("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv))
var wg sync.WaitGroup
// The long-poll holds the exclusive getUpdates lease (one bot per token); a bot
// that does not own it only delivers sends over the bot-link.
if cfg.OwnsUpdates {
wg.Go(func() { b.Run(ctx) })
}
wg.Go(func() {
if err := client.Run(ctx); err != nil && ctx.Err() == nil {
logger.Error("bot-link client stopped", zap.Error(err))
}
})
<-ctx.Done()
wg.Wait()
return nil
}
// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
var lvl zap.AtomicLevel
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return nil, err
}
cfg := zap.NewProductionConfig()
cfg.Level = lvl
return cfg.Build()
}
@@ -1,8 +1,9 @@
// Command telegram is the Telegram platform side-service (the "connector"). It is // Command validator is the home-side Telegram validator. It serves the validation
// the only component holding the bot token: it runs the Bot API long-poll loop // gRPC API (pkg/proto/telegram/v1 ValidateInitData/ValidateLoginWidget) the gateway
// (Mini App launch + /start deep-links) and serves the connector gRPC API // calls during Telegram auth. It holds the bot token only as the HMAC secret and
// (pkg/proto/telegram/v1) that the gateway and backend call over the trusted // never reaches the Telegram Bot API, so it runs on the main host with no VPN and
// internal network. See platform/telegram/README.md. // no Telegram egress, and game login stays independent of the remote bot. See
// platform/telegram/README.md.
package main package main
import ( import (
@@ -20,24 +21,23 @@ import (
telegramv1 "scrabble/pkg/proto/telegram/v1" telegramv1 "scrabble/pkg/proto/telegram/v1"
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/config" "scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/connector"
"scrabble/platform/telegram/internal/initdata" "scrabble/platform/telegram/internal/initdata"
"scrabble/platform/telegram/internal/loginwidget" "scrabble/platform/telegram/internal/loginwidget"
"scrabble/platform/telegram/internal/validator"
) )
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit. // telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second const telemetryShutdownTimeout = 5 * time.Second
func main() { func main() {
cfg, err := config.Load() cfg, err := config.LoadValidator()
if err != nil { if err != nil {
log.Fatalf("telegram: load config: %v", err) log.Fatalf("validator: load config: %v", err)
} }
logger, err := newLogger(cfg.LogLevel) logger, err := newLogger(cfg.LogLevel)
if err != nil { if err != nil {
log.Fatalf("telegram: build logger: %v", err) log.Fatalf("validator: build logger: %v", err)
} }
defer func() { _ = logger.Sync() }() defer func() { _ = logger.Sync() }()
@@ -45,13 +45,12 @@ func main() {
defer stop() defer stop()
if err := run(ctx, cfg, logger); err != nil { if err := run(ctx, cfg, logger); err != nil {
logger.Fatal("telegram: terminated", zap.Error(err)) logger.Fatal("validator: terminated", zap.Error(err))
} }
} }
// run wires the bot and the gRPC server and serves both until the context is // run wires the validator gRPC server and serves it until the context is cancelled.
// cancelled. func run(ctx context.Context, cfg config.ValidatorConfig, logger *zap.Logger) error {
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
tel, err := pkgtel.New(ctx, cfg.Telemetry) tel, err := pkgtel.New(ctx, cfg.Telemetry)
if err != nil { if err != nil {
return err return err
@@ -67,23 +66,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Warn("telemetry: start runtime metrics", zap.Error(err)) logger.Warn("telemetry: start runtime metrics", zap.Error(err))
} }
// The single bot validates launch data and delivers push/admin messages. srv := validator.NewServer(initdata.NewHMACValidator(cfg.Token), loginwidget.NewHMACValidator(cfg.Token))
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
}, logger)
if err != nil {
return err
}
srv := connector.NewServer(connector.BotRuntime{
Sender: b,
ChannelID: cfg.GameChannelID,
InitValidator: initdata.NewHMACValidator(cfg.Token),
WidgetValidator: loginwidget.NewHMACValidator(cfg.Token),
}, logger)
grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler())) grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
telegramv1.RegisterTelegramServer(grpcServer, srv) telegramv1.RegisterTelegramServer(grpcServer, srv)
@@ -92,18 +75,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err return err
} }
// The long-poll loop and the gRPC server run together; cancelling the context
// stops the bot loop and gracefully drains the gRPC server.
go b.Run(ctx)
go func() { go func() {
<-ctx.Done() <-ctx.Done()
grpcServer.GracefulStop() grpcServer.GracefulStop()
}() }()
logger.Info("telegram connector starting", logger.Info("telegram validator starting", zap.String("grpc_addr", cfg.GRPCAddr))
zap.String("grpc_addr", cfg.GRPCAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
zap.Bool("test_env", cfg.TestEnv))
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
return err return err
} }
+1
View File
@@ -7,6 +7,7 @@ require (
github.com/google/flatbuffers v23.5.26+incompatible github.com/google/flatbuffers v23.5.26+incompatible
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
go.uber.org/zap v1.27.1 go.uber.org/zap v1.27.1
golang.org/x/time v0.15.0
google.golang.org/grpc v1.80.0 google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
scrabble/pkg v0.0.0 scrabble/pkg v0.0.0
+26
View File
@@ -13,6 +13,7 @@ import (
tgbot "github.com/go-telegram/bot" tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"go.uber.org/zap" "go.uber.org/zap"
"golang.org/x/time/rate"
) )
// Config configures the bot wrapper. // Config configures the bot wrapper.
@@ -25,6 +26,10 @@ type Config struct {
TestEnv bool TestEnv bool
// MiniAppURL is the base URL of the Mini App launch button. // MiniAppURL is the base URL of the Mini App launch button.
MiniAppURL string MiniAppURL string
// SendRatePerSecond caps outbound sends (Notify and SendText) to respect the
// Telegram Bot API flood limits; 0 disables the limiter. The burst equals the
// per-second rate.
SendRatePerSecond int
} }
// Bot wraps a Telegram Bot API client and the Mini App launch URL. // Bot wraps a Telegram Bot API client and the Mini App launch URL.
@@ -32,6 +37,9 @@ type Bot struct {
api *tgbot.Bot api *tgbot.Bot
miniAppURL string miniAppURL string
log *zap.Logger log *zap.Logger
// limiter throttles outbound sends to stay under the Bot API flood limits; nil
// disables throttling.
limiter *rate.Limiter
} }
// New builds the bot wrapper, registering the /start handler and a default handler // New builds the bot wrapper, registering the /start handler and a default handler
@@ -42,6 +50,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
log = zap.NewNop() log = zap.NewNop()
} }
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log} t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
}
opts := []tgbot.Option{ opts := []tgbot.Option{
tgbot.WithDefaultHandler(t.handleStart), tgbot.WithDefaultHandler(t.handleStart),
@@ -85,6 +96,9 @@ func (t *Bot) Run(ctx context.Context) {
// Notify sends a notification message with a Mini App launch button that opens the // Notify sends a notification message with a Mini App launch button that opens the
// app at startParam (empty opens the lobby). // app at startParam (empty opens the lobby).
func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error { func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error {
if err := t.throttle(ctx); err != nil {
return err
}
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ _, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: chatID, ChatID: chatID,
Text: text, Text: text,
@@ -95,10 +109,22 @@ func (t *Bot) Notify(ctx context.Context, chatID int64, text, buttonText, startP
// SendText sends a plain text message with no markup (admin use). // SendText sends a plain text message with no markup (admin use).
func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error { func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error {
if err := t.throttle(ctx); err != nil {
return err
}
_, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}) _, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text})
return err return err
} }
// throttle blocks until the rate limiter admits one send, or ctx is cancelled. It
// is a no-op when no limiter is configured.
func (t *Bot) throttle(ctx context.Context) error {
if t.limiter == nil {
return nil
}
return t.limiter.Wait(ctx)
}
// handleStart replies to /start (with an optional deep-link payload) and to any // handleStart replies to /start (with an optional deep-link payload) and to any
// other message with a Mini App launch button. // other message with a Mini App launch button.
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) { func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
@@ -0,0 +1,131 @@
package botlink
import (
"context"
"time"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"go.uber.org/zap"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/keepalive"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
const (
// clientKeepaliveTime is how often the bot pings the gateway to hold the WAN
// connection open (kept above the gateway's MinTime to avoid an enforcement ban).
clientKeepaliveTime = 30 * time.Second
// clientKeepaliveTimeout bounds the wait for a keepalive ping reply.
clientKeepaliveTimeout = 10 * time.Second
)
// ClientConfig configures the bot's dial side of the reverse bot-link.
type ClientConfig struct {
// GatewayAddr is the gateway bot-link endpoint to dial.
GatewayAddr string
// InstanceID identifies this bot to the gateway.
InstanceID string
// OwnsUpdates reports whether this bot runs the exclusive getUpdates long-poll.
OwnsUpdates bool
// Creds is the transport credentials for the dial (mutual TLS in production,
// built from pkg/mtls).
Creds credentials.TransportCredentials
// ReconnectDelay is the pause before re-dialing after the stream ends.
ReconnectDelay time.Duration
}
// Client maintains the long-lived bot-link to the gateway, executing the commands
// it receives and re-dialing after any break.
type Client struct {
cfg ClientConfig
exec *Executor
log *zap.Logger
}
// NewClient builds the bot-link client over the executor.
func NewClient(cfg ClientConfig, exec *Executor, log *zap.Logger) *Client {
if log == nil {
log = zap.NewNop()
}
return &Client{cfg: cfg, exec: exec, log: log}
}
// Run dials the gateway and keeps the bot-link open, re-dialing after each break,
// until ctx is cancelled. The gRPC connection auto-reconnects the transport; this
// loop re-opens the Link stream on top of it.
func (c *Client) Run(ctx context.Context) error {
conn, err := grpc.NewClient(c.cfg.GatewayAddr,
grpc.WithTransportCredentials(c.cfg.Creds),
grpc.WithStatsHandler(otelgrpc.NewClientHandler()),
grpc.WithKeepaliveParams(keepalive.ClientParameters{
Time: clientKeepaliveTime,
Timeout: clientKeepaliveTimeout,
PermitWithoutStream: true,
}),
)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
client := botlinkv1.NewBotLinkClient(conn)
for ctx.Err() == nil {
if err := c.serve(ctx, client); err != nil && ctx.Err() == nil {
c.log.Warn("bot-link stream ended", zap.Error(err))
}
if !sleep(ctx, c.cfg.ReconnectDelay) {
break
}
}
return ctx.Err()
}
// serve opens one Link stream, registers with a Hello, then executes commands and
// replies with an Ack each until the stream ends.
func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) error {
stream, err := client.Link(ctx)
if err != nil {
return err
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Hello{Hello: &botlinkv1.Hello{
InstanceId: c.cfg.InstanceID,
OwnsUpdates: c.cfg.OwnsUpdates,
}}}); err != nil {
return err
}
c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates))
for {
msg, err := stream.Recv()
if err != nil {
return err
}
cmd := msg.GetCommand()
if cmd == nil {
continue
}
delivered, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
if herr != nil {
ack.Error = herr.Error()
}
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
return err
}
}
}
// sleep waits for d or until ctx is cancelled, reporting whether it waited the full
// duration.
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
@@ -0,0 +1,87 @@
package botlink
import (
"fmt"
"net"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// testLinkServer registers one bot, pushes a single Notify command, and records the
// Ack the client returns.
type testLinkServer struct {
botlinkv1.UnimplementedBotLinkServer
acks chan *botlinkv1.Ack
}
func (s *testLinkServer) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.ToBot]) error {
hello, err := stream.Recv()
if err != nil {
return err
}
if hello.GetHello() == nil {
return fmt.Errorf("first message was not Hello")
}
if err := stream.Send(&botlinkv1.ToBot{Command: &botlinkv1.Command{
CommandId: "c1",
Payload: &botlinkv1.Command_Notify{Notify: &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn",
Payload: yourTurnPayload("7c9e6679-7425-40de-944b-e07fc1f90ae7"), Language: "en",
}},
}}); err != nil {
return err
}
for {
msg, err := stream.Recv()
if err != nil {
return err
}
if ack := msg.GetAck(); ack != nil {
s.acks <- ack
return nil
}
}
}
// TestClientServesCommands verifies the bot client dials, registers, executes a
// pushed command through the executor, and acks it.
func TestClientServesCommands(t *testing.T) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
ts := &testLinkServer{acks: make(chan *botlinkv1.Ack, 1)}
srv := grpc.NewServer()
botlinkv1.RegisterBotLinkServer(srv, ts)
go func() { _ = srv.Serve(lis) }()
t.Cleanup(srv.Stop)
sender := &fakeSender{}
client := NewClient(ClientConfig{
GatewayAddr: lis.Addr().String(),
InstanceID: "test",
Creds: insecure.NewCredentials(),
ReconnectDelay: 50 * time.Millisecond,
}, NewExecutor(sender, 0, nil), nil)
go func() { _ = client.Run(t.Context()) }()
select {
case ack := <-ts.acks:
if ack.GetCommandId() != "c1" || !ack.GetDelivered() {
t.Errorf("ack = %+v, want command_id c1 delivered=true", ack)
}
case <-time.After(2 * time.Second):
t.Fatal("server received no ack")
}
// The channel receive above happens-after the client wrote the sender call.
if len(sender.notify) != 1 || sender.notify[0].chatID != 12345 {
t.Errorf("sender notify calls = %+v, want one call to chat 12345", sender.notify)
}
}
@@ -0,0 +1,110 @@
// Package botlink is the bot side of the reverse Telegram channel: the bot dials
// the gateway over mTLS (pkg/proto/botlink/v1), opens one long-lived Link stream,
// and executes the send Commands the gateway pushes, replying with an Ack per
// command. It keeps the Bot API token and egress on the remote bot host with no
// inbound port. See docs/ARCHITECTURE.md.
package botlink
import (
"context"
"fmt"
"strconv"
"go.uber.org/zap"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
"scrabble/platform/telegram/internal/render"
)
// Sender delivers Telegram messages to a chat. *bot.Bot implements it.
type Sender interface {
// Notify sends a notification with a Mini App launch button to chatID.
Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error
// SendText sends a plain text message to chatID.
SendText(ctx context.Context, chatID int64, text string) error
}
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
// the former connector semantics (false when the kind is not rendered out-of-app,
// the user never started the bot, or no channel is configured); a returned error is
// an unexpected or malformed failure carried back in the Ack error field, distinct
// from a clean not-delivered.
type Executor struct {
sender Sender
channelID int64
log *zap.Logger
}
// NewExecutor builds the executor over a bot sender and the optional game channel.
func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
if log == nil {
log = zap.NewNop()
}
return &Executor{sender: sender, channelID: channelID, log: log}
}
// Handle dispatches one command to the matching Bot API send.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
switch p := cmd.GetPayload().(type) {
case *botlinkv1.Command_Notify:
return e.notify(ctx, p.Notify)
case *botlinkv1.Command_SendToUser:
return e.sendToUser(ctx, p.SendToUser)
case *botlinkv1.Command_SendToChannel:
return e.sendToChannel(ctx, p.SendToChannel)
default:
return false, fmt.Errorf("botlink: empty command")
}
}
// notify renders an out-of-app push and sends it with a Mini App launch button.
func (e *Executor) notify(ctx context.Context, req *telegramv1.NotifyRequest) (bool, error) {
msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage())
if !ok {
return false, nil
}
chat, err := parseChatID(req.GetExternalId())
if err != nil {
return false, err
}
if err := e.sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil {
e.log.Warn("notify delivery failed", zap.String("kind", req.GetKind()), zap.Error(err))
return false, nil
}
return true, nil
}
// sendToUser sends an admin text message to one user.
func (e *Executor) sendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (bool, error) {
chat, err := parseChatID(req.GetExternalId())
if err != nil {
return false, err
}
if err := e.sender.SendText(ctx, chat, req.GetText()); err != nil {
e.log.Warn("send to user failed", zap.Error(err))
return false, nil
}
return true, nil
}
// sendToChannel posts an admin text message to the bot's game channel.
func (e *Executor) sendToChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (bool, error) {
if e.channelID == 0 {
return false, fmt.Errorf("botlink: game channel is not configured")
}
if err := e.sender.SendText(ctx, e.channelID, req.GetText()); err != nil {
e.log.Warn("send to channel failed", zap.Error(err))
return false, nil
}
return true, nil
}
// parseChatID converts a Telegram identity external_id into a numeric chat id.
func parseChatID(externalID string) (int64, error) {
id, err := strconv.ParseInt(externalID, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid external_id %q", externalID)
}
return id, nil
}
@@ -0,0 +1,129 @@
package botlink
import (
"context"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"scrabble/pkg/fbs/scrabblefb"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
telegramv1 "scrabble/pkg/proto/telegram/v1"
)
// fakeSender records the delivery calls the executor makes.
type fakeSender struct {
notify []notifyCall
text []textCall
err error
}
type notifyCall struct {
chatID int64
text, buttonText, startParam string
}
type textCall struct {
chatID int64
text string
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
return f.err
}
func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) error {
f.text = append(f.text, textCall{chatID, text})
return f.err
}
func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID)
scrabblefb.YourTurnEventStart(b)
scrabblefb.YourTurnEventAddGameId(b, gid)
b.Finish(scrabblefb.YourTurnEventEnd(b))
return b.FinishedBytes()
}
func notifyCmd(externalID, kind string, payload []byte, language string) *botlinkv1.Command {
return &botlinkv1.Command{Payload: &botlinkv1.Command_Notify{Notify: &telegramv1.NotifyRequest{
ExternalId: externalID, Kind: kind, Payload: payload, Language: language,
}}}
}
func TestExecutorNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered {
t.Fatal("expected delivered=true")
}
if len(sender.notify) != 1 {
t.Fatalf("notify calls = %d, want 1", len(sender.notify))
}
if got := sender.notify[0]; got.chatID != 12345 || got.startParam != "g"+gameID {
t.Errorf("notify call = %+v, want chatID 12345 startParam g%s", got, gameID)
}
}
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
if err != nil {
t.Fatalf("handle: %v", err)
}
if delivered {
t.Error("expected delivered=false for an unrendered kind")
}
if len(sender.notify) != 0 {
t.Errorf("sender called %d times, want 0", len(sender.notify))
}
}
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
func TestExecutorSendToUser(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
delivered, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || len(sender.text) != 1 || sender.text[0].chatID != 999 || sender.text[0].text != "hi" {
t.Errorf("send to user = %v / calls %+v", delivered, sender.text)
}
}
func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
t.Run("unconfigured", func(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), channelCmd); err == nil {
t.Error("expected an error when no channel is configured")
}
})
t.Run("configured", func(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 555, nil)
delivered, err := exec.Handle(context.Background(), channelCmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || len(sender.text) != 1 || sender.text[0].chatID != 555 {
t.Errorf("send to channel: %+v", sender.text)
}
})
}
+195 -49
View File
@@ -1,4 +1,8 @@
// Package config loads the Telegram connector's environment configuration. // Package config loads the environment configuration for the two Telegram
// platform binaries: the home validator (HMAC of Mini App / Login Widget data, no
// Telegram egress) and the remote bot (Bot API long-poll + sendMessage, dialing
// the gateway over the reverse mTLS bot-link). The bot token lives in both
// processes — the validator needs it only as the HMAC secret (ARCHITECTURE.md §12).
package config package config
import ( import (
@@ -6,73 +10,173 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
// Config is the Telegram connector's runtime configuration, read from the // ValidatorConfig is the home validator's runtime configuration.
// environment. The bot token lives only in this process (ARCHITECTURE.md §12). type ValidatorConfig struct {
type Config struct { // Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). The
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). It both // validator uses it only as the HMAC secret for Mini App initData and Login
// authenticates the Bot API client and is the HMAC secret for Mini App initData // Widget validation; it never calls the Bot API.
// and Login Widget validation.
Token string Token string
// GameChannelID is the chat id of the bot's game channel for SendToGameChannel // GRPCAddr is the listen address of the validator gRPC server the gateway calls
// (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts). // (TELEGRAM_VALIDATOR_GRPC_ADDR, default :9091).
GameChannelID int64
// GRPCAddr is the listen address of the connector gRPC server that gateway and
// backend call (TELEGRAM_GRPC_ADDR, default :9091).
GRPCAddr string GRPCAddr string
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it
// is the base of every launch button, to which a deep-link adds a startapp
// query parameter (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string
// APIBaseURL overrides the Bot API host (TELEGRAM_API_BASE_URL, optional;
// default https://api.telegram.org) — used for a local mock or a self-hosted
// Bot API server.
APIBaseURL string
// TestEnv routes the Bot API client to Telegram's test environment
// (.../bot<token>/test/METHOD) (TELEGRAM_TEST_ENV=true, default false).
TestEnv bool
// LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info). // LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info).
LogLevel string LogLevel string
// Telemetry configures the OpenTelemetry providers (shared bootstrap). // Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config Telemetry pkgtel.Config
} }
// Load reads the connector configuration from the environment, applying defaults // BotConfig is the remote bot's runtime configuration.
// and validating the required fields. type BotConfig struct {
func Load() (Config, error) { // Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required).
cfg := Config{ Token string
Token: os.Getenv("TELEGRAM_BOT_TOKEN"), // GameChannelID is the chat id of the bot's game channel for the admin channel
GRPCAddr: envOr("TELEGRAM_GRPC_ADDR", ":9091"), // post (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"), GameChannelID int64
APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"), // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true", // the base of every launch button (TELEGRAM_MINIAPP_URL, required).
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), MiniAppURL string
// APIBaseURL overrides the Bot API host (TELEGRAM_API_BASE_URL, optional;
// default https://api.telegram.org).
APIBaseURL string
// TestEnv routes the Bot API client to Telegram's test environment
// (TELEGRAM_TEST_ENV=true, default false).
TestEnv bool
// OwnsUpdates reports whether this bot runs the exclusive getUpdates long-poll
// (TELEGRAM_OWNS_UPDATES, default true). Exactly one bot per token must own it.
OwnsUpdates bool
// SendRatePerSecond caps outbound Bot API sends to respect Telegram flood limits
// (TELEGRAM_SEND_RATE_PER_SECOND, default 25; 0 disables the limiter).
SendRatePerSecond int
// BotLink configures the reverse mTLS channel the bot dials.
BotLink BotLinkClientConfig
// LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info).
LogLevel string
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
// BotLinkClientConfig is the bot's dial side of the reverse bot-link.
type BotLinkClientConfig struct {
// GatewayAddr is the gateway bot-link endpoint to dial (TELEGRAM_GATEWAY_ADDR,
// required), e.g. "gateway.example.com:9443".
GatewayAddr string
// ServerName is the gateway certificate's expected SNI / CN
// (TELEGRAM_BOTLINK_SERVER_NAME, required).
ServerName string
// InstanceID identifies this bot to the gateway (TELEGRAM_INSTANCE_ID, default
// the hostname).
InstanceID string
// CertFile, KeyFile and CAFile are the bot client certificate, its key and the
// CA bundle that signs the gateway server certificate (required).
CertFile string
KeyFile string
CAFile string
// ReconnectDelay is the pause before re-dialing after the stream ends
// (TELEGRAM_BOTLINK_RECONNECT_DELAY, default 2s).
ReconnectDelay time.Duration
}
const (
defaultValidatorGRPCAddr = ":9091"
defaultBotReconnectDelay = 2 * time.Second
defaultSendRatePerSecond = 25
)
// LoadValidator reads the validator configuration from the environment.
func LoadValidator() (ValidatorConfig, error) {
cfg := ValidatorConfig{
Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
GRPCAddr: envOr("TELEGRAM_VALIDATOR_GRPC_ADDR", defaultValidatorGRPCAddr),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
} }
if v := strings.TrimSpace(os.Getenv("TELEGRAM_GAME_CHANNEL_ID")); v != "" { tel, err := loadTelemetry("scrabble-telegram-validator")
id, err := strconv.ParseInt(v, 10, 64) if err != nil {
if err != nil { return ValidatorConfig{}, err
return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID %q: %w", v, err)
}
cfg.GameChannelID = id
} }
tel := pkgtel.DefaultConfig("scrabble-telegram") cfg.Telemetry = tel
if cfg.Token == "" {
return ValidatorConfig{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
}
return cfg, nil
}
// LoadBot reads the bot configuration from the environment.
func LoadBot() (BotConfig, error) {
cfg := BotConfig{
Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"),
APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"),
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
OwnsUpdates: os.Getenv("TELEGRAM_OWNS_UPDATES") != "false",
SendRatePerSecond: defaultSendRatePerSecond,
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
ServerName: os.Getenv("TELEGRAM_BOTLINK_SERVER_NAME"),
InstanceID: envOr("TELEGRAM_INSTANCE_ID", hostname()),
CertFile: os.Getenv("TELEGRAM_BOTLINK_TLS_CERT"),
KeyFile: os.Getenv("TELEGRAM_BOTLINK_TLS_KEY"),
CAFile: os.Getenv("TELEGRAM_BOTLINK_TLS_CA"),
},
}
var err error
if cfg.GameChannelID, err = envInt64("TELEGRAM_GAME_CHANNEL_ID", 0); err != nil {
return BotConfig{}, err
}
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
return BotConfig{}, err
}
if cfg.BotLink.ReconnectDelay, err = envDuration("TELEGRAM_BOTLINK_RECONNECT_DELAY", defaultBotReconnectDelay); err != nil {
return BotConfig{}, err
}
tel, err := loadTelemetry("scrabble-telegram-bot")
if err != nil {
return BotConfig{}, err
}
cfg.Telemetry = tel
if cfg.Token == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
}
if cfg.MiniAppURL == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required")
}
if cfg.BotLink.GatewayAddr == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_GATEWAY_ADDR is required")
}
if cfg.BotLink.ServerName == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_SERVER_NAME is required")
}
if cfg.BotLink.CertFile == "" || cfg.BotLink.KeyFile == "" || cfg.BotLink.CAFile == "" {
return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_TLS_CERT, _KEY and _CA are required")
}
return cfg, nil
}
// loadTelemetry builds the shared OpenTelemetry config with the given default
// service name, applying the TELEGRAM_* overrides and validating the result.
func loadTelemetry(defaultService string) (pkgtel.Config, error) {
tel := pkgtel.DefaultConfig(defaultService)
tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName) tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName)
tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter) tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter)
tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter) tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
cfg.Telemetry = tel if err := tel.Validate(); err != nil {
if cfg.Token == "" { return pkgtel.Config{}, fmt.Errorf("config: %w", err)
return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
} }
if cfg.MiniAppURL == "" { return tel, nil
return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required") }
// hostname returns the machine hostname, or "telegram-bot" when it cannot be read.
func hostname() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
} }
if err := cfg.Telemetry.Validate(); err != nil { return "telegram-bot"
return Config{}, fmt.Errorf("config: %w", err)
}
return cfg, nil
} }
// envOr returns the environment value for key, or def when it is unset or empty. // envOr returns the environment value for key, or def when it is unset or empty.
@@ -82,3 +186,45 @@ func envOr(key, def string) string {
} }
return def return def
} }
// envInt parses the environment variable named key as an int, returning fallback
// when unset and an error when set but malformed.
func envInt(key string, fallback int) (int, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback, nil
}
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("config: %s %q: %w", key, v, err)
}
return n, nil
}
// envInt64 parses the environment variable named key as an int64, returning
// fallback when unset and an error when set but malformed.
func envInt64(key string, fallback int64) (int64, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback, nil
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return 0, fmt.Errorf("config: %s %q: %w", key, v, err)
}
return n, nil
}
// envDuration parses the environment variable named key as a Go duration,
// returning fallback when unset and an error when set but malformed.
func envDuration(key string, fallback time.Duration) (time.Duration, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf("config: %s %q: %w", key, v, err)
}
return d, nil
}
@@ -6,71 +6,110 @@ import (
pkgtel "scrabble/pkg/telemetry" pkgtel "scrabble/pkg/telemetry"
) )
// setRequired sets the required connector variables (the bot token + the Mini App // setBotRequired sets every required bot variable so LoadBot reaches the optional
// URL) so Load reaches the telemetry checks. // parsing and telemetry checks.
func setRequired(t *testing.T) { func setBotRequired(t *testing.T) {
t.Helper() t.Helper()
t.Setenv("TELEGRAM_BOT_TOKEN", "test-token") t.Setenv("TELEGRAM_BOT_TOKEN", "bot-token")
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app") t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
t.Setenv("TELEGRAM_GATEWAY_ADDR", "gateway.example.org:9443")
t.Setenv("TELEGRAM_BOTLINK_SERVER_NAME", "gateway.example.org")
t.Setenv("TELEGRAM_BOTLINK_TLS_CERT", "/certs/bot.crt")
t.Setenv("TELEGRAM_BOTLINK_TLS_KEY", "/certs/bot.key")
t.Setenv("TELEGRAM_BOTLINK_TLS_CA", "/certs/ca.crt")
} }
// TestLoadBot verifies the bot parsing: the token is read and the game channel id is // TestLoadValidator verifies the validator reads the token, defaults its address
// parsed when present. // and names its telemetry service.
func TestLoadBot(t *testing.T) { func TestLoadValidator(t *testing.T) {
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app") t.Setenv("TELEGRAM_BOT_TOKEN", "secret")
t.Setenv("TELEGRAM_BOT_TOKEN", "bot-token") c, err := LoadValidator()
t.Setenv("TELEGRAM_GAME_CHANNEL_ID", "-100111")
c, err := Load()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("LoadValidator: %v", err)
}
if c.Token != "secret" {
t.Errorf("Token = %q, want secret", c.Token)
}
if c.GRPCAddr != defaultValidatorGRPCAddr {
t.Errorf("GRPCAddr = %q, want %q", c.GRPCAddr, defaultValidatorGRPCAddr)
}
if c.Telemetry.ServiceName != "scrabble-telegram-validator" {
t.Errorf("ServiceName = %q, want scrabble-telegram-validator", c.Telemetry.ServiceName)
}
}
// TestLoadValidatorRequiresToken verifies the validator fails without a token.
func TestLoadValidatorRequiresToken(t *testing.T) {
t.Setenv("TELEGRAM_BOT_TOKEN", "")
if _, err := LoadValidator(); err == nil {
t.Fatal("LoadValidator: expected an error without a token, got nil")
}
}
// TestLoadBot verifies the bot parses the token, channel id and owns_updates and
// names its telemetry service.
func TestLoadBot(t *testing.T) {
setBotRequired(t)
t.Setenv("TELEGRAM_GAME_CHANNEL_ID", "-100111")
c, err := LoadBot()
if err != nil {
t.Fatalf("LoadBot: %v", err)
} }
if c.Token != "bot-token" || c.GameChannelID != -100111 { if c.Token != "bot-token" || c.GameChannelID != -100111 {
t.Errorf("config = token %q / channel %d, want bot-token / -100111", c.Token, c.GameChannelID) t.Errorf("config = token %q / channel %d, want bot-token / -100111", c.Token, c.GameChannelID)
} }
if !c.OwnsUpdates {
t.Error("OwnsUpdates = false, want true by default")
}
if c.SendRatePerSecond != defaultSendRatePerSecond {
t.Errorf("SendRatePerSecond = %d, want %d", c.SendRatePerSecond, defaultSendRatePerSecond)
}
if c.Telemetry.ServiceName != "scrabble-telegram-bot" {
t.Errorf("ServiceName = %q, want scrabble-telegram-bot", c.Telemetry.ServiceName)
}
} }
// TestLoadOptionalChannel verifies the game channel id defaults to 0 when unset. // TestLoadBotOwnsUpdatesOptOut verifies TELEGRAM_OWNS_UPDATES=false disables the
func TestLoadOptionalChannel(t *testing.T) { // long-poll ownership.
setRequired(t) func TestLoadBotOwnsUpdatesOptOut(t *testing.T) {
c, err := Load() setBotRequired(t)
t.Setenv("TELEGRAM_OWNS_UPDATES", "false")
c, err := LoadBot()
if err != nil { if err != nil {
t.Fatalf("Load: %v", err) t.Fatalf("LoadBot: %v", err)
} }
if c.GameChannelID != 0 { if c.OwnsUpdates {
t.Errorf("GameChannelID = %d, want 0", c.GameChannelID) t.Error("OwnsUpdates = true, want false")
} }
} }
// TestLoadRequiresBot verifies Load fails when no bot token is configured. // TestLoadBotRequired verifies LoadBot fails when a required variable is missing.
func TestLoadRequiresBot(t *testing.T) { func TestLoadBotRequired(t *testing.T) {
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app") cases := []string{
if _, err := Load(); err == nil { "TELEGRAM_BOT_TOKEN",
t.Fatal("Load: expected an error when no bot token is set, got nil") "TELEGRAM_MINIAPP_URL",
"TELEGRAM_GATEWAY_ADDR",
"TELEGRAM_BOTLINK_SERVER_NAME",
"TELEGRAM_BOTLINK_TLS_CERT",
}
for _, missing := range cases {
t.Run(missing, func(t *testing.T) {
setBotRequired(t)
t.Setenv(missing, "")
if _, err := LoadBot(); err == nil {
t.Fatalf("LoadBot: expected an error without %s, got nil", missing)
}
})
} }
} }
// TestLoadTelemetryDefaults verifies the connector telemetry defaults: the // TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
// "scrabble-telegram" service name and both exporters off. // fails validation (the validator path).
func TestLoadTelemetryDefaults(t *testing.T) {
setRequired(t)
c, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Telemetry.ServiceName != "scrabble-telegram" {
t.Errorf("Telemetry.ServiceName = %q, want scrabble-telegram", c.Telemetry.ServiceName)
}
if c.Telemetry.TracesExporter != pkgtel.ExporterNone || c.Telemetry.MetricsExporter != pkgtel.ExporterNone {
t.Errorf("exporters = %q/%q, want none/none", c.Telemetry.TracesExporter, c.Telemetry.MetricsExporter)
}
}
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported
// set fails validation.
func TestLoadRejectsUnsupportedExporter(t *testing.T) { func TestLoadRejectsUnsupportedExporter(t *testing.T) {
setRequired(t) t.Setenv("TELEGRAM_BOT_TOKEN", "secret")
t.Setenv("TELEGRAM_OTEL_TRACES_EXPORTER", "jaeger") t.Setenv("TELEGRAM_OTEL_TRACES_EXPORTER", "jaeger")
if _, err := Load(); err == nil { if _, err := LoadValidator(); err == nil {
t.Fatal("Load: expected an error for an unsupported exporter, got nil") t.Fatal("LoadValidator: expected an error for an unsupported exporter, got nil")
} }
_ = pkgtel.ExporterNone
} }
@@ -1,147 +0,0 @@
// Package connector implements the Telegram gRPC service (pkg/proto/telegram/v1):
// the gateway calls ValidateInitData (Mini App auth) and Notify (out-of-app push);
// the admin surface calls SendToUser and SendToGameChannel. The generic
// methods address a recipient by the identity external_id, so a future platform
// connector can implement the same service.
//
// The connector hosts a single bot. ValidateInitData/ValidateLoginWidget verify
// launch data against its token; Notify renders the message in the recipient's
// interface language (the single bot needs no routing); the admin methods deliver
// through that bot.
package connector
import (
"context"
"fmt"
"strconv"
"go.uber.org/zap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
telegramv1 "scrabble/pkg/proto/telegram/v1"
"scrabble/platform/telegram/internal/initdata"
"scrabble/platform/telegram/internal/loginwidget"
"scrabble/platform/telegram/internal/render"
)
// Sender delivers Telegram messages to a chat. *bot.Bot implements it.
type Sender interface {
// Notify sends a notification with a Mini App launch button to chatID.
Notify(ctx context.Context, chatID int64, text, buttonText, startParam string) error
// SendText sends a plain text message to chatID.
SendText(ctx context.Context, chatID int64, text string) error
}
// BotRuntime is the configured bot: its sender, game channel id, and the two HMAC
// validators bound to its token.
type BotRuntime struct {
// Sender delivers messages through the bot.
Sender Sender
// ChannelID is the bot's game channel (0 disables channel posts).
ChannelID int64
// InitValidator verifies Mini App initData signed by the bot's token.
InitValidator initdata.Validator
// WidgetValidator verifies Login Widget data signed by the bot's token.
WidgetValidator loginwidget.Validator
}
// Server implements telegramv1.TelegramServer over the single configured bot.
type Server struct {
telegramv1.UnimplementedTelegramServer
bot BotRuntime
log *zap.Logger
}
// NewServer builds the gRPC service from the configured bot.
func NewServer(bot BotRuntime, log *zap.Logger) *Server {
if log == nil {
log = zap.NewNop()
}
return &Server{bot: bot, log: log}
}
// ValidateInitData verifies Mini App launch data against the bot's token and returns
// the user identity.
func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) {
u, err := s.bot.InitValidator.Validate(req.GetInitData())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &telegramv1.ValidateInitDataResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
}, nil
}
// ValidateLoginWidget verifies Login Widget authorization data against the bot's
// token and returns the user identity, for attaching a Telegram identity to an
// existing account.
func (s *Server) ValidateLoginWidget(ctx context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) {
u, err := s.bot.WidgetValidator.Validate(req.GetData())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &telegramv1.ValidateLoginWidgetResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
}, nil
}
// Notify renders and delivers an out-of-app notification through the bot. The message
// is rendered in the recipient's interface language (req language). It reports
// delivered=false (without an error) when the kind is not pushed out-of-app or the
// bot could not deliver (e.g. the user never started it), so the gateway treats a
// fallback miss as best-effort.
func (s *Server) Notify(ctx context.Context, req *telegramv1.NotifyRequest) (*telegramv1.NotifyResponse, error) {
msg, ok := render.Render(req.GetKind(), req.GetPayload(), req.GetLanguage())
if !ok {
return &telegramv1.NotifyResponse{Delivered: false}, nil
}
chat, err := parseChatID(req.GetExternalId())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if err := s.bot.Sender.Notify(ctx, chat, msg.Text, msg.ButtonText, msg.StartParam); err != nil {
s.log.Warn("notify delivery failed", zap.String("kind", req.GetKind()), zap.Error(err))
return &telegramv1.NotifyResponse{Delivered: false}, nil
}
return &telegramv1.NotifyResponse{Delivered: true}, nil
}
// SendToUser sends an arbitrary admin message to one user through the bot.
func (s *Server) SendToUser(ctx context.Context, req *telegramv1.SendToUserRequest) (*telegramv1.SendResponse, error) {
chat, err := parseChatID(req.GetExternalId())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
if err := s.bot.Sender.SendText(ctx, chat, req.GetText()); err != nil {
s.log.Warn("send to user failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, nil
}
return &telegramv1.SendResponse{Delivered: true}, nil
}
// SendToGameChannel posts an arbitrary admin message to the bot's game channel.
func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) {
if s.bot.ChannelID == 0 {
return nil, status.Error(codes.FailedPrecondition, "game channel is not configured")
}
if err := s.bot.Sender.SendText(ctx, s.bot.ChannelID, req.GetText()); err != nil {
s.log.Warn("send to channel failed", zap.Error(err))
return &telegramv1.SendResponse{Delivered: false}, nil
}
return &telegramv1.SendResponse{Delivered: true}, nil
}
// parseChatID converts a Telegram identity external_id into a numeric chat id.
func parseChatID(externalID string) (int64, error) {
id, err := strconv.ParseInt(externalID, 10, 64)
if err != nil {
return 0, fmt.Errorf("invalid external_id %q", externalID)
}
return id, nil
}
@@ -1,186 +0,0 @@
package connector
import (
"context"
"testing"
flatbuffers "github.com/google/flatbuffers/go"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"scrabble/pkg/fbs/scrabblefb"
telegramv1 "scrabble/pkg/proto/telegram/v1"
"scrabble/platform/telegram/internal/initdata"
"scrabble/platform/telegram/internal/loginwidget"
)
// stubValidator returns a fixed user / error from Validate.
type stubValidator struct {
user initdata.User
err error
}
func (s stubValidator) Validate(string) (initdata.User, error) { return s.user, s.err }
// stubWidgetValidator returns a fixed user / error from the Login Widget Validate.
type stubWidgetValidator struct {
user loginwidget.User
err error
}
func (s stubWidgetValidator) Validate(string) (loginwidget.User, error) { return s.user, s.err }
// fakeSender records the delivery calls the server makes.
type fakeSender struct {
notify []notifyCall
text []textCall
err error
}
type notifyCall struct {
chatID int64
text, buttonText, startParam string
}
type textCall struct {
chatID int64
text string
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
return f.err
}
func (f *fakeSender) SendText(_ context.Context, chatID int64, text string) error {
f.text = append(f.text, textCall{chatID, text})
return f.err
}
// botRT assembles the bot runtime for a test.
func botRT(sender Sender, channelID int64, iv initdata.Validator, wv loginwidget.Validator) BotRuntime {
return BotRuntime{Sender: sender, ChannelID: channelID, InitValidator: iv, WidgetValidator: wv}
}
func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID)
scrabblefb.YourTurnEventStart(b)
scrabblefb.YourTurnEventAddGameId(b, gid)
b.Finish(scrabblefb.YourTurnEventEnd(b))
return b.FinishedBytes()
}
func TestValidateInitData(t *testing.T) {
want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "en-GB"}
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{user: want}, stubWidgetValidator{}), nil)
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "en-GB" {
t.Errorf("resp = %+v, want %+v", resp, want)
}
bad := NewServer(botRT(&fakeSender{}, 0, stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{}), nil)
if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
}
func TestValidateLoginWidget(t *testing.T) {
want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"}
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{user: want}), nil)
resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" {
t.Errorf("resp = %+v, want %+v", resp, want)
}
bad := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget}), nil)
if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
}
func TestNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{}
srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "your_turn", Payload: yourTurnPayload(gameID), Language: "en",
})
if err != nil {
t.Fatalf("notify: %v", err)
}
if !resp.GetDelivered() {
t.Fatal("expected delivered=true")
}
if len(sender.notify) != 1 {
t.Fatalf("notify calls = %d, want 1", len(sender.notify))
}
if got := sender.notify[0]; got.chatID != 12345 || got.startParam != "g"+gameID {
t.Errorf("notify call = %+v, want chatID 12345 startParam g%s", got, gameID)
}
}
func TestNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{}
srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "12345", Kind: "opponent_moved", Language: "en",
})
if err != nil {
t.Fatalf("notify: %v", err)
}
if resp.GetDelivered() {
t.Error("expected delivered=false for an unrendered kind")
}
if len(sender.notify) != 0 {
t.Errorf("sender called %d times, want 0", len(sender.notify))
}
}
func TestNotifyInvalidExternalID(t *testing.T) {
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
_, err := srv.Notify(context.Background(), &telegramv1.NotifyRequest{
ExternalId: "not-a-number", Kind: "your_turn", Payload: yourTurnPayload("g"), Language: "en",
})
if status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
}
func TestSendToUser(t *testing.T) {
sender := &fakeSender{}
srv := NewServer(botRT(sender, 0, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.SendToUser(context.Background(), &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"})
if err != nil {
t.Fatalf("send to user: %v", err)
}
if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 999 || sender.text[0].text != "hi" {
t.Errorf("send to user = %v / calls %+v", resp.GetDelivered(), sender.text)
}
}
func TestSendToGameChannel(t *testing.T) {
t.Run("unconfigured", func(t *testing.T) {
srv := NewServer(botRT(&fakeSender{}, 0, stubValidator{}, stubWidgetValidator{}), nil)
_, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "x"})
if status.Code(err) != codes.FailedPrecondition {
t.Errorf("err code = %v, want FailedPrecondition", status.Code(err))
}
})
t.Run("configured", func(t *testing.T) {
sender := &fakeSender{}
srv := NewServer(botRT(sender, 555, stubValidator{}, stubWidgetValidator{}), nil)
resp, err := srv.SendToGameChannel(context.Background(), &telegramv1.SendToGameChannelRequest{Text: "news"})
if err != nil {
t.Fatalf("send to channel: %v", err)
}
if !resp.GetDelivered() || len(sender.text) != 1 || sender.text[0].chatID != 555 {
t.Errorf("send to channel: %+v", sender.text)
}
})
}
@@ -0,0 +1,61 @@
// Package validator implements the home-side Telegram validator gRPC service: it
// verifies Mini App initData and Login Widget data with the bot token's HMAC and
// never calls the Bot API. The gateway calls it during the auth.telegram and
// link.telegram edge operations. It holds the token only as the HMAC secret, so it
// can run on the main host with no VPN and no Telegram egress (ARCHITECTURE.md §12).
package validator
import (
"context"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
telegramv1 "scrabble/pkg/proto/telegram/v1"
"scrabble/platform/telegram/internal/initdata"
"scrabble/platform/telegram/internal/loginwidget"
)
// Server implements the validation subset of telegramv1.TelegramServer; the
// delivery methods (Notify, SendToUser, SendToGameChannel) are intentionally
// unimplemented here — those run on the remote bot over the bot-link.
type Server struct {
telegramv1.UnimplementedTelegramServer
initValidator initdata.Validator
widgetValidator loginwidget.Validator
}
// NewServer builds the validator from the two HMAC validators bound to the token.
func NewServer(iv initdata.Validator, wv loginwidget.Validator) *Server {
return &Server{initValidator: iv, widgetValidator: wv}
}
// ValidateInitData verifies Mini App launch data against the bot's token and
// returns the user identity.
func (s *Server) ValidateInitData(_ context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) {
u, err := s.initValidator.Validate(req.GetInitData())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &telegramv1.ValidateInitDataResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
}, nil
}
// ValidateLoginWidget verifies Login Widget authorization data against the bot's
// token and returns the user identity, for attaching a Telegram identity to an
// existing account.
func (s *Server) ValidateLoginWidget(_ context.Context, req *telegramv1.ValidateLoginWidgetRequest) (*telegramv1.ValidateLoginWidgetResponse, error) {
u, err := s.widgetValidator.Validate(req.GetData())
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
return &telegramv1.ValidateLoginWidgetResponse{
ExternalId: u.ExternalID,
Username: u.Username,
FirstName: u.FirstName,
}, nil
}
@@ -0,0 +1,63 @@
package validator
import (
"context"
"testing"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
telegramv1 "scrabble/pkg/proto/telegram/v1"
"scrabble/platform/telegram/internal/initdata"
"scrabble/platform/telegram/internal/loginwidget"
)
// stubValidator returns a fixed user / error from Validate.
type stubValidator struct {
user initdata.User
err error
}
func (s stubValidator) Validate(string) (initdata.User, error) { return s.user, s.err }
// stubWidgetValidator returns a fixed user / error from the Login Widget Validate.
type stubWidgetValidator struct {
user loginwidget.User
err error
}
func (s stubWidgetValidator) Validate(string) (loginwidget.User, error) { return s.user, s.err }
func TestValidateInitData(t *testing.T) {
want := initdata.User{ExternalID: "42", Username: "neo", FirstName: "Thomas", LanguageCode: "en-GB"}
srv := NewServer(stubValidator{user: want}, stubWidgetValidator{})
resp, err := srv.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{InitData: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" || resp.GetLanguageCode() != "en-GB" {
t.Errorf("resp = %+v, want %+v", resp, want)
}
bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, stubWidgetValidator{})
if _, err := bad.ValidateInitData(context.Background(), &telegramv1.ValidateInitDataRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
}
func TestValidateLoginWidget(t *testing.T) {
want := loginwidget.User{ExternalID: "42", Username: "neo", FirstName: "Thomas"}
srv := NewServer(stubValidator{}, stubWidgetValidator{user: want})
resp, err := srv.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{Data: "x"})
if err != nil {
t.Fatalf("validate: %v", err)
}
if resp.GetExternalId() != "42" || resp.GetUsername() != "neo" || resp.GetFirstName() != "Thomas" {
t.Errorf("resp = %+v, want %+v", resp, want)
}
bad := NewServer(stubValidator{}, stubWidgetValidator{err: loginwidget.ErrInvalidLoginWidget})
if _, err := bad.ValidateLoginWidget(context.Background(), &telegramv1.ValidateLoginWidgetRequest{}); status.Code(err) != codes.InvalidArgument {
t.Errorf("err code = %v, want InvalidArgument", status.Code(err))
}
}