Files
scrabble-game/backend
Ilia Denisov cf66ed7e26
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s
Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
New platform/telegram connector (own container, bot token only there):
- go-telegram/bot long-poll loop: /start deep-links + Mini App launch button.
- gRPC API pkg/proto/telegram/v1 (Telegram service): ValidateInitData, Notify
  (renders a localized message + deep-link button), SendToUser/SendToGameChannel
  (admin, wired in Stage 10). Generic methods are platform-agnostic (external_id).
- Bot API base override for Telegram's test environment; Dockerfile + compose
  (VPN sidecar, no public ingress); README.

Gateway:
- initData validation relocated from the gateway into the connector; the gateway
  calls ValidateInitData over gRPC (GATEWAY_CONNECTOR_ADDR), drops the bot token,
  and deletes internal/auth.
- Out-of-app push: runPushPump routes events whose recipient has no live in-app
  stream to connector.Notify, gated by /internal/push-target + the in-app-only
  flag (race-free de-dup); HasSubscribers added to the push hub.

Backend:
- Migration 00007 accounts.notifications_in_app_only (default true) + jetgen.
- ProvisionTelegram seeds a new account's language/display name from the launch
  fields; IdentityExternalID reverse lookup; /internal/push-target handler.

UI:
- Telegram Mini App launch: detect initData, apply themeParams, authTelegram,
  route the deep-link start_param (g/i/f); /telegram/ guard redirects outside
  Telegram. Vite relative base + telegram-web-app.js. In-app-only profile toggle;
  share-to-Telegram link for a friend code. Vitest + Playwright coverage.

Wire/docs/CI: fbs Profile/UpdateProfileRequest gain notifications_in_app_only
(Go + TS); go.work uses ./platform/telegram; go-unit.yaml covers it; PLAN,
ARCHITECTURE, FUNCTIONAL (+ru), UI_DESIGN, READMEs updated.
2026-06-04 07:10:21 +02:00
..

backend

Internal-only domain service for the Scrabble platform (module scrabble/backend). It owns identity/sessions, accounts, and — in later stages — the lobby, game runtime, robot, chat, history and administration. Its only network consumers are the gateway and the platform side-services; it is never exposed publicly.

As of Stage 1 the backend provides the foundation: configuration, the HTTP listener with the /api/v1 route-group skeleton and probes, the Postgres pool with embedded goose migrations, OpenTelemetry wiring, an in-memory session cache, and the durable accounts / identities / sessions data model. The session and account REST endpoints are added with the gateway (Stage 6); Stage 1 ships the store/service layer they will call.

Stage 2 adds internal/engine, the in-process bridge to the scrabble-solver library: a versioned dictionary registry, a deterministic tile bag, and a pure rules Game (legal plays, passes, exchanges, resignations and end-condition detection) that emits dictionary-independent move records. It is a library only; the game domain wires it into the process in Stage 3.

