From 9814d78ae3ce450f6d42743d1c850c308a862254 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 4 Jun 2026 01:42:54 +0200 Subject: [PATCH] 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. --- .dockerignore | 6 + .gitea/workflows/go-unit.yaml | 8 +- CLAUDE.md | 4 +- PLAN.md | 91 +++- backend/README.md | 6 +- backend/internal/account/account.go | 124 ++++- backend/internal/account/profile.go | 21 +- backend/internal/account/provision_test.go | 48 ++ backend/internal/inttest/account_test.go | 110 ++++ .../postgres/jet/backend/model/accounts.go | 25 +- .../postgres/jet/backend/table/accounts.go | 81 +-- .../00007_telegram_notifications.sql | 17 + backend/internal/server/dto.go | 42 +- backend/internal/server/handlers.go | 4 + backend/internal/server/handlers_account.go | 30 +- backend/internal/server/handlers_auth.go | 64 ++- docs/ARCHITECTURE.md | 60 ++- docs/FUNCTIONAL.md | 24 +- docs/FUNCTIONAL_ru.md | 24 +- docs/UI_DESIGN.md | 6 +- gateway/README.md | 7 +- gateway/cmd/gateway/main.go | 53 +- gateway/internal/auth/telegram_test.go | 92 ---- gateway/internal/backendclient/api.go | 51 +- gateway/internal/backendclient/api_social.go | 15 +- gateway/internal/config/config.go | 27 +- gateway/internal/connector/client.go | 82 +++ gateway/internal/connector/routing.go | 23 + gateway/internal/connector/routing_test.go | 38 ++ gateway/internal/push/hub.go | 15 + gateway/internal/push/hub_test.go | 18 + gateway/internal/transcode/encode.go | 1 + gateway/internal/transcode/transcode.go | 22 +- .../internal/transcode/transcode_social.go | 15 +- .../transcode/transcode_social_test.go | 16 +- .../transcode/transcode_telegram_test.go | 91 ++++ go.work | 1 + go.work.sum | 7 +- pkg/fbs/scrabble.fbs | 8 +- pkg/fbs/scrabblefb/Profile.go | 17 +- pkg/fbs/scrabblefb/UpdateProfileRequest.go | 17 +- pkg/proto/telegram/v1/telegram.pb.go | 508 ++++++++++++++++++ pkg/proto/telegram/v1/telegram.proto | 83 +++ pkg/proto/telegram/v1/telegram_grpc.pb.go | 276 ++++++++++ platform/telegram/Dockerfile | 22 + platform/telegram/README.md | 86 +++ platform/telegram/cmd/telegram/main.go | 94 ++++ platform/telegram/deploy/docker-compose.yml | 58 ++ platform/telegram/go.mod | 12 + platform/telegram/internal/bot/bot.go | 155 ++++++ platform/telegram/internal/bot/bot_test.go | 100 ++++ platform/telegram/internal/config/config.go | 72 +++ .../telegram/internal/connector/server.go | 114 ++++ .../internal/connector/server_test.go | 155 ++++++ .../telegram/internal/deeplink/deeplink.go | 56 ++ .../internal/deeplink/deeplink_test.go | 41 ++ .../telegram/internal/initdata/validator.go | 68 +-- .../internal/initdata/validator_test.go | 85 +++ platform/telegram/internal/render/render.go | 80 +++ .../telegram/internal/render/render_test.go | 115 ++++ ui/e2e/fixtures.ts | 18 + ui/e2e/game.spec.ts | 2 +- ui/e2e/smoke.spec.ts | 2 +- ui/e2e/social.spec.ts | 2 +- ui/e2e/telegram.spec.ts | 45 ++ ui/e2e/zoom.spec.ts | 2 +- ui/index.html | 3 + ui/src/gen/fbs/scrabblefb/profile.ts | 14 +- .../fbs/scrabblefb/update-profile-request.ts | 14 +- ui/src/lib/app.svelte.ts | 53 +- ui/src/lib/client.ts | 1 + ui/src/lib/codec.ts | 10 + ui/src/lib/deeplink.test.ts | 38 ++ ui/src/lib/deeplink.ts | 49 ++ ui/src/lib/i18n/en.ts | 2 + ui/src/lib/i18n/ru.ts | 2 + ui/src/lib/mock/client.ts | 3 + ui/src/lib/mock/data.ts | 1 + ui/src/lib/model.ts | 3 + ui/src/lib/telegram.test.ts | 39 ++ ui/src/lib/telegram.ts | 67 +++ ui/src/lib/transport.ts | 3 + ui/src/screens/Friends.svelte | 5 + ui/src/screens/Profile.svelte | 7 + ui/vite.config.ts | 3 + 85 files changed, 3607 insertions(+), 372 deletions(-) create mode 100644 .dockerignore create mode 100644 backend/internal/account/provision_test.go create mode 100644 backend/internal/postgres/migrations/00007_telegram_notifications.sql delete mode 100644 gateway/internal/auth/telegram_test.go create mode 100644 gateway/internal/connector/client.go create mode 100644 gateway/internal/connector/routing.go create mode 100644 gateway/internal/connector/routing_test.go create mode 100644 gateway/internal/transcode/transcode_telegram_test.go create mode 100644 pkg/proto/telegram/v1/telegram.pb.go create mode 100644 pkg/proto/telegram/v1/telegram.proto create mode 100644 pkg/proto/telegram/v1/telegram_grpc.pb.go create mode 100644 platform/telegram/Dockerfile create mode 100644 platform/telegram/README.md create mode 100644 platform/telegram/cmd/telegram/main.go create mode 100644 platform/telegram/deploy/docker-compose.yml create mode 100644 platform/telegram/go.mod create mode 100644 platform/telegram/internal/bot/bot.go create mode 100644 platform/telegram/internal/bot/bot_test.go create mode 100644 platform/telegram/internal/config/config.go create mode 100644 platform/telegram/internal/connector/server.go create mode 100644 platform/telegram/internal/connector/server_test.go create mode 100644 platform/telegram/internal/deeplink/deeplink.go create mode 100644 platform/telegram/internal/deeplink/deeplink_test.go rename gateway/internal/auth/telegram.go => platform/telegram/internal/initdata/validator.go (61%) create mode 100644 platform/telegram/internal/initdata/validator_test.go create mode 100644 platform/telegram/internal/render/render.go create mode 100644 platform/telegram/internal/render/render_test.go create mode 100644 ui/e2e/fixtures.ts create mode 100644 ui/e2e/telegram.spec.ts create mode 100644 ui/src/lib/deeplink.test.ts create mode 100644 ui/src/lib/deeplink.ts create mode 100644 ui/src/lib/telegram.test.ts create mode 100644 ui/src/lib/telegram.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cd702d6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +# Keep Docker build contexts small (the connector builds from the repo root). +.git +**/node_modules +ui/dist +ui/test-results +ui/playwright-report diff --git a/.gitea/workflows/go-unit.yaml b/.gitea/workflows/go-unit.yaml index 051a23b..7d1a4f3 100644 --- a/.gitea/workflows/go-unit.yaml +++ b/.gitea/workflows/go-unit.yaml @@ -11,6 +11,7 @@ on: - 'backend/**' - 'gateway/**' - 'pkg/**' + - 'platform/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/go-unit.yaml' @@ -20,6 +21,7 @@ on: - 'backend/**' - 'gateway/**' - 'pkg/**' + - 'platform/**' - 'go.work' - 'go.work.sum' - '.gitea/workflows/go-unit.yaml' @@ -56,10 +58,10 @@ jobs: fi - name: vet - run: go vet ./backend/... ./pkg/... ./gateway/... + run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... - name: build - run: go build ./backend/... ./pkg/... ./gateway/... + run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... - name: test # -count=1 disables the test cache so a green run never depends on a @@ -67,4 +69,4 @@ jobs: # tests at the committed DAWGs in the sibling checkout. env: BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg - run: go test -count=1 ./backend/... ./pkg/... ./gateway/... + run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/... diff --git a/CLAUDE.md b/CLAUDE.md index 10e9cf0..3676bba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,7 +111,8 @@ backend/ # module scrabble/backend internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes internal/inttest/ # //go:build integration Postgres-backed tests docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md -gateway/ ui/ pkg/ platform/ # added by their stages +gateway/ ui/ pkg/ # added by their stages +platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API ``` ## Build & test @@ -121,6 +122,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 run ./backend/cmd/backend # /healthz, /readyz on :8080 cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) diff --git a/PLAN.md b/PLAN.md index db14e9a..a7ac569 100644 --- a/PLAN.md +++ b/PLAN.md @@ -42,7 +42,7 @@ independent (see ARCHITECTURE §9.1). | 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** | | 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** | -| 9 | Telegram integration (bot side-service, deep-link, push) | todo | +| 9 | Telegram integration (bot side-service, deep-link, push) | **done** | | 10 | Admin & dictionary ops (complaint review, version reload) | todo | | 11 | Account linking & merge | todo | | 12 | Polish (observability, perf with evidence, deploy) | todo | @@ -605,6 +605,84 @@ Open details: deployment target/host; dashboards; load expectations. OS and can't be forced to match, and a select also avoids the iOS "clear" button that would empty a time field. +- **Stage 9** (interview + implementation): + - **Connector as its own container** (interview): the Telegram side-service is a + standalone module `platform/telegram` (binary `cmd/telegram`) holding the bot + token **only there**; gateway and backend reach it by **unauthenticated gRPC** + on the trusted internal network, and it egresses to `api.telegram.org` through a + **VPN sidecar** (`deploy/docker-compose.yml`, mirroring `../15-puzzle`). Bot + library **`github.com/go-telegram/bot`** (one new dep), **long-poll** updates. + - **initData validation moved off the gateway** (interview): the gateway's HMAC + validator was **relocated** into the connector (`internal/initdata`, now also + returning `language_code`); the gateway calls `connector.ValidateInitData` over + gRPC during `auth.telegram`. The hop is negligible (loopback gRPC, once per + login). `GATEWAY_TELEGRAM_BOT_TOKEN` is gone; `GATEWAY_CONNECTOR_ADDR` replaces + it. The `gateway/internal/auth` package was deleted. + - **Connector gRPC API** (`pkg/proto/telegram/v1`, service `Telegram`): the + generic methods are **platform-agnostic**, keyed by the identity `external_id` + (so a future VK/MAX connector reuses them); only `ValidateInitData` is + Telegram-specific. Methods: `ValidateInitData`, **`Notify`** (the out-of-app push + — renders a localized message + a Mini App deep-link button from the FlatBuffers + payload), `SendToUser` and `SendToGameChannel` (arbitrary admin messages — built + and unit-tested now, **wired to the admin surface in Stage 10**; the game channel + id lives only in connector config). + - **Push = fallback, gateway-routed, de-dup by presence** (interview): the gateway + already consumes the firehose and knows in-app presence (`push.Hub.HasSubscribers`), + so it decides in-app vs out-of-app **atomically**: for a recipient with **no live + in-app stream** it fetches a new backend `/internal/push-target` + (`{external_id, language, notifications_in_app_only}`) and calls `connector.Notify` + only when they have a Telegram identity and have **not** set the new flag. Push + set: `your_turn`, `nudge`, `match_found`, and the `notify` sub-kinds `invitation`/ + `friend_request` (the connector skips the rest). Delivery runs in a goroutine so a + slow connector never stalls the firehose; best-effort (no cursor resume — single + instance, §10). + - **Profile flag `notifications_in_app_only`** (interview, **default true** → push + is **opt-in**): migration `00007` (+ jetgen), threaded through + `account.Profile`/`UpdateProfile`, the REST DTOs, the fbs `Profile`/ + `UpdateProfileRequest` (default `true` in the schema so an unset field reads + conservatively), and a Profile-screen toggle. Flagged at review: the channel is + silent until a user turns it off. + - **Language seeding from the platform** (discharges the Stage 8 forward-note): + `account.ProvisionTelegram` seeds a **brand-new** account's `preferred_language` + from the Telegram `language_code` and its display name from `first_name`/ + `username` (existing accounts untouched); the UI's `adoptSession` already adopts + the server language when the user has not locked a locale, so no extra UI seeding + was needed. The gateway forwards the fields from `ValidateInitData`. + - **Mini App = `/telegram/` + guard** (interview): the gateway serves the one SPA + build under `/telegram/` (Vite **relative base**; the hash router is + path-agnostic). The UI detects a Telegram launch by `Telegram.WebApp.initData`, + applies `themeParams`, authenticates via the existing `auth.telegram` op (UI + `authTelegram` codec/client/transport/mock added), and routes the deep-link + `start_param` (`g`/`i`/`f` → game / lobby-invitation / friend-code redeem). On the + `/telegram/` path **without** initData it redirects to the site root. The official + `telegram-web-app.js` loads from `index.html` (harmless outside Telegram). + - **Deep-link scheme** (shared Go `platform/telegram/internal/deeplink` ↔ TS + `ui/src/lib/deeplink.ts`): `g` / `i` / `f<6-digit + code>` / empty = lobby. A friend-code **share-to-Telegram** link is shown when + `VITE_TELEGRAM_LINK` is configured (**partially discharges TODO-5**; QR still + open). The `Notify` button and the bot `/start` reply both wrap the payload as + `?startapp=`. + - **Test environment** (interview nuance): the Bot API base is overridable for + Telegram's test environment — `TELEGRAM_TEST_ENV=true` suffixes the token with + `/test` so the client hits `/bot/test/METHOD` (`TELEGRAM_API_BASE_URL` + overrides the host for a mock/self-hosted server). + - **Deploy groundwork** (interview): `platform/telegram/Dockerfile` (builds the + connector standalone — drops backend/gateway and the solver replace from a copy + of `go.work`, validated with `docker build`) + the connector-scoped compose with + the VPN sidecar; a root `.dockerignore`. **No public ingress** for the connector + (long-poll + sidecar egress); the host reverse proxy routes only to the gateway + port, which serves the Mini App. The full multi-service deploy is **Stage 12**. + - **Wire/codegen/CI**: new proto `pkg/proto/telegram/v1` (committed Go); fbs + `Profile`/`UpdateProfileRequest` gained `notifications_in_app_only` (committed Go + + TS). `go.work` gains `use ./platform/telegram`; deps via `go mod edit` + + `go work sync` (no-tidy). `go-unit.yaml` gained the `platform/**` path filter and + builds/vets/tests `./platform/telegram/...`. UI grows to ~86 KB gzip JS (budget + 100 KB). The connector's unit tests use an httptest fake Bot API; a Playwright + smoke drives the Mini App launch + guard with an injected `window.Telegram`. + - **Stage 10 forward-note**: the admin surface will wire `connector.SendToUser`/ + `SendToGameChannel` (backend gains its own connector client) for operator + broadcasts to a user and the game channel. + ## Deferred TODOs (cross-stage) - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, @@ -637,11 +715,12 @@ Open details: deployment target/host; dashboards; load expectations. value)` table so the UI stops duplicating it, and optionally moving tile exchange to letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift. -- **TODO-5 — QR / deep-link friend codes (owner's idea, Stage 8).** The one-time - friend code is entered by hand today. Once the Telegram/native deep-link scheme - exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can - add you by scanning rather than typing. The code semantics (12 h TTL, single use, - one active per issuer) stay as-is; only the delivery changes. +- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:* + the deep-link scheme now exists (`f`, shared Go ↔ TS), the bot redeems it on + launch, and the UI shows a **share-to-Telegram** link for an issued code when + `VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a + friend can add you by scanning rather than tapping/typing. The code semantics + (12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes. - **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).** The play-with-friends form has no preselected variant today (an empty, required pick). Default it from the player's history (the variant they play most, from diff --git a/backend/README.md b/backend/README.md index 909c304..8646114 100644 --- a/backend/README.md +++ b/backend/README.md @@ -67,7 +67,11 @@ list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*` `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. +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`](../pkg) module. diff --git a/backend/internal/account/account.go b/backend/internal/account/account.go index 48b38ad..5788c90 100644 --- a/backend/internal/account/account.go +++ b/backend/internal/account/account.go @@ -10,7 +10,9 @@ import ( "database/sql" "errors" "fmt" + "strings" "time" + "unicode/utf8" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" @@ -56,9 +58,13 @@ type Account struct { BlockFriendRequests bool // IsGuest marks an ephemeral guest account: a durable row with no identity, // excluded from statistics, friends and history. - IsGuest bool - CreatedAt time.Time - UpdatedAt time.Time + IsGuest bool + // NotificationsInAppOnly confines notifications to the in-app live stream when + // true (the default): the platform side-service skips out-of-app push for the + // account (Stage 9). + NotificationsInAppOnly bool + CreatedAt time.Time + UpdatedAt time.Time } // Store is the Postgres-backed query surface for accounts and identities. @@ -77,6 +83,22 @@ func NewStore(db *sql.DB) *Store { // resolved by re-reading the winner's account. A platform identity is recorded // as confirmed; an email identity starts unconfirmed. func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) { + return s.provision(ctx, kind, externalID, provisionSeed{}) +} + +// ProvisionTelegram provisions (or finds) the account bound to a Telegram +// identity. On first contact only, it seeds the new account's preferred language +// from the Telegram client languageCode (when it maps to a supported language) and +// its display name from firstName (falling back to username); an already-existing +// account is returned unchanged, so a later profile edit is never overwritten. +func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) { + return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName)) +} + +// provision finds the account for (kind, externalID) or creates it with seed, +// collapsing a concurrent-create race on the identity unique constraint into a +// re-read of the winner's account. +func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) { acc, err := s.findByIdentity(ctx, kind, externalID) if err == nil { return acc, nil @@ -85,7 +107,7 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return Account{}, err } - acc, err = s.create(ctx, kind, externalID) + acc, err = s.create(ctx, kind, externalID, seed) if err != nil { if isUniqueViolation(err) { // A concurrent caller created the identity first; return theirs. @@ -96,6 +118,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string return acc, nil } +// provisionSeed carries the optional create-time profile seed for a brand-new +// account (Telegram first contact). Empty fields fall back to the accounts table +// defaults, so an unknown language keeps the 'en' default and an empty name keeps +// the ” default. +type provisionSeed struct { + preferredLanguage string + displayName string +} + +// telegramSeed derives the create-time seed from Telegram launch fields: a +// supported preferred language from languageCode (an ISO-639 code, possibly +// region-tagged like "ru-RU"), and a display name from firstName or, failing that, +// username (capped to maxDisplayName runes). +func telegramSeed(languageCode, username, firstName string) provisionSeed { + var seed provisionSeed + if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" { + seed.preferredLanguage = lang + } + name := strings.TrimSpace(firstName) + if name == "" { + name = strings.TrimSpace(username) + } + if utf8.RuneCountInString(name) > maxDisplayName { + name = string([]rune(name)[:maxDisplayName]) + } + seed.displayName = name + return seed +} + // GetByID loads the account identified by id, or ErrNotFound when it is absent. func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) { stmt := postgres.SELECT(table.Accounts.AllColumns). @@ -113,6 +164,29 @@ func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) { return modelToAccount(row), nil } +// IdentityExternalID returns the external_id of the account's identity of the +// given kind, or ErrNotFound when the account has no such identity. The Telegram +// side-service uses it (through the gateway push-target lookup) to address an +// out-of-app notification to a recipient's Telegram chat. +func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) { + stmt := postgres.SELECT(table.Identities.ExternalID). + FROM(table.Identities). + WHERE( + table.Identities.AccountID.EQ(postgres.UUID(accountID)). + AND(table.Identities.Kind.EQ(postgres.String(kind))), + ). + LIMIT(1) + + var row model.Identities + if err := stmt.QueryContext(ctx, s.db, &row); err != nil { + if errors.Is(err, qrm.ErrNoRows) { + return "", ErrNotFound + } + return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err) + } + return row.ExternalID, nil +} + // findByIdentity joins identities to accounts and returns the matching account, // or ErrNotFound. func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { @@ -137,9 +211,9 @@ func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Ac return modelToAccount(row), nil } -// create inserts a new account and its first identity inside one transaction -// and returns the persisted account row. -func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) { +// create inserts a new account (seeded from seed) and its first identity inside +// one transaction and returns the persisted account row. +func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) { accountID, err := uuid.NewV7() if err != nil { return Account{}, fmt.Errorf("account: new account id: %w", err) @@ -151,9 +225,16 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e var created Account err = withTx(ctx, s.db, func(tx *sql.Tx) error { + // Seed the new row's display name and language (Telegram first contact); an + // empty seed reproduces the table defaults ('' and 'en') the other callers + // relied on, so their behaviour is unchanged. + lang := seed.preferredLanguage + if lang == "" { + lang = "en" + } insertAccount := table.Accounts. - INSERT(table.Accounts.AccountID). - VALUES(accountID). + INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage). + VALUES(accountID, seed.displayName, lang). RETURNING(table.Accounts.AllColumns) var row model.Accounts @@ -230,18 +311,19 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) { // modelToAccount projects a generated model row into the public Account struct. func modelToAccount(row model.Accounts) Account { return Account{ - ID: row.AccountID, - DisplayName: row.DisplayName, - PreferredLanguage: row.PreferredLanguage, - TimeZone: row.TimeZone, - AwayStart: row.AwayStart, - AwayEnd: row.AwayEnd, - HintBalance: int(row.HintBalance), - BlockChat: row.BlockChat, - BlockFriendRequests: row.BlockFriendRequests, - IsGuest: row.IsGuest, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, + ID: row.AccountID, + DisplayName: row.DisplayName, + PreferredLanguage: row.PreferredLanguage, + TimeZone: row.TimeZone, + AwayStart: row.AwayStart, + AwayEnd: row.AwayEnd, + HintBalance: int(row.HintBalance), + BlockChat: row.BlockChat, + BlockFriendRequests: row.BlockFriendRequests, + IsGuest: row.IsGuest, + NotificationsInAppOnly: row.NotificationsInAppOnly, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, } } diff --git a/backend/internal/account/profile.go b/backend/internal/account/profile.go index 814cca5..03f6150 100644 --- a/backend/internal/account/profile.go +++ b/backend/internal/account/profile.go @@ -39,13 +39,14 @@ var ErrInvalidProfile = errors.New("account: invalid profile") // and AwayEnd carry only the hour and minute of the daily away window, in the // account's TimeZone. type ProfileUpdate struct { - DisplayName string - PreferredLanguage string // "en" or "ru" - TimeZone string // an IANA location name - AwayStart time.Time - AwayEnd time.Time - BlockChat bool - BlockFriendRequests bool + DisplayName string + PreferredLanguage string // "en" or "ru" + TimeZone string // an IANA location name + AwayStart time.Time + AwayEnd time.Time + BlockChat bool + BlockFriendRequests bool + NotificationsInAppOnly bool } // UpdateProfile validates and overwrites the editable fields of the account, then @@ -71,11 +72,13 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate stmt := table.Accounts.UPDATE( table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.AwayStart, table.Accounts.AwayEnd, - table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt, + table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, + table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt, ).SET( postgres.String(name), postgres.String(lang), postgres.String(tz), postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd), - postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()), + postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), + postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()), ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))). RETURNING(table.Accounts.AllColumns) diff --git a/backend/internal/account/provision_test.go b/backend/internal/account/provision_test.go new file mode 100644 index 0000000..5417f13 --- /dev/null +++ b/backend/internal/account/provision_test.go @@ -0,0 +1,48 @@ +package account + +import ( + "strings" + "testing" + "unicode/utf8" +) + +// TestTelegramSeed covers the pure mapping from Telegram launch fields to the +// create-time account seed: supported-language detection (bare and region-tagged), +// the first-name / username display-name precedence, and trimming. +func TestTelegramSeed(t *testing.T) { + cases := map[string]struct { + languageCode, username, firstName string + wantLang, wantName string + }{ + "ru bare": {"ru", "user", "Иван", "ru", "Иван"}, + "en region-tagged": {"en-US", "user", "John", "en", "John"}, + "ru region-tagged": {"ru-RU", "", "Пётр", "ru", "Пётр"}, + "unknown language": {"fr", "frodo", "Frodo", "", "Frodo"}, + "empty language": {"", "neo", "Neo", "", "Neo"}, + "first name wins": {"en", "handle", "Real Name", "en", "Real Name"}, + "username fallback": {"en", "handle", "", "en", "handle"}, + "both empty": {"en", "", "", "en", ""}, + "trimmed": {" RU ", " ", " Anna ", "ru", "Anna"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := telegramSeed(tc.languageCode, tc.username, tc.firstName) + if got.preferredLanguage != tc.wantLang { + t.Errorf("preferredLanguage = %q, want %q", got.preferredLanguage, tc.wantLang) + } + if got.displayName != tc.wantName { + t.Errorf("displayName = %q, want %q", got.displayName, tc.wantName) + } + }) + } +} + +// TestTelegramSeedTruncatesLongName checks an over-long Telegram name is capped to +// maxDisplayName runes (counted in runes, not bytes). +func TestTelegramSeedTruncatesLongName(t *testing.T) { + long := strings.Repeat("я", maxDisplayName+5) + got := telegramSeed("ru", "", long) + if n := utf8.RuneCountInString(got.displayName); n != maxDisplayName { + t.Errorf("display name rune count = %d, want %d", n, maxDisplayName) + } +} diff --git a/backend/internal/inttest/account_test.go b/backend/internal/inttest/account_test.go index 314f2c4..bcf3bad 100644 --- a/backend/internal/inttest/account_test.go +++ b/backend/internal/inttest/account_test.go @@ -104,3 +104,113 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool { } return confirmed } + +// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact +// seeds the new account's language and display name from the launch fields, +// defaults the in-app-only flag on, and never overwrites an existing account on a +// later login (Stage 9 language seeding). +func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + ext := "tg-" + uuid.NewString() + + acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван") + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if acc.PreferredLanguage != "ru" { + t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage) + } + if acc.DisplayName != "Иван" { + t.Errorf("DisplayName = %q, want Иван", acc.DisplayName) + } + if !acc.NotificationsInAppOnly { + t.Error("NotificationsInAppOnly should default to true") + } + + // A later login with different fields returns the same account, unchanged. + again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other") + if err != nil { + t.Fatalf("re-provision telegram: %v", err) + } + if again.ID != acc.ID { + t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID) + } + if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" { + t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName) + } +} + +// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram +// client language falls back to the account default rather than failing the +// language CHECK. +func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) { + ctx := context.Background() + acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "") + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if acc.PreferredLanguage != "en" { + t.Errorf("PreferredLanguage = %q, want default en", acc.PreferredLanguage) + } +} + +// TestIdentityExternalID covers the reverse identity lookup the push-target route +// uses: it returns the external_id for the matching kind and ErrNotFound otherwise, +// including for a guest that carries no identity. +func TestIdentityExternalID(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + ext := "tg-" + uuid.NewString() + acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User") + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + got, err := store.IdentityExternalID(ctx, acc.ID, account.KindTelegram) + if err != nil { + t.Fatalf("identity external id: %v", err) + } + if got != ext { + t.Errorf("external id = %q, want %q", got, ext) + } + if _, err := store.IdentityExternalID(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrNotFound) { + t.Errorf("email lookup = %v, want ErrNotFound", err) + } + guest := provisionGuest(t) + if _, err := store.IdentityExternalID(ctx, guest, account.KindTelegram); !errors.Is(err, account.ErrNotFound) { + t.Errorf("guest lookup = %v, want ErrNotFound", err) + } +} + +// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists +// through UpdateProfile and reads back through GetByID. +func TestNotificationsInAppOnlyRoundTrip(t *testing.T) { + ctx := context.Background() + store := account.NewStore(testDB) + acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player") + if err != nil { + t.Fatalf("provision telegram: %v", err) + } + if !acc.NotificationsInAppOnly { + t.Fatal("default should be in-app-only true") + } + updated, err := store.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{ + DisplayName: "Player", + PreferredLanguage: "en", + TimeZone: "UTC", + NotificationsInAppOnly: false, + }) + if err != nil { + t.Fatalf("update profile: %v", err) + } + if updated.NotificationsInAppOnly { + t.Error("update did not clear NotificationsInAppOnly") + } + got, err := store.GetByID(ctx, acc.ID) + if err != nil { + t.Fatalf("get by id: %v", err) + } + if got.NotificationsInAppOnly { + t.Error("GetByID still reports in-app-only after clearing") + } +} diff --git a/backend/internal/postgres/jet/backend/model/accounts.go b/backend/internal/postgres/jet/backend/model/accounts.go index 31aac98..081dbbf 100644 --- a/backend/internal/postgres/jet/backend/model/accounts.go +++ b/backend/internal/postgres/jet/backend/model/accounts.go @@ -13,16 +13,17 @@ import ( ) type Accounts struct { - AccountID uuid.UUID `sql:"primary_key"` - DisplayName string - PreferredLanguage string - TimeZone string - BlockChat bool - BlockFriendRequests bool - CreatedAt time.Time - UpdatedAt time.Time - AwayStart time.Time - AwayEnd time.Time - HintBalance int32 - IsGuest bool + AccountID uuid.UUID `sql:"primary_key"` + DisplayName string + PreferredLanguage string + TimeZone string + BlockChat bool + BlockFriendRequests bool + CreatedAt time.Time + UpdatedAt time.Time + AwayStart time.Time + AwayEnd time.Time + HintBalance int32 + IsGuest bool + NotificationsInAppOnly bool } diff --git a/backend/internal/postgres/jet/backend/table/accounts.go b/backend/internal/postgres/jet/backend/table/accounts.go index a4bd060..f130516 100644 --- a/backend/internal/postgres/jet/backend/table/accounts.go +++ b/backend/internal/postgres/jet/backend/table/accounts.go @@ -17,18 +17,19 @@ type accountsTable struct { postgres.Table // Columns - AccountID postgres.ColumnString - DisplayName postgres.ColumnString - PreferredLanguage postgres.ColumnString - TimeZone postgres.ColumnString - BlockChat postgres.ColumnBool - BlockFriendRequests postgres.ColumnBool - CreatedAt postgres.ColumnTimestampz - UpdatedAt postgres.ColumnTimestampz - AwayStart postgres.ColumnTime - AwayEnd postgres.ColumnTime - HintBalance postgres.ColumnInteger - IsGuest postgres.ColumnBool + AccountID postgres.ColumnString + DisplayName postgres.ColumnString + PreferredLanguage postgres.ColumnString + TimeZone postgres.ColumnString + BlockChat postgres.ColumnBool + BlockFriendRequests postgres.ColumnBool + CreatedAt postgres.ColumnTimestampz + UpdatedAt postgres.ColumnTimestampz + AwayStart postgres.ColumnTime + AwayEnd postgres.ColumnTime + HintBalance postgres.ColumnInteger + IsGuest postgres.ColumnBool + NotificationsInAppOnly postgres.ColumnBool AllColumns postgres.ColumnList MutableColumns postgres.ColumnList @@ -70,39 +71,41 @@ func newAccountsTable(schemaName, tableName, alias string) *AccountsTable { func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { var ( - AccountIDColumn = postgres.StringColumn("account_id") - DisplayNameColumn = postgres.StringColumn("display_name") - PreferredLanguageColumn = postgres.StringColumn("preferred_language") - TimeZoneColumn = postgres.StringColumn("time_zone") - BlockChatColumn = postgres.BoolColumn("block_chat") - BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests") - CreatedAtColumn = postgres.TimestampzColumn("created_at") - UpdatedAtColumn = postgres.TimestampzColumn("updated_at") - AwayStartColumn = postgres.TimeColumn("away_start") - AwayEndColumn = postgres.TimeColumn("away_end") - HintBalanceColumn = postgres.IntegerColumn("hint_balance") - IsGuestColumn = postgres.BoolColumn("is_guest") - allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} - mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} - defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} + AccountIDColumn = postgres.StringColumn("account_id") + DisplayNameColumn = postgres.StringColumn("display_name") + PreferredLanguageColumn = postgres.StringColumn("preferred_language") + TimeZoneColumn = postgres.StringColumn("time_zone") + BlockChatColumn = postgres.BoolColumn("block_chat") + BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests") + CreatedAtColumn = postgres.TimestampzColumn("created_at") + UpdatedAtColumn = postgres.TimestampzColumn("updated_at") + AwayStartColumn = postgres.TimeColumn("away_start") + AwayEndColumn = postgres.TimeColumn("away_end") + HintBalanceColumn = postgres.IntegerColumn("hint_balance") + IsGuestColumn = postgres.BoolColumn("is_guest") + NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only") + allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn} + mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn} + defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn} ) return accountsTable{ Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), //Columns - AccountID: AccountIDColumn, - DisplayName: DisplayNameColumn, - PreferredLanguage: PreferredLanguageColumn, - TimeZone: TimeZoneColumn, - BlockChat: BlockChatColumn, - BlockFriendRequests: BlockFriendRequestsColumn, - CreatedAt: CreatedAtColumn, - UpdatedAt: UpdatedAtColumn, - AwayStart: AwayStartColumn, - AwayEnd: AwayEndColumn, - HintBalance: HintBalanceColumn, - IsGuest: IsGuestColumn, + AccountID: AccountIDColumn, + DisplayName: DisplayNameColumn, + PreferredLanguage: PreferredLanguageColumn, + TimeZone: TimeZoneColumn, + BlockChat: BlockChatColumn, + BlockFriendRequests: BlockFriendRequestsColumn, + CreatedAt: CreatedAtColumn, + UpdatedAt: UpdatedAtColumn, + AwayStart: AwayStartColumn, + AwayEnd: AwayEndColumn, + HintBalance: HintBalanceColumn, + IsGuest: IsGuestColumn, + NotificationsInAppOnly: NotificationsInAppOnlyColumn, AllColumns: allColumns, MutableColumns: mutableColumns, diff --git a/backend/internal/postgres/migrations/00007_telegram_notifications.sql b/backend/internal/postgres/migrations/00007_telegram_notifications.sql new file mode 100644 index 0000000..72c0353 --- /dev/null +++ b/backend/internal/postgres/migrations/00007_telegram_notifications.sql @@ -0,0 +1,17 @@ +-- +goose Up +-- Stage 9 Telegram integration: a per-account toggle that confines notifications +-- to the in-app live stream. When notifications_in_app_only is true (the default), +-- the platform side-service (Telegram) sends no out-of-app push; turning it off +-- opts into out-of-app push, which the gateway delivers only while the account has +-- no live in-app stream, so the in-app and platform channels never duplicate. Adds +-- a column, so the generated jet code is regenerated (cmd/jetgen). +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts + ADD COLUMN notifications_in_app_only boolean NOT NULL DEFAULT true; + +-- +goose Down +SET search_path = backend, pg_catalog; + +ALTER TABLE accounts + DROP COLUMN notifications_in_app_only; diff --git a/backend/internal/server/dto.go b/backend/internal/server/dto.go index e997847..11e17e7 100644 --- a/backend/internal/server/dto.go +++ b/backend/internal/server/dto.go @@ -35,16 +35,17 @@ type resolveResponse struct { // profileResponse is the authenticated account's own profile. AwayStart and AwayEnd // are the daily away window's "HH:MM" local-time bounds (in TimeZone). type profileResponse struct { - UserID string `json:"user_id"` - DisplayName string `json:"display_name"` - PreferredLanguage string `json:"preferred_language"` - TimeZone string `json:"time_zone"` - AwayStart string `json:"away_start"` - AwayEnd string `json:"away_end"` - HintBalance int `json:"hint_balance"` - BlockChat bool `json:"block_chat"` - BlockFriendRequests bool `json:"block_friend_requests"` - IsGuest bool `json:"is_guest"` + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + AwayStart string `json:"away_start"` + AwayEnd string `json:"away_end"` + HintBalance int `json:"hint_balance"` + BlockChat bool `json:"block_chat"` + BlockFriendRequests bool `json:"block_friend_requests"` + IsGuest bool `json:"is_guest"` + NotificationsInAppOnly bool `json:"notifications_in_app_only"` } // tileDTO is one placed (or to-place) tile. @@ -148,16 +149,17 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse { // profileResponseFor projects an account into its profile DTO. func profileResponseFor(acc account.Account) profileResponse { return profileResponse{ - UserID: acc.ID.String(), - DisplayName: acc.DisplayName, - PreferredLanguage: acc.PreferredLanguage, - TimeZone: acc.TimeZone, - AwayStart: acc.AwayStart.Format(awayTimeLayout), - AwayEnd: acc.AwayEnd.Format(awayTimeLayout), - HintBalance: acc.HintBalance, - BlockChat: acc.BlockChat, - BlockFriendRequests: acc.BlockFriendRequests, - IsGuest: acc.IsGuest, + UserID: acc.ID.String(), + DisplayName: acc.DisplayName, + PreferredLanguage: acc.PreferredLanguage, + TimeZone: acc.TimeZone, + AwayStart: acc.AwayStart.Format(awayTimeLayout), + AwayEnd: acc.AwayEnd.Format(awayTimeLayout), + HintBalance: acc.HintBalance, + BlockChat: acc.BlockChat, + BlockFriendRequests: acc.BlockFriendRequests, + IsGuest: acc.IsGuest, + NotificationsInAppOnly: acc.NotificationsInAppOnly, } } diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 9c8b0c2..f3a4b98 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -31,6 +31,10 @@ func (s *Server) registerRoutes() { in.POST("/sessions/email/login", s.handleEmailLogin) in.POST("/sessions/resolve", s.handleResolveSession) in.POST("/sessions/revoke", s.handleRevokeSession) + // Out-of-app push routing for the platform side-service (Stage 9): the + // gateway resolves a recipient's Telegram chat + language + in-app-only flag + // before delivering an out-of-app notification. + in.POST("/push-target", s.handlePushTarget) } u := s.user if s.accounts != nil { diff --git a/backend/internal/server/handlers_account.go b/backend/internal/server/handlers_account.go index 051edd5..af405ac 100644 --- a/backend/internal/server/handlers_account.go +++ b/backend/internal/server/handlers_account.go @@ -18,13 +18,14 @@ import ( // updateProfileRequest is the full editable profile. away_start/away_end are // "HH:MM" local-time bounds of the daily away window. type updateProfileRequest struct { - DisplayName string `json:"display_name"` - PreferredLanguage string `json:"preferred_language"` - TimeZone string `json:"time_zone"` - AwayStart string `json:"away_start"` - AwayEnd string `json:"away_end"` - BlockChat bool `json:"block_chat"` - BlockFriendRequests bool `json:"block_friend_requests"` + DisplayName string `json:"display_name"` + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + AwayStart string `json:"away_start"` + AwayEnd string `json:"away_end"` + BlockChat bool `json:"block_chat"` + BlockFriendRequests bool `json:"block_friend_requests"` + NotificationsInAppOnly bool `json:"notifications_in_app_only"` } // statsDTO is a durable account's lifetime statistics (the derived games-played and @@ -80,13 +81,14 @@ func (s *Server) handleUpdateProfile(c *gin.Context) { return } acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{ - DisplayName: req.DisplayName, - PreferredLanguage: req.PreferredLanguage, - TimeZone: req.TimeZone, - AwayStart: awayStart, - AwayEnd: awayEnd, - BlockChat: req.BlockChat, - BlockFriendRequests: req.BlockFriendRequests, + DisplayName: req.DisplayName, + PreferredLanguage: req.PreferredLanguage, + TimeZone: req.TimeZone, + AwayStart: awayStart, + AwayEnd: awayEnd, + BlockChat: req.BlockChat, + BlockFriendRequests: req.BlockFriendRequests, + NotificationsInAppOnly: req.NotificationsInAppOnly, }) if err != nil { s.abortErr(c, err) diff --git a/backend/internal/server/handlers_auth.go b/backend/internal/server/handlers_auth.go index a110ef2..fd6cd73 100644 --- a/backend/internal/server/handlers_auth.go +++ b/backend/internal/server/handlers_auth.go @@ -1,9 +1,11 @@ package server import ( + "errors" "net/http" "github.com/gin-gonic/gin" + "github.com/google/uuid" "scrabble/backend/internal/account" ) @@ -14,21 +16,26 @@ import ( // account and mint the opaque session. The backend trusts the gateway on this // segment (docs/ARCHITECTURE.md §12). -// telegramAuthRequest carries the platform user id the gateway extracted from a -// validated initData payload. +// telegramAuthRequest carries the identity the connector extracted from a +// validated initData payload. Username, FirstName and LanguageCode seed a +// brand-new account's display name and language (first contact only). type telegramAuthRequest struct { - ExternalID string `json:"external_id"` + ExternalID string `json:"external_id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LanguageCode string `json:"language_code"` } // handleTelegramAuth provisions (or finds) the account bound to a Telegram -// identity and mints a session for it. +// identity and mints a session for it, seeding a new account's display name and +// language from the supplied Telegram fields. func (s *Server) handleTelegramAuth(c *gin.Context) { var req telegramAuthRequest if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { abortBadRequest(c, "external_id is required") return } - acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID) + acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName) if err != nil { s.abortErr(c, err) return @@ -36,6 +43,53 @@ func (s *Server) handleTelegramAuth(c *gin.Context) { s.mintSession(c, acc) } +// pushTargetRequest asks for a user's out-of-app push routing data by account id. +type pushTargetRequest struct { + UserID string `json:"user_id"` +} + +// pushTargetResponse carries what the gateway needs to route an out-of-app push: +// the recipient's Telegram external_id (empty when they have no Telegram +// identity, e.g. a guest or email-only account), the preferred language for the +// message template, and whether they confined notifications to the in-app stream. +type pushTargetResponse struct { + ExternalID string `json:"external_id"` + Language string `json:"language"` + NotificationsInAppOnly bool `json:"notifications_in_app_only"` +} + +// handlePushTarget resolves a user id to the data the gateway needs to deliver an +// out-of-app Telegram notification — the gateway-only internal counterpart of the +// in-app push stream. A user with no Telegram identity yields an empty external_id, +// which the gateway treats as "no out-of-app channel". +func (s *Server) handlePushTarget(c *gin.Context) { + var req pushTargetRequest + if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" { + abortBadRequest(c, "user_id is required") + return + } + uid, err := uuid.Parse(req.UserID) + if err != nil { + abortBadRequest(c, "user_id must be a uuid") + return + } + acc, err := s.accounts.GetByID(c.Request.Context(), uid) + if err != nil { + s.abortErr(c, err) + return + } + ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram) + if err != nil && !errors.Is(err, account.ErrNotFound) { + s.abortErr(c, err) + return + } + c.JSON(http.StatusOK, pushTargetResponse{ + ExternalID: ext, + Language: acc.PreferredLanguage, + NotificationsInAppOnly: acc.NotificationsInAppOnly, + }) +} + // handleGuestAuth provisions a fresh ephemeral guest account and mints a session. func (s *Server) handleGuestAuth(c *gin.Context) { acc, err := s.accounts.ProvisionGuest(c.Request.Context()) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b13e9bb..1ab61f3 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -43,9 +43,15 @@ Three executables plus per-platform side-services: a server-driven channel later, §10), and a client **board-style** setting (bonus-label mode). The visual/interaction design system is documented in [`UI_DESIGN.md`](UI_DESIGN.md). -- **`platform/`** *(planned)* — per-platform side-services (Telegram bot - first): deep-link invites and platform-native push notifications. They talk - to `backend` over an internal API. +- **`platform/telegram`** — the Telegram side-service (the "connector", module + `scrabble/platform/telegram`). 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 + a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation + and out-of-app push) and `backend` (admin messaging — Stage 10) 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. ```mermaid flowchart LR @@ -55,7 +61,9 @@ flowchart LR Gateway -- in-app stream --> Client Backend -- pgx --> Postgres[(Postgres)] Backend -. embeds .- Solver[[scrabble-solver library]] - Telegram[Telegram bot side-service] -- internal API --> Backend + Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector] + Backend -. admin gRPC, Stage 10 .-> Telegram + Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram)) ``` The MVP runs `gateway` and `backend` as single-instance processes inside a @@ -92,10 +100,12 @@ Platform-native, deliberately simple: **no Ed25519 client keys, no per-request 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** — the platform's - signed launch data (e.g. Telegram `initData` HMAC), an email-code login, or a - guest bootstrap — then mints a **thin opaque server session token** - (`session_id`). +- The gateway validates the originating credential **once** — Telegram `initData` + (delegated to the connector'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). - The client holds `session_id` in memory for the app session (browser/OS storage is optional and may be unavailable; losing it means re-login). - The gateway caches `session → user_id` and injects `X-User-ID`. Session @@ -318,7 +328,8 @@ requires (there is no DM surface; chat is per-game). keys are application-generated **UUIDv7**. - Tables: `accounts` (durable internal accounts; Stage 3 added the away-window columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's - migration `00005` added the `is_guest` flag for ephemeral guest rows), + migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's + migration `00007` added the `notifications_in_app_only` out-of-app push toggle), `identities` (platform/email/robot identities, unique `(kind, external_id)`; Stage 5's migration `00004` admits the `robot` kind), `sessions` (revoke-only opaque-token hashes), the Stage 3 game tables @@ -387,9 +398,16 @@ the backend and forwarded verbatim. A client that is not currently streaming fal back to the matchmaker's `Poll` for match-found and, for the lobby **notification badge** (incoming friend requests + open invitations), the client polls on lobby open and on focus as well as re-polling on the `notify` event — covering a push -missed while the app was hidden. Out-of-app platform push (your-turn, nudge) is -wired in Stage 9; session-revocation events and cursor-based stream resume are -deferred (single-instance MVP). +missed while the app was hidden. **Out-of-app platform push** (Stage 9) 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`, language, and the `notifications_in_app_only` flag) and asks the +**Telegram connector** to deliver 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 out-of-app set is +your-turn, nudge, match-found and the invitation / friend-request notify sub-kinds; +the connector renders the message and skips the rest. Session-revocation events and +cursor-based stream resume stay deferred (single-instance MVP). A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md). It is a client-side **mock** rotation today; a server-driven source (operational notices, @@ -417,11 +435,12 @@ promotions) is future work and would deliver short markdown messages (text + lin | Concern | Enforced by | | --- | --- | | Public rate limiting / anti-abuse | gateway | -| Platform credential validation, session minting | gateway | +| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector | +| 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) | | Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints | -| backend ↔ gateway trust | the network (only gateway may reach backend) | +| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) | This is an explicit, accepted MVP risk: compromise of the gateway↔backend network segment defeats backend authentication. Mitigated by network isolation; @@ -438,10 +457,15 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app ## 13. Deployment (informational) -Single public origin, path-routed: the UI, the gateway public surface and the -admin surface share one host that terminates TLS. MVP runs one `gateway`, one -`backend`, one Postgres. Docker/compose environments are introduced when there -is something to deploy. +Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini +App under `/telegram/`** (the gateway serves the static UI build; outside Telegram +that path redirects to the root), the gateway public surface and the admin surface +share one host that terminates TLS. 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. MVP runs one `gateway`, one `backend`, one +Postgres, plus the connector. The connector's Docker/compose ships now +(`platform/telegram/deploy`, mirroring `../15-puzzle`); the full multi-service deploy +is Stage 12. ## 14. CI & branches diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index ee0bb43..68d5562 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -22,15 +22,19 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A costs nothing when the rack has no legal move. The word-check accepts only the variant's alphabet, remembers answers within the session and rate-limits repeats. -### Identity & sessions *(Stage 1 / 6)* +### Identity & sessions *(Stage 1 / 6 / 9)* A player arrives from a platform (Telegram first), via email login, or as an ephemeral guest. The gateway validates the credential once and mints a thin -session token; the backend resolves it to an internal `user_id`. Guests are -session-only with restricted features (auto-match only; no friends, stats or -history). While the app is open the client keeps a live stream and receives -in-app updates in real time — the opponent's move, your turn, chat, nudges and a -found match; out-of-app push (your turn, nudge) is delivered by the platform -later (Stage 9). +session token; the backend resolves it to an internal `user_id`. A **Telegram Mini +App** launch authenticates from the platform's signed `initData`, themes the UI to +the Telegram colours, and — on first contact — seeds the new account's interface +language from the Telegram client. Guests are session-only with restricted features +(auto-match only; no friends, stats or history). While the app is open the client +keeps a live stream and receives in-app updates in real time — the opponent's move, +your turn, chat, nudges and a found match. When the app is **closed**, the chosen +out-of-app events (your turn, nudge, a found match, an invitation or friend request) +arrive as a **Telegram notification** instead — unless the player keeps notifications +in the app only (a profile setting, **on by default**). ### Accounts, linking & merge *(Stage 1 / 10)* First platform contact auto-provisions a durable account. From the profile a @@ -42,9 +46,9 @@ account (stats summed, games/friends transferred). Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a per-variant pool and is paired with the next waiting human; after 10 s with no human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are -formed by inviting players from the friend list (deep-link invites arrive with the -platform integration): the inviter chooses the settings and the game starts once -every invitee has accepted — any decline cancels it, and an unanswered invitation +formed by inviting players from the friend list (an invitation, like a friend code, +is shareable as a Telegram deep link that opens it directly): the inviter chooses the +settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation expires after seven days. ### Playing a game *(Stage 3)* diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index fcf1ca0..344bb45 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -23,15 +23,19 @@ top-1 подсказку, безлимитную проверку слова с Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии и ограничивает частоту повторов. -### Личность и сессии *(Stage 1 / 6)* +### Личность и сессии *(Stage 1 / 6 / 9)* Игрок приходит с платформы (сначала Telegram), через email-вход или как эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий -session-токен; backend сопоставляет его с внутренним `user_id`. Гость — -только сессия, с урезанными функциями (только авто-подбор; без друзей, -статистики и истории). Пока приложение открыто, клиент держит живой стрим и -получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и -найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит -позже (Stage 9). +session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram +Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс +в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по +языку Telegram-клиента. Гость — только сессия, с урезанными функциями (только +авто-подбор; без друзей, статистики и истории). Пока приложение открыто, клиент +держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход, +чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие +события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят +вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления +только в приложении (настройка профиля, **включена по умолчанию**). ### Аккаунты, привязка и слияние *(Stage 1 / 10)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок @@ -43,9 +47,9 @@ session-токен; backend сопоставляет его с внутренн Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока) встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4) -формируются приглашением игроков из списка друзей (приглашения по deep-link -появятся с платформенной интеграцией): инициатор выбирает настройки, и партия -стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без +формируются приглашением игроков из списка друзей (приглашение, как и код друга, +можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор +выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без ответа приглашение протухает через семь дней. ### Игровой процесс *(Stage 3)* diff --git a/docs/UI_DESIGN.md b/docs/UI_DESIGN.md index 0f566f0..d42d6ec 100644 --- a/docs/UI_DESIGN.md +++ b/docs/UI_DESIGN.md @@ -5,8 +5,10 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via -`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themeParams-ready** -(the tokens can be overridden at runtime). +`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9): +on a Telegram Mini App launch — the app is served under `/telegram/` and detects the +launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at +runtime; opened outside Telegram, the `/telegram/` path redirects to the site root. ## Layout shell (`components/Screen.svelte`) diff --git a/gateway/README.md b/gateway/README.md index bd4d4e5..957184d 100644 --- a/gateway/README.md +++ b/gateway/README.md @@ -19,7 +19,7 @@ internal/config/ # GATEWAY_* env config internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client internal/session/ # in-memory session cache (LRU/TTL, backend fallback) internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) -internal/auth/ # Telegram initData HMAC validator (seam + fixtures) +internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing internal/push/ # live-event fan-out hub (per-user client streams) internal/transcode/ # FlatBuffers<->REST bridge + message_type registry internal/connectsrv/ # the Connect Gateway service over h2c @@ -39,6 +39,11 @@ 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. + The Stage 6 message-type slice: `auth.telegram`, `auth.guest`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index a13a61e..75efb06 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -18,9 +18,9 @@ import ( "go.uber.org/zap" "scrabble/gateway/internal/admin" - "scrabble/gateway/internal/auth" "scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/config" + "scrabble/gateway/internal/connector" "scrabble/gateway/internal/connectsrv" "scrabble/gateway/internal/push" "scrabble/gateway/internal/ratelimit" @@ -73,14 +73,20 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { limiter := ratelimit.New() hub := push.NewHub(0) - var tg auth.TelegramValidator - if cfg.TelegramBotToken != "" { - tg = auth.NewHMACValidator(cfg.TelegramBotToken) + var conn *connector.Client + var validator transcode.TelegramValidator + if cfg.ConnectorAddr != "" { + conn, err = connector.New(cfg.ConnectorAddr) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + validator = conn } else { - logger.Warn("telegram auth disabled (GATEWAY_TELEGRAM_BOT_TOKEN unset)") + logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)") } - registry := transcode.NewRegistry(backend, tg) + registry := transcode.NewRegistry(backend, validator) edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, @@ -91,8 +97,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error { Logger: logger, }) - // Bridge the backend push stream into the fan-out hub. - go runPushPump(ctx, backend, hub, logger) + // 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) public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()} servers := []*namedServer{{name: "public", srv: public}} @@ -153,8 +160,10 @@ func runServers(ctx context.Context, cancel context.CancelFunc, servers []*named } // runPushPump keeps a backend push subscription open, forwarding every event to -// the hub and re-subscribing after the stream ends, until the context is done. -func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, logger *zap.Logger) { +// 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) { for ctx.Err() == nil { stream, err := backend.SubscribePush(ctx, gatewayID) if err != nil { @@ -178,6 +187,12 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H Payload: ev.GetPayload(), 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) + } } if !sleep(ctx, pushReconnectDelay) { return @@ -185,6 +200,24 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H } } +// deliverOutOfApp resolves the recipient's push target and, when they have a +// 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) { + target, err := backend.PushTarget(ctx, userID) + if err != nil { + logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err)) + return + } + if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) { + return + } + 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)) + } +} + // 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 { diff --git a/gateway/internal/auth/telegram_test.go b/gateway/internal/auth/telegram_test.go deleted file mode 100644 index 1280bd3..0000000 --- a/gateway/internal/auth/telegram_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package auth_test - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "net/url" - "sort" - "strconv" - "strings" - "testing" - "time" - - "scrabble/gateway/internal/auth" -) - -// signedInitData builds a valid Telegram initData query string for botToken, -// computing the hash exactly as Telegram does. -func signedInitData(botToken string, fields map[string]string) string { - keys := make([]string, 0, len(fields)) - for k := range fields { - keys = append(keys, k) - } - sort.Strings(keys) - lines := make([]string, 0, len(keys)) - for _, k := range keys { - lines = append(lines, k+"="+fields[k]) - } - secretMAC := hmac.New(sha256.New, []byte("WebAppData")) - secretMAC.Write([]byte(botToken)) - secret := secretMAC.Sum(nil) - mac := hmac.New(sha256.New, secret) - mac.Write([]byte(strings.Join(lines, "\n"))) - hash := hex.EncodeToString(mac.Sum(nil)) - - v := url.Values{} - for k, val := range fields { - v.Set(k, val) - } - v.Set("hash", hash) - return v.Encode() -} - -func TestValidateAcceptsGenuineInitData(t *testing.T) { - const token = "test-bot-token" - fields := map[string]string{ - "auth_date": strconv.FormatInt(time.Now().Unix(), 10), - "query_id": "abc", - "user": `{"id":42,"first_name":"Ann","username":"ann"}`, - } - u, err := auth.NewHMACValidator(token).Validate(signedInitData(token, fields)) - if err != nil { - t.Fatalf("validate genuine: %v", err) - } - if u.ID != "42" || u.Username != "ann" { - t.Fatalf("user = %+v", u) - } -} - -func TestValidateRejectsTamperedHash(t *testing.T) { - const token = "test-bot-token" - fields := map[string]string{ - "auth_date": strconv.FormatInt(time.Now().Unix(), 10), - "user": `{"id":42}`, - } - data := signedInitData(token, fields) + "0" // corrupt the trailing hash - if _, err := auth.NewHMACValidator(token).Validate(data); err == nil { - t.Fatal("expected rejection of tampered init data") - } -} - -func TestValidateRejectsWrongToken(t *testing.T) { - fields := map[string]string{ - "auth_date": strconv.FormatInt(time.Now().Unix(), 10), - "user": `{"id":42}`, - } - data := signedInitData("real-token", fields) - if _, err := auth.NewHMACValidator("other-token").Validate(data); err == nil { - t.Fatal("expected rejection under a different bot token") - } -} - -func TestValidateRejectsStaleInitData(t *testing.T) { - const token = "test-bot-token" - fields := map[string]string{ - "auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10), - "user": `{"id":42}`, - } - if _, err := auth.NewHMACValidator(token).Validate(signedInitData(token, fields)); err == nil { - t.Fatal("expected rejection of stale init data") - } -} diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index 68a83bf..943076e 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -20,16 +20,17 @@ type SessionResp struct { // ProfileResp is an account's own profile. type ProfileResp struct { - UserID string `json:"user_id"` - DisplayName string `json:"display_name"` - PreferredLanguage string `json:"preferred_language"` - TimeZone string `json:"time_zone"` - AwayStart string `json:"away_start"` - AwayEnd string `json:"away_end"` - HintBalance int `json:"hint_balance"` - BlockChat bool `json:"block_chat"` - BlockFriendRequests bool `json:"block_friend_requests"` - IsGuest bool `json:"is_guest"` + UserID string `json:"user_id"` + DisplayName string `json:"display_name"` + PreferredLanguage string `json:"preferred_language"` + TimeZone string `json:"time_zone"` + AwayStart string `json:"away_start"` + AwayEnd string `json:"away_end"` + HintBalance int `json:"hint_balance"` + BlockChat bool `json:"block_chat"` + BlockFriendRequests bool `json:"block_friend_requests"` + IsGuest bool `json:"is_guest"` + NotificationsInAppOnly bool `json:"notifications_in_app_only"` } // TileJSON is one placed tile, used in both play requests and move responses. @@ -109,11 +110,35 @@ type ChatResp struct { CreatedAtUnix int64 `json:"created_at_unix"` } -// TelegramAuth provisions/finds the Telegram account and mints a session. -func (c *Client) TelegramAuth(ctx context.Context, externalID string) (SessionResp, error) { +// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a +// brand-new account's display name and language from the validated launch fields. +func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) { var out SessionResp err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", - map[string]string{"external_id": externalID}, &out) + map[string]string{ + "external_id": externalID, + "language_code": languageCode, + "username": username, + "first_name": firstName, + }, &out) + return out, err +} + +// PushTargetResp is a recipient's out-of-app push routing data: their Telegram +// external_id (empty when they have no Telegram identity), preferred language, and +// whether they confined notifications to the in-app stream. +type PushTargetResp struct { + ExternalID string `json:"external_id"` + Language string `json:"language"` + NotificationsInAppOnly bool `json:"notifications_in_app_only"` +} + +// PushTarget resolves a user id to their out-of-app Telegram routing data (the +// gateway uses it to decide whether to deliver an event over platform push). +func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp, error) { + var out PushTargetResp + err := c.do(ctx, http.MethodPost, "/api/v1/internal/push-target", "", "", + map[string]string{"user_id": userID}, &out) return out, err } diff --git a/gateway/internal/backendclient/api_social.go b/gateway/internal/backendclient/api_social.go index ff6ddd5..e9b3bab 100644 --- a/gateway/internal/backendclient/api_social.go +++ b/gateway/internal/backendclient/api_social.go @@ -215,13 +215,14 @@ func (c *Client) ListInvitations(ctx context.Context, userID string) (Invitation func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp) (ProfileResp, error) { var out ProfileResp body := map[string]any{ - "display_name": p.DisplayName, - "preferred_language": p.PreferredLanguage, - "time_zone": p.TimeZone, - "away_start": p.AwayStart, - "away_end": p.AwayEnd, - "block_chat": p.BlockChat, - "block_friend_requests": p.BlockFriendRequests, + "display_name": p.DisplayName, + "preferred_language": p.PreferredLanguage, + "time_zone": p.TimeZone, + "away_start": p.AwayStart, + "away_end": p.AwayEnd, + "block_chat": p.BlockChat, + "block_friend_requests": p.BlockFriendRequests, + "notifications_in_app_only": p.NotificationsInAppOnly, } err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out) return out, err diff --git a/gateway/internal/config/config.go b/gateway/internal/config/config.go index 3906e65..4da67c0 100644 --- a/gateway/internal/config/config.go +++ b/gateway/internal/config/config.go @@ -28,9 +28,10 @@ type Config struct { // checks before proxying admin traffic to the backend. Empty disables admin. AdminUser string AdminPassword string - // TelegramBotToken is the secret used to validate Telegram initData HMACs. - // Empty disables the telegram auth path. - TelegramBotToken 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 // SessionTTL bounds how long a resolved session stays cached; SessionCacheMax // caps the number of cached sessions. SessionTTL time.Duration @@ -83,16 +84,16 @@ func DefaultRateLimit() RateLimitConfig { func Load() (Config, error) { var err error c := Config{ - HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), - AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr), - LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), - BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), - BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), - AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), - AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), - TelegramBotToken: os.Getenv("GATEWAY_TELEGRAM_BOT_TOKEN"), - SessionCacheMax: defaultSessionCacheMax, - RateLimit: DefaultRateLimit(), + HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), + AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr), + LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), + BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), + 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"), + SessionCacheMax: defaultSessionCacheMax, + RateLimit: DefaultRateLimit(), } if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil { return Config{}, err diff --git a/gateway/internal/connector/client.go b/gateway/internal/connector/client.go new file mode 100644 index 0000000..0ed64b7 --- /dev/null +++ b/gateway/internal/connector/client.go @@ -0,0 +1,82 @@ +// 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 + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + + telegramv1 "scrabble/pkg/proto/telegram/v1" +) + +// ErrInvalidInitData is returned by ValidateInitData when the connector rejects the +// launch data (a gRPC InvalidArgument), letting the transcode layer surface a stable +// result code. +var ErrInvalidInitData = errors.New("connector: invalid telegram init data") + +// User is a validated Mini App identity. +type User struct { + ExternalID string + Username string + FirstName string + LanguageCode string +} + +// Client wraps the connector's Telegram gRPC service. +type Client struct { + conn *grpc.ClientConn + c telegramv1.TelegramClient +} + +// New dials the connector gRPC endpoint. +func New(addr string) (*Client, error) { + conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("connector: dial %s: %w", addr, err) + } + return &Client{conn: conn, c: telegramv1.NewTelegramClient(conn)}, nil +} + +// Close releases the gRPC connection. +func (c *Client) Close() error { return c.conn.Close() } + +// ValidateInitData verifies Mini App launch data and returns the user identity, +// mapping a connector InvalidArgument to ErrInvalidInitData. +func (c *Client) ValidateInitData(ctx context.Context, initData string) (User, error) { + resp, err := c.c.ValidateInitData(ctx, &telegramv1.ValidateInitDataRequest{InitData: initData}) + if err != nil { + if status.Code(err) == codes.InvalidArgument { + return User{}, ErrInvalidInitData + } + return User{}, err + } + return User{ + ExternalID: resp.GetExternalId(), + Username: resp.GetUsername(), + FirstName: resp.GetFirstName(), + LanguageCode: resp.GetLanguageCode(), + }, 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 +} diff --git a/gateway/internal/connector/routing.go b/gateway/internal/connector/routing.go new file mode 100644 index 0000000..babfd8f --- /dev/null +++ b/gateway/internal/connector/routing.go @@ -0,0 +1,23 @@ +package connector + +// outOfAppKinds is the set of backend push kinds delivered out-of-app; the rest +// stay in-app only (opponent_moved and chat_message are too noisy for a platform +// notification). +var outOfAppKinds = map[string]bool{ + "your_turn": true, + "nudge": true, + "match_found": true, + "notify": true, +} + +// OutOfAppKind reports whether a push kind is eligible for out-of-app delivery. +func OutOfAppKind(kind string) bool { return outOfAppKinds[kind] } + +// DeliverToTarget reports whether a resolved push target should receive an +// out-of-app message: it has a Telegram identity (externalID != "") and has not +// confined notifications to the app (inAppOnly == false). Combined with the +// caller's "recipient is offline" check, this is the dedup rule that keeps the +// platform push free of duplicates with the in-app stream. +func DeliverToTarget(externalID string, inAppOnly bool) bool { + return externalID != "" && !inAppOnly +} diff --git a/gateway/internal/connector/routing_test.go b/gateway/internal/connector/routing_test.go new file mode 100644 index 0000000..7cb62c1 --- /dev/null +++ b/gateway/internal/connector/routing_test.go @@ -0,0 +1,38 @@ +package connector + +import "testing" + +func TestOutOfAppKind(t *testing.T) { + out := []string{"your_turn", "nudge", "match_found", "notify"} + for _, k := range out { + if !OutOfAppKind(k) { + t.Errorf("OutOfAppKind(%q) = false, want true", k) + } + } + for _, k := range []string{"opponent_moved", "chat_message", "", "unknown"} { + if OutOfAppKind(k) { + t.Errorf("OutOfAppKind(%q) = true, want false", k) + } + } +} + +func TestDeliverToTarget(t *testing.T) { + cases := []struct { + name string + externalID string + inAppOnly bool + want bool + }{ + {"telegram + opted in", "12345", false, true}, + {"in-app only suppresses", "12345", true, false}, + {"no telegram identity", "", false, false}, + {"no identity and in-app only", "", true, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := DeliverToTarget(tc.externalID, tc.inAppOnly); got != tc.want { + t.Errorf("DeliverToTarget(%q, %v) = %v, want %v", tc.externalID, tc.inAppOnly, got, tc.want) + } + }) + } +} diff --git a/gateway/internal/push/hub.go b/gateway/internal/push/hub.go index 656a796..a879382 100644 --- a/gateway/internal/push/hub.go +++ b/gateway/internal/push/hub.go @@ -86,3 +86,18 @@ func (h *Hub) SubscriberCount() int { defer h.mu.Unlock() return len(h.subs) } + +// HasSubscribers reports whether any live client stream is registered for userID. +// It gates out-of-app push: an online user is already reached in-app, so the +// platform push (Telegram) is skipped for them — keeping the fallback channel free +// of duplicates. +func (h *Hub) HasSubscribers(userID string) bool { + h.mu.Lock() + defer h.mu.Unlock() + for _, s := range h.subs { + if s.userID == userID { + return true + } + } + return false +} diff --git a/gateway/internal/push/hub_test.go b/gateway/internal/push/hub_test.go index 949c987..c876b4b 100644 --- a/gateway/internal/push/hub_test.go +++ b/gateway/internal/push/hub_test.go @@ -54,3 +54,21 @@ func TestHubUnsubscribeClosesChannel(t *testing.T) { } h.Publish(push.Event{UserID: "u"}) // must not panic } + +func TestHubHasSubscribers(t *testing.T) { + h := push.NewHub(2) + if h.HasSubscribers("u") { + t.Fatal("no subscribers yet") + } + _, cancel := h.Subscribe("u") + if !h.HasSubscribers("u") { + t.Error("u should be reported online after Subscribe") + } + if h.HasSubscribers("other") { + t.Error("other has no subscription") + } + cancel() + if h.HasSubscribers("u") { + t.Error("u should be offline after unsubscribe") + } +} diff --git a/gateway/internal/transcode/encode.go b/gateway/internal/transcode/encode.go index 456c571..80ce5de 100644 --- a/gateway/internal/transcode/encode.go +++ b/gateway/internal/transcode/encode.go @@ -56,6 +56,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte { fb.ProfileAddIsGuest(b, p.IsGuest) fb.ProfileAddAwayStart(b, awayStart) fb.ProfileAddAwayEnd(b, awayEnd) + fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly) b.Finish(fb.ProfileEnd(b)) return b.FinishedBytes() } diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index a88983f..bfc74f5 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -9,8 +9,8 @@ import ( "context" "errors" - "scrabble/gateway/internal/auth" "scrabble/gateway/internal/backendclient" + "scrabble/gateway/internal/connector" fb "scrabble/pkg/fbs/scrabblefb" ) @@ -63,10 +63,16 @@ type Registry struct { ops map[string]Op } +// TelegramValidator validates Mini App launch data via the connector side-service. +// *connector.Client implements it; a nil value disables the telegram auth path. +type TelegramValidator interface { + ValidateInitData(ctx context.Context, initData string) (connector.User, error) +} + // NewRegistry builds the slice's message-type catalog over the backend client. -// The Telegram auth op is registered only when a validator is supplied (a bot -// token is configured); otherwise auth.telegram is simply unknown. -func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Registry { +// The Telegram auth op is registered only when a validator is supplied (the +// connector is configured); otherwise auth.telegram is simply unknown. +func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry { r := &Registry{ops: make(map[string]Op)} if tg != nil { r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} @@ -109,20 +115,20 @@ func DomainCode(err error) (string, bool) { if errors.As(err, &apiErr) { return apiErr.Code, true } - if errors.Is(err, auth.ErrInvalidInitData) { + if errors.Is(err, connector.ErrInvalidInitData) { return "invalid_init_data", true } return "", false } -func authTelegramHandler(backend *backendclient.Client, tg auth.TelegramValidator) Handler { +func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) - user, err := tg.Validate(string(in.InitData())) + user, err := tg.ValidateInitData(ctx, string(in.InitData())) if err != nil { return nil, err } - sess, err := backend.TelegramAuth(ctx, user.ID) + sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName) if err != nil { return nil, err } diff --git a/gateway/internal/transcode/transcode_social.go b/gateway/internal/transcode/transcode_social.go index 019fd9a..e64e574 100644 --- a/gateway/internal/transcode/transcode_social.go +++ b/gateway/internal/transcode/transcode_social.go @@ -233,13 +233,14 @@ func profileUpdateHandler(backend *backendclient.Client) Handler { return func(ctx context.Context, req Request) ([]byte, error) { in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0) p := backendclient.ProfileResp{ - DisplayName: string(in.DisplayName()), - PreferredLanguage: string(in.PreferredLanguage()), - TimeZone: string(in.TimeZone()), - AwayStart: string(in.AwayStart()), - AwayEnd: string(in.AwayEnd()), - BlockChat: in.BlockChat(), - BlockFriendRequests: in.BlockFriendRequests(), + DisplayName: string(in.DisplayName()), + PreferredLanguage: string(in.PreferredLanguage()), + TimeZone: string(in.TimeZone()), + AwayStart: string(in.AwayStart()), + AwayEnd: string(in.AwayEnd()), + BlockChat: in.BlockChat(), + BlockFriendRequests: in.BlockFriendRequests(), + NotificationsInAppOnly: in.NotificationsInAppOnly(), } out, err := backend.UpdateProfile(ctx, req.UserID, p) if err != nil { diff --git a/gateway/internal/transcode/transcode_social_test.go b/gateway/internal/transcode/transcode_social_test.go index 06f3da2..e510df0 100644 --- a/gateway/internal/transcode/transcode_social_test.go +++ b/gateway/internal/transcode/transcode_social_test.go @@ -2,6 +2,7 @@ package transcode_test import ( "context" + "encoding/json" "net/http" "testing" @@ -202,11 +203,15 @@ func TestGcgRoundTrip(t *testing.T) { } func TestProfileUpdateRoundTripAway(t *testing.T) { + var gotBody map[string]any backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/profile" { t.Errorf("unexpected %s %q", r.Method, r.URL.Path) } - _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"ru","time_zone":"Europe/Moscow","away_start":"00:00","away_end":"07:30"}`)) + _ = json.NewDecoder(r.Body).Decode(&gotBody) + // Respond with notifications_in_app_only=false to exercise the encode path + // carrying a non-default value back to the client. + _, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"ru","time_zone":"Europe/Moscow","away_start":"00:00","away_end":"07:30","notifications_in_app_only":false}`)) }) defer cleanup() @@ -225,6 +230,7 @@ func TestProfileUpdateRoundTripAway(t *testing.T) { fb.UpdateProfileRequestAddTimeZone(b, tz) fb.UpdateProfileRequestAddAwayStart(b, as) fb.UpdateProfileRequestAddAwayEnd(b, ae) + fb.UpdateProfileRequestAddNotificationsInAppOnly(b, true) b.Finish(fb.UpdateProfileRequestEnd(b)) payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) @@ -235,4 +241,12 @@ func TestProfileUpdateRoundTripAway(t *testing.T) { if string(p.AwayStart()) != "00:00" || string(p.AwayEnd()) != "07:30" || string(p.PreferredLanguage()) != "ru" { t.Fatalf("profile away round-trip wrong: start=%q end=%q lang=%q", p.AwayStart(), p.AwayEnd(), p.PreferredLanguage()) } + // The request's in-app-only flag (true) must reach the backend, and the backend's + // value (false) must come back in the encoded Profile. + if v, ok := gotBody["notifications_in_app_only"].(bool); !ok || v != true { + t.Errorf("forwarded notifications_in_app_only = %v (ok=%v), want true", gotBody["notifications_in_app_only"], ok) + } + if p.NotificationsInAppOnly() { + t.Error("response notifications_in_app_only = true, want false") + } } diff --git a/gateway/internal/transcode/transcode_telegram_test.go b/gateway/internal/transcode/transcode_telegram_test.go new file mode 100644 index 0000000..deb7dc1 --- /dev/null +++ b/gateway/internal/transcode/transcode_telegram_test.go @@ -0,0 +1,91 @@ +package transcode_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/gateway/internal/connector" + "scrabble/gateway/internal/transcode" + fb "scrabble/pkg/fbs/scrabblefb" +) + +// fakeValidator stands in for the connector's ValidateInitData RPC. +type fakeValidator struct { + user connector.User + err error +} + +func (f fakeValidator) ValidateInitData(context.Context, string) (connector.User, error) { + return f.user, f.err +} + +func telegramLoginPayload(initData string) []byte { + b := flatbuffers.NewBuilder(0) + off := b.CreateString(initData) + fb.TelegramLoginRequestStart(b) + fb.TelegramLoginRequestAddInitData(b, off) + b.Finish(fb.TelegramLoginRequestEnd(b)) + return b.FinishedBytes() +} + +func TestTelegramAuthForwardsSeedFields(t *testing.T) { + var gotBody map[string]string + backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/internal/sessions/telegram" { + t.Errorf("unexpected path %q", r.URL.Path) + } + _ = json.NewDecoder(r.Body).Decode(&gotBody) + _, _ = w.Write([]byte(`{"token":"tok-tg","user_id":"u-tg","is_guest":false,"display_name":"Иван"}`)) + }) + defer cleanup() + + v := fakeValidator{user: connector.User{ExternalID: "42", Username: "neo", FirstName: "Иван", LanguageCode: "ru"}} + reg := transcode.NewRegistry(backend, v) + op, ok := reg.Lookup(transcode.MsgAuthTelegram) + if !ok { + t.Fatal("auth.telegram not registered") + } + + payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")}) + if err != nil { + t.Fatalf("handler: %v", err) + } + sess := fb.GetRootAsSession(payload, 0) + if string(sess.Token()) != "tok-tg" || string(sess.UserId()) != "u-tg" { + t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId()) + } + // The validated launch fields are forwarded so the backend can seed a new account. + if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" { + t.Errorf("forwarded body = %+v, want external_id=42 language_code=ru first_name=Иван", gotBody) + } +} + +func TestTelegramAuthInvalidInitData(t *testing.T) { + backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) { + t.Error("backend must not be called when initData is invalid") + }) + defer cleanup() + + reg := transcode.NewRegistry(backend, fakeValidator{err: connector.ErrInvalidInitData}) + op, _ := reg.Lookup(transcode.MsgAuthTelegram) + + _, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("bad")}) + if code, ok := transcode.DomainCode(err); !ok || code != "invalid_init_data" { + t.Errorf("DomainCode = (%q, %v), want (invalid_init_data, true)", code, ok) + } +} + +// TestTelegramAuthDisabledWithoutConnector confirms a nil validator leaves +// auth.telegram unregistered. +func TestTelegramAuthDisabledWithoutConnector(t *testing.T) { + backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {}) + defer cleanup() + reg := transcode.NewRegistry(backend, nil) + if _, ok := reg.Lookup(transcode.MsgAuthTelegram); ok { + t.Error("auth.telegram should be unregistered without a connector") + } +} diff --git a/go.work b/go.work index d79d833..b5762d7 100644 --- a/go.work +++ b/go.work @@ -5,6 +5,7 @@ use ./backend use ( ./gateway ./pkg + ./platform/telegram ) // The scrabble-solver engine is consumed in-process as a library. Its module diff --git a/go.work.sum b/go.work.sum index af41602..33a2549 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,7 +1,5 @@ cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= -connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= @@ -23,6 +21,8 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps= +github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= @@ -81,12 +81,9 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= diff --git a/pkg/fbs/scrabble.fbs b/pkg/fbs/scrabble.fbs index 2452eb4..cdef56f 100644 --- a/pkg/fbs/scrabble.fbs +++ b/pkg/fbs/scrabble.fbs @@ -104,7 +104,9 @@ table Ack { // --- profile (authenticated) --- // Profile is the authenticated account's own profile view. away_start/away_end are -// the "HH:MM" daily away-window bounds (added trailing — backward-compatible). +// the "HH:MM" daily away-window bounds. notifications_in_app_only (default true) +// suppresses out-of-app platform push, leaving only the in-app live stream (both +// added trailing — backward-compatible). table Profile { user_id:string; display_name:string; @@ -116,6 +118,7 @@ table Profile { is_guest:bool; away_start:string; away_end:string; + notifications_in_app_only:bool = true; } // --- game (authenticated) --- @@ -256,6 +259,8 @@ table AccountRef { // UpdateProfileRequest overwrites the full editable profile (the client sends the // complete desired profile). away_start/away_end are "HH:MM" bounds. +// notifications_in_app_only (trailing — backward-compatible) toggles out-of-app +// platform push off when set. table UpdateProfileRequest { display_name:string; preferred_language:string; @@ -264,6 +269,7 @@ table UpdateProfileRequest { away_end:string; block_chat:bool; block_friend_requests:bool; + notifications_in_app_only:bool = true; } // EmailBindRequest asks the backend to send a confirm-code binding email to the diff --git a/pkg/fbs/scrabblefb/Profile.go b/pkg/fbs/scrabblefb/Profile.go index c5fd6e3..5619a81 100644 --- a/pkg/fbs/scrabblefb/Profile.go +++ b/pkg/fbs/scrabblefb/Profile.go @@ -137,8 +137,20 @@ func (rcv *Profile) AwayEnd() []byte { return nil } +func (rcv *Profile) NotificationsInAppOnly() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(24)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return true +} + +func (rcv *Profile) MutateNotificationsInAppOnly(n bool) bool { + return rcv._tab.MutateBoolSlot(24, n) +} + func ProfileStart(builder *flatbuffers.Builder) { - builder.StartObject(10) + builder.StartObject(11) } func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) @@ -170,6 +182,9 @@ func ProfileAddAwayStart(builder *flatbuffers.Builder, awayStart flatbuffers.UOf func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(awayEnd), 0) } +func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) { + builder.PrependBoolSlot(10, notificationsInAppOnly, true) +} func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/fbs/scrabblefb/UpdateProfileRequest.go b/pkg/fbs/scrabblefb/UpdateProfileRequest.go index 81f843d..980219a 100644 --- a/pkg/fbs/scrabblefb/UpdateProfileRequest.go +++ b/pkg/fbs/scrabblefb/UpdateProfileRequest.go @@ -105,8 +105,20 @@ func (rcv *UpdateProfileRequest) MutateBlockFriendRequests(n bool) bool { return rcv._tab.MutateBoolSlot(16, n) } +func (rcv *UpdateProfileRequest) NotificationsInAppOnly() bool { + o := flatbuffers.UOffsetT(rcv._tab.Offset(18)) + if o != 0 { + return rcv._tab.GetBool(o + rcv._tab.Pos) + } + return true +} + +func (rcv *UpdateProfileRequest) MutateNotificationsInAppOnly(n bool) bool { + return rcv._tab.MutateBoolSlot(18, n) +} + func UpdateProfileRequestStart(builder *flatbuffers.Builder) { - builder.StartObject(7) + builder.StartObject(8) } func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0) @@ -129,6 +141,9 @@ func UpdateProfileRequestAddBlockChat(builder *flatbuffers.Builder, blockChat bo func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequests bool) { builder.PrependBoolSlot(6, blockFriendRequests, false) } +func UpdateProfileRequestAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) { + builder.PrependBoolSlot(7, notificationsInAppOnly, true) +} func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { return builder.EndObject() } diff --git a/pkg/proto/telegram/v1/telegram.pb.go b/pkg/proto/telegram/v1/telegram.pb.go new file mode 100644 index 0000000..0670b1c --- /dev/null +++ b/pkg/proto/telegram/v1/telegram.pb.go @@ -0,0 +1,508 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: telegram/v1/telegram.proto + +// Package scrabble.telegram.v1 is the RPC contract of the Telegram platform +// side-service (the "connector"). The connector holds the bot token and is the +// only component that talks to the Telegram Bot API; gateway and backend reach it +// over plain gRPC on the trusted internal network (ARCHITECTURE.md §1, §12). +// +// The generic delivery methods (Notify, SendToUser, SendToGameChannel) are +// platform-agnostic: they 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. ValidateInitData is the one Telegram-specific method (it parses +// Telegram Web App launch data); it is kept here for now. + +package telegramv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + 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) +) + +// ValidateInitDataRequest carries the raw Telegram Web App initData string. +type ValidateInitDataRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + InitData string `protobuf:"bytes,1,opt,name=init_data,json=initData,proto3" json:"init_data,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateInitDataRequest) Reset() { + *x = ValidateInitDataRequest{} + mi := &file_telegram_v1_telegram_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateInitDataRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateInitDataRequest) ProtoMessage() {} + +func (x *ValidateInitDataRequest) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_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 ValidateInitDataRequest.ProtoReflect.Descriptor instead. +func (*ValidateInitDataRequest) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{0} +} + +func (x *ValidateInitDataRequest) GetInitData() string { + if x != nil { + return x.InitData + } + return "" +} + +// ValidateInitDataResponse is the validated identity. external_id is the Telegram +// user id used as the identities external_id; language_code seeds a new account's +// preferred language. +type ValidateInitDataResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + Username string `protobuf:"bytes,2,opt,name=username,proto3" json:"username,omitempty"` + FirstName string `protobuf:"bytes,3,opt,name=first_name,json=firstName,proto3" json:"first_name,omitempty"` + LanguageCode string `protobuf:"bytes,4,opt,name=language_code,json=languageCode,proto3" json:"language_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidateInitDataResponse) Reset() { + *x = ValidateInitDataResponse{} + mi := &file_telegram_v1_telegram_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidateInitDataResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidateInitDataResponse) ProtoMessage() {} + +func (x *ValidateInitDataResponse) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_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 ValidateInitDataResponse.ProtoReflect.Descriptor instead. +func (*ValidateInitDataResponse) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{1} +} + +func (x *ValidateInitDataResponse) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +func (x *ValidateInitDataResponse) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *ValidateInitDataResponse) GetFirstName() string { + if x != nil { + return x.FirstName + } + return "" +} + +func (x *ValidateInitDataResponse) GetLanguageCode() string { + if x != nil { + return x.LanguageCode + } + return "" +} + +// NotifyRequest addresses a push event to one recipient. kind is the backend push +// catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers +// scrabblefb.* body for that kind; language (en/ru) selects the message template. +type NotifyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"` + Payload []byte `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"` + Language string `protobuf:"bytes,4,opt,name=language,proto3" json:"language,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyRequest) Reset() { + *x = NotifyRequest{} + mi := &file_telegram_v1_telegram_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyRequest) ProtoMessage() {} + +func (x *NotifyRequest) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_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 NotifyRequest.ProtoReflect.Descriptor instead. +func (*NotifyRequest) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{2} +} + +func (x *NotifyRequest) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +func (x *NotifyRequest) GetKind() string { + if x != nil { + return x.Kind + } + return "" +} + +func (x *NotifyRequest) GetPayload() []byte { + if x != nil { + return x.Payload + } + return nil +} + +func (x *NotifyRequest) GetLanguage() string { + if x != nil { + return x.Language + } + return "" +} + +// NotifyResponse reports whether a message was actually sent (false when the kind +// is not rendered out-of-app or the user has not started the bot). +type NotifyResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Delivered bool `protobuf:"varint,1,opt,name=delivered,proto3" json:"delivered,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotifyResponse) Reset() { + *x = NotifyResponse{} + mi := &file_telegram_v1_telegram_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotifyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotifyResponse) ProtoMessage() {} + +func (x *NotifyResponse) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_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 NotifyResponse.ProtoReflect.Descriptor instead. +func (*NotifyResponse) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{3} +} + +func (x *NotifyResponse) GetDelivered() bool { + if x != nil { + return x.Delivered + } + return false +} + +// SendToUserRequest is an admin text message to one user by external_id. +type SendToUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + ExternalId string `protobuf:"bytes,1,opt,name=external_id,json=externalId,proto3" json:"external_id,omitempty"` + Text string `protobuf:"bytes,2,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendToUserRequest) Reset() { + *x = SendToUserRequest{} + mi := &file_telegram_v1_telegram_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendToUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendToUserRequest) ProtoMessage() {} + +func (x *SendToUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_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 SendToUserRequest.ProtoReflect.Descriptor instead. +func (*SendToUserRequest) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{4} +} + +func (x *SendToUserRequest) GetExternalId() string { + if x != nil { + return x.ExternalId + } + return "" +} + +func (x *SendToUserRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +// SendToGameChannelRequest is an admin text message to the configured game channel. +type SendToGameChannelRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendToGameChannelRequest) Reset() { + *x = SendToGameChannelRequest{} + mi := &file_telegram_v1_telegram_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendToGameChannelRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendToGameChannelRequest) ProtoMessage() {} + +func (x *SendToGameChannelRequest) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_proto_msgTypes[5] + 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 SendToGameChannelRequest.ProtoReflect.Descriptor instead. +func (*SendToGameChannelRequest) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{5} +} + +func (x *SendToGameChannelRequest) GetText() string { + if x != nil { + return x.Text + } + return "" +} + +// SendResponse reports whether the message was sent. +type SendResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Delivered bool `protobuf:"varint,1,opt,name=delivered,proto3" json:"delivered,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SendResponse) Reset() { + *x = SendResponse{} + mi := &file_telegram_v1_telegram_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SendResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendResponse) ProtoMessage() {} + +func (x *SendResponse) ProtoReflect() protoreflect.Message { + mi := &file_telegram_v1_telegram_proto_msgTypes[6] + 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 SendResponse.ProtoReflect.Descriptor instead. +func (*SendResponse) Descriptor() ([]byte, []int) { + return file_telegram_v1_telegram_proto_rawDescGZIP(), []int{6} +} + +func (x *SendResponse) GetDelivered() bool { + if x != nil { + return x.Delivered + } + return false +} + +var File_telegram_v1_telegram_proto protoreflect.FileDescriptor + +const file_telegram_v1_telegram_proto_rawDesc = "" + + "\n" + + "\x1atelegram/v1/telegram.proto\x12\x14scrabble.telegram.v1\"6\n" + + "\x17ValidateInitDataRequest\x12\x1b\n" + + "\tinit_data\x18\x01 \x01(\tR\binitData\"\x9b\x01\n" + + "\x18ValidateInitDataResponse\x12\x1f\n" + + "\vexternal_id\x18\x01 \x01(\tR\n" + + "externalId\x12\x1a\n" + + "\busername\x18\x02 \x01(\tR\busername\x12\x1d\n" + + "\n" + + "first_name\x18\x03 \x01(\tR\tfirstName\x12#\n" + + "\rlanguage_code\x18\x04 \x01(\tR\flanguageCode\"z\n" + + "\rNotifyRequest\x12\x1f\n" + + "\vexternal_id\x18\x01 \x01(\tR\n" + + "externalId\x12\x12\n" + + "\x04kind\x18\x02 \x01(\tR\x04kind\x12\x18\n" + + "\apayload\x18\x03 \x01(\fR\apayload\x12\x1a\n" + + "\blanguage\x18\x04 \x01(\tR\blanguage\".\n" + + "\x0eNotifyResponse\x12\x1c\n" + + "\tdelivered\x18\x01 \x01(\bR\tdelivered\"H\n" + + "\x11SendToUserRequest\x12\x1f\n" + + "\vexternal_id\x18\x01 \x01(\tR\n" + + "externalId\x12\x12\n" + + "\x04text\x18\x02 \x01(\tR\x04text\".\n" + + "\x18SendToGameChannelRequest\x12\x12\n" + + "\x04text\x18\x01 \x01(\tR\x04text\",\n" + + "\fSendResponse\x12\x1c\n" + + "\tdelivered\x18\x01 \x01(\bR\tdelivered2\x96\x03\n" + + "\bTelegram\x12q\n" + + "\x10ValidateInitData\x12-.scrabble.telegram.v1.ValidateInitDataRequest\x1a..scrabble.telegram.v1.ValidateInitDataResponse\x12S\n" + + "\x06Notify\x12#.scrabble.telegram.v1.NotifyRequest\x1a$.scrabble.telegram.v1.NotifyResponse\x12Y\n" + + "\n" + + "SendToUser\x12'.scrabble.telegram.v1.SendToUserRequest\x1a\".scrabble.telegram.v1.SendResponse\x12g\n" + + "\x11SendToGameChannel\x12..scrabble.telegram.v1.SendToGameChannelRequest\x1a\".scrabble.telegram.v1.SendResponseB+Z)scrabble/pkg/proto/telegram/v1;telegramv1b\x06proto3" + +var ( + file_telegram_v1_telegram_proto_rawDescOnce sync.Once + file_telegram_v1_telegram_proto_rawDescData []byte +) + +func file_telegram_v1_telegram_proto_rawDescGZIP() []byte { + file_telegram_v1_telegram_proto_rawDescOnce.Do(func() { + file_telegram_v1_telegram_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_telegram_v1_telegram_proto_rawDesc), len(file_telegram_v1_telegram_proto_rawDesc))) + }) + return file_telegram_v1_telegram_proto_rawDescData +} + +var file_telegram_v1_telegram_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_telegram_v1_telegram_proto_goTypes = []any{ + (*ValidateInitDataRequest)(nil), // 0: scrabble.telegram.v1.ValidateInitDataRequest + (*ValidateInitDataResponse)(nil), // 1: scrabble.telegram.v1.ValidateInitDataResponse + (*NotifyRequest)(nil), // 2: scrabble.telegram.v1.NotifyRequest + (*NotifyResponse)(nil), // 3: scrabble.telegram.v1.NotifyResponse + (*SendToUserRequest)(nil), // 4: scrabble.telegram.v1.SendToUserRequest + (*SendToGameChannelRequest)(nil), // 5: scrabble.telegram.v1.SendToGameChannelRequest + (*SendResponse)(nil), // 6: scrabble.telegram.v1.SendResponse +} +var file_telegram_v1_telegram_proto_depIdxs = []int32{ + 0, // 0: scrabble.telegram.v1.Telegram.ValidateInitData:input_type -> scrabble.telegram.v1.ValidateInitDataRequest + 2, // 1: scrabble.telegram.v1.Telegram.Notify:input_type -> scrabble.telegram.v1.NotifyRequest + 4, // 2: scrabble.telegram.v1.Telegram.SendToUser:input_type -> scrabble.telegram.v1.SendToUserRequest + 5, // 3: scrabble.telegram.v1.Telegram.SendToGameChannel:input_type -> scrabble.telegram.v1.SendToGameChannelRequest + 1, // 4: scrabble.telegram.v1.Telegram.ValidateInitData:output_type -> scrabble.telegram.v1.ValidateInitDataResponse + 3, // 5: scrabble.telegram.v1.Telegram.Notify:output_type -> scrabble.telegram.v1.NotifyResponse + 6, // 6: scrabble.telegram.v1.Telegram.SendToUser:output_type -> scrabble.telegram.v1.SendResponse + 6, // 7: scrabble.telegram.v1.Telegram.SendToGameChannel:output_type -> scrabble.telegram.v1.SendResponse + 4, // [4:8] is the sub-list for method output_type + 0, // [0:4] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_telegram_v1_telegram_proto_init() } +func file_telegram_v1_telegram_proto_init() { + if File_telegram_v1_telegram_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_telegram_v1_telegram_proto_rawDesc), len(file_telegram_v1_telegram_proto_rawDesc)), + NumEnums: 0, + NumMessages: 7, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_telegram_v1_telegram_proto_goTypes, + DependencyIndexes: file_telegram_v1_telegram_proto_depIdxs, + MessageInfos: file_telegram_v1_telegram_proto_msgTypes, + }.Build() + File_telegram_v1_telegram_proto = out.File + file_telegram_v1_telegram_proto_goTypes = nil + file_telegram_v1_telegram_proto_depIdxs = nil +} diff --git a/pkg/proto/telegram/v1/telegram.proto b/pkg/proto/telegram/v1/telegram.proto new file mode 100644 index 0000000..5a9af90 --- /dev/null +++ b/pkg/proto/telegram/v1/telegram.proto @@ -0,0 +1,83 @@ +syntax = "proto3"; + +// Package scrabble.telegram.v1 is the RPC contract of the Telegram platform +// side-service (the "connector"). The connector holds the bot token and is the +// only component that talks to the Telegram Bot API; gateway and backend reach it +// over plain gRPC on the trusted internal network (ARCHITECTURE.md §1, §12). +// +// The generic delivery methods (Notify, SendToUser, SendToGameChannel) are +// platform-agnostic: they 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. ValidateInitData is the one Telegram-specific method (it parses +// Telegram Web App launch data); it is kept here for now. +package scrabble.telegram.v1; + +option go_package = "scrabble/pkg/proto/telegram/v1;telegramv1"; + +// Telegram is the connector RPC surface. +service Telegram { + // ValidateInitData verifies Telegram Mini App launch data (HMAC) and returns + // the authenticated user. The gateway calls it during the auth.telegram edge + // operation, then provisions the session through the backend internal API. + rpc ValidateInitData(ValidateInitDataRequest) returns (ValidateInitDataResponse); + // Notify delivers an out-of-app notification for a backend push event. The + // gateway calls it only for a recipient with no live in-app stream (so the + // platform push never duplicates in-app delivery). The connector renders a + // localized message with a Mini App deep-link button from the FlatBuffers + // payload; unrenderable kinds are skipped (delivered=false). + rpc Notify(NotifyRequest) returns (NotifyResponse); + // SendToUser sends an arbitrary text message to one user (admin use, wired in + // Stage 10). delivered is false when the user has not started the bot. + rpc SendToUser(SendToUserRequest) returns (SendResponse); + // SendToGameChannel posts an arbitrary text message to the bot's configured + // game channel (admin use, wired in Stage 10); the channel id lives only in the + // connector configuration. + rpc SendToGameChannel(SendToGameChannelRequest) returns (SendResponse); +} + +// ValidateInitDataRequest carries the raw Telegram Web App initData string. +message ValidateInitDataRequest { + string init_data = 1; +} + +// ValidateInitDataResponse is the validated identity. external_id is the Telegram +// user id used as the identities external_id; language_code seeds a new account's +// preferred language. +message ValidateInitDataResponse { + string external_id = 1; + string username = 2; + string first_name = 3; + string language_code = 4; +} + +// NotifyRequest addresses a push event to one recipient. kind is the backend push +// catalog kind (your_turn, nudge, match_found, notify); payload is the FlatBuffers +// scrabblefb.* body for that kind; language (en/ru) selects the message template. +message NotifyRequest { + string external_id = 1; + string kind = 2; + bytes payload = 3; + string language = 4; +} + +// NotifyResponse reports whether a message was actually sent (false when the kind +// is not rendered out-of-app or the user has not started the bot). +message NotifyResponse { + bool delivered = 1; +} + +// SendToUserRequest is an admin text message to one user by external_id. +message SendToUserRequest { + string external_id = 1; + string text = 2; +} + +// SendToGameChannelRequest is an admin text message to the configured game channel. +message SendToGameChannelRequest { + string text = 1; +} + +// SendResponse reports whether the message was sent. +message SendResponse { + bool delivered = 1; +} diff --git a/pkg/proto/telegram/v1/telegram_grpc.pb.go b/pkg/proto/telegram/v1/telegram_grpc.pb.go new file mode 100644 index 0000000..6c7fa9d --- /dev/null +++ b/pkg/proto/telegram/v1/telegram_grpc.pb.go @@ -0,0 +1,276 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc (unknown) +// source: telegram/v1/telegram.proto + +// Package scrabble.telegram.v1 is the RPC contract of the Telegram platform +// side-service (the "connector"). The connector holds the bot token and is the +// only component that talks to the Telegram Bot API; gateway and backend reach it +// over plain gRPC on the trusted internal network (ARCHITECTURE.md §1, §12). +// +// The generic delivery methods (Notify, SendToUser, SendToGameChannel) are +// platform-agnostic: they 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. ValidateInitData is the one Telegram-specific method (it parses +// Telegram Web App launch data); it is kept here for now. + +package telegramv1 + +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 ( + Telegram_ValidateInitData_FullMethodName = "/scrabble.telegram.v1.Telegram/ValidateInitData" + Telegram_Notify_FullMethodName = "/scrabble.telegram.v1.Telegram/Notify" + Telegram_SendToUser_FullMethodName = "/scrabble.telegram.v1.Telegram/SendToUser" + Telegram_SendToGameChannel_FullMethodName = "/scrabble.telegram.v1.Telegram/SendToGameChannel" +) + +// TelegramClient is the client API for Telegram 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. +// +// Telegram is the connector RPC surface. +type TelegramClient interface { + // ValidateInitData verifies Telegram Mini App launch data (HMAC) and returns + // the authenticated user. The gateway calls it during the auth.telegram edge + // operation, then provisions the session through the backend internal API. + ValidateInitData(ctx context.Context, in *ValidateInitDataRequest, opts ...grpc.CallOption) (*ValidateInitDataResponse, error) + // Notify delivers an out-of-app notification for a backend push event. The + // gateway calls it only for a recipient with no live in-app stream (so the + // platform push never duplicates in-app delivery). The connector renders a + // localized message with a Mini App deep-link button from the FlatBuffers + // payload; unrenderable kinds are skipped (delivered=false). + Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error) + // SendToUser sends an arbitrary text message to one user (admin use, wired in + // Stage 10). delivered is false when the user has not started the bot. + SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error) + // SendToGameChannel posts an arbitrary text message to the bot's configured + // game channel (admin use, wired in Stage 10); the channel id lives only in the + // connector configuration. + SendToGameChannel(ctx context.Context, in *SendToGameChannelRequest, opts ...grpc.CallOption) (*SendResponse, error) +} + +type telegramClient struct { + cc grpc.ClientConnInterface +} + +func NewTelegramClient(cc grpc.ClientConnInterface) TelegramClient { + return &telegramClient{cc} +} + +func (c *telegramClient) ValidateInitData(ctx context.Context, in *ValidateInitDataRequest, opts ...grpc.CallOption) (*ValidateInitDataResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ValidateInitDataResponse) + err := c.cc.Invoke(ctx, Telegram_ValidateInitData_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *telegramClient) Notify(ctx context.Context, in *NotifyRequest, opts ...grpc.CallOption) (*NotifyResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(NotifyResponse) + err := c.cc.Invoke(ctx, Telegram_Notify_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *telegramClient) SendToUser(ctx context.Context, in *SendToUserRequest, opts ...grpc.CallOption) (*SendResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendResponse) + err := c.cc.Invoke(ctx, Telegram_SendToUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *telegramClient) SendToGameChannel(ctx context.Context, in *SendToGameChannelRequest, opts ...grpc.CallOption) (*SendResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SendResponse) + err := c.cc.Invoke(ctx, Telegram_SendToGameChannel_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// TelegramServer is the server API for Telegram service. +// All implementations must embed UnimplementedTelegramServer +// for forward compatibility. +// +// Telegram is the connector RPC surface. +type TelegramServer interface { + // ValidateInitData verifies Telegram Mini App launch data (HMAC) and returns + // the authenticated user. The gateway calls it during the auth.telegram edge + // operation, then provisions the session through the backend internal API. + ValidateInitData(context.Context, *ValidateInitDataRequest) (*ValidateInitDataResponse, error) + // Notify delivers an out-of-app notification for a backend push event. The + // gateway calls it only for a recipient with no live in-app stream (so the + // platform push never duplicates in-app delivery). The connector renders a + // localized message with a Mini App deep-link button from the FlatBuffers + // payload; unrenderable kinds are skipped (delivered=false). + Notify(context.Context, *NotifyRequest) (*NotifyResponse, error) + // SendToUser sends an arbitrary text message to one user (admin use, wired in + // Stage 10). delivered is false when the user has not started the bot. + SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error) + // SendToGameChannel posts an arbitrary text message to the bot's configured + // game channel (admin use, wired in Stage 10); the channel id lives only in the + // connector configuration. + SendToGameChannel(context.Context, *SendToGameChannelRequest) (*SendResponse, error) + mustEmbedUnimplementedTelegramServer() +} + +// UnimplementedTelegramServer 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 UnimplementedTelegramServer struct{} + +func (UnimplementedTelegramServer) ValidateInitData(context.Context, *ValidateInitDataRequest) (*ValidateInitDataResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ValidateInitData not implemented") +} +func (UnimplementedTelegramServer) Notify(context.Context, *NotifyRequest) (*NotifyResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Notify not implemented") +} +func (UnimplementedTelegramServer) SendToUser(context.Context, *SendToUserRequest) (*SendResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendToUser not implemented") +} +func (UnimplementedTelegramServer) SendToGameChannel(context.Context, *SendToGameChannelRequest) (*SendResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendToGameChannel not implemented") +} +func (UnimplementedTelegramServer) mustEmbedUnimplementedTelegramServer() {} +func (UnimplementedTelegramServer) testEmbeddedByValue() {} + +// UnsafeTelegramServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to TelegramServer will +// result in compilation errors. +type UnsafeTelegramServer interface { + mustEmbedUnimplementedTelegramServer() +} + +func RegisterTelegramServer(s grpc.ServiceRegistrar, srv TelegramServer) { + // If the following call pancis, it indicates UnimplementedTelegramServer 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(&Telegram_ServiceDesc, srv) +} + +func _Telegram_ValidateInitData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateInitDataRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TelegramServer).ValidateInitData(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Telegram_ValidateInitData_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TelegramServer).ValidateInitData(ctx, req.(*ValidateInitDataRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Telegram_Notify_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(NotifyRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TelegramServer).Notify(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Telegram_Notify_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TelegramServer).Notify(ctx, req.(*NotifyRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Telegram_SendToUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendToUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TelegramServer).SendToUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Telegram_SendToUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TelegramServer).SendToUser(ctx, req.(*SendToUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Telegram_SendToGameChannel_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendToGameChannelRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TelegramServer).SendToGameChannel(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Telegram_SendToGameChannel_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TelegramServer).SendToGameChannel(ctx, req.(*SendToGameChannelRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Telegram_ServiceDesc is the grpc.ServiceDesc for Telegram service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Telegram_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "scrabble.telegram.v1.Telegram", + HandlerType: (*TelegramServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "ValidateInitData", + Handler: _Telegram_ValidateInitData_Handler, + }, + { + MethodName: "Notify", + Handler: _Telegram_Notify_Handler, + }, + { + MethodName: "SendToUser", + Handler: _Telegram_SendToUser_Handler, + }, + { + MethodName: "SendToGameChannel", + Handler: _Telegram_SendToGameChannel_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "telegram/v1/telegram.proto", +} diff --git a/platform/telegram/Dockerfile b/platform/telegram/Dockerfile new file mode 100644 index 0000000..16ebd9d --- /dev/null +++ b/platform/telegram/Dockerfile @@ -0,0 +1,22 @@ +# Telegram connector image. +# +# The connector imports only the shared scrabble/pkg module, so the build drops the +# other workspace modules (backend, gateway) 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: ../../..). +FROM golang:1.26.3-alpine AS build +WORKDIR /src + +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. +RUN go work edit -dropuse=./backend -dropuse=./gateway -dropreplace=scrabble-solver + +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -o /out/telegram ./platform/telegram/cmd/telegram + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /out/telegram /usr/local/bin/telegram +ENTRYPOINT ["/usr/local/bin/telegram"] diff --git a/platform/telegram/README.md b/platform/telegram/README.md new file mode 100644 index 0000000..5e05f22 --- /dev/null +++ b/platform/telegram/README.md @@ -0,0 +1,86 @@ +# scrabble/platform/telegram — Telegram connector + +The Telegram platform side-service. It is the **only** component that holds the bot +token: it runs the 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. + +## Responsibilities + +- **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. 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. +- **Bot chat.** `/start ` (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** (wired in Stage 10). `SendToUser` and `SendToGameChannel` send + arbitrary text to one user or the configured game channel. + +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. + +## gRPC API + +`pkg/proto/telegram/v1`, service `Telegram`: `ValidateInitData`, `Notify`, +`SendToUser`, `SendToGameChannel`. 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: + +| Parameter | Destination | +| --- | --- | +| `g` | open that game | +| `i` | open that invitation | +| `f<6-digit code>` | redeem that friend code | +| empty / unknown | the lobby | + +The bot turns a `/start ` or a notification target into a launch-button URL +`?startapp=`. + +## Configuration + +| Env var | Default | Meaning | +| --- | --- | --- | +| `TELEGRAM_BOT_TOKEN` | — (required) | Bot API token + the initData HMAC secret | +| `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/test/METHOD`) | +| `TELEGRAM_GAME_CHANNEL_ID` | — | game channel chat id for `SendToGameChannel` | +| `TELEGRAM_LOG_LEVEL` | `info` | zap log level | + +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 `/bot/`). + +## 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 +``` + +## 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 lands with Stage 12. + +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. diff --git a/platform/telegram/cmd/telegram/main.go b/platform/telegram/cmd/telegram/main.go new file mode 100644 index 0000000..115a917 --- /dev/null +++ b/platform/telegram/cmd/telegram/main.go @@ -0,0 +1,94 @@ +// 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. +package main + +import ( + "context" + "errors" + "log" + "net" + "os/signal" + "syscall" + + "go.uber.org/zap" + "google.golang.org/grpc" + + telegramv1 "scrabble/pkg/proto/telegram/v1" + "scrabble/platform/telegram/internal/bot" + "scrabble/platform/telegram/internal/config" + "scrabble/platform/telegram/internal/connector" + "scrabble/platform/telegram/internal/initdata" +) + +func main() { + cfg, err := config.Load() + if err != nil { + log.Fatalf("telegram: load config: %v", err) + } + logger, err := newLogger(cfg.LogLevel) + if err != nil { + log.Fatalf("telegram: 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("telegram: 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 { + b, err := bot.New(bot.Config{ + Token: cfg.BotToken, + APIBaseURL: cfg.APIBaseURL, + TestEnv: cfg.TestEnv, + MiniAppURL: cfg.MiniAppURL, + }, logger) + if err != nil { + return err + } + srv := connector.NewServer(initdata.NewHMACValidator(cfg.BotToken), b, cfg.GameChannelID, logger) + + grpcServer := grpc.NewServer() + telegramv1.RegisterTelegramServer(grpcServer, srv) + + lis, err := net.Listen("tcp", cfg.GRPCAddr) + if err != nil { + 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)) + if err := grpcServer.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { + return err + } + 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() +} diff --git a/platform/telegram/deploy/docker-compose.yml b/platform/telegram/deploy/docker-compose.yml new file mode 100644 index 0000000..44288a0 --- /dev/null +++ b/platform/telegram/deploy/docker-compose.yml @@ -0,0 +1,58 @@ +# Deploy descriptor for the Telegram connector (the platform side-service). +# +# Networking mirrors the sibling ../15-puzzle/deploy/docker-compose.yml: +# - The `vpn` sidecar (developer/amneziawg-sidecar) holds the tunnel and provides +# the netns shared by `app` (network_mode: "service:vpn"). All of the +# connector's egress to api.telegram.org therefore leaves through the tunnel. +# - `vpn` is the one attached to the external `edge` network, with the alias +# `telegram`, so the other services reach the connector's gRPC port at +# `telegram:9091` inside the shared netns. The connector needs NO public +# ingress — it long-polls Telegram and only answers internal gRPC. +# +# The connector joins the same `edge` network as `backend` and `gateway` (the full +# service set rolled out together on a dev-environment deploy). The gateway calls it +# with GATEWAY_CONNECTOR_ADDR=telegram:9091; the backend admin surface (Stage 10) +# will use the same address. The single public ingress for the host reverse proxy +# (caddy) is the gateway's HTTP port, which also serves the Mini App under /telegram/ +# (ARCHITECTURE.md §13). The full multi-service compose lands with Stage 12; this is +# the connector-scoped descriptor. +name: scrabble-telegram + +services: + vpn: + container_name: scrabble-telegram-vpn + image: docker.iliadenisov.ru/developer/amneziawg-sidecar:latest + restart: unless-stopped + privileged: true + environment: + AWG_CONF: ${AWG_CONF:?set AWG_CONF} + networks: + edge: + aliases: + - telegram + + app: + container_name: scrabble-telegram + image: scrabble-telegram:latest + build: + # Build from the repository root so go.work, pkg/ and platform/telegram/ are + # all in the Docker context (see platform/telegram/Dockerfile). + context: ../../.. + dockerfile: platform/telegram/Dockerfile + restart: unless-stopped + depends_on: + - vpn + network_mode: "service:vpn" + environment: + # The bot token lives ONLY in this container (ARCHITECTURE.md §12). + TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN} + TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} + TELEGRAM_GRPC_ADDR: ${TELEGRAM_GRPC_ADDR:-:9091} + # Set to true when deploying into Telegram's test environment. + TELEGRAM_TEST_ENV: ${TELEGRAM_TEST_ENV:-false} + TELEGRAM_API_BASE_URL: ${TELEGRAM_API_BASE_URL:-} + TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-} + +networks: + edge: + external: true diff --git a/platform/telegram/go.mod b/platform/telegram/go.mod new file mode 100644 index 0000000..29bee4c --- /dev/null +++ b/platform/telegram/go.mod @@ -0,0 +1,12 @@ +module scrabble/platform/telegram + +go 1.26.3 + +require ( + github.com/go-telegram/bot v1.21.0 + github.com/google/flatbuffers v23.5.26+incompatible + go.uber.org/zap v1.27.1 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 + scrabble/pkg v0.0.0 +) diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go new file mode 100644 index 0000000..1f639d3 --- /dev/null +++ b/platform/telegram/internal/bot/bot.go @@ -0,0 +1,155 @@ +// Package bot wraps the Telegram Bot API client (github.com/go-telegram/bot): it +// runs the long-poll update loop — replying to /start (with an optional deep-link +// payload) and any other message with a Mini App launch button — and sends the +// notification and admin messages the connector requests. The bot token lives only +// in this process. +package bot + +import ( + "context" + "net/url" + "strings" + + tgbot "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" + "go.uber.org/zap" +) + +// Config configures the bot wrapper. +type Config struct { + // Token is the Bot API token. + Token string + // APIBaseURL overrides the Bot API host ("" uses https://api.telegram.org). + APIBaseURL string + // TestEnv routes requests to the Bot API test environment. + TestEnv bool + // MiniAppURL is the base URL of the Mini App launch button. + MiniAppURL string +} + +// Bot wraps a Telegram Bot API client and the Mini App launch URL. +type Bot struct { + api *tgbot.Bot + miniAppURL string + log *zap.Logger +} + +// New builds the bot wrapper, registering the /start handler and a default handler +// that both reply with a Mini App launch button. It does not start polling; call +// Run for that. +func New(cfg Config, log *zap.Logger) (*Bot, error) { + if log == nil { + log = zap.NewNop() + } + t := &Bot{miniAppURL: cfg.MiniAppURL, log: log} + + token := cfg.Token + if cfg.TestEnv { + // The Bot API test environment lives under /bot/test/METHOD; the + // client builds /bot/, so suffixing the token with + // "/test" injects the test segment without a custom host. + token += "/test" + } + opts := []tgbot.Option{ + tgbot.WithDefaultHandler(t.handleStart), + tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart), + } + if cfg.APIBaseURL != "" { + opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL)) + } + api, err := tgbot.New(token, opts...) + if err != nil { + return nil, err + } + t.api = api + return t, nil +} + +// Run sets the bot commands and the Mini App menu button, then blocks on the +// long-poll update loop until ctx is cancelled. +func (t *Bot) Run(ctx context.Context) { + if _, err := t.api.SetMyCommands(ctx, &tgbot.SetMyCommandsParams{ + Commands: []models.BotCommand{{Command: "start", Description: "Open Scrabble"}}, + }); err != nil { + t.log.Warn("set commands failed", zap.Error(err)) + } + if _, err := t.api.SetChatMenuButton(ctx, &tgbot.SetChatMenuButtonParams{ + MenuButton: models.MenuButtonWebApp{ + Type: models.MenuButtonTypeWebApp, + Text: "Play", + WebApp: models.WebAppInfo{URL: t.miniAppURL}, + }, + }); err != nil { + t.log.Warn("set menu button failed", zap.Error(err)) + } + t.api.Start(ctx) +} + +// 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 { + _, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ + ChatID: chatID, + Text: text, + ReplyMarkup: t.launchMarkup(buttonText, startParam), + }) + return err +} + +// SendText sends a plain text message with no markup (admin use). +func (t *Bot) SendText(ctx context.Context, chatID int64, text string) error { + _, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ChatID: chatID, Text: text}) + return err +} + +// 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) { + if update.Message == nil { + return + } + startParam := startPayload(update.Message.Text) + if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: "Tap to open Scrabble.", + ReplyMarkup: t.launchMarkup("Open Scrabble", startParam), + }); err != nil { + t.log.Warn("reply to start failed", zap.Error(err)) + } +} + +// launchMarkup builds the single-button inline keyboard that opens the Mini App at +// startParam. +func (t *Bot) launchMarkup(buttonText, startParam string) *models.InlineKeyboardMarkup { + return &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{{ + {Text: buttonText, WebApp: &models.WebAppInfo{URL: t.launchURL(startParam)}}, + }}, + } +} + +// launchURL appends the deep-link start parameter to the Mini App URL as a startapp +// query parameter; an empty parameter returns the base URL unchanged. +func (t *Bot) launchURL(startParam string) string { + if startParam == "" { + return t.miniAppURL + } + u, err := url.Parse(t.miniAppURL) + if err != nil { + return t.miniAppURL + } + q := u.Query() + q.Set("startapp", startParam) + u.RawQuery = q.Encode() + return u.String() +} + +// startPayload extracts the deep-link payload from a "/start " command; +// any other text yields an empty payload (open the lobby). +func startPayload(text string) string { + const cmd = "/start" + if !strings.HasPrefix(text, cmd) { + return "" + } + return strings.TrimSpace(strings.TrimPrefix(text, cmd)) +} diff --git a/platform/telegram/internal/bot/bot_test.go b/platform/telegram/internal/bot/bot_test.go new file mode 100644 index 0000000..4367b19 --- /dev/null +++ b/platform/telegram/internal/bot/bot_test.go @@ -0,0 +1,100 @@ +package bot + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" +) + +// fakeBotAPI answers getMe (so bot.New succeeds offline) and records the last +// sendMessage form fields. +type fakeBotAPI struct { + chatID string + text string + replyMarkup string +} + +func (f *fakeBotAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/getMe"): + io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"test","username":"testbot"}}`) + case strings.HasSuffix(r.URL.Path, "/sendMessage"): + f.chatID = r.FormValue("chat_id") + f.text = r.FormValue("text") + f.replyMarkup = r.FormValue("reply_markup") + io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) + default: + io.WriteString(w, `{"ok":true,"result":true}`) + } +} + +func newTestBot(t *testing.T, api http.Handler) *Bot { + t.Helper() + srv := httptest.NewServer(api) + t.Cleanup(srv.Close) + b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/telegram/"}, zap.NewNop()) + if err != nil { + t.Fatalf("new bot: %v", err) + } + return b +} + +func TestNotifyBuildsLaunchButton(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + if err := b.Notify(context.Background(), 12345, "It's your turn.", "Open game", "g7c9e"); err != nil { + t.Fatalf("notify: %v", err) + } + if api.chatID != "12345" { + t.Errorf("chat_id = %q, want 12345", api.chatID) + } + if api.text != "It's your turn." { + t.Errorf("text = %q", api.text) + } + if !strings.Contains(api.replyMarkup, "web_app") || !strings.Contains(api.replyMarkup, "startapp=g7c9e") { + t.Errorf("reply_markup = %q, want a web_app button with startapp=g7c9e", api.replyMarkup) + } +} + +func TestSendTextHasNoMarkup(t *testing.T) { + api := &fakeBotAPI{} + b := newTestBot(t, api) + if err := b.SendText(context.Background(), 999, "plain"); err != nil { + t.Fatalf("send text: %v", err) + } + if api.chatID != "999" || api.text != "plain" { + t.Errorf("chat_id=%q text=%q, want 999/plain", api.chatID, api.text) + } + if api.replyMarkup != "" { + t.Errorf("reply_markup = %q, want empty", api.replyMarkup) + } +} + +func TestStartPayload(t *testing.T) { + cases := map[string]string{ + "/start g123": "g123", + "/start": "", + "/start f99 ": "f99", + "hello": "", + } + for in, want := range cases { + if got := startPayload(in); got != want { + t.Errorf("startPayload(%q) = %q, want %q", in, got, want) + } + } +} + +func TestLaunchURL(t *testing.T) { + b := &Bot{miniAppURL: "https://example.com/telegram/"} + if got := b.launchURL(""); got != "https://example.com/telegram/" { + t.Errorf("empty start param = %q, want the base URL", got) + } + if got := b.launchURL("g123"); !strings.Contains(got, "startapp=g123") { + t.Errorf("launchURL = %q, want startapp=g123", got) + } +} diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go new file mode 100644 index 0000000..5e13130 --- /dev/null +++ b/platform/telegram/internal/config/config.go @@ -0,0 +1,72 @@ +// Package config loads the Telegram connector's environment configuration. +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// 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 { + // BotToken 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 validation. + BotToken string + // GRPCAddr is the listen address of the connector gRPC server that gateway and + // backend call (TELEGRAM_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/test/METHOD) (TELEGRAM_TEST_ENV=true, default false). + TestEnv bool + // GameChannelID is the chat id of the bot's game channel for SendToGameChannel + // (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts). + GameChannelID int64 + // LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info). + LogLevel string +} + +// Load reads the connector configuration from the environment, applying defaults +// and validating the required fields. +func Load() (Config, error) { + cfg := Config{ + BotToken: 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"), + } + if cfg.BotToken == "" { + return Config{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required") + } + if cfg.MiniAppURL == "" { + return Config{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required") + } + 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 + } + return cfg, nil +} + +// envOr returns the environment value for key, or def when it is unset or empty. +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} diff --git a/platform/telegram/internal/connector/server.go b/platform/telegram/internal/connector/server.go new file mode 100644 index 0000000..2f3123e --- /dev/null +++ b/platform/telegram/internal/connector/server.go @@ -0,0 +1,114 @@ +// 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 (Stage 10) will call SendToUser and SendToGameChannel. The +// generic methods address a recipient by the identity external_id, so a future +// platform connector can implement the same service. +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/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 +} + +// Server implements telegramv1.TelegramServer. +type Server struct { + telegramv1.UnimplementedTelegramServer + validator initdata.Validator + sender Sender + channelID int64 + log *zap.Logger +} + +// NewServer builds the gRPC service from a validator (for ValidateInitData), a +// sender (the bot), and the configured game channel id (0 disables channel posts). +func NewServer(validator initdata.Validator, sender Sender, channelID int64, log *zap.Logger) *Server { + if log == nil { + log = zap.NewNop() + } + return &Server{validator: validator, sender: sender, channelID: channelID, log: log} +} + +// ValidateInitData verifies Mini App launch data and returns the user identity. +func (s *Server) ValidateInitData(ctx context.Context, req *telegramv1.ValidateInitDataRequest) (*telegramv1.ValidateInitDataResponse, error) { + u, err := s.validator.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 +} + +// Notify renders and delivers an out-of-app notification. It reports +// delivered=false (without an error) for a kind that is not pushed out-of-app or a +// delivery the bot could not complete (e.g. the user never started the bot), 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.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. +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.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 admin message to the configured game channel. +func (s *Server) SendToGameChannel(ctx context.Context, req *telegramv1.SendToGameChannelRequest) (*telegramv1.SendResponse, error) { + if s.channelID == 0 { + return nil, status.Error(codes.FailedPrecondition, "game channel is not configured") + } + if err := s.sender.SendText(ctx, s.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 +} diff --git a/platform/telegram/internal/connector/server_test.go b/platform/telegram/internal/connector/server_test.go new file mode 100644 index 0000000..8682e3e --- /dev/null +++ b/platform/telegram/internal/connector/server_test.go @@ -0,0 +1,155 @@ +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" +) + +// 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 } + +// 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 +} + +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: "ru"} + srv := NewServer(stubValidator{user: want}, &fakeSender{}, 0, 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() != "ru" { + t.Errorf("resp = %+v, want %+v", resp, want) + } + + bad := NewServer(stubValidator{err: initdata.ErrInvalidInitData}, &fakeSender{}, 0, 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 TestNotifyDelivers(t *testing.T) { + const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" + sender := &fakeSender{} + srv := NewServer(stubValidator{}, sender, 0, 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(stubValidator{}, sender, 0, 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(stubValidator{}, &fakeSender{}, 0, 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(stubValidator{}, sender, 0, 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(stubValidator{}, &fakeSender{}, 0, 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(stubValidator{}, sender, 555, 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 calls = %+v", sender.text) + } + }) +} diff --git a/platform/telegram/internal/deeplink/deeplink.go b/platform/telegram/internal/deeplink/deeplink.go new file mode 100644 index 0000000..34d6db6 --- /dev/null +++ b/platform/telegram/internal/deeplink/deeplink.go @@ -0,0 +1,56 @@ +// Package deeplink builds and parses the Telegram Mini App "start parameters" that +// route a launch to a specific destination. The scheme is shared verbatim with the +// UI (ui/src/lib/deeplink.ts): a one-character kind prefix followed by a value — +// +// g open that game +// i open that invitation +// f<6-digit code> redeem that friend code +// +// An empty or unrecognised parameter opens the lobby. UUIDs keep their dashes, +// which are allowed in a Telegram startapp parameter ([A-Za-z0-9_-]). +package deeplink + +import "strings" + +// Kind prefixes for the start-parameter scheme. +const ( + prefixGame = "g" + prefixInvitation = "i" + prefixFriendCode = "f" +) + +// Kind classifies a start parameter. +type Kind int + +// The start-parameter kinds. +const ( + KindLobby Kind = iota + KindGame + KindInvitation + KindFriendCode +) + +// Game returns the start parameter that opens the game with the given id. +func Game(id string) string { return prefixGame + id } + +// Invitation returns the start parameter that opens the invitation with the id. +func Invitation(id string) string { return prefixInvitation + id } + +// FriendCode returns the start parameter that redeems the given friend code. +func FriendCode(code string) string { return prefixFriendCode + code } + +// Parse classifies a start parameter and returns its value (the part after the +// kind prefix). An empty or unrecognised parameter is KindLobby with an empty +// value. +func Parse(p string) (Kind, string) { + switch { + case strings.HasPrefix(p, prefixGame): + return KindGame, p[len(prefixGame):] + case strings.HasPrefix(p, prefixInvitation): + return KindInvitation, p[len(prefixInvitation):] + case strings.HasPrefix(p, prefixFriendCode): + return KindFriendCode, p[len(prefixFriendCode):] + default: + return KindLobby, "" + } +} diff --git a/platform/telegram/internal/deeplink/deeplink_test.go b/platform/telegram/internal/deeplink/deeplink_test.go new file mode 100644 index 0000000..3d5f820 --- /dev/null +++ b/platform/telegram/internal/deeplink/deeplink_test.go @@ -0,0 +1,41 @@ +package deeplink + +import "testing" + +func TestBuildAndParse(t *testing.T) { + cases := []struct { + name string + param string + wantKind Kind + wantValue string + }{ + {"game", Game("7c9e6679-7425-40de-944b-e07fc1f90ae7"), KindGame, "7c9e6679-7425-40de-944b-e07fc1f90ae7"}, + {"invitation", Invitation("11111111-2222-3333-4444-555555555555"), KindInvitation, "11111111-2222-3333-4444-555555555555"}, + {"friend code", FriendCode("123456"), KindFriendCode, "123456"}, + {"empty is lobby", "", KindLobby, ""}, + {"unknown is lobby", "x-nope", KindLobby, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotKind, gotValue := Parse(tc.param) + if gotKind != tc.wantKind { + t.Errorf("kind = %d, want %d", gotKind, tc.wantKind) + } + if gotValue != tc.wantValue { + t.Errorf("value = %q, want %q", gotValue, tc.wantValue) + } + }) + } +} + +func TestBuildPrefixes(t *testing.T) { + if Game("x") != "gx" { + t.Errorf("Game = %q, want gx", Game("x")) + } + if Invitation("x") != "ix" { + t.Errorf("Invitation = %q, want ix", Invitation("x")) + } + if FriendCode("123456") != "f123456" { + t.Errorf("FriendCode = %q, want f123456", FriendCode("123456")) + } +} diff --git a/gateway/internal/auth/telegram.go b/platform/telegram/internal/initdata/validator.go similarity index 61% rename from gateway/internal/auth/telegram.go rename to platform/telegram/internal/initdata/validator.go index c4352b9..6e96da8 100644 --- a/gateway/internal/auth/telegram.go +++ b/platform/telegram/internal/initdata/validator.go @@ -1,8 +1,8 @@ -// Package auth holds the gateway's credential validators. The only non-trivial -// one is the Telegram Web App initData HMAC check; guest and email logins carry -// no gateway-side secret and are validated by the backend. The validator is an -// interface so handlers test against fixtures without a bot token. -package auth +// Package initdata validates Telegram Mini App launch data (initData). It lives in +// the connector because the HMAC secret is the bot token, which is held only here +// (ARCHITECTURE.md §12); the gateway calls the connector's ValidateInitData RPC +// instead of validating the launch data itself. +package initdata import ( "crypto/hmac" @@ -19,23 +19,25 @@ import ( // ErrInvalidInitData is returned when initData fails HMAC validation, is missing // the hash, is malformed, or is older than the freshness window. -var ErrInvalidInitData = errors.New("auth: invalid telegram init data") +var ErrInvalidInitData = errors.New("initdata: invalid telegram init data") // defaultMaxAge bounds how old a validated initData payload may be. const defaultMaxAge = 24 * time.Hour -// TelegramUser is the identity extracted from a validated initData payload. ID -// is the platform user id used as the identity's external_id. -type TelegramUser struct { - ID string - Username string - FirstName string +// User is the identity extracted from a validated initData payload. ExternalID is +// the Telegram user id used as the identities external_id; LanguageCode seeds a +// new account's preferred language (Stage 9). +type User struct { + ExternalID string + Username string + FirstName string + LanguageCode string } -// TelegramValidator validates Telegram Web App launch data and returns the -// authenticated user. -type TelegramValidator interface { - Validate(initData string) (TelegramUser, error) +// Validator validates Telegram Web App launch data and returns the authenticated +// user. It is an interface so the connector can be tested with a fixture. +type Validator interface { + Validate(initData string) (User, error) } // HMACValidator validates initData against a bot token per Telegram's documented @@ -53,22 +55,22 @@ func NewHMACValidator(botToken string) *HMACValidator { } // Validate parses and verifies initData, returning the authenticated user. -func (v *HMACValidator) Validate(initData string) (TelegramUser, error) { +func (v *HMACValidator) Validate(initData string) (User, error) { values, err := url.ParseQuery(initData) if err != nil { - return TelegramUser{}, ErrInvalidInitData + return User{}, ErrInvalidInitData } hash := values.Get("hash") if hash == "" { - return TelegramUser{}, ErrInvalidInitData + return User{}, ErrInvalidInitData } values.Del("hash") if !v.checkSignature(values, hash) { - return TelegramUser{}, ErrInvalidInitData + return User{}, ErrInvalidInitData } if err := v.checkFreshness(values.Get("auth_date")); err != nil { - return TelegramUser{}, err + return User{}, err } return parseUser(values.Get("user")) } @@ -111,23 +113,25 @@ func (v *HMACValidator) checkFreshness(authDate string) error { return nil } -// parseUser extracts the user id and names from the user JSON field. -func parseUser(userJSON string) (TelegramUser, error) { +// parseUser extracts the user id, names and language from the user JSON field. +func parseUser(userJSON string) (User, error) { if userJSON == "" { - return TelegramUser{}, ErrInvalidInitData + return User{}, ErrInvalidInitData } var u struct { - ID int64 `json:"id"` - Username string `json:"username"` - FirstName string `json:"first_name"` + ID int64 `json:"id"` + Username string `json:"username"` + FirstName string `json:"first_name"` + LanguageCode string `json:"language_code"` } if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 { - return TelegramUser{}, ErrInvalidInitData + return User{}, ErrInvalidInitData } - return TelegramUser{ - ID: strconv.FormatInt(u.ID, 10), - Username: u.Username, - FirstName: u.FirstName, + return User{ + ExternalID: strconv.FormatInt(u.ID, 10), + Username: u.Username, + FirstName: u.FirstName, + LanguageCode: u.LanguageCode, }, nil } diff --git a/platform/telegram/internal/initdata/validator_test.go b/platform/telegram/internal/initdata/validator_test.go new file mode 100644 index 0000000..9f525cd --- /dev/null +++ b/platform/telegram/internal/initdata/validator_test.go @@ -0,0 +1,85 @@ +package initdata + +import ( + "encoding/hex" + "errors" + "net/url" + "sort" + "strconv" + "strings" + "testing" + "time" +) + +const testToken = "123456:TESTTOKEN" + +// signInitData builds a validly signed initData query string for the given token +// and decoded fields, mirroring Telegram's data-check algorithm. +func signInitData(token string, fields map[string]string) string { + keys := make([]string, 0, len(fields)) + for k := range fields { + keys = append(keys, k) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, k := range keys { + lines = append(lines, k+"="+fields[k]) + } + secret := hmacSHA256([]byte("WebAppData"), []byte(token)) + mac := hmacSHA256(secret, []byte(strings.Join(lines, "\n"))) + + v := url.Values{} + for k, val := range fields { + v.Set(k, val) + } + v.Set("hash", hex.EncodeToString(mac)) + return v.Encode() +} + +func freshFields() map[string]string { + return map[string]string{ + "auth_date": strconv.FormatInt(time.Now().Unix(), 10), + "user": `{"id":42,"username":"neo","first_name":"Thomas","language_code":"ru"}`, + } +} + +func TestValidateOK(t *testing.T) { + initData := signInitData(testToken, freshFields()) + u, err := NewHMACValidator(testToken).Validate(initData) + if err != nil { + t.Fatalf("validate: %v", err) + } + if u.ExternalID != "42" || u.Username != "neo" || u.FirstName != "Thomas" || u.LanguageCode != "ru" { + t.Errorf("user = %+v, want {42 neo Thomas ru}", u) + } +} + +func TestValidateRejects(t *testing.T) { + valid := signInitData(testToken, freshFields()) + + t.Run("tampered hash", func(t *testing.T) { + tampered := strings.Replace(valid, "hash=", "hash=00", 1) + if _, err := NewHMACValidator(testToken).Validate(tampered); !errors.Is(err, ErrInvalidInitData) { + t.Errorf("err = %v, want ErrInvalidInitData", err) + } + }) + t.Run("wrong token", func(t *testing.T) { + if _, err := NewHMACValidator("other:TOKEN").Validate(valid); !errors.Is(err, ErrInvalidInitData) { + t.Errorf("err = %v, want ErrInvalidInitData", err) + } + }) + t.Run("missing hash", func(t *testing.T) { + if _, err := NewHMACValidator(testToken).Validate("user=%7B%7D&auth_date=1"); !errors.Is(err, ErrInvalidInitData) { + t.Errorf("err = %v, want ErrInvalidInitData", err) + } + }) + t.Run("stale auth_date", func(t *testing.T) { + stale := signInitData(testToken, map[string]string{ + "auth_date": strconv.FormatInt(time.Now().Add(-48*time.Hour).Unix(), 10), + "user": `{"id":42}`, + }) + if _, err := NewHMACValidator(testToken).Validate(stale); !errors.Is(err, ErrInvalidInitData) { + t.Errorf("err = %v, want ErrInvalidInitData", err) + } + }) +} diff --git a/platform/telegram/internal/render/render.go b/platform/telegram/internal/render/render.go new file mode 100644 index 0000000..e6ff16a --- /dev/null +++ b/platform/telegram/internal/render/render.go @@ -0,0 +1,80 @@ +// Package render turns a backend push event into a localized Telegram message with +// a Mini App deep-link. Only the out-of-app push set is rendered (your_turn, nudge, +// match_found, and the invitation / friend_request notify sub-kinds); every other +// kind returns ok=false so the connector skips it (the in-app stream still carries +// it). +package render + +import ( + "scrabble/pkg/fbs/scrabblefb" + "scrabble/platform/telegram/internal/deeplink" +) + +// Message is a rendered notification: the body text, the launch-button label and +// the deep-link start parameter (empty opens the lobby). +type Message struct { + Text string + ButtonText string + StartParam string +} + +// Render builds the localized message for a backend push event of the given kind +// and FlatBuffers payload, in language lang ("ru" selects Russian; anything else +// is English). It returns ok=false for a kind that is not delivered out-of-app. +func Render(kind string, payload []byte, lang string) (Message, bool) { + p := english + if lang == "ru" { + p = russian + } + switch kind { + case "your_turn": + ev := scrabblefb.GetRootAsYourTurnEvent(payload, 0) + return Message{Text: p.yourTurn, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true + case "nudge": + ev := scrabblefb.GetRootAsNudgeEvent(payload, 0) + return Message{Text: p.nudge, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true + case "match_found": + ev := scrabblefb.GetRootAsMatchFoundEvent(payload, 0) + return Message{Text: p.matchFound, ButtonText: p.openGame, StartParam: deeplink.Game(string(ev.GameId()))}, true + case "notify": + ev := scrabblefb.GetRootAsNotificationEvent(payload, 0) + switch string(ev.Kind()) { + case "invitation": + return Message{Text: p.invitation, ButtonText: p.open}, true + case "friend_request": + return Message{Text: p.friendRequest, ButtonText: p.open}, true + } + } + return Message{}, false +} + +// phrases is one language's message catalog. +type phrases struct { + yourTurn string + nudge string + matchFound string + invitation string + friendRequest string + openGame string + open string +} + +var english = phrases{ + yourTurn: "It's your turn.", + nudge: "You were nudged — it's your turn.", + matchFound: "Your game is ready.", + invitation: "You have a new game invitation.", + friendRequest: "You have a new friend request.", + openGame: "Open game", + open: "Open", +} + +var russian = phrases{ + yourTurn: "Ваш ход.", + nudge: "Вас поторопили — ваш ход.", + matchFound: "Игра найдена.", + invitation: "Вас пригласили в игру.", + friendRequest: "Вам пришла заявка в друзья.", + openGame: "Открыть игру", + open: "Открыть", +} diff --git a/platform/telegram/internal/render/render_test.go b/platform/telegram/internal/render/render_test.go new file mode 100644 index 0000000..f6d5606 --- /dev/null +++ b/platform/telegram/internal/render/render_test.go @@ -0,0 +1,115 @@ +package render + +import ( + "testing" + + flatbuffers "github.com/google/flatbuffers/go" + + "scrabble/pkg/fbs/scrabblefb" +) + +const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7" + +func yourTurnPayload(id string) []byte { + b := flatbuffers.NewBuilder(0) + gid := b.CreateString(id) + scrabblefb.YourTurnEventStart(b) + scrabblefb.YourTurnEventAddGameId(b, gid) + b.Finish(scrabblefb.YourTurnEventEnd(b)) + return b.FinishedBytes() +} + +func nudgePayload(id string) []byte { + b := flatbuffers.NewBuilder(0) + gid := b.CreateString(id) + scrabblefb.NudgeEventStart(b) + scrabblefb.NudgeEventAddGameId(b, gid) + b.Finish(scrabblefb.NudgeEventEnd(b)) + return b.FinishedBytes() +} + +func matchFoundPayload(id string) []byte { + b := flatbuffers.NewBuilder(0) + gid := b.CreateString(id) + scrabblefb.MatchFoundEventStart(b) + scrabblefb.MatchFoundEventAddGameId(b, gid) + b.Finish(scrabblefb.MatchFoundEventEnd(b)) + return b.FinishedBytes() +} + +func notifyPayload(kind string) []byte { + b := flatbuffers.NewBuilder(0) + k := b.CreateString(kind) + scrabblefb.NotificationEventStart(b) + scrabblefb.NotificationEventAddKind(b, k) + b.Finish(scrabblefb.NotificationEventEnd(b)) + return b.FinishedBytes() +} + +func TestRenderGameEvents(t *testing.T) { + cases := []struct { + name, kind string + payload []byte + }{ + {"your_turn", "your_turn", yourTurnPayload(gameID)}, + {"nudge", "nudge", nudgePayload(gameID)}, + {"match_found", "match_found", matchFoundPayload(gameID)}, + } + for _, tc := range cases { + t.Run(tc.name+" en", func(t *testing.T) { + m, ok := Render(tc.kind, tc.payload, "en") + if !ok { + t.Fatal("expected ok") + } + if m.StartParam != "g"+gameID { + t.Errorf("StartParam = %q, want g%s", m.StartParam, gameID) + } + if m.ButtonText != "Open game" { + t.Errorf("ButtonText = %q, want Open game", m.ButtonText) + } + if m.Text == "" { + t.Error("Text is empty") + } + }) + t.Run(tc.name+" ru", func(t *testing.T) { + m, ok := Render(tc.kind, tc.payload, "ru") + if !ok { + t.Fatal("expected ok") + } + if m.ButtonText != "Открыть игру" { + t.Errorf("ButtonText = %q, want Открыть игру", m.ButtonText) + } + }) + } +} + +func TestRenderNotify(t *testing.T) { + cases := map[string]struct { + subKind string + wantOK bool + }{ + "invitation": {"invitation", true}, + "friend_request": {"friend_request", true}, + "friend_added": {"friend_added", false}, + "game_started": {"game_started", false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + m, ok := Render("notify", notifyPayload(tc.subKind), "en") + if ok != tc.wantOK { + t.Fatalf("ok = %v, want %v", ok, tc.wantOK) + } + if ok && m.StartParam != "" { + t.Errorf("StartParam = %q, want empty (lobby)", m.StartParam) + } + }) + } +} + +func TestRenderSkipsUnpushedKinds(t *testing.T) { + for _, kind := range []string{"opponent_moved", "chat_message", "unknown"} { + if _, ok := Render(kind, nil, "en"); ok { + t.Errorf("kind %q: ok = true, want false", kind) + } + } +} diff --git a/ui/e2e/fixtures.ts b/ui/e2e/fixtures.ts new file mode 100644 index 0000000..40f9068 --- /dev/null +++ b/ui/e2e/fixtures.ts @@ -0,0 +1,18 @@ +import { test as base } from '@playwright/test'; + +// All e2e specs run hermetically against the mock transport. Neutralise the real +// telegram-web-app.js (loaded from the CDN in index.html) so the suite never blocks +// on telegram.org — it is unreachable from the CI runner, and a render-blocking +// { setLocale(guess); } + // Telegram Mini App launch: apply the platform theme, authenticate via initData, + // and route any deep-link start parameter. On the dedicated /telegram/ entry path + // outside Telegram (no initData), refuse to render and send the visitor to the + // site root. + if (onTelegramPath() && !insideTelegram()) { + if (typeof location !== 'undefined') location.replace('/'); + return; + } + if (insideTelegram()) { + const launch = telegramLaunch(); + if (launch.theme) applyTelegramTheme(launch.theme); + try { + await adoptSession(await gateway.authTelegram(launch.initData)); + await routeStartParam(launch.startParam); + } catch (err) { + handleError(err); + navigate('/login'); + } + app.ready = true; + return; + } + const saved = await loadSession(); if (saved) { await adoptSession(saved); @@ -154,6 +178,32 @@ export async function bootstrap(): Promise { app.ready = true; } +/** + * routeStartParam navigates a Telegram deep-link start parameter to its target: a + * specific game, the friends screen with a friend-code redemption, or the lobby + * (where invitations surface as a badge). + */ +async function routeStartParam(param: string): Promise { + const link = parseStartParam(param); + switch (link.kind) { + case 'game': + navigate(`/game/${link.id}`); + return; + case 'friendCode': + navigate('/friends'); + try { + const friend = await gateway.friendCodeRedeem(link.code); + showToast(t('friends.added', { name: friend.displayName })); + void refreshNotifications(); + } catch (err) { + handleError(err); + } + return; + default: + navigate('/'); + } +} + export async function loginGuest(): Promise { try { const s = await gateway.authGuest(app.locale); @@ -233,6 +283,7 @@ async function persistLanguageToServer(locale: Locale): Promise { awayEnd: p.awayEnd, blockChat: p.blockChat, blockFriendRequests: p.blockFriendRequests, + notificationsInAppOnly: p.notificationsInAppOnly, }); } catch { // The client locale already changed; the server sync is best-effort. diff --git a/ui/src/lib/client.ts b/ui/src/lib/client.ts index 318ec51..51802db 100644 --- a/ui/src/lib/client.ts +++ b/ui/src/lib/client.ts @@ -52,6 +52,7 @@ export type Unsubscribe = () => void; export interface GatewayClient { // --- auth (unauthenticated) --- + authTelegram(initData: string): Promise; authGuest(locale?: string): Promise; authEmailRequest(email: string): Promise; authEmailLogin(email: string, code: string): Promise; diff --git a/ui/src/lib/codec.ts b/ui/src/lib/codec.ts index 5148151..330dc2c 100644 --- a/ui/src/lib/codec.ts +++ b/ui/src/lib/codec.ts @@ -146,6 +146,14 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array { return finish(b, fb.ChatPostRequest.endChatPostRequest(b)); } +export function encodeTelegramLogin(initData: string): Uint8Array { + const b = new Builder(512); + const d = b.createString(initData); + fb.TelegramLoginRequest.startTelegramLoginRequest(b); + fb.TelegramLoginRequest.addInitData(b, d); + return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b)); +} + export function encodeGuestLogin(locale: string): Uint8Array { const b = new Builder(64); const l = b.createString(locale); @@ -264,6 +272,7 @@ export function decodeProfile(buf: Uint8Array): Profile { blockChat: p.blockChat(), blockFriendRequests: p.blockFriendRequests(), isGuest: p.isGuest(), + notificationsInAppOnly: p.notificationsInAppOnly(), }; } @@ -444,6 +453,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array { fb.UpdateProfileRequest.addAwayEnd(b, ae); fb.UpdateProfileRequest.addBlockChat(b, p.blockChat); fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests); + fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly); return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); } diff --git a/ui/src/lib/deeplink.test.ts b/ui/src/lib/deeplink.test.ts new file mode 100644 index 0000000..b0d2718 --- /dev/null +++ b/ui/src/lib/deeplink.test.ts @@ -0,0 +1,38 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink'; + +describe('parseStartParam', () => { + it('classifies game / invitation / friend code', () => { + expect(parseStartParam('g7c9e6679')).toEqual({ kind: 'game', id: '7c9e6679' }); + expect(parseStartParam('iabc-123')).toEqual({ kind: 'invitation', id: 'abc-123' }); + expect(parseStartParam('f123456')).toEqual({ kind: 'friendCode', code: '123456' }); + }); + + it('falls back to the lobby for empty / unknown / value-less params', () => { + expect(parseStartParam('')).toEqual({ kind: 'lobby' }); + expect(parseStartParam(undefined)).toEqual({ kind: 'lobby' }); + expect(parseStartParam(null)).toEqual({ kind: 'lobby' }); + expect(parseStartParam('x-nope')).toEqual({ kind: 'lobby' }); + expect(parseStartParam('g')).toEqual({ kind: 'lobby' }); + }); + + it('round-trips the build helpers', () => { + expect(parseStartParam(gameParam('id1'))).toEqual({ kind: 'game', id: 'id1' }); + expect(parseStartParam(invitationParam('id2'))).toEqual({ kind: 'invitation', id: 'id2' }); + expect(parseStartParam(friendCodeParam('654321'))).toEqual({ kind: 'friendCode', code: '654321' }); + }); +}); + +describe('shareLink', () => { + afterEach(() => vi.unstubAllEnvs()); + + it('returns null without a configured base', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', ''); + expect(shareLink('gx')).toBeNull(); + }); + + it('wraps a payload in a startapp link', () => { + vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app'); + expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456'); + }); +}); diff --git a/ui/src/lib/deeplink.ts b/ui/src/lib/deeplink.ts new file mode 100644 index 0000000..b75ad45 --- /dev/null +++ b/ui/src/lib/deeplink.ts @@ -0,0 +1,49 @@ +// Telegram Mini App deep-link "start parameters", mirroring the connector's Go +// scheme (platform/telegram/internal/deeplink): a one-character kind prefix plus a +// value — +// g open that game +// i open that invitation +// f<6-digit code> redeem that friend code +// An empty or unrecognised parameter opens the lobby. + +export type DeepLink = + | { kind: 'lobby' } + | { kind: 'game'; id: string } + | { kind: 'invitation'; id: string } + | { kind: 'friendCode'; code: string }; + +/** parseStartParam classifies a Telegram start parameter into a routing target. */ +export function parseStartParam(param: string | undefined | null): DeepLink { + if (!param) return { kind: 'lobby' }; + const value = param.slice(1); + if (!value) return { kind: 'lobby' }; + switch (param[0]) { + case 'g': + return { kind: 'game', id: value }; + case 'i': + return { kind: 'invitation', id: value }; + case 'f': + return { kind: 'friendCode', code: value }; + default: + return { kind: 'lobby' }; + } +} + +/** gameParam builds the start parameter that opens a game. */ +export const gameParam = (id: string): string => 'g' + id; +/** invitationParam builds the start parameter that opens an invitation. */ +export const invitationParam = (id: string): string => 'i' + id; +/** friendCodeParam builds the start parameter that redeems a friend code. */ +export const friendCodeParam = (code: string): string => 'f' + code; + +/** + * shareLink wraps a deep-link start parameter in a t.me Mini App link, using the + * VITE_TELEGRAM_LINK base (e.g. https://t.me//). It returns null when the + * base is not configured, so callers can hide the share affordance. + */ +export function shareLink(param: string): string | null { + const base = import.meta.env.VITE_TELEGRAM_LINK as string | undefined; + if (!base) return null; + const sep = base.includes('?') ? '&' : '?'; + return `${base}${sep}startapp=${encodeURIComponent(param)}`; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 9296c5e..4f1677c 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -110,6 +110,7 @@ export const en = { 'profile.to': 'To', 'profile.blockChat': 'Disable chat', 'profile.blockFriendRequests': 'Disable friend requests', + 'profile.notificationsInAppOnly': 'Notifications in the app only', 'profile.email': 'Email', 'profile.bindEmail': 'Bind email', 'profile.emailCode': 'Confirmation code', @@ -180,6 +181,7 @@ export const en = { 'friends.redeem': 'Add', 'friends.copy': 'Copy', 'friends.codeCopied': 'Code copied.', + 'friends.shareTelegram': 'Share via Telegram', 'friends.added': 'Added {name}.', 'friends.blockedList': 'Blocked players', 'friends.unblock': 'Unblock', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index b992967..56396a2 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -111,6 +111,7 @@ export const ru: Record = { 'profile.to': 'До', 'profile.blockChat': 'Отключить чат', 'profile.blockFriendRequests': 'Отключить заявки в друзья', + 'profile.notificationsInAppOnly': 'Уведомления только в приложении', 'profile.email': 'Эл. почта', 'profile.bindEmail': 'Привязать почту', 'profile.emailCode': 'Код подтверждения', @@ -181,6 +182,7 @@ export const ru: Record = { 'friends.redeem': 'Добавить', 'friends.copy': 'Копировать', 'friends.codeCopied': 'Код скопирован.', + 'friends.shareTelegram': 'Поделиться через Telegram', 'friends.added': 'Добавлен(а) {name}.', 'friends.blockedList': 'Заблокированные', 'friends.unblock': 'Разблокировать', diff --git a/ui/src/lib/mock/client.ts b/ui/src/lib/mock/client.ts index fd8ae7b..45f7d44 100644 --- a/ui/src/lib/mock/client.ts +++ b/ui/src/lib/mock/client.ts @@ -100,6 +100,9 @@ export class MockGateway implements GatewayClient { } // --- auth --- + async authTelegram(): Promise { + return { ...SESSION, isGuest: false }; + } async authGuest(): Promise { return { ...SESSION }; } diff --git a/ui/src/lib/mock/data.ts b/ui/src/lib/mock/data.ts index 82bc2cf..dacc485 100644 --- a/ui/src/lib/mock/data.ts +++ b/ui/src/lib/mock/data.ts @@ -36,6 +36,7 @@ export const PROFILE: Profile = { blockChat: false, blockFriendRequests: false, isGuest: false, + notificationsInAppOnly: true, }; // Seed social/account data for the mock (pnpm start + Playwright). The mock profile diff --git a/ui/src/lib/model.ts b/ui/src/lib/model.ts index 2d4cfb8..20e3ddd 100644 --- a/ui/src/lib/model.ts +++ b/ui/src/lib/model.ts @@ -107,6 +107,8 @@ export interface Profile { blockChat: boolean; blockFriendRequests: boolean; isGuest: boolean; + /** Confine notifications to the in-app stream (no out-of-app platform push). */ + notificationsInAppOnly: boolean; } /** The full editable profile sent to profileUpdate (overwrites every field). */ @@ -118,6 +120,7 @@ export interface ProfileUpdate { awayEnd: string; blockChat: boolean; blockFriendRequests: boolean; + notificationsInAppOnly: boolean; } /** A referenced account with its display name (friend, blocked user, invitee). */ diff --git a/ui/src/lib/telegram.test.ts b/ui/src/lib/telegram.test.ts new file mode 100644 index 0000000..b08d91f --- /dev/null +++ b/ui/src/lib/telegram.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { insideTelegram, telegramLaunch } from './telegram'; + +function stubWebApp(initData: string, startParam?: string) { + vi.stubGlobal('window', { + Telegram: { + WebApp: { + initData, + initDataUnsafe: startParam ? { start_param: startParam } : {}, + themeParams: { bg_color: '#101418' }, + ready: () => {}, + expand: () => {}, + }, + }, + }); +} + +describe('telegram launch detection', () => { + afterEach(() => vi.unstubAllGlobals()); + + it('is not inside Telegram without a window', () => { + expect(insideTelegram()).toBe(false); + }); + + it('is inside Telegram only with non-empty initData', () => { + stubWebApp(''); + expect(insideTelegram()).toBe(false); + stubWebApp('query_id=abc'); + expect(insideTelegram()).toBe(true); + }); + + it('telegramLaunch returns initData, start param and theme', () => { + stubWebApp('query_id=abc', 'g123'); + const launch = telegramLaunch(); + expect(launch.initData).toBe('query_id=abc'); + expect(launch.startParam).toBe('g123'); + expect(launch.theme?.bg_color).toBe('#101418'); + }); +}); diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts new file mode 100644 index 0000000..a20b25e --- /dev/null +++ b/ui/src/lib/telegram.ts @@ -0,0 +1,67 @@ +// Telegram Mini App SDK access. The official telegram-web-app.js (loaded in +// index.html) exposes window.Telegram.WebApp; this wraps the subset the app uses: +// launch detection, initData (for auth.telegram), the deep-link start parameter, +// theme params, and ready()/expand(). Every helper is safe to call outside Telegram. + +import type { TelegramThemeParams } from './theme'; + +interface TelegramWebApp { + initData: string; + initDataUnsafe?: { start_param?: string }; + themeParams?: TelegramThemeParams; + ready?: () => void; + expand?: () => void; +} + +function webApp(): TelegramWebApp | undefined { + if (typeof window === 'undefined') return undefined; + return (window as unknown as { Telegram?: { WebApp?: TelegramWebApp } }).Telegram?.WebApp; +} + +/** + * insideTelegram reports whether the app launched as a Telegram Mini App — the SDK + * is present and carries non-empty initData (an ordinary browser tab has neither). + */ +export function insideTelegram(): boolean { + const w = webApp(); + return !!w && typeof w.initData === 'string' && w.initData.length > 0; +} + +/** TelegramLaunch is the data a Mini App launch carries. */ +export interface TelegramLaunch { + initData: string; + startParam: string; + theme: TelegramThemeParams | undefined; +} + +/** + * telegramLaunch readies the Mini App (full-height, ready signal) and returns its + * launch data: the raw initData (for auth.telegram), the deep-link start parameter + * (from the SDK or, for a bot web_app button, the page URL), and the theme params. + */ +export function telegramLaunch(): TelegramLaunch { + const w = webApp(); + if (!w) return { initData: '', startParam: startParamFromURL(), theme: undefined }; + w.ready?.(); + w.expand?.(); + const startParam = w.initDataUnsafe?.start_param ?? startParamFromURL(); + return { initData: w.initData, startParam, theme: w.themeParams }; +} + +/** + * startParamFromURL reads a startapp parameter from the page URL — a bot web_app + * launch button carries the deep-link there rather than in initDataUnsafe. + */ +function startParamFromURL(): string { + if (typeof location === 'undefined') return ''; + return new URLSearchParams(location.search).get('startapp') ?? ''; +} + +/** + * onTelegramPath reports whether the app is served under the dedicated Telegram + * entry path (/telegram/); outside Telegram on that path the app refuses to render. + */ +export function onTelegramPath(): boolean { + if (typeof location === 'undefined') return false; + return location.pathname.startsWith('/telegram/'); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 5d7e5c4..d1181f5 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -54,6 +54,9 @@ export function createTransport(baseUrl: string): GatewayClient { token = t; }, + async authTelegram(initData) { + return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData))); + }, async authGuest(locale) { return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); }, diff --git a/ui/src/screens/Friends.svelte b/ui/src/screens/Friends.svelte index eaec81e..fd5611e 100644 --- a/ui/src/screens/Friends.svelte +++ b/ui/src/screens/Friends.svelte @@ -4,6 +4,7 @@ import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { gateway } from '../lib/gateway'; import { t } from '../lib/i18n/index.svelte'; + import { friendCodeParam, shareLink } from '../lib/deeplink'; import type { AccountRef, FriendCode } from '../lib/model'; let friends = $state([]); @@ -97,6 +98,7 @@ {#if code} + {@const tg = shareLink(friendCodeParam(code.code))}
@@ -105,6 +107,9 @@ {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} + {#if tg} + {t('friends.shareTelegram')} + {/if}
{:else} diff --git a/ui/src/screens/Profile.svelte b/ui/src/screens/Profile.svelte index 7982be8..a3483e6 100644 --- a/ui/src/screens/Profile.svelte +++ b/ui/src/screens/Profile.svelte @@ -25,6 +25,7 @@ let endM = $state('00'); let blockChat = $state(false); let blockFriendRequests = $state(false); + let notificationsInAppOnly = $state(true); let emailInput = $state(''); let codeInput = $state(''); let emailSent = $state(false); @@ -47,6 +48,7 @@ [endH, endM] = splitTime(p.awayEnd); blockChat = p.blockChat; blockFriendRequests = p.blockFriendRequests; + notificationsInAppOnly = p.notificationsInAppOnly; editing = true; } @@ -68,6 +70,7 @@ awayEnd, blockChat, blockFriendRequests, + notificationsInAppOnly, }); editing = false; showToast(t('profile.saved')); @@ -143,6 +146,10 @@ {t('profile.blockFriendRequests')} +
diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 8b61f6f..58e463f 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -9,6 +9,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'; const RPC_PREFIX = '/scrabble.edge.v1.Gateway'; export default defineConfig(({ mode }) => ({ + // Relative asset base so the one build serves under any path — the gateway maps the + // Telegram Mini App to /telegram/ (the hash router is path-agnostic). + base: './', plugins: [svelte()], server: { port: 5173,