feat(telegram): split connector into home validator + remote bot (mTLS bot-link) #96
+32
-21
@@ -288,6 +288,11 @@ jobs:
|
||||
mkdir -p "$conf"
|
||||
cp -r caddy otelcol prometheus tempo grafana "$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
|
||||
# (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)"
|
||||
@@ -323,33 +328,39 @@ jobs:
|
||||
docker logs --tail 50 scrabble-backend || true
|
||||
exit 1
|
||||
|
||||
- name: Probe the Telegram connector liveness
|
||||
- name: Probe the Telegram validator and bot liveness
|
||||
run: |
|
||||
set -u
|
||||
# The gateway probe cannot see a crash-looping connector (it long-polls and
|
||||
# egresses through the VPN sidecar, with no public ingress). Inspect the
|
||||
# container directly: it must be running, not restarting, with a stable
|
||||
# restart count. A grace period lets the VPN handshake settle (the connector
|
||||
# may restart a few times first).
|
||||
# The gateway/backend probes cannot see a crash-looping validator or bot
|
||||
# (the validator answers only internal gRPC; the bot long-polls + egresses
|
||||
# through the VPN sidecar with no public ingress). Inspect the containers
|
||||
# directly: each must be running, not restarting, with a stable restart
|
||||
# count. A grace period lets the VPN handshake and the bot-link dial settle.
|
||||
sleep 20
|
||||
for i in $(seq 1 20); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' scrabble-telegram 2>/dev/null || echo missing)"
|
||||
restarting="$(docker inspect -f '{{.State.Restarting}}' scrabble-telegram 2>/dev/null || echo true)"
|
||||
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||
c1="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
sleep 5
|
||||
c2="$(docker inspect -f '{{.RestartCount}}' scrabble-telegram)"
|
||||
if [ "$c1" = "$c2" ]; then
|
||||
echo "connector healthy: status=$status restarts=$c2"
|
||||
exit 0
|
||||
for name in scrabble-telegram-validator scrabble-telegram-bot; do
|
||||
ok=
|
||||
for i in $(seq 1 20); do
|
||||
status="$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null || echo missing)"
|
||||
restarting="$(docker inspect -f '{{.State.Restarting}}' "$name" 2>/dev/null || echo true)"
|
||||
if [ "$status" = "running" ] && [ "$restarting" = "false" ]; then
|
||||
c1="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||
sleep 5
|
||||
c2="$(docker inspect -f '{{.RestartCount}}' "$name")"
|
||||
if [ "$c1" = "$c2" ]; then
|
||||
echo "$name healthy: status=$status restarts=$c2"
|
||||
ok=1
|
||||
break
|
||||
fi
|
||||
echo "$name still restarting ($c1 -> $c2); waiting"
|
||||
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
|
||||
sleep 3
|
||||
done
|
||||
echo "connector not healthy; recent logs:"
|
||||
docker logs --tail 80 scrabble-telegram || true
|
||||
exit 1
|
||||
|
||||
- name: Prune dangling images
|
||||
if: always()
|
||||
|
||||
@@ -17,5 +17,9 @@
|
||||
**/.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/scheduled_tasks.lock
|
||||
|
||||
@@ -125,9 +125,9 @@ backend/ # module scrabble/backend
|
||||
internal/inttest/ # //go:build integration Postgres-backed tests
|
||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||
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)
|
||||
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
|
||||
```
|
||||
|
||||
@@ -138,7 +138,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
|
||||
go vet ./backend/...
|
||||
gofmt -l . # must print nothing
|
||||
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
|
||||
|
||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
||||
|
||||
@@ -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** |
|
||||
| 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** |
|
||||
| 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) |
|
||||
|
||||
## 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
|
||||
schema is edited in the baseline (no prod data); `game_players.account_id` is nullable for the
|
||||
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
|
||||
|
||||
|
||||
+6
-5
@@ -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
|
||||
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
|
||||
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
|
||||
`accounts.notifications_in_app_only` flag (default true).
|
||||
`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` +
|
||||
`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 Telegram-connector client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) — each
|
||||
broadcast renders through the single bot in an operator-chosen language. There is one bot,
|
||||
backend client (`internal/connector`, `BACKEND_CONNECTOR_ADDR`) that calls the gateway's
|
||||
**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
|
||||
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
|
||||
@@ -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/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/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
|
||||
```
|
||||
|
||||
@@ -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_PASSWORD` | — | SMTP password. |
|
||||
| `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_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. |
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Package connector is the backend's gRPC client for the Telegram platform
|
||||
// connector side-service. The admin console uses it to send operator broadcasts:
|
||||
// a direct message to one user, or a post to the game channel, through the single
|
||||
// bot. The connector lives on the trusted internal network, so the connection uses
|
||||
// insecure (plaintext) transport credentials (docs/ARCHITECTURE.md §12). It mirrors
|
||||
// gateway/internal/connector, narrowed to the two broadcast methods the admin
|
||||
// surface needs.
|
||||
// Package connector is the backend's gRPC client for operator broadcasts: a direct
|
||||
// message to one user, or a post to the game channel. It calls the gateway's
|
||||
// bot-link relay (which forwards the send to the remote bot over the reverse mTLS
|
||||
// link and reports back whether it was delivered). The relay lives on the trusted
|
||||
// internal network, so the connection uses insecure (plaintext) transport
|
||||
// credentials (docs/ARCHITECTURE.md §12). It speaks the Telegram service contract,
|
||||
// narrowed to the two broadcast methods the admin surface needs.
|
||||
package connector
|
||||
|
||||
import (
|
||||
|
||||
+6
-2
@@ -38,8 +38,12 @@ VITE_GATEWAY_URL=
|
||||
GRAFANA_ROOT_URL=/_gm/grafana/ # set the full https URL behind a real domain
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
|
||||
# --- Telegram connector -----------------------------------------------------
|
||||
AWG_CONF= # required; AmneziaWG sidecar config
|
||||
# --- Telegram validator + bot -----------------------------------------------
|
||||
# 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_GAME_CHANNEL_ID=
|
||||
TELEGRAM_MINIAPP_URL= # required
|
||||
|
||||
+22
-16
@@ -1,8 +1,8 @@
|
||||
# deploy
|
||||
|
||||
The full Scrabble contour: `backend` + `gateway` + the static `landing` + Postgres +
|
||||
the Telegram connector (with a VPN sidecar) + the observability stack (OTel
|
||||
Collector → Prometheus + Tempo → Grafana), fronted by a **caddy** that owns a single
|
||||
the Telegram `validator` + `bot` (the bot with a VPN sidecar) + the observability stack
|
||||
(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
|
||||
[`../docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md) §13; this file is the
|
||||
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. |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `prometheus` | `prom/prometheus` | Metrics, 15d retention. |
|
||||
| `tempo` | `grafana/tempo` | Traces, 72h retention. |
|
||||
@@ -58,12 +59,13 @@ compose binds from this directory.
|
||||
| Variable | Gitea kind | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `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>'`. |
|
||||
| `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
|
||||
compose, but the connector **fails at boot** when it is empty.
|
||||
**Plus the bot token** — `TELEGRAM_BOT_TOKEN` (secret), shared by the validator (HMAC
|
||||
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)
|
||||
|
||||
@@ -72,7 +74,7 @@ compose, but the connector **fails at boot** when it is empty.
|
||||
| `POSTGRES_DB` | variable | `scrabble` | Database name. |
|
||||
| `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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -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
|
||||
missing config: `BACKEND_POSTGRES_DSN` (→ `postgres`, `search_path=backend`),
|
||||
`GATEWAY_BACKEND_HTTP_URL`/`_GRPC_ADDR` (→ `backend`),
|
||||
`GATEWAY_CONNECTOR_ADDR`/`BACKEND_CONNECTOR_ADDR` (→ `telegram:9091`), and all three
|
||||
services' `*_OTEL_*_EXPORTER=otlp` → `OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
|
||||
(`_INSECURE=true`). The connector shares the VPN sidecar's netns: routing to the
|
||||
collector'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`
|
||||
("produced zero addresses"); without it the netns uses Docker's resolver, which
|
||||
resolves both `otelcol` and `api.telegram.org`. `GATEWAY_ADMIN_*` is intentionally
|
||||
**unset** — caddy owns `/_gm` in the contour.
|
||||
`GATEWAY_VALIDATOR_ADDR` (→ `validator:9091`), `BACKEND_CONNECTOR_ADDR` (→ the gateway
|
||||
bot-link relay `gateway:9092`), the bot's `TELEGRAM_GATEWAY_ADDR` (→ `gateway:9443`,
|
||||
mTLS) with the `GATEWAY_BOTLINK_*` / `TELEGRAM_BOTLINK_*` cert paths under `/certs` (the
|
||||
mTLS material is generated by `deploy/gen-certs.sh`, gitignored, regenerated each
|
||||
deploy), and all services' `*_OTEL_*_EXPORTER=otlp` →
|
||||
`OTEL_EXPORTER_OTLP_ENDPOINT=http://otelcol:4317`
|
||||
(`_INSECURE=true`). The bot shares the VPN sidecar's netns: routing to the
|
||||
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)
|
||||
|
||||
|
||||
+81
-22
@@ -1,6 +1,6 @@
|
||||
# Full deploy descriptor for the Scrabble test contour: backend + gateway +
|
||||
# Postgres + the Telegram connector (with its VPN sidecar) + the observability
|
||||
# stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
|
||||
# Postgres + the Telegram validator + bot (the bot with its VPN sidecar) + the
|
||||
# observability stack (OTel Collector -> Prometheus + Tempo -> Grafana). Driven by
|
||||
# .gitea/workflows/ci.yaml (`docker compose up -d --build`); env values are
|
||||
# interpolated from Gitea Actions TEST_ secrets/variables exported by the deploy
|
||||
# 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
|
||||
# (no host caddy) set CADDY_SITE_ADDRESS to the domain so the caddy
|
||||
# does its own ACME — the contour is then self-contained.
|
||||
# - The connector egresses to api.telegram.org through the `vpn` sidecar
|
||||
# (network_mode: service:vpn); it answers internal gRPC at `telegram:9091`.
|
||||
# - The validator answers internal gRPC at `validator:9091` (no VPN, HMAC only).
|
||||
# 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
|
||||
|
||||
# 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_HTTP_ADDR: ":8080"
|
||||
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_SERVICE_NAME: scrabble-backend
|
||||
BACKEND_OTEL_TRACES_EXPORTER: otlp
|
||||
@@ -135,7 +139,17 @@ services:
|
||||
GATEWAY_HTTP_ADDR: ":8081"
|
||||
GATEWAY_BACKEND_HTTP_URL: http://backend:8080
|
||||
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_SERVICE_NAME: scrabble-gateway
|
||||
GATEWAY_OTEL_TRACES_EXPORTER: otlp
|
||||
@@ -146,6 +160,10 @@ services:
|
||||
GOMAXPROCS: "3"
|
||||
# GATEWAY_ADMIN_* intentionally unset: in the deployed contour the front
|
||||
# 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
|
||||
# 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.
|
||||
@@ -182,7 +200,39 @@ services:
|
||||
memory: 128M
|
||||
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:
|
||||
container_name: scrabble-telegram-vpn
|
||||
image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest
|
||||
@@ -195,40 +245,49 @@ services:
|
||||
internal:
|
||||
aliases: [telegram]
|
||||
|
||||
telegram:
|
||||
container_name: scrabble-telegram
|
||||
image: scrabble-telegram:latest
|
||||
bot:
|
||||
container_name: scrabble-telegram-bot
|
||||
image: scrabble-telegram-bot:latest
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: platform/telegram/Dockerfile
|
||||
target: bot
|
||||
restart: unless-stopped
|
||||
logging: *default-logging
|
||||
depends_on: [vpn]
|
||||
network_mode: "service:vpn"
|
||||
environment:
|
||||
# The bot token lives ONLY in this container (ARCHITECTURE.md §12). The connector
|
||||
# requires it at boot; an empty value leaves the Telegram side down while the rest
|
||||
# of the contour still comes up.
|
||||
# The bot token lives on the bot host (ARCHITECTURE.md §12). The bot requires it
|
||||
# at boot; an empty value leaves the bot down while the rest of the contour comes up.
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
|
||||
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||
TELEGRAM_GRPC_ADDR: ":9091"
|
||||
TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false}
|
||||
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_SERVICE_NAME: scrabble-telegram
|
||||
# The connector shares the VPN sidecar's netns. Routing to the collector's
|
||||
# internal IP stays off the tunnel (connected route), but the sidecar's DNS
|
||||
# hijacks name resolution: AWG_CONF must NOT carry a `DNS=` directive, else
|
||||
# `otelcol` won't resolve ("produced zero addresses"). Without DNS= the netns
|
||||
# uses Docker's resolver, which resolves both otelcol and api.telegram.org
|
||||
# (see deploy/README.md).
|
||||
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
|
||||
# The bot shares the VPN sidecar's netns. Routing to internal IPs stays off the
|
||||
# tunnel (connected route), but the sidecar's DNS hijacks name resolution:
|
||||
# AWG_CONF must NOT carry a `DNS=` directive, else `otelcol` and `gateway` won't
|
||||
# resolve. Without DNS= the netns uses Docker's resolver, which resolves the
|
||||
# internal services and api.telegram.org (see deploy/README.md).
|
||||
TELEGRAM_OTEL_TRACES_EXPORTER: otlp
|
||||
TELEGRAM_OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://otelcol:4317
|
||||
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"
|
||||
volumes:
|
||||
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
|
||||
Executable
+61
@@ -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
@@ -45,16 +45,23 @@ Three executables plus per-platform side-services:
|
||||
and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
- **`platform/telegram`** — the Telegram side-service (the "connector", module
|
||||
`scrabble/platform/telegram`). It is the only component holding the bot token — **one
|
||||
unified bot** (one token + one optional game channel, §3). It
|
||||
runs a Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
|
||||
a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation
|
||||
and out-of-app push) and `backend` (operator broadcasts) call over the
|
||||
trusted internal network. Its generic delivery methods are **platform-agnostic**
|
||||
(keyed by the identity `external_id`), so a future VK/MAX connector reuses them; only
|
||||
initData validation is Telegram-specific. It runs in its own container, egressing to
|
||||
Telegram through a VPN sidecar.
|
||||
- **`platform/telegram`** — the Telegram side-service (module
|
||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||
(**one bot**, one optional game channel, §3):
|
||||
- the **validator** (`cmd/validator`) verifies Mini App initData and Login Widget
|
||||
data by HMAC (the bot token is the secret) and **never reaches the Bot API**, so
|
||||
it runs on the main host with no VPN. The gateway calls its gRPC API
|
||||
(`pkg/proto/telegram/v1`) over the trusted internal network during Telegram auth,
|
||||
so **game login is independent of Telegram reachability** (§10).
|
||||
- the **bot** (`cmd/bot`) 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**
|
||||
(`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
|
||||
flowchart LR
|
||||
@@ -64,9 +71,11 @@ flowchart LR
|
||||
Gateway -- in-app stream --> Client
|
||||
Backend -- pgx --> Postgres[(Postgres)]
|
||||
Backend -. embeds .- Solver[[scrabble-solver library]]
|
||||
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
|
||||
Backend -. operator broadcasts (gRPC) .-> Telegram
|
||||
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
|
||||
Gateway -- gRPC (validate initData) --> Validator[Telegram validator]
|
||||
Bot[Telegram bot] -. dials, reverse mTLS bot-link .-> Gateway
|
||||
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
|
||||
@@ -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).
|
||||
|
||||
- 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
|
||||
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`)
|
||||
and display name (§4).
|
||||
- **Single bot.** The connector hosts **one unified bot** (one token + one optional
|
||||
game channel). `ValidateInitData` 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
|
||||
and display name (§4). The validator runs on the main host and never reaches the Bot
|
||||
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
|
||||
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
||||
game channel), split into a home **validator** and a remote **bot** that share the
|
||||
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
|
||||
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
|
||||
@@ -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.
|
||||
- **Linking** is initiated from an authenticated profile and proves
|
||||
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
|
||||
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"
|
||||
@@ -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
|
||||
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
|
||||
language, and the `notifications_in_app_only` flag). It then asks the **Telegram connector**
|
||||
to deliver — through the **single bot** — a
|
||||
localized message with a Mini App deep-link button, only when the recipient has a Telegram
|
||||
identity and has not confined notifications to the app, so the two channels never duplicate. The
|
||||
connector renders the message in that language; there is no per-bot routing. The out-of-app set is
|
||||
language, and the `notifications_in_app_only` flag). It then pushes a deliver command over
|
||||
the **bot-link** to the remote **bot** — **fire-and-forget, best-effort** (dropped, with a
|
||||
metric, when no bot is connected) — only when the recipient has a Telegram identity and has
|
||||
not confined notifications to the app, so the two channels never duplicate. The bot renders a
|
||||
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;
|
||||
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
|
||||
block-state sync to the blocker) never become a platform push. Operator broadcasts
|
||||
(`SendToUser` / `SendToGameChannel`, §10 admin) render in an **operator-chosen** language in
|
||||
the console, sent through the same single bot. Session-revocation events and
|
||||
cursor-based stream resume stay deferred (single-instance MVP).
|
||||
the console; the backend calls them on the **gateway's bot-link relay**, which forwards them
|
||||
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),
|
||||
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
|
||||
|
||||
- Structured logging with `go.uber.org/zap` (JSON). OpenTelemetry tracer and
|
||||
meter providers are wired in **all three services** (backend, gateway, the
|
||||
Telegram connector) through a shared `pkg/telemetry` bootstrap, env-gated per
|
||||
meter providers are wired in **all services** (backend, gateway, the Telegram
|
||||
validator and bot) through a shared `pkg/telemetry` bootstrap, env-gated per
|
||||
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
|
||||
available for debugging; **`otlp`** (gRPC, endpoint from the standard
|
||||
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
|
||||
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**
|
||||
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
|
||||
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 |
|
||||
| --- | --- |
|
||||
| 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 → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| 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) |
|
||||
| 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 |
|
||||
| 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
|
||||
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
|
||||
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
|
||||
**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
|
||||
**Telegram connector** runs as a separate container with **no public ingress** — it
|
||||
long-polls Telegram and egresses through a VPN sidecar, answering only internal gRPC.
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
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`,
|
||||
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
|
||||
with provisioned datasources and dashboards. All three services export OTLP to the
|
||||
collector; the connector shares the VPN sidecar's netns, so its `AWG_CONF` must not
|
||||
with provisioned datasources and dashboards. All services export OTLP to the
|
||||
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
|
||||
`otelcol`; without it the netns uses Docker's resolver, which resolves both
|
||||
`otelcol` and `api.telegram.org`). Inter-service traffic uses a private `internal`
|
||||
`otelcol` / `gateway`; without it the netns uses Docker's resolver, which resolves
|
||||
`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
|
||||
(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 —
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
a merge.
|
||||
- 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
|
||||
the gateway (`GET /`) **and the Telegram connector's liveness** (via
|
||||
`docker inspect`: running, not restarting, stable restart count, with a
|
||||
VPN-handshake grace period, since the connector has no public ingress and a
|
||||
crash-loop is otherwise invisible). A PR into `master` is test-only; the prod
|
||||
to — `development` (it generates the bot-link certs, then `docker compose up -d
|
||||
--build` on the runner host), then probes the gateway (`GET /`) **and the Telegram
|
||||
validator's and bot's liveness** (via `docker inspect`: running, not restarting,
|
||||
stable restart count, with a VPN-handshake grace period, since neither has public
|
||||
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
|
||||
`TEST_`/`PROD_` per contour.
|
||||
- The engine consumes `scrabble-solver` as a **published, versioned module**
|
||||
|
||||
+2
-2
@@ -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;
|
||||
versions are immutable and games in progress keep their own), and the **pending
|
||||
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**.
|
||||
State-changing actions are protected by a same-origin check; the console tracks no
|
||||
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
|
||||
**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
|
||||
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
|
||||
|
||||
@@ -293,7 +293,7 @@ identity, их игры) и **игры** — сводка, места, запи
|
||||
активной; версии неизменяемы, а идущие партии остаются на своей) и **список ожидающих
|
||||
правок**, выведенный из принятых жалоб (он питает офлайн-пересборку и отмечается
|
||||
применённым после обновления). Если
|
||||
подключён Telegram-коннектор, оператор также может **написать пользователю** (по его
|
||||
подключён Telegram-бот, оператор также может **написать пользователю** (по его
|
||||
Telegram-identity) или **отправить пост в игровой канал**. Изменяющие действия
|
||||
защищены проверкой same-origin; личность оператора не отслеживается.
|
||||
|
||||
@@ -325,7 +325,7 @@ high-rate флага. С карточки пользователя операт
|
||||
|
||||
Консоль ведёт и очередь **обратной связи** (`/_gm/feedback`): присланные игроками сообщения с фильтром
|
||||
**непрочитанные / прочитанные / архив** и поиском по пользователю, каждое — с отправителем, источником,
|
||||
каналом (и языком бота-коннектора — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
|
||||
каналом (и языком бота — en/ru — для сообщения из Telegram), языком интерфейса отправителя,
|
||||
IP и вложением. Оператор может пометить сообщение прочитанным, **ответить** игроку (доставка
|
||||
в приложение), отправить в архив, удалить или удалить все сообщения этого игрока — и вместе с удалением
|
||||
**запретить игроку обратную связь** (роль `feedback_banned`, отличная от полной блокировки аккаунта:
|
||||
|
||||
+14
-6
@@ -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
|
||||
failures become Connect error codes.
|
||||
|
||||
`auth.telegram` validates the Mini App `initData` by calling the **Telegram connector**
|
||||
(`GATEWAY_CONNECTOR_ADDR`), which holds the bot token; the gateway also routes
|
||||
out-of-app push to that connector for recipients with no live in-app stream
|
||||
(ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled.
|
||||
`auth.telegram` validates the Mini App `initData` by calling the **Telegram validator**
|
||||
(`GATEWAY_VALIDATOR_ADDR`), which holds the bot token (HMAC); out-of-app push for
|
||||
recipients with no live in-app stream goes to the remote **bot** over the reverse **mTLS
|
||||
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`,
|
||||
`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
|
||||
— `link.email.request/confirm/merge` and `link.telegram.confirm/merge`
|
||||
(`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.
|
||||
|
||||
## 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_TIMEOUT` | `5s` | per backend REST call |
|
||||
| `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_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) |
|
||||
|
||||
+89
-18
@@ -9,16 +9,23 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"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"
|
||||
|
||||
"scrabble/gateway/internal/admin"
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/botlink"
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/connectsrv"
|
||||
@@ -26,9 +33,23 @@ import (
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
"scrabble/gateway/internal/session"
|
||||
"scrabble/gateway/internal/transcode"
|
||||
"scrabble/pkg/mtls"
|
||||
botlinkv1 "scrabble/pkg/proto/botlink/v1"
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
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 (
|
||||
// shutdownTimeout bounds the graceful HTTP shutdown.
|
||||
shutdownTimeout = 10 * time.Second
|
||||
@@ -100,17 +121,47 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
tracker := ratelimit.NewTracker()
|
||||
hub := push.NewHub(0)
|
||||
|
||||
var conn *connector.Client
|
||||
var validator transcode.TelegramValidator
|
||||
if cfg.ConnectorAddr != "" {
|
||||
conn, err = connector.New(cfg.ConnectorAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
if cfg.ValidatorAddr != "" {
|
||||
conn, cerr := connector.New(cfg.ValidatorAddr)
|
||||
if cerr != nil {
|
||||
return cerr
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
validator = conn
|
||||
} 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
|
||||
@@ -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
|
||||
// channel via the connector).
|
||||
go runPushPump(ctx, backend, hub, conn, logger)
|
||||
// channel via the bot-link).
|
||||
go runPushPump(ctx, backend, hub, botHub, logger)
|
||||
// Periodically summarise rate-limiter rejections (Warn log + backend report).
|
||||
go runThrottleReporter(ctx, tracker, backend, logger)
|
||||
|
||||
@@ -195,6 +246,27 @@ func runServers(ctx context.Context, cancel context.CancelFunc, servers []*named
|
||||
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
|
||||
// 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
|
||||
@@ -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 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).
|
||||
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 {
|
||||
stream, err := backend.SubscribePush(ctx, gatewayID)
|
||||
if err != nil {
|
||||
@@ -255,10 +327,10 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
|
||||
EventID: ev.GetEventId(),
|
||||
})
|
||||
// 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
|
||||
// so a slow connector never stalls the in-app firehose.
|
||||
if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
|
||||
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
|
||||
// deliver the event over the bot-link. Done in a goroutine so a slow
|
||||
// target lookup never stalls the in-app firehose.
|
||||
if bot != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
|
||||
go deliverOutOfApp(ctx, backend, bot, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
|
||||
}
|
||||
}
|
||||
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
|
||||
// connector to deliver the event. It is best-effort: every failure is logged and
|
||||
// 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)
|
||||
if err != nil {
|
||||
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) {
|
||||
return
|
||||
}
|
||||
// The single bot renders the message in the recipient's interface language.
|
||||
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil {
|
||||
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
// Fire-and-forget down the bot-link; the bot renders the message in the
|
||||
// recipient's interface language and is dropped (best-effort) if no bot is up.
|
||||
bot.Send(botlink.NotifyCommand(target.ExternalID, kind, payload, target.Language))
|
||||
}
|
||||
|
||||
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
|
||||
|
||||
@@ -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,
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -28,10 +28,13 @@ type Config struct {
|
||||
// checks before proxying admin traffic to the backend. Empty disables admin.
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
// ConnectorAddr is the gRPC address of the Telegram connector side-service. The
|
||||
// gateway calls it to validate Mini App initData and to deliver out-of-app push.
|
||||
// Empty disables the telegram auth path and the out-of-app push channel.
|
||||
ConnectorAddr string
|
||||
// ValidatorAddr is the gRPC address of the Telegram validator side-service (home,
|
||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||
// Login Widget data. Empty disables the telegram auth path.
|
||||
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
|
||||
// caps the number of cached sessions.
|
||||
SessionTTL time.Duration
|
||||
@@ -47,6 +50,29 @@ type Config struct {
|
||||
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
|
||||
// keyed per client IP; the authenticated class is keyed per user id; the email
|
||||
// sub-limit guards the costly email-code path per IP.
|
||||
@@ -72,6 +98,7 @@ const (
|
||||
defaultSessionCacheMax = 50000
|
||||
defaultPushHeartbeatInterval = 10 * time.Second // under the ~15 s edge idle timeout
|
||||
defaultServiceName = "scrabble-gateway"
|
||||
defaultBotLinkSendTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
// 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),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ConnectorAddr: os.Getenv("GATEWAY_CONNECTOR_ADDR"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
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.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 {
|
||||
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 {
|
||||
return Config{}, err
|
||||
}
|
||||
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
|
||||
// Basic-Auth credentials are configured).
|
||||
func (c Config) AdminEnabled() bool {
|
||||
@@ -159,6 +199,11 @@ func (c Config) validate() error {
|
||||
if c.MaxBodyBytes <= 0 {
|
||||
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 {
|
||||
return fmt.Errorf("config: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
// Package connector is the gateway's gRPC client for the Telegram connector
|
||||
// side-service: it validates Mini App initData and delivers out-of-app push. The
|
||||
// connector lives on the trusted internal network, so the connection uses insecure
|
||||
// (plaintext) transport credentials (ARCHITECTURE.md §12).
|
||||
// Package connector is the gateway's gRPC client for the Telegram validator
|
||||
// side-service: it validates Mini App initData and Login Widget data. The validator
|
||||
// lives on the trusted internal network and holds the bot token only for HMAC, so
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -92,18 +94,3 @@ func (c *Client) ValidateLoginWidget(ctx context.Context, data string) (User, er
|
||||
FirstName: resp.GetFirstName(),
|
||||
}, 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
# scrabble/gateway replace and the scrabble-solver replace from a copy of go.work: it
|
||||
# needs neither their sources nor the solver sibling checkout.
|
||||
# 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
|
||||
WORKDIR /src
|
||||
|
||||
@@ -13,11 +16,18 @@ COPY go.work go.work.sum ./
|
||||
COPY pkg ./pkg
|
||||
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 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
|
||||
COPY --from=build /out/telegram /usr/local/bin/telegram
|
||||
ENTRYPOINT ["/usr/local/bin/telegram"]
|
||||
# --- validator (home) --------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot AS validator
|
||||
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
@@ -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
|
||||
token: it runs a Bot API long-poll loop (Mini App launch + deep-links) and serves
|
||||
the connector gRPC API that the gateway and backend call over the trusted internal
|
||||
network. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12.
|
||||
The Telegram platform side-service, split into **two binaries** that share the bot
|
||||
token, so Telegram egress lives off the main host while game login does not depend on
|
||||
it. See [`docs/ARCHITECTURE.md`](../../docs/ARCHITECTURE.md) §1/§3/§10/§12/§13.
|
||||
|
||||
## 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,
|
||||
configured by `TELEGRAM_BOT_TOKEN` and `TELEGRAM_GAME_CHANNEL_ID`. `ValidateInitData`
|
||||
validates `initData` against that single token (it does not validate ⇒ invalid) and
|
||||
returns only the Telegram user identity — there is no per-bot service language and no
|
||||
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.
|
||||
Both hold the same token. One bot owns the exclusive `getUpdates` long-poll
|
||||
(`TELEGRAM_OWNS_UPDATES`, exactly one per token, else Telegram returns 409); the
|
||||
design leaves seams (a gateway bot registry, the `owns_updates` flag, per-command ids)
|
||||
for running several bots later, not built yet.
|
||||
|
||||
## Responsibilities
|
||||
## Validator
|
||||
|
||||
- **Mini App auth.** `ValidateInitData` verifies Telegram Web App `initData` (HMAC
|
||||
under the bot token) and returns the user identity. The gateway calls it during
|
||||
the `auth.telegram` edge operation, then provisions the session through the
|
||||
backend internal API — so the bot token never leaves this process.
|
||||
- **Out-of-app push.** `Notify` renders a backend push event (your_turn, nudge,
|
||||
match_found, and the invitation / friend_request notify sub-kinds) into a
|
||||
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).
|
||||
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
|
||||
delivery.
|
||||
`ValidateInitData` validates `initData` against the token and returns only the
|
||||
Telegram user identity (no per-bot service language, no supported-languages set);
|
||||
`ValidateLoginWidget` verifies Telegram **Login Widget** web sign-in data — HMAC under
|
||||
`SHA-256(bot_token)`, distinct from initData (`internal/loginwidget`) — for attaching a
|
||||
Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
`InvalidArgument`.
|
||||
|
||||
## Bot
|
||||
|
||||
- **Send commands.** The gateway pushes `Notify` (out-of-app push: your_turn,
|
||||
game_over, nudge, match_found, and the invitation / friend_request notify sub-kinds),
|
||||
`SendToUser` and `SendToGameChannel` (operator broadcasts) down the bot-link. The bot
|
||||
renders the message in the command's `language` (the recipient's interface language;
|
||||
operator-chosen for broadcasts) with a Mini App launch button and sends it. It replies
|
||||
with an `Ack` per command (`delivered` mirrors the former connector semantics —
|
||||
false when the kind is not rendered out-of-app or the user never started the bot).
|
||||
- **Bot chat.** `/start <payload>` (and the chat menu button) reply with a Mini App
|
||||
launch button; a deep-link payload routes the launch to a game / invitation /
|
||||
friend code.
|
||||
- **Admin messaging.** `SendToUser` and `SendToGameChannel` send
|
||||
arbitrary text to one user or a game channel through the single bot, rendered in the
|
||||
`language` the operator chooses in the admin console.
|
||||
launch button; a deep-link payload routes the launch to a game / invitation / friend
|
||||
code. This is **self-contained** — the bot never calls back into the game, so `/start`
|
||||
onboarding works even when the game is down.
|
||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||
default 25) to respect the Bot API flood limits.
|
||||
|
||||
The generic methods (`Notify`, `SendToUser`, `SendToGameChannel`) address a
|
||||
recipient by the identity `external_id` (as in the backend `identities` table), so a
|
||||
future VK / MAX connector can implement the same service; only `ValidateInitData` is
|
||||
Telegram-specific.
|
||||
The send commands address a recipient by the identity `external_id` (as in the backend
|
||||
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData
|
||||
parsing is Telegram-specific.
|
||||
|
||||
## gRPC API
|
||||
## gRPC contracts
|
||||
|
||||
`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`,
|
||||
`ValidateLoginWidget`, `Notify`, `SendToUser`, `SendToGameChannel`. Generated Go is
|
||||
committed under `pkg`. `ValidateLoginWidget` verifies Telegram **Login
|
||||
Widget** web sign-in data — HMAC under `SHA-256(bot_token)`, distinct from initData
|
||||
(`internal/loginwidget`) — for attaching a Telegram identity to an account from a
|
||||
browser.
|
||||
- `pkg/proto/telegram/v1`, service `Telegram` — served by the **validator**
|
||||
(`ValidateInitData`, `ValidateLoginWidget`). Its `Notify` / `SendToUser` /
|
||||
`SendToGameChannel` request shapes are reused as bot-link command payloads; the
|
||||
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
|
||||
relay.
|
||||
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
|
||||
on the gateway (`Hello` / `Command` / `Ack`). Generated Go is committed under `pkg`.
|
||||
|
||||
## Deep-link scheme
|
||||
|
||||
Shared verbatim with the UI (`ui/src/lib/deeplink.ts`). A Mini App start parameter
|
||||
is a one-character kind prefix plus a value:
|
||||
Shared verbatim with the UI (`ui/src/lib/deeplink.ts`). A Mini App start parameter is a
|
||||
one-character kind prefix plus a value:
|
||||
|
||||
| Parameter | Destination |
|
||||
| --- | --- |
|
||||
@@ -67,40 +76,57 @@ The bot turns a `/start <payload>` or a notification target into a launch-button
|
||||
|
||||
## Configuration
|
||||
|
||||
Shared:
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `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_BOT_TOKEN` | — (required) | the bot's API token + initData HMAC secret |
|
||||
| `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_METRICS_EXPORTER` | `none` | `none`, `stdout` or `otlp` |
|
||||
|
||||
The **test environment** is selected by `TELEGRAM_TEST_ENV=true`, which suffixes the
|
||||
Bot API path with `/test` (the connector appends it to the token, since the client
|
||||
builds `<host>/bot<token>/<method>`).
|
||||
Validator (`cmd/validator`):
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `TELEGRAM_VALIDATOR_GRPC_ADDR` | `:9091` | validator gRPC listen address (the gateway dials it) |
|
||||
|
||||
Bot (`cmd/bot`):
|
||||
|
||||
| Env var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `TELEGRAM_MINIAPP_URL` | — (required) | Mini App HTTPS origin (BotFather-registered) |
|
||||
| `TELEGRAM_GATEWAY_ADDR` | — (required) | the gateway bot-link endpoint to dial |
|
||||
| `TELEGRAM_BOTLINK_SERVER_NAME` | — (required) | the gateway certificate's expected SNI / CN |
|
||||
| `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert |
|
||||
| `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` |
|
||||
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
||||
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
||||
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
||||
| `TELEGRAM_BOTLINK_RECONNECT_DELAY` | `2s` | pause before re-dialing after the stream ends |
|
||||
| `TELEGRAM_API_BASE_URL` | `https://api.telegram.org` | Bot API host override (mock / self-hosted) |
|
||||
| `TELEGRAM_TEST_ENV` | `false` | route to the Bot API **test environment** (`/bot<token>/test/METHOD`) |
|
||||
|
||||
## Build, test, run
|
||||
|
||||
```sh
|
||||
go build ./platform/telegram/...
|
||||
go test ./platform/telegram/... # unit tests use an httptest fake Bot API
|
||||
go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN
|
||||
go test ./platform/telegram/... # unit tests use an httptest fake Bot API + an in-process bot-link
|
||||
go run ./platform/telegram/cmd/validator # needs TELEGRAM_BOT_TOKEN
|
||||
go run ./platform/telegram/cmd/bot # needs the bot env above + mTLS cert files
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
The connector runs in its **own container** with the bot token held only there and
|
||||
all egress through a VPN sidecar (`deploy/docker-compose.yml`, mirroring
|
||||
`../../15-puzzle`). It needs no public ingress — it long-polls Telegram and answers
|
||||
internal gRPC at `telegram:9091` on the shared `edge` network. The host reverse proxy
|
||||
routes public traffic to the **gateway** port only, which serves the Mini App under
|
||||
`/telegram/`. The full multi-service deploy is `deploy/docker-compose.yml`.
|
||||
`platform/telegram/Dockerfile` builds both binaries on distroless nonroot with two
|
||||
targets, `validator` and `bot`. In the test contour (`deploy/docker-compose.yml`) the
|
||||
**validator** runs on the internal network (no VPN); the **bot** keeps a VPN sidecar
|
||||
for Telegram egress and dials the gateway bot-link by its internal name. The bot-link
|
||||
mTLS material is generated by `deploy/gen-certs.sh`. In prod the bot runs on a separate
|
||||
host with native Telegram access and dials the gateway's published bot-link port with
|
||||
`PROD_` certificates (the deferred final stage — see `PRERELEASE.md`).
|
||||
|
||||
A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS
|
||||
Mini App origin, and the connector container; the unit tests cover the wire format,
|
||||
templates, deep-links and the gRPC handlers without a live bot.
|
||||
A real end-to-end Telegram smoke needs a BotFather bot, its token, a public HTTPS Mini
|
||||
App origin, and the bot container; the unit tests cover the wire format, templates,
|
||||
deep-links, the validator handlers and the bot-link client/executor without a live bot.
|
||||
|
||||
@@ -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
|
||||
// the only component holding the bot token: it runs the Bot API long-poll loop
|
||||
// (Mini App launch + /start deep-links) and serves the connector gRPC API
|
||||
// (pkg/proto/telegram/v1) that the gateway and backend call over the trusted
|
||||
// internal network. See platform/telegram/README.md.
|
||||
// Command validator is the home-side Telegram validator. It serves the validation
|
||||
// gRPC API (pkg/proto/telegram/v1 ValidateInitData/ValidateLoginWidget) the gateway
|
||||
// calls during Telegram auth. It holds the bot token only as the HMAC secret and
|
||||
// never reaches the Telegram Bot API, so it runs on the main host with no VPN and
|
||||
// no Telegram egress, and game login stays independent of the remote bot. See
|
||||
// platform/telegram/README.md.
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -20,24 +21,23 @@ import (
|
||||
|
||||
telegramv1 "scrabble/pkg/proto/telegram/v1"
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
"scrabble/platform/telegram/internal/bot"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/connector"
|
||||
"scrabble/platform/telegram/internal/initdata"
|
||||
"scrabble/platform/telegram/internal/loginwidget"
|
||||
"scrabble/platform/telegram/internal/validator"
|
||||
)
|
||||
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
const telemetryShutdownTimeout = 5 * time.Second
|
||||
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
cfg, err := config.LoadValidator()
|
||||
if err != nil {
|
||||
log.Fatalf("telegram: load config: %v", err)
|
||||
log.Fatalf("validator: load config: %v", err)
|
||||
}
|
||||
logger, err := newLogger(cfg.LogLevel)
|
||||
if err != nil {
|
||||
log.Fatalf("telegram: build logger: %v", err)
|
||||
log.Fatalf("validator: build logger: %v", err)
|
||||
}
|
||||
defer func() { _ = logger.Sync() }()
|
||||
|
||||
@@ -45,13 +45,12 @@ func main() {
|
||||
defer stop()
|
||||
|
||||
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
|
||||
// cancelled.
|
||||
func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
// run wires the validator gRPC server and serves it until the context is cancelled.
|
||||
func run(ctx context.Context, cfg config.ValidatorConfig, logger *zap.Logger) error {
|
||||
tel, err := pkgtel.New(ctx, cfg.Telemetry)
|
||||
if err != nil {
|
||||
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))
|
||||
}
|
||||
|
||||
// The single bot validates launch data and delivers push/admin messages.
|
||||
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)
|
||||
|
||||
srv := validator.NewServer(initdata.NewHMACValidator(cfg.Token), loginwidget.NewHMACValidator(cfg.Token))
|
||||
grpcServer := grpc.NewServer(grpc.StatsHandler(otelgrpc.NewServerHandler()))
|
||||
telegramv1.RegisterTelegramServer(grpcServer, srv)
|
||||
|
||||
@@ -92,18 +75,12 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
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() {
|
||||
<-ctx.Done()
|
||||
grpcServer.GracefulStop()
|
||||
}()
|
||||
|
||||
logger.Info("telegram connector starting",
|
||||
zap.String("grpc_addr", cfg.GRPCAddr),
|
||||
zap.String("miniapp_url", cfg.MiniAppURL),
|
||||
zap.Bool("test_env", cfg.TestEnv))
|
||||
logger.Info("telegram validator starting", zap.String("grpc_addr", cfg.GRPCAddr))
|
||||
if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
|
||||
return err
|
||||
}
|
||||
@@ -7,6 +7,7 @@ require (
|
||||
github.com/google/flatbuffers v23.5.26+incompatible
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.68.0
|
||||
go.uber.org/zap v1.27.1
|
||||
golang.org/x/time v0.15.0
|
||||
google.golang.org/grpc v1.80.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
scrabble/pkg v0.0.0
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
// Config configures the bot wrapper.
|
||||
@@ -25,6 +26,10 @@ type Config struct {
|
||||
TestEnv bool
|
||||
// MiniAppURL is the base URL of the Mini App launch button.
|
||||
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.
|
||||
@@ -32,6 +37,9 @@ type Bot struct {
|
||||
api *tgbot.Bot
|
||||
miniAppURL string
|
||||
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
|
||||
@@ -42,6 +50,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log}
|
||||
if cfg.SendRatePerSecond > 0 {
|
||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||
}
|
||||
|
||||
opts := []tgbot.Option{
|
||||
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
|
||||
// app at startParam (empty opens the lobby).
|
||||
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{
|
||||
ChatID: chatID,
|
||||
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).
|
||||
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})
|
||||
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
|
||||
// other message with a Mini App launch button.
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
@@ -6,73 +10,173 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkgtel "scrabble/pkg/telemetry"
|
||||
)
|
||||
|
||||
// Config is the Telegram connector's runtime configuration, read from the
|
||||
// environment. The bot token lives only in this process (ARCHITECTURE.md §12).
|
||||
type Config struct {
|
||||
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). It both
|
||||
// authenticates the Bot API client and is the HMAC secret for Mini App initData
|
||||
// and Login Widget validation.
|
||||
// ValidatorConfig is the home validator's runtime configuration.
|
||||
type ValidatorConfig struct {
|
||||
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). The
|
||||
// validator uses it only as the HMAC secret for Mini App initData and Login
|
||||
// Widget validation; it never calls the Bot API.
|
||||
Token string
|
||||
// GameChannelID is the chat id of the bot's game channel for SendToGameChannel
|
||||
// (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
|
||||
GameChannelID int64
|
||||
// GRPCAddr is the listen address of the connector gRPC server that gateway and
|
||||
// backend call (TELEGRAM_GRPC_ADDR, default :9091).
|
||||
// GRPCAddr is the listen address of the validator gRPC server the gateway calls
|
||||
// (TELEGRAM_VALIDATOR_GRPC_ADDR, default :9091).
|
||||
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 string
|
||||
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
|
||||
Telemetry pkgtel.Config
|
||||
}
|
||||
|
||||
// Load reads the connector configuration from the environment, applying defaults
|
||||
// and validating the required fields.
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Token: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
||||
GRPCAddr: envOr("TELEGRAM_GRPC_ADDR", ":9091"),
|
||||
MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"),
|
||||
APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"),
|
||||
TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true",
|
||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||
// BotConfig is the remote bot's runtime configuration.
|
||||
type BotConfig struct {
|
||||
// Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required).
|
||||
Token string
|
||||
// GameChannelID is the chat id of the bot's game channel for the admin channel
|
||||
// post (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts).
|
||||
GameChannelID int64
|
||||
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
||||
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
||||
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 != "" {
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("config: TELEGRAM_GAME_CHANNEL_ID %q: %w", v, err)
|
||||
}
|
||||
cfg.GameChannelID = id
|
||||
tel, err := loadTelemetry("scrabble-telegram-validator")
|
||||
if err != nil {
|
||||
return ValidatorConfig{}, err
|
||||
}
|
||||
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.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter)
|
||||
tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter)
|
||||
cfg.Telemetry = tel
|
||||
if cfg.Token == "" {
|
||||
return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required")
|
||||
if err := tel.Validate(); err != nil {
|
||||
return pkgtel.Config{}, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
if cfg.MiniAppURL == "" {
|
||||
return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required")
|
||||
return tel, nil
|
||||
}
|
||||
|
||||
// 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 Config{}, fmt.Errorf("config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
return "telegram-bot"
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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"
|
||||
)
|
||||
|
||||
// setRequired sets the required connector variables (the bot token + the Mini App
|
||||
// URL) so Load reaches the telemetry checks.
|
||||
func setRequired(t *testing.T) {
|
||||
// setBotRequired sets every required bot variable so LoadBot reaches the optional
|
||||
// parsing and telemetry checks.
|
||||
func setBotRequired(t *testing.T) {
|
||||
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_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
|
||||
// parsed when present.
|
||||
func TestLoadBot(t *testing.T) {
|
||||
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "bot-token")
|
||||
t.Setenv("TELEGRAM_GAME_CHANNEL_ID", "-100111")
|
||||
c, err := Load()
|
||||
// TestLoadValidator verifies the validator reads the token, defaults its address
|
||||
// and names its telemetry service.
|
||||
func TestLoadValidator(t *testing.T) {
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "secret")
|
||||
c, err := LoadValidator()
|
||||
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 {
|
||||
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.
|
||||
func TestLoadOptionalChannel(t *testing.T) {
|
||||
setRequired(t)
|
||||
c, err := Load()
|
||||
// TestLoadBotOwnsUpdatesOptOut verifies TELEGRAM_OWNS_UPDATES=false disables the
|
||||
// long-poll ownership.
|
||||
func TestLoadBotOwnsUpdatesOptOut(t *testing.T) {
|
||||
setBotRequired(t)
|
||||
t.Setenv("TELEGRAM_OWNS_UPDATES", "false")
|
||||
c, err := LoadBot()
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
t.Fatalf("LoadBot: %v", err)
|
||||
}
|
||||
if c.GameChannelID != 0 {
|
||||
t.Errorf("GameChannelID = %d, want 0", c.GameChannelID)
|
||||
if c.OwnsUpdates {
|
||||
t.Error("OwnsUpdates = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadRequiresBot verifies Load fails when no bot token is configured.
|
||||
func TestLoadRequiresBot(t *testing.T) {
|
||||
t.Setenv("TELEGRAM_MINIAPP_URL", "https://example.org/app")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error when no bot token is set, got nil")
|
||||
// TestLoadBotRequired verifies LoadBot fails when a required variable is missing.
|
||||
func TestLoadBotRequired(t *testing.T) {
|
||||
cases := []string{
|
||||
"TELEGRAM_BOT_TOKEN",
|
||||
"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
|
||||
// "scrabble-telegram" service name and both exporters off.
|
||||
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.
|
||||
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
|
||||
// fails validation (the validator path).
|
||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||
setRequired(t)
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "secret")
|
||||
t.Setenv("TELEGRAM_OTEL_TRACES_EXPORTER", "jaeger")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load: expected an error for an unsupported exporter, got nil")
|
||||
if _, err := LoadValidator(); err == 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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user