Stage 3 adds internal/game, the game domain over the engine. Active games are event-sourced: a games row plus an append-only decoded move journal, with the live engine.Game kept warm in a cache and rebuilt by replay on a miss. It provides create, the play/pass/exchange/resign transitions, an unlimited score/legality preview, the hint (per-game allowance plus a profile wallet), the word-check tool with complaint capture, per-player game state, history and GCG export, per-account statistics on finish, and a background turn-timeout sweeper that auto-resigns overdue turns (honouring each player's daily away window). Like Stages 12 it is a service/store layer; the HTTP surface lands with the gateway (Stage 6).

Stage 4 adds the lobby and social fabric. internal/lobby holds an in-memory matchmaking pool (FIFO per variant, pairs two humans into an auto-match) and friend-game invitations (invite → accept, starting a 24 player game once every invitee accepts). internal/social owns the friend graph (request/accept), per-user blocks, and per-game chat with nudges folded in as a message kind; chat messages are length-capped, content-filtered (no links/emails/phone numbers, including obfuscated forms) and stored with the sender's IP. internal/account gains profile editing and the email confirm-code flow (a Mailer seam: SMTP or a development log mailer). The engine now also handles multi-player drop-out: in a 34 player game a resignation or timeout drops that seat and the rest play on (the tile disposition is a per-game setting), the game ending when one active seat remains. As before this is a service/store layer — chat and nudges are persisted but their live delivery, and all REST endpoints, arrive with the gateway (Stage 6); the services are exposed via Server accessors for those handlers.

Stage 5 adds the robot opponent (internal/robot). A pool of durable accounts — each a kind='robot' identity, provisioned at startup with chat and friend requests blocked — backs a human-like name pool. A background driver plays the robot's moves through the public game API as an ordinary seated player (so only internal/engine imports the solver): it decides once per game whether to play to win (≈ 40%), targets a small score margin, and times its moves with a right-skewed delay, a night-sleep window anchored to the opponent's timezone, and nudge behaviour — all derived deterministically from the game seed, so it keeps no extra state. The matchmaker now substitutes a pooled robot after a 10-second wait and exposes Poll so a waiting player can collect the started game (the live match-found notification arrives with the gateway).

Stage 6 opens the backend to the edge. The route groups gain their first handlers (internal/server/handlers_*.go): gateway-only session endpoints under /api/v1/internal (Telegram/guest/email login → mint, resolve, revoke) and a slice of authenticated /api/v1/user operations (profile, submit play, game state, lobby enqueue/poll, chat). Stage 8 fills in the social/account/history operations under /api/v1/user: friends/* (request/respond/cancel/unfriend, list/incoming, the one-time code issue/redeem), blocks/*, invitations/* (create/accept/decline/cancel/list), PUT profile, email/{request,confirm}, stats, and games/:id/gcg (finished-only). A new internal/notify hub feeds a second listener — internal/pushgrpc, a gRPC server (BACKEND_GRPC_ADDR) streaming live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the gateway. Stage 9 adds the gateway-only POST /api/v1/internal/push-target (a user's Telegram external_id, language and notifications_in_app_only flag) that the gateway uses to route out-of-app push to the Telegram connector, extends the Telegram login to seed a new account's language and display name from the launch fields, and adds migration 00007 (accounts.notifications_in_app_only, default true). Migration 00005 adds accounts.is_guest: an ephemeral guest is a durable row with no identity, excluded from statistics. The shared wire contracts live in the sibling ../pkg module.

Package layout

cmd/backend/         # entrypoint: telemetry -> db+migrate -> registry -> cache -> game+sweeper -> robot pool+driver -> lobby+social -> server
cmd/jetgen/          # dev tool: regenerate go-jet code from a throwaway container
internal/config/     # env configuration (composes postgres + telemetry + game config)
internal/telemetry/  # OpenTelemetry providers + per-request timing middleware
internal/postgres/   # pgx-over-database/sql pool (otelsql), goose migrations
  migrations/        #   embedded *.sql (goose), schema `backend`
  jet/               #   generated go-jet models + table builders (committed)
internal/account/    # durable accounts + platform/email identities (store)
internal/session/    # opaque tokens, sessions store, write-through cache, service
internal/server/     # gin engine, route groups, X-User-ID middleware, probes
internal/engine/     # in-process scrabble-solver bridge: registry, bag, Game, replay
internal/game/       # game domain: lifecycle, journal+cache, hint, word-check, GCG, sweeper
internal/social/     # friend graph, per-user blocks, per-game chat + nudge, content filter
internal/lobby/      # in-memory matchmaking pool (+ robot substitution) + friend-game invitations
internal/robot/      # human-like robot opponent: account pool, seed-derived strategy, move driver

Configuration (environment)

Variable Default Notes
BACKEND_HTTP_ADDR :8080 HTTP (REST) listen address.
BACKEND_GRPC_ADDR :9090 gRPC listen address for the live-event push stream to the gateway.
BACKEND_LOG_LEVEL info debug / info / warn / error.
BACKEND_POSTGRES_DSN Required. pgx/libpq URL; must pin search_path=backend.
BACKEND_POSTGRES_MAX_OPEN_CONNS 25 Pool max open connections.
BACKEND_POSTGRES_MAX_IDLE_CONNS 5 Pool max idle connections.
BACKEND_POSTGRES_CONN_MAX_LIFETIME 30m Max connection lifetime.
BACKEND_POSTGRES_OPERATION_TIMEOUT 5s Connect attempt + /readyz ping bound.
BACKEND_SERVICE_NAME scrabble-backend OpenTelemetry service.name.
BACKEND_OTEL_TRACES_EXPORTER none none or stdout (OTLP arrives later).
BACKEND_OTEL_METRICS_EXPORTER none none or stdout.
BACKEND_DICT_DIR Required. Directory of committed .dawg dictionaries.
BACKEND_DICT_VERSION v1 Dictionary version new games pin.
BACKEND_GAME_TIMEOUT_SWEEP_INTERVAL 1m How often the turn-timeout sweeper runs.
BACKEND_GAME_CACHE_TTL 24h Idle window before a live game is evicted from cache.
BACKEND_LOBBY_ROBOT_WAIT 10s Auto-match wait before a robot is substituted for a missing human.
BACKEND_LOBBY_REAPER_INTERVAL 1s How often the substitution reaper scans for over-waited players.
BACKEND_ROBOT_DRIVE_INTERVAL 30s How often the robot driver scans for due robot turns.
BACKEND_SMTP_HOST Email relay host. Empty selects the development log mailer (the confirm-code is logged, not sent).
BACKEND_SMTP_PORT 587 Email relay port.
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.

Run

docker run -d --name scrabble-pg -e POSTGRES_PASSWORD=dev -p 5432:5432 postgres:17-alpine
BACKEND_POSTGRES_DSN='postgres://postgres:dev@localhost:5432/postgres?search_path=backend&sslmode=disable' \
  BACKEND_DICT_DIR=../../scrabble-solver/dawg \
  go run ./cmd/backend

On boot the backend opens the pool, creates the backend schema if needed, applies the embedded migrations, loads the dictionaries into the engine registry (a hard dependency — a missing dictionary aborts the boot), warms the session cache and starts the game turn-timeout sweeper. GET /healthz reports liveness; GET /readyz reports 200 only when the database answers and the session cache is warmed.

Migrations & generated code

Migrations are plain goose SQL under internal/postgres/migrations (sequential NNNNN_name.sql), embedded and applied at startup. After changing the schema, regenerate the committed go-jet code (needs Docker):

go run ./cmd/jetgen     # rewrites internal/postgres/jet against a temp container

Engine & dictionaries

internal/engine consumes the sibling scrabble-solver module in-process. Its bare module path (scrabble-solver, not a URL) cannot be fetched via VCS, so the workspace go.work carries replace scrabble-solver => ../scrabble-solver and the build must run from the repository root (the workspace), not from this module in isolation. github.com/iliadenisov/dafsa (the DAWG loader) is a direct dependency. CI clones the public solver repository into ../scrabble-solver before building (see .gitea/workflows/); locally, check it out next to this repository. Committed dictionaries (en_sowpods.dawg, ru_scrabble.dawg, ru_erudit.dawg) live in the solver's dawg/ directory; the engine loads them by (variant, dict_version) from a directory path. Since Stage 3 the backend loads them at startup from the required BACKEND_DICT_DIR (a missing dictionary aborts the boot); the future versioned-artifact direction is recorded in ../PLAN.md TODO-2.

Tests

go test -count=1 ./...                       # unit tests (no Docker)
go test -tags=integration -count=1 -p=1 ./... # Postgres-backed (needs Docker)

Integration tests are guarded by the integration build tag and run against a throwaway postgres:17-alpine container; they fail loudly when Docker is absent rather than skipping. The internal/engine tests load the committed DAWGs from BACKEND_DICT_DIR (defaulting to the sibling ../scrabble-solver/dawg) and fail loudly when that directory is absent.