Stage 9: Telegram integration (connector side-service, Mini App, out-of-app push)
Tests · Go / test (push) Successful in 7s
Tests · Integration / integration (push) Successful in 12s
Tests · Go / test (pull_request) Successful in 6s
Tests · Integration / integration (pull_request) Successful in 11s
Tests · UI / test (pull_request) Successful in 19s

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.
This commit is contained in:
Ilia Denisov
2026-06-04 01:42:54 +02:00
parent 1012fb47a0
commit cf66ed7e26
86 changed files with 3624 additions and 372 deletions
+6
View File
@@ -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
+5 -3
View File
@@ -11,6 +11,7 @@ on:
- 'backend/**' - 'backend/**'
- 'gateway/**' - 'gateway/**'
- 'pkg/**' - 'pkg/**'
- 'platform/**'
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- '.gitea/workflows/go-unit.yaml' - '.gitea/workflows/go-unit.yaml'
@@ -20,6 +21,7 @@ on:
- 'backend/**' - 'backend/**'
- 'gateway/**' - 'gateway/**'
- 'pkg/**' - 'pkg/**'
- 'platform/**'
- 'go.work' - 'go.work'
- 'go.work.sum' - 'go.work.sum'
- '.gitea/workflows/go-unit.yaml' - '.gitea/workflows/go-unit.yaml'
@@ -56,10 +58,10 @@ jobs:
fi fi
- name: vet - name: vet
run: go vet ./backend/... ./pkg/... ./gateway/... run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: build - name: build
run: go build ./backend/... ./pkg/... ./gateway/... run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
- name: test - name: test
# -count=1 disables the test cache so a green run never depends on a # -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. # tests at the committed DAWGs in the sibling checkout.
env: env:
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg 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/...
+3 -1
View File
@@ -111,7 +111,8 @@ backend/ # module scrabble/backend
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
internal/inttest/ # //go:build integration Postgres-backed tests internal/inttest/ # //go:build integration Postgres-backed tests
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
gateway/ ui/ pkg/ 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 ## Build & test
@@ -121,6 +122,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
go vet ./backend/... go vet ./backend/...
gofmt -l . # must print nothing gofmt -l . # must print nothing
go test -count=1 ./backend/... go test -count=1 ./backend/...
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
go run ./backend/cmd/backend # /healthz, /readyz on :8080 go run ./backend/cmd/backend # /healthz, /readyz on :8080
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+) cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
+95 -6
View File
@@ -42,7 +42,7 @@ independent (see ARCHITECTURE §9.1).
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** | | 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** | | 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** | | 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 | | 10 | Admin & dictionary ops (complaint review, version reload) | todo |
| 11 | Account linking & merge | todo | | 11 | Account linking & merge | todo |
| 12 | Polish (observability, perf with evidence, deploy) | todo | | 12 | Polish (observability, perf with evidence, deploy) | todo |
@@ -605,6 +605,94 @@ 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 OS and can't be forced to match, and a select also avoids the iOS "clear" button
that would empty a time field. 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<game uuid>` / `i<invitation uuid>` / `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
`<MiniAppURL>?startapp=<payload>`.
- **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<token>/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.
- **Verification-time fixes** (caught by the CI gate): (1) the gateway transcode
dropped `notifications_in_app_only` in four places (`ProfileResp`, `encodeProfile`,
`profileUpdateHandler`, the `UpdateProfile` body) so the toggle never reached the
backend — fixed, with a round-trip transcode test added. (2) The e2e suite was made
**hermetic** (a shared `ui/e2e/fixtures.ts` blocks the real `telegram-web-app.js`):
the render-blocking CDN `<script>` hung every page load on the CI runner, where
telegram.org is unreachable, timing out all non-Telegram specs. (3) A pre-existing
time-of-day flake in `TestTimeoutSweep` (the default 00:0007:00 away window made
the sweeper skip when CI ran with `now-1h` inside it) was made deterministic by
clearing the test account's away window.
## Deferred TODOs (cross-stage) ## Deferred TODOs (cross-stage)
- **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable, - **TODO-1 — publish & version the solver.** Once `scrabble-solver` is stable,
@@ -637,11 +725,12 @@ Open details: deployment target/host; dashboards; load expectations.
value)` table so the UI stops duplicating it, and optionally moving tile exchange to 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 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. 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 - **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
friend code is entered by hand today. Once the Telegram/native deep-link scheme the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can launch, and the UI shows a **share-to-Telegram** link for an issued code when
add you by scanning rather than typing. The code semantics (12 h TTL, single use, `VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a
one active per issuer) stay as-is; only the delivery changes. 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).** - **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 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 pick). Default it from the player's history (the variant they play most, from
+5 -1
View File
@@ -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 `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 second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
gateway. 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 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 with no identity, excluded from statistics. The shared wire contracts live in the
sibling [`../pkg`](../pkg) module. sibling [`../pkg`](../pkg) module.
+103 -21
View File
@@ -10,7 +10,9 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"fmt" "fmt"
"strings"
"time" "time"
"unicode/utf8"
"github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm" "github.com/go-jet/jet/v2/qrm"
@@ -56,9 +58,13 @@ type Account struct {
BlockFriendRequests bool BlockFriendRequests bool
// IsGuest marks an ephemeral guest account: a durable row with no identity, // IsGuest marks an ephemeral guest account: a durable row with no identity,
// excluded from statistics, friends and history. // excluded from statistics, friends and history.
IsGuest bool IsGuest bool
CreatedAt time.Time // NotificationsInAppOnly confines notifications to the in-app live stream when
UpdatedAt time.Time // 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. // 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 // resolved by re-reading the winner's account. A platform identity is recorded
// as confirmed; an email identity starts unconfirmed. // as confirmed; an email identity starts unconfirmed.
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) { 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) acc, err := s.findByIdentity(ctx, kind, externalID)
if err == nil { if err == nil {
return acc, nil return acc, nil
@@ -85,7 +107,7 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
return Account{}, err return Account{}, err
} }
acc, err = s.create(ctx, kind, externalID) acc, err = s.create(ctx, kind, externalID, seed)
if err != nil { if err != nil {
if isUniqueViolation(err) { if isUniqueViolation(err) {
// A concurrent caller created the identity first; return theirs. // 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 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. // 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) { func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
stmt := postgres.SELECT(table.Accounts.AllColumns). 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 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, // findByIdentity joins identities to accounts and returns the matching account,
// or ErrNotFound. // or ErrNotFound.
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) { 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 return modelToAccount(row), nil
} }
// create inserts a new account and its first identity inside one transaction // create inserts a new account (seeded from seed) and its first identity inside
// and returns the persisted account row. // one transaction and returns the persisted account row.
func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) { func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
accountID, err := uuid.NewV7() accountID, err := uuid.NewV7()
if err != nil { if err != nil {
return Account{}, fmt.Errorf("account: new account id: %w", err) 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 var created Account
err = withTx(ctx, s.db, func(tx *sql.Tx) error { 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. insertAccount := table.Accounts.
INSERT(table.Accounts.AccountID). INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
VALUES(accountID). VALUES(accountID, seed.displayName, lang).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
var row model.Accounts 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. // modelToAccount projects a generated model row into the public Account struct.
func modelToAccount(row model.Accounts) Account { func modelToAccount(row model.Accounts) Account {
return Account{ return Account{
ID: row.AccountID, ID: row.AccountID,
DisplayName: row.DisplayName, DisplayName: row.DisplayName,
PreferredLanguage: row.PreferredLanguage, PreferredLanguage: row.PreferredLanguage,
TimeZone: row.TimeZone, TimeZone: row.TimeZone,
AwayStart: row.AwayStart, AwayStart: row.AwayStart,
AwayEnd: row.AwayEnd, AwayEnd: row.AwayEnd,
HintBalance: int(row.HintBalance), HintBalance: int(row.HintBalance),
BlockChat: row.BlockChat, BlockChat: row.BlockChat,
BlockFriendRequests: row.BlockFriendRequests, BlockFriendRequests: row.BlockFriendRequests,
IsGuest: row.IsGuest, IsGuest: row.IsGuest,
CreatedAt: row.CreatedAt, NotificationsInAppOnly: row.NotificationsInAppOnly,
UpdatedAt: row.UpdatedAt, CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
} }
} }
+12 -9
View File
@@ -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 // and AwayEnd carry only the hour and minute of the daily away window, in the
// account's TimeZone. // account's TimeZone.
type ProfileUpdate struct { type ProfileUpdate struct {
DisplayName string DisplayName string
PreferredLanguage string // "en" or "ru" PreferredLanguage string // "en" or "ru"
TimeZone string // an IANA location name TimeZone string // an IANA location name
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
NotificationsInAppOnly bool
} }
// UpdateProfile validates and overwrites the editable fields of the account, then // 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( stmt := table.Accounts.UPDATE(
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone, table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
table.Accounts.AwayStart, table.Accounts.AwayEnd, 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( ).SET(
postgres.String(name), postgres.String(lang), postgres.String(tz), postgres.String(name), postgres.String(lang), postgres.String(tz),
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd), 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))). ).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
RETURNING(table.Accounts.AllColumns) RETURNING(table.Accounts.AllColumns)
@@ -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)
}
}
+110
View File
@@ -104,3 +104,113 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
} }
return confirmed 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")
}
}
+7
View File
@@ -312,6 +312,13 @@ func TestTimeoutSweep(t *testing.T) {
} }
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour)) backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
// Disable the to-move account's away window: with the default 00:0007:00
// window the sweeper (correctly) declines to time out a player whose deadline
// fell while they were asleep, which made this test fail whenever CI ran with
// now-1h inside that window (e.g. ~07:00 UTC). An empty window keeps the test
// deterministic regardless of the time of day.
setAway(t, seats[0], "UTC", "00:00", "00:00")
// The sweep is global over the shared pool; assert the target game itself, // The sweep is global over the shared pool; assert the target game itself,
// not the count, since other tests leave active games behind. // not the count, since other tests leave active games behind.
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 { if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
@@ -13,16 +13,17 @@ import (
) )
type Accounts struct { type Accounts struct {
AccountID uuid.UUID `sql:"primary_key"` AccountID uuid.UUID `sql:"primary_key"`
DisplayName string DisplayName string
PreferredLanguage string PreferredLanguage string
TimeZone string TimeZone string
BlockChat bool BlockChat bool
BlockFriendRequests bool BlockFriendRequests bool
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
AwayStart time.Time AwayStart time.Time
AwayEnd time.Time AwayEnd time.Time
HintBalance int32 HintBalance int32
IsGuest bool IsGuest bool
NotificationsInAppOnly bool
} }
@@ -17,18 +17,19 @@ type accountsTable struct {
postgres.Table postgres.Table
// Columns // Columns
AccountID postgres.ColumnString AccountID postgres.ColumnString
DisplayName postgres.ColumnString DisplayName postgres.ColumnString
PreferredLanguage postgres.ColumnString PreferredLanguage postgres.ColumnString
TimeZone postgres.ColumnString TimeZone postgres.ColumnString
BlockChat postgres.ColumnBool BlockChat postgres.ColumnBool
BlockFriendRequests postgres.ColumnBool BlockFriendRequests postgres.ColumnBool
CreatedAt postgres.ColumnTimestampz CreatedAt postgres.ColumnTimestampz
UpdatedAt postgres.ColumnTimestampz UpdatedAt postgres.ColumnTimestampz
AwayStart postgres.ColumnTime AwayStart postgres.ColumnTime
AwayEnd postgres.ColumnTime AwayEnd postgres.ColumnTime
HintBalance postgres.ColumnInteger HintBalance postgres.ColumnInteger
IsGuest postgres.ColumnBool IsGuest postgres.ColumnBool
NotificationsInAppOnly postgres.ColumnBool
AllColumns postgres.ColumnList AllColumns postgres.ColumnList
MutableColumns postgres.ColumnList MutableColumns postgres.ColumnList
@@ -70,39 +71,41 @@ func newAccountsTable(schemaName, tableName, alias string) *AccountsTable {
func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable { func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
var ( var (
AccountIDColumn = postgres.StringColumn("account_id") AccountIDColumn = postgres.StringColumn("account_id")
DisplayNameColumn = postgres.StringColumn("display_name") DisplayNameColumn = postgres.StringColumn("display_name")
PreferredLanguageColumn = postgres.StringColumn("preferred_language") PreferredLanguageColumn = postgres.StringColumn("preferred_language")
TimeZoneColumn = postgres.StringColumn("time_zone") TimeZoneColumn = postgres.StringColumn("time_zone")
BlockChatColumn = postgres.BoolColumn("block_chat") BlockChatColumn = postgres.BoolColumn("block_chat")
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests") BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
CreatedAtColumn = postgres.TimestampzColumn("created_at") CreatedAtColumn = postgres.TimestampzColumn("created_at")
UpdatedAtColumn = postgres.TimestampzColumn("updated_at") UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
AwayStartColumn = postgres.TimeColumn("away_start") AwayStartColumn = postgres.TimeColumn("away_start")
AwayEndColumn = postgres.TimeColumn("away_end") AwayEndColumn = postgres.TimeColumn("away_end")
HintBalanceColumn = postgres.IntegerColumn("hint_balance") HintBalanceColumn = postgres.IntegerColumn("hint_balance")
IsGuestColumn = postgres.BoolColumn("is_guest") IsGuestColumn = postgres.BoolColumn("is_guest")
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn} allColumns = postgres.ColumnList{AccountIDColumn, 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} 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{ return accountsTable{
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...), Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
//Columns //Columns
AccountID: AccountIDColumn, AccountID: AccountIDColumn,
DisplayName: DisplayNameColumn, DisplayName: DisplayNameColumn,
PreferredLanguage: PreferredLanguageColumn, PreferredLanguage: PreferredLanguageColumn,
TimeZone: TimeZoneColumn, TimeZone: TimeZoneColumn,
BlockChat: BlockChatColumn, BlockChat: BlockChatColumn,
BlockFriendRequests: BlockFriendRequestsColumn, BlockFriendRequests: BlockFriendRequestsColumn,
CreatedAt: CreatedAtColumn, CreatedAt: CreatedAtColumn,
UpdatedAt: UpdatedAtColumn, UpdatedAt: UpdatedAtColumn,
AwayStart: AwayStartColumn, AwayStart: AwayStartColumn,
AwayEnd: AwayEndColumn, AwayEnd: AwayEndColumn,
HintBalance: HintBalanceColumn, HintBalance: HintBalanceColumn,
IsGuest: IsGuestColumn, IsGuest: IsGuestColumn,
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
AllColumns: allColumns, AllColumns: allColumns,
MutableColumns: mutableColumns, MutableColumns: mutableColumns,
@@ -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;
+22 -20
View File
@@ -35,16 +35,17 @@ type resolveResponse struct {
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd // profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
// are the daily away window's "HH:MM" local-time bounds (in TimeZone). // are the daily away window's "HH:MM" local-time bounds (in TimeZone).
type profileResponse struct { type profileResponse struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"` PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"` TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"` AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"` AwayEnd string `json:"away_end"`
HintBalance int `json:"hint_balance"` HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"` BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"` BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"` IsGuest bool `json:"is_guest"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
} }
// tileDTO is one placed (or to-place) tile. // 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. // profileResponseFor projects an account into its profile DTO.
func profileResponseFor(acc account.Account) profileResponse { func profileResponseFor(acc account.Account) profileResponse {
return profileResponse{ return profileResponse{
UserID: acc.ID.String(), UserID: acc.ID.String(),
DisplayName: acc.DisplayName, DisplayName: acc.DisplayName,
PreferredLanguage: acc.PreferredLanguage, PreferredLanguage: acc.PreferredLanguage,
TimeZone: acc.TimeZone, TimeZone: acc.TimeZone,
AwayStart: acc.AwayStart.Format(awayTimeLayout), AwayStart: acc.AwayStart.Format(awayTimeLayout),
AwayEnd: acc.AwayEnd.Format(awayTimeLayout), AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
HintBalance: acc.HintBalance, HintBalance: acc.HintBalance,
BlockChat: acc.BlockChat, BlockChat: acc.BlockChat,
BlockFriendRequests: acc.BlockFriendRequests, BlockFriendRequests: acc.BlockFriendRequests,
IsGuest: acc.IsGuest, IsGuest: acc.IsGuest,
NotificationsInAppOnly: acc.NotificationsInAppOnly,
} }
} }
+4
View File
@@ -31,6 +31,10 @@ func (s *Server) registerRoutes() {
in.POST("/sessions/email/login", s.handleEmailLogin) in.POST("/sessions/email/login", s.handleEmailLogin)
in.POST("/sessions/resolve", s.handleResolveSession) in.POST("/sessions/resolve", s.handleResolveSession)
in.POST("/sessions/revoke", s.handleRevokeSession) 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 u := s.user
if s.accounts != nil { if s.accounts != nil {
+16 -14
View File
@@ -18,13 +18,14 @@ import (
// updateProfileRequest is the full editable profile. away_start/away_end are // updateProfileRequest is the full editable profile. away_start/away_end are
// "HH:MM" local-time bounds of the daily away window. // "HH:MM" local-time bounds of the daily away window.
type updateProfileRequest struct { type updateProfileRequest struct {
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"` PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"` TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"` AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"` AwayEnd string `json:"away_end"`
BlockChat bool `json:"block_chat"` BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"` 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 // 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 return
} }
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{ acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
DisplayName: req.DisplayName, DisplayName: req.DisplayName,
PreferredLanguage: req.PreferredLanguage, PreferredLanguage: req.PreferredLanguage,
TimeZone: req.TimeZone, TimeZone: req.TimeZone,
AwayStart: awayStart, AwayStart: awayStart,
AwayEnd: awayEnd, AwayEnd: awayEnd,
BlockChat: req.BlockChat, BlockChat: req.BlockChat,
BlockFriendRequests: req.BlockFriendRequests, BlockFriendRequests: req.BlockFriendRequests,
NotificationsInAppOnly: req.NotificationsInAppOnly,
}) })
if err != nil { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
+59 -5
View File
@@ -1,9 +1,11 @@
package server package server
import ( import (
"errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
) )
@@ -14,21 +16,26 @@ import (
// account and mint the opaque session. The backend trusts the gateway on this // account and mint the opaque session. The backend trusts the gateway on this
// segment (docs/ARCHITECTURE.md §12). // segment (docs/ARCHITECTURE.md §12).
// telegramAuthRequest carries the platform user id the gateway extracted from a // telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language (first contact only).
type telegramAuthRequest struct { 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 // 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) { func (s *Server) handleTelegramAuth(c *gin.Context) {
var req telegramAuthRequest var req telegramAuthRequest
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" { if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
abortBadRequest(c, "external_id is required") abortBadRequest(c, "external_id is required")
return 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 { if err != nil {
s.abortErr(c, err) s.abortErr(c, err)
return return
@@ -36,6 +43,53 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
s.mintSession(c, acc) 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. // handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
func (s *Server) handleGuestAuth(c *gin.Context) { func (s *Server) handleGuestAuth(c *gin.Context) {
acc, err := s.accounts.ProvisionGuest(c.Request.Context()) acc, err := s.accounts.ProvisionGuest(c.Request.Context())
+42 -18
View File
@@ -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 a server-driven channel later, §10), and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md). [`UI_DESIGN.md`](UI_DESIGN.md).
- **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot - **`platform/telegram`** — the Telegram side-service (the "connector", module
first): deep-link invites and platform-native push notifications. They talk `scrabble/platform/telegram`). It is the only component holding the bot token: it
to `backend` over an internal API. 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 ```mermaid
flowchart LR flowchart LR
@@ -55,7 +61,9 @@ flowchart LR
Gateway -- in-app stream --> Client Gateway -- in-app stream --> Client
Backend -- pgx --> Postgres[(Postgres)] Backend -- pgx --> Postgres[(Postgres)]
Backend -. embeds .- Solver[[scrabble-solver library]] Backend -. embeds .- Solver[[scrabble-solver library]]
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 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 signing, no anti-replay crypto** (these were considered and dropped — players
arrive from a platform rather than completing a mandatory registration). arrive from a platform rather than completing a mandatory registration).
- The gateway validates the originating credential **once**the platform's - The gateway validates the originating credential **once**Telegram `initData`
signed launch data (e.g. Telegram `initData` HMAC), an email-code login, or a (delegated to the connector's `ValidateInitData` RPC, which holds the bot token —
guest bootstrap — then mints a **thin opaque server session token** the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
(`session_id`). 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 - 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). storage is optional and may be unavailable; losing it means re-login).
- The gateway caches `session → user_id` and injects `X-User-ID`. Session - 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**. keys are application-generated **UUIDv7**.
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window - 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 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)`; `identities` (platform/email/robot identities, unique `(kind, external_id)`;
Stage 5's migration `00004` admits the `robot` kind), Stage 5's migration `00004` admits the `robot` kind),
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables `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 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 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 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 missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
wired in Stage 9; session-revocation events and cursor-based stream resume are the **gateway** routes from the same firehose: for an event whose recipient has **no
deferred (single-instance MVP). 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). 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, 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 | | Concern | Enforced by |
| --- | --- | | --- | --- |
| Public rate limiting / anti-abuse | gateway | | 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 | | Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints | | 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 This is an explicit, accepted MVP risk: compromise of the gateway↔backend
network segment defeats backend authentication. Mitigated by network isolation; network segment defeats backend authentication. Mitigated by network isolation;
@@ -438,10 +457,15 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
## 13. Deployment (informational) ## 13. Deployment (informational)
Single public origin, path-routed: the UI, the gateway public surface and the Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
admin surface share one host that terminates TLS. MVP runs one `gateway`, one App under `/telegram/`** (the gateway serves the static UI build; outside Telegram
`backend`, one Postgres. Docker/compose environments are introduced when there that path redirects to the root), the gateway public surface and the admin surface
is something to deploy. 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 ## 14. CI & branches
+14 -10
View File
@@ -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 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. 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 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 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 token; the backend resolves it to an internal `user_id`. A **Telegram Mini
session-only with restricted features (auto-match only; no friends, stats or App** launch authenticates from the platform's signed `initData`, themes the UI to
history). While the app is open the client keeps a live stream and receives the Telegram colours, and — on first contact — seeds the new account's interface
in-app updates in real time — the opponent's move, your turn, chat, nudges and a language from the Telegram client. Guests are session-only with restricted features
found match; out-of-app push (your turn, nudge) is delivered by the platform (auto-match only; no friends, stats or history). While the app is open the client
later (Stage 9). 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)* ### Accounts, linking & merge *(Stage 1 / 10)*
First platform contact auto-provisions a durable account. From the profile a 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 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 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 (24) are human the robot substitutes (the robot arrives in Stage 5). Friend games (24) are
formed by inviting players from the friend list (deep-link invites arrive with the formed by inviting players from the friend list (an invitation, like a friend code,
platform integration): the inviter chooses the settings and the game starts once is shareable as a Telegram deep link that opens it directly): the inviter chooses the
every invitee has accepted — any decline cancels it, and an unanswered invitation settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
expires after seven days. expires after seven days.
### Playing a game *(Stage 3)* ### Playing a game *(Stage 3)*
+14 -10
View File
@@ -23,15 +23,19 @@ top-1 подсказку, безлимитную проверку слова с
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
и ограничивает частоту повторов. и ограничивает частоту повторов.
### Личность и сессии *(Stage 1 / 6)* ### Личность и сессии *(Stage 1 / 6 / 9)*
Игрок приходит с платформы (сначала Telegram), через email-вход или как Игрок приходит с платформы (сначала Telegram), через email-вход или как
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Гость — session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
только сессия, с урезанными функциями (только авто-подбор; без друзей, Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
статистики и истории). Пока приложение открыто, клиент держит живой стрим и в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и языку Telegram-клиента. Гость — только сессия, с урезанными функциями (только
найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит авто-подбор; без друзей, статистики и истории). Пока приложение открыто, клиент
позже (Stage 9). держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
только в приложении (настройка профиля, **включена по умолчанию**).
### Аккаунты, привязка и слияние *(Stage 1 / 10)* ### Аккаунты, привязка и слияние *(Stage 1 / 10)*
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
@@ -43,9 +47,9 @@ session-токен; backend сопоставляет его с внутренн
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока) Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4) без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
формируются приглашением игроков из списка друзей (приглашения по deep-link формируются приглашением игроков из списка друзей (приглашение, как и код друга,
появятся с платформенной интеграцией): инициатор выбирает настройки, и партия можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
ответа приглашение протухает через семь дней. ответа приглашение протухает через семь дней.
### Игровой процесс *(Stage 3)* ### Игровой процесс *(Stage 3)*
+4 -2
View File
@@ -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 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 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 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** `prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9):
(the tokens can be overridden at runtime). 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`) ## Layout shell (`components/Screen.svelte`)
+6 -1
View File
@@ -19,7 +19,7 @@ internal/config/ # GATEWAY_* env config
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
internal/session/ # in-memory session cache (LRU/TTL, backend fallback) internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate) 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/push/ # live-event fan-out hub (per-user client streams)
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
internal/connectsrv/ # the Connect Gateway service over h2c 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 outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge
failures become Connect error codes. failures become Connect error codes.
`auth.telegram` validates the Mini App `initData` by calling the **Telegram connector**
(`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`, The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`, `auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events `game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
+43 -10
View File
@@ -18,9 +18,9 @@ import (
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/gateway/internal/admin" "scrabble/gateway/internal/admin"
"scrabble/gateway/internal/auth"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/config" "scrabble/gateway/internal/config"
"scrabble/gateway/internal/connector"
"scrabble/gateway/internal/connectsrv" "scrabble/gateway/internal/connectsrv"
"scrabble/gateway/internal/push" "scrabble/gateway/internal/push"
"scrabble/gateway/internal/ratelimit" "scrabble/gateway/internal/ratelimit"
@@ -73,14 +73,20 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
limiter := ratelimit.New() limiter := ratelimit.New()
hub := push.NewHub(0) hub := push.NewHub(0)
var tg auth.TelegramValidator var conn *connector.Client
if cfg.TelegramBotToken != "" { var validator transcode.TelegramValidator
tg = auth.NewHMACValidator(cfg.TelegramBotToken) if cfg.ConnectorAddr != "" {
conn, err = connector.New(cfg.ConnectorAddr)
if err != nil {
return err
}
defer func() { _ = conn.Close() }()
validator = conn
} else { } 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{ edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry, Registry: registry,
Sessions: sessions, Sessions: sessions,
@@ -91,8 +97,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Logger: logger, Logger: logger,
}) })
// Bridge the backend push stream into the fan-out hub. // Bridge the backend push stream into the fan-out hub (and the out-of-app
go runPushPump(ctx, backend, hub, logger) // channel via the connector).
go runPushPump(ctx, backend, hub, conn, logger)
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()} public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
servers := []*namedServer{{name: "public", srv: public}} 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 // 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. // the hub and re-subscribing after the stream ends, until the context is done. For
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, logger *zap.Logger) { // 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 { for ctx.Err() == nil {
stream, err := backend.SubscribePush(ctx, gatewayID) stream, err := backend.SubscribePush(ctx, gatewayID)
if err != nil { if err != nil {
@@ -178,6 +187,12 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
Payload: ev.GetPayload(), Payload: ev.GetPayload(),
EventID: ev.GetEventId(), 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) { if !sleep(ctx, pushReconnectDelay) {
return 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 // sleep waits for d or until ctx is cancelled, reporting whether it waited the
// full duration. // full duration.
func sleep(ctx context.Context, d time.Duration) bool { func sleep(ctx context.Context, d time.Duration) bool {
-92
View File
@@ -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")
}
}
+38 -13
View File
@@ -20,16 +20,17 @@ type SessionResp struct {
// ProfileResp is an account's own profile. // ProfileResp is an account's own profile.
type ProfileResp struct { type ProfileResp struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
DisplayName string `json:"display_name"` DisplayName string `json:"display_name"`
PreferredLanguage string `json:"preferred_language"` PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"` TimeZone string `json:"time_zone"`
AwayStart string `json:"away_start"` AwayStart string `json:"away_start"`
AwayEnd string `json:"away_end"` AwayEnd string `json:"away_end"`
HintBalance int `json:"hint_balance"` HintBalance int `json:"hint_balance"`
BlockChat bool `json:"block_chat"` BlockChat bool `json:"block_chat"`
BlockFriendRequests bool `json:"block_friend_requests"` BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"` 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. // 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"` CreatedAtUnix int64 `json:"created_at_unix"`
} }
// TelegramAuth provisions/finds the Telegram account and mints a session. // TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
func (c *Client) TelegramAuth(ctx context.Context, externalID string) (SessionResp, error) { // 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 var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", 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 return out, err
} }
+8 -7
View File
@@ -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) { func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp) (ProfileResp, error) {
var out ProfileResp var out ProfileResp
body := map[string]any{ body := map[string]any{
"display_name": p.DisplayName, "display_name": p.DisplayName,
"preferred_language": p.PreferredLanguage, "preferred_language": p.PreferredLanguage,
"time_zone": p.TimeZone, "time_zone": p.TimeZone,
"away_start": p.AwayStart, "away_start": p.AwayStart,
"away_end": p.AwayEnd, "away_end": p.AwayEnd,
"block_chat": p.BlockChat, "block_chat": p.BlockChat,
"block_friend_requests": p.BlockFriendRequests, "block_friend_requests": p.BlockFriendRequests,
"notifications_in_app_only": p.NotificationsInAppOnly,
} }
err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out) err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out)
return out, err return out, err
+14 -13
View File
@@ -28,9 +28,10 @@ type Config struct {
// checks before proxying admin traffic to the backend. Empty disables admin. // checks before proxying admin traffic to the backend. Empty disables admin.
AdminUser string AdminUser string
AdminPassword string AdminPassword string
// TelegramBotToken is the secret used to validate Telegram initData HMACs. // ConnectorAddr is the gRPC address of the Telegram connector side-service. The
// Empty disables the telegram auth path. // gateway calls it to validate Mini App initData and to deliver out-of-app push.
TelegramBotToken string // 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 // SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
// caps the number of cached sessions. // caps the number of cached sessions.
SessionTTL time.Duration SessionTTL time.Duration
@@ -83,16 +84,16 @@ func DefaultRateLimit() RateLimitConfig {
func Load() (Config, error) { func Load() (Config, error) {
var err error var err error
c := Config{ c := Config{
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr), HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr), AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel), LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL), BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr), BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"), AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"), AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
TelegramBotToken: os.Getenv("GATEWAY_TELEGRAM_BOT_TOKEN"), ConnectorAddr: os.Getenv("GATEWAY_CONNECTOR_ADDR"),
SessionCacheMax: defaultSessionCacheMax, SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(), RateLimit: DefaultRateLimit(),
} }
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil { if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
return Config{}, err return Config{}, err
+82
View File
@@ -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
}
+23
View File
@@ -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
}
@@ -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)
}
})
}
}
+15
View File
@@ -86,3 +86,18 @@ func (h *Hub) SubscriberCount() int {
defer h.mu.Unlock() defer h.mu.Unlock()
return len(h.subs) 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
}
+18
View File
@@ -54,3 +54,21 @@ func TestHubUnsubscribeClosesChannel(t *testing.T) {
} }
h.Publish(push.Event{UserID: "u"}) // must not panic 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")
}
}
+1
View File
@@ -56,6 +56,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
fb.ProfileAddIsGuest(b, p.IsGuest) fb.ProfileAddIsGuest(b, p.IsGuest)
fb.ProfileAddAwayStart(b, awayStart) fb.ProfileAddAwayStart(b, awayStart)
fb.ProfileAddAwayEnd(b, awayEnd) fb.ProfileAddAwayEnd(b, awayEnd)
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
b.Finish(fb.ProfileEnd(b)) b.Finish(fb.ProfileEnd(b))
return b.FinishedBytes() return b.FinishedBytes()
} }
+14 -8
View File
@@ -9,8 +9,8 @@ import (
"context" "context"
"errors" "errors"
"scrabble/gateway/internal/auth"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector"
fb "scrabble/pkg/fbs/scrabblefb" fb "scrabble/pkg/fbs/scrabblefb"
) )
@@ -63,10 +63,16 @@ type Registry struct {
ops map[string]Op 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. // 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 // The Telegram auth op is registered only when a validator is supplied (the
// token is configured); otherwise auth.telegram is simply unknown. // connector is configured); otherwise auth.telegram is simply unknown.
func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Registry { func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
r := &Registry{ops: make(map[string]Op)} r := &Registry{ops: make(map[string]Op)}
if tg != nil { if tg != nil {
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)} r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
@@ -109,20 +115,20 @@ func DomainCode(err error) (string, bool) {
if errors.As(err, &apiErr) { if errors.As(err, &apiErr) {
return apiErr.Code, true return apiErr.Code, true
} }
if errors.Is(err, auth.ErrInvalidInitData) { if errors.Is(err, connector.ErrInvalidInitData) {
return "invalid_init_data", true return "invalid_init_data", true
} }
return "", false 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) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
user, err := tg.Validate(string(in.InitData())) user, err := tg.ValidateInitData(ctx, string(in.InitData()))
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
@@ -233,13 +233,14 @@ func profileUpdateHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0) in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0)
p := backendclient.ProfileResp{ p := backendclient.ProfileResp{
DisplayName: string(in.DisplayName()), DisplayName: string(in.DisplayName()),
PreferredLanguage: string(in.PreferredLanguage()), PreferredLanguage: string(in.PreferredLanguage()),
TimeZone: string(in.TimeZone()), TimeZone: string(in.TimeZone()),
AwayStart: string(in.AwayStart()), AwayStart: string(in.AwayStart()),
AwayEnd: string(in.AwayEnd()), AwayEnd: string(in.AwayEnd()),
BlockChat: in.BlockChat(), BlockChat: in.BlockChat(),
BlockFriendRequests: in.BlockFriendRequests(), BlockFriendRequests: in.BlockFriendRequests(),
NotificationsInAppOnly: in.NotificationsInAppOnly(),
} }
out, err := backend.UpdateProfile(ctx, req.UserID, p) out, err := backend.UpdateProfile(ctx, req.UserID, p)
if err != nil { if err != nil {
@@ -2,6 +2,7 @@ package transcode_test
import ( import (
"context" "context"
"encoding/json"
"net/http" "net/http"
"testing" "testing"
@@ -202,11 +203,15 @@ func TestGcgRoundTrip(t *testing.T) {
} }
func TestProfileUpdateRoundTripAway(t *testing.T) { func TestProfileUpdateRoundTripAway(t *testing.T) {
var gotBody map[string]any
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) { backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/profile" { if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/profile" {
t.Errorf("unexpected %s %q", r.Method, r.URL.Path) 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() defer cleanup()
@@ -225,6 +230,7 @@ func TestProfileUpdateRoundTripAway(t *testing.T) {
fb.UpdateProfileRequestAddTimeZone(b, tz) fb.UpdateProfileRequestAddTimeZone(b, tz)
fb.UpdateProfileRequestAddAwayStart(b, as) fb.UpdateProfileRequestAddAwayStart(b, as)
fb.UpdateProfileRequestAddAwayEnd(b, ae) fb.UpdateProfileRequestAddAwayEnd(b, ae)
fb.UpdateProfileRequestAddNotificationsInAppOnly(b, true)
b.Finish(fb.UpdateProfileRequestEnd(b)) b.Finish(fb.UpdateProfileRequestEnd(b))
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()}) 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" { 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()) 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")
}
} }
@@ -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")
}
}
+1
View File
@@ -5,6 +5,7 @@ use ./backend
use ( use (
./gateway ./gateway
./pkg ./pkg
./platform/telegram
) )
// The scrabble-solver engine is consumed in-process as a library. Its module // The scrabble-solver engine is consumed in-process as a library. Its module
+2 -5
View File
@@ -1,7 +1,5 @@
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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= 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/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c= 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-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-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-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/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-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= 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/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= 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.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.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= 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/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= 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= howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI= mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
+7 -1
View File
@@ -104,7 +104,9 @@ table Ack {
// --- profile (authenticated) --- // --- profile (authenticated) ---
// Profile is the authenticated account's own profile view. away_start/away_end are // 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 { table Profile {
user_id:string; user_id:string;
display_name:string; display_name:string;
@@ -116,6 +118,7 @@ table Profile {
is_guest:bool; is_guest:bool;
away_start:string; away_start:string;
away_end:string; away_end:string;
notifications_in_app_only:bool = true;
} }
// --- game (authenticated) --- // --- game (authenticated) ---
@@ -256,6 +259,8 @@ table AccountRef {
// UpdateProfileRequest overwrites the full editable profile (the client sends the // UpdateProfileRequest overwrites the full editable profile (the client sends the
// complete desired profile). away_start/away_end are "HH:MM" bounds. // 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 { table UpdateProfileRequest {
display_name:string; display_name:string;
preferred_language:string; preferred_language:string;
@@ -264,6 +269,7 @@ table UpdateProfileRequest {
away_end:string; away_end:string;
block_chat:bool; block_chat:bool;
block_friend_requests:bool; block_friend_requests:bool;
notifications_in_app_only:bool = true;
} }
// EmailBindRequest asks the backend to send a confirm-code binding email to the // EmailBindRequest asks the backend to send a confirm-code binding email to the
+16 -1
View File
@@ -137,8 +137,20 @@ func (rcv *Profile) AwayEnd() []byte {
return nil 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) { func ProfileStart(builder *flatbuffers.Builder) {
builder.StartObject(10) builder.StartObject(11)
} }
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) { func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0) 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) { func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(awayEnd), 0) 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 { func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+16 -1
View File
@@ -105,8 +105,20 @@ func (rcv *UpdateProfileRequest) MutateBlockFriendRequests(n bool) bool {
return rcv._tab.MutateBoolSlot(16, n) 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) { func UpdateProfileRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(7) builder.StartObject(8)
} }
func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) { func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0) 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) { func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequests bool) {
builder.PrependBoolSlot(6, blockFriendRequests, false) builder.PrependBoolSlot(6, blockFriendRequests, false)
} }
func UpdateProfileRequestAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
builder.PrependBoolSlot(7, notificationsInAppOnly, true)
}
func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT { func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject() return builder.EndObject()
} }
+508
View File
@@ -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
}
+83
View File
@@ -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;
}
+276
View File
@@ -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",
}
+22
View File
@@ -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"]
+86
View File
@@ -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 <payload>` (and the chat menu button) reply with a Mini App
launch button; a deep-link payload routes the launch to a game / invitation /
friend code.
- **Admin messaging** (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<game uuid>` | open that game |
| `i<invitation uuid>` | open that invitation |
| `f<6-digit code>` | redeem that friend code |
| empty / unknown | the lobby |
The bot turns a `/start <payload>` or a notification target into a launch-button URL
`<MiniAppURL>?startapp=<payload>`.
## Configuration
| 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<token>/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 `<host>/bot<token>/<method>`).
## Build, test, run
```sh
go build ./platform/telegram/...
go test ./platform/telegram/... # unit tests use an httptest fake Bot API
go run ./platform/telegram/cmd/telegram # needs a real TELEGRAM_BOT_TOKEN
```
## 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.
+94
View File
@@ -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()
}
@@ -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
+12
View File
@@ -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
)
+155
View File
@@ -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<token>/test/METHOD; the
// client builds <host>/bot<token>/<method>, 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 <payload>" 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))
}
+100
View File
@@ -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)
}
}
@@ -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<token>/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
}
@@ -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
}
@@ -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)
}
})
}
@@ -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<game uuid> open that game
// i<invitation uuid> 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, ""
}
}
@@ -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"))
}
}
@@ -1,8 +1,8 @@
// Package auth holds the gateway's credential validators. The only non-trivial // Package initdata validates Telegram Mini App launch data (initData). It lives in
// one is the Telegram Web App initData HMAC check; guest and email logins carry // the connector because the HMAC secret is the bot token, which is held only here
// no gateway-side secret and are validated by the backend. The validator is an // (ARCHITECTURE.md §12); the gateway calls the connector's ValidateInitData RPC
// interface so handlers test against fixtures without a bot token. // instead of validating the launch data itself.
package auth package initdata
import ( import (
"crypto/hmac" "crypto/hmac"
@@ -19,23 +19,25 @@ import (
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing // ErrInvalidInitData is returned when initData fails HMAC validation, is missing
// the hash, is malformed, or is older than the freshness window. // 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. // defaultMaxAge bounds how old a validated initData payload may be.
const defaultMaxAge = 24 * time.Hour const defaultMaxAge = 24 * time.Hour
// TelegramUser is the identity extracted from a validated initData payload. ID // User is the identity extracted from a validated initData payload. ExternalID is
// is the platform user id used as the identity's external_id. // the Telegram user id used as the identities external_id; LanguageCode seeds a
type TelegramUser struct { // new account's preferred language (Stage 9).
ID string type User struct {
Username string ExternalID string
FirstName string Username string
FirstName string
LanguageCode string
} }
// TelegramValidator validates Telegram Web App launch data and returns the // Validator validates Telegram Web App launch data and returns the authenticated
// authenticated user. // user. It is an interface so the connector can be tested with a fixture.
type TelegramValidator interface { type Validator interface {
Validate(initData string) (TelegramUser, error) Validate(initData string) (User, error)
} }
// HMACValidator validates initData against a bot token per Telegram's documented // 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. // 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) values, err := url.ParseQuery(initData)
if err != nil { if err != nil {
return TelegramUser{}, ErrInvalidInitData return User{}, ErrInvalidInitData
} }
hash := values.Get("hash") hash := values.Get("hash")
if hash == "" { if hash == "" {
return TelegramUser{}, ErrInvalidInitData return User{}, ErrInvalidInitData
} }
values.Del("hash") values.Del("hash")
if !v.checkSignature(values, hash) { if !v.checkSignature(values, hash) {
return TelegramUser{}, ErrInvalidInitData return User{}, ErrInvalidInitData
} }
if err := v.checkFreshness(values.Get("auth_date")); err != nil { if err := v.checkFreshness(values.Get("auth_date")); err != nil {
return TelegramUser{}, err return User{}, err
} }
return parseUser(values.Get("user")) return parseUser(values.Get("user"))
} }
@@ -111,23 +113,25 @@ func (v *HMACValidator) checkFreshness(authDate string) error {
return nil return nil
} }
// parseUser extracts the user id and names from the user JSON field. // parseUser extracts the user id, names and language from the user JSON field.
func parseUser(userJSON string) (TelegramUser, error) { func parseUser(userJSON string) (User, error) {
if userJSON == "" { if userJSON == "" {
return TelegramUser{}, ErrInvalidInitData return User{}, ErrInvalidInitData
} }
var u struct { var u struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"`
} }
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 { if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
return TelegramUser{}, ErrInvalidInitData return User{}, ErrInvalidInitData
} }
return TelegramUser{ return User{
ID: strconv.FormatInt(u.ID, 10), ExternalID: strconv.FormatInt(u.ID, 10),
Username: u.Username, Username: u.Username,
FirstName: u.FirstName, FirstName: u.FirstName,
LanguageCode: u.LanguageCode,
}, nil }, nil
} }
@@ -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)
}
})
}
@@ -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: "Открыть",
}
@@ -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)
}
}
}
+18
View File
@@ -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
// <script> to it would hang every page load. Specs that exercise the Telegram launch
// inject their own window.Telegram via addInitScript before navigating.
export const test = base.extend({
page: async ({ page }, use) => {
await page.route('**/telegram-web-app.js', (route) =>
route.fulfill({ status: 200, contentType: 'application/javascript', body: '' }),
);
await use(page);
},
});
export { expect } from '@playwright/test';
export type { Page } from '@playwright/test';
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from './fixtures';
// Behaviour/display coverage for the polished game screen, driven entirely by the mock // Behaviour/display coverage for the polished game screen, driven entirely by the mock
// transport (no backend). These lock the round-1..4 interactions so future UI edits // transport (no backend). These lock the round-1..4 interactions so future UI edits
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'; import { expect, test } from './fixtures';
// The playable-slice smoke against the mock transport: guest login -> lobby shows the // The playable-slice smoke against the mock transport: guest login -> lobby shows the
// seeded active game -> open it -> the board renders committed tiles -> place a rack // seeded active game -> open it -> the board renders committed tiles -> place a rack
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test, type Page } from './fixtures';
// Stage 8 social / account / history surfaces against the mock transport (no backend). // Stage 8 social / account / history surfaces against the mock transport (no backend).
// The mock profile is a durable account, so friends, invitations, stats and the GCG // The mock profile is a durable account, so friends, invitations, stats and the GCG
+45
View File
@@ -0,0 +1,45 @@
import { expect, test } from './fixtures';
// The shared fixture already neutralises the real telegram-web-app.js, so these
// specs control window.Telegram deterministically (injected below) with no network.
// A minimal valid-looking Telegram WebApp stub: non-empty initData triggers the Mini
// App launch path (the mock gateway accepts any initData and returns a durable
// session); themeParams override the design tokens.
function webAppStub(startParam = '') {
return {
Telegram: {
WebApp: {
initData: 'query_id=test&user=%7B%22id%22%3A1%7D&auth_date=1&hash=deadbeef',
initDataUnsafe: startParam ? { start_param: startParam } : {},
themeParams: { bg_color: '#101418', text_color: '#ffffff' },
ready() {},
expand() {},
},
},
};
}
test('Telegram launch auto-authenticates into the lobby and applies the theme', async ({ page }) => {
await page.addInitScript((stub) => {
Object.assign(window, stub);
}, webAppStub());
await page.goto('/');
// No guest-login click: the Mini App authenticates from initData and lands on the lobby.
await expect(page.getByText('Active games')).toBeVisible();
// The Telegram themeParams override the background token at runtime.
await expect
.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--bg').trim()))
.toBe('#101418');
});
test('outside Telegram, the /telegram/ entry redirects to the site root', async ({ page }) => {
await page.goto('/telegram/');
// The guard sends a non-Telegram visitor back to the root, where the normal
// (guest / email) login is shown.
await expect(page.getByRole('button', { name: /guest/i })).toBeVisible();
await expect(page).not.toHaveURL(/\/telegram\//);
});
+1 -1
View File
@@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'; import { expect, test } from './fixtures';
// Item 5: zooming the board must enlarge the labels too (a magnifying-glass zoom). // Item 5: zooming the board must enlarge the labels too (a magnifying-glass zoom).
// cqw is sized against the zoom-scaled board, so the font grows with the cells. // cqw is sized against the zoom-scaled board, so the font grows with the cells.
+3
View File
@@ -2,6 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<!-- Telegram Mini App SDK: defines window.Telegram.WebApp. Harmless outside
Telegram (initData is empty), so it loads on every entry. -->
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch <!-- user-scalable=no: the board owns zoom; we do not want the browser's pinch
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). --> to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
<meta <meta
+12 -2
View File
@@ -82,8 +82,13 @@ awayEnd(optionalEncoding?:any):string|Uint8Array|null {
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null; return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
} }
notificationsInAppOnly():boolean {
const offset = this.bb!.__offset(this.bb_pos, 24);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
}
static startProfile(builder:flatbuffers.Builder) { static startProfile(builder:flatbuffers.Builder) {
builder.startObject(10); builder.startObject(11);
} }
static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) { static addUserId(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset) {
@@ -126,12 +131,16 @@ static addAwayEnd(builder:flatbuffers.Builder, awayEndOffset:flatbuffers.Offset)
builder.addFieldOffset(9, awayEndOffset, 0); builder.addFieldOffset(9, awayEndOffset, 0);
} }
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
builder.addFieldInt8(10, +notificationsInAppOnly, +true);
}
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset { static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset):flatbuffers.Offset { static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, hintBalance:number, blockChat:boolean, blockFriendRequests:boolean, isGuest:boolean, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, notificationsInAppOnly:boolean):flatbuffers.Offset {
Profile.startProfile(builder); Profile.startProfile(builder);
Profile.addUserId(builder, userIdOffset); Profile.addUserId(builder, userIdOffset);
Profile.addDisplayName(builder, displayNameOffset); Profile.addDisplayName(builder, displayNameOffset);
@@ -143,6 +152,7 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse
Profile.addIsGuest(builder, isGuest); Profile.addIsGuest(builder, isGuest);
Profile.addAwayStart(builder, awayStartOffset); Profile.addAwayStart(builder, awayStartOffset);
Profile.addAwayEnd(builder, awayEndOffset); Profile.addAwayEnd(builder, awayEndOffset);
Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly);
return Profile.endProfile(builder); return Profile.endProfile(builder);
} }
} }
@@ -65,8 +65,13 @@ blockFriendRequests():boolean {
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false; return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : false;
} }
notificationsInAppOnly():boolean {
const offset = this.bb!.__offset(this.bb_pos, 18);
return offset ? !!this.bb!.readInt8(this.bb_pos + offset) : true;
}
static startUpdateProfileRequest(builder:flatbuffers.Builder) { static startUpdateProfileRequest(builder:flatbuffers.Builder) {
builder.startObject(7); builder.startObject(8);
} }
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) { static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
@@ -97,12 +102,16 @@ static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:b
builder.addFieldInt8(6, +blockFriendRequests, +false); builder.addFieldInt8(6, +blockFriendRequests, +false);
} }
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
builder.addFieldInt8(7, +notificationsInAppOnly, +true);
}
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset { static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject(); const offset = builder.endObject();
return offset; return offset;
} }
static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean):flatbuffers.Offset { static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset, preferredLanguageOffset:flatbuffers.Offset, timeZoneOffset:flatbuffers.Offset, awayStartOffset:flatbuffers.Offset, awayEndOffset:flatbuffers.Offset, blockChat:boolean, blockFriendRequests:boolean, notificationsInAppOnly:boolean):flatbuffers.Offset {
UpdateProfileRequest.startUpdateProfileRequest(builder); UpdateProfileRequest.startUpdateProfileRequest(builder);
UpdateProfileRequest.addDisplayName(builder, displayNameOffset); UpdateProfileRequest.addDisplayName(builder, displayNameOffset);
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset); UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
@@ -111,6 +120,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
UpdateProfileRequest.addAwayEnd(builder, awayEndOffset); UpdateProfileRequest.addAwayEnd(builder, awayEndOffset);
UpdateProfileRequest.addBlockChat(builder, blockChat); UpdateProfileRequest.addBlockChat(builder, blockChat);
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests); UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
return UpdateProfileRequest.endUpdateProfileRequest(builder); return UpdateProfileRequest.endUpdateProfileRequest(builder);
} }
} }
+52 -1
View File
@@ -8,7 +8,9 @@ import { gateway } from './gateway';
import { GatewayError } from './client'; import { GatewayError } from './client';
import { navigate, router } from './router.svelte'; import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { applyReduceMotion, applyTheme, type ThemePref } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { insideTelegram, onTelegramPath, telegramLaunch } from './telegram';
import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import type { BoardLabelMode } from './boardlabels'; import type { BoardLabelMode } from './boardlabels';
@@ -144,6 +146,28 @@ export async function bootstrap(): Promise<void> {
setLocale(guess); 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(); const saved = await loadSession();
if (saved) { if (saved) {
await adoptSession(saved); await adoptSession(saved);
@@ -154,6 +178,32 @@ export async function bootstrap(): Promise<void> {
app.ready = true; 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<void> {
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<void> { export async function loginGuest(): Promise<void> {
try { try {
const s = await gateway.authGuest(app.locale); const s = await gateway.authGuest(app.locale);
@@ -233,6 +283,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
awayEnd: p.awayEnd, awayEnd: p.awayEnd,
blockChat: p.blockChat, blockChat: p.blockChat,
blockFriendRequests: p.blockFriendRequests, blockFriendRequests: p.blockFriendRequests,
notificationsInAppOnly: p.notificationsInAppOnly,
}); });
} catch { } catch {
// The client locale already changed; the server sync is best-effort. // The client locale already changed; the server sync is best-effort.
+1
View File
@@ -52,6 +52,7 @@ export type Unsubscribe = () => void;
export interface GatewayClient { export interface GatewayClient {
// --- auth (unauthenticated) --- // --- auth (unauthenticated) ---
authTelegram(initData: string): Promise<Session>;
authGuest(locale?: string): Promise<Session>; authGuest(locale?: string): Promise<Session>;
authEmailRequest(email: string): Promise<void>; authEmailRequest(email: string): Promise<void>;
authEmailLogin(email: string, code: string): Promise<Session>; authEmailLogin(email: string, code: string): Promise<Session>;
+10
View File
@@ -146,6 +146,14 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
return finish(b, fb.ChatPostRequest.endChatPostRequest(b)); 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 { export function encodeGuestLogin(locale: string): Uint8Array {
const b = new Builder(64); const b = new Builder(64);
const l = b.createString(locale); const l = b.createString(locale);
@@ -264,6 +272,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
blockChat: p.blockChat(), blockChat: p.blockChat(),
blockFriendRequests: p.blockFriendRequests(), blockFriendRequests: p.blockFriendRequests(),
isGuest: p.isGuest(), isGuest: p.isGuest(),
notificationsInAppOnly: p.notificationsInAppOnly(),
}; };
} }
@@ -444,6 +453,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
fb.UpdateProfileRequest.addAwayEnd(b, ae); fb.UpdateProfileRequest.addAwayEnd(b, ae);
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat); fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests); fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b)); return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
} }
+38
View File
@@ -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');
});
});
+49
View File
@@ -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<game uuid> open that game
// i<invitation uuid> 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/<bot>/<app>). 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)}`;
}
+2
View File
@@ -110,6 +110,7 @@ export const en = {
'profile.to': 'To', 'profile.to': 'To',
'profile.blockChat': 'Disable chat', 'profile.blockChat': 'Disable chat',
'profile.blockFriendRequests': 'Disable friend requests', 'profile.blockFriendRequests': 'Disable friend requests',
'profile.notificationsInAppOnly': 'Notifications in the app only',
'profile.email': 'Email', 'profile.email': 'Email',
'profile.bindEmail': 'Bind email', 'profile.bindEmail': 'Bind email',
'profile.emailCode': 'Confirmation code', 'profile.emailCode': 'Confirmation code',
@@ -180,6 +181,7 @@ export const en = {
'friends.redeem': 'Add', 'friends.redeem': 'Add',
'friends.copy': 'Copy', 'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.', 'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share via Telegram',
'friends.added': 'Added {name}.', 'friends.added': 'Added {name}.',
'friends.blockedList': 'Blocked players', 'friends.blockedList': 'Blocked players',
'friends.unblock': 'Unblock', 'friends.unblock': 'Unblock',
+2
View File
@@ -111,6 +111,7 @@ export const ru: Record<MessageKey, string> = {
'profile.to': 'До', 'profile.to': 'До',
'profile.blockChat': 'Отключить чат', 'profile.blockChat': 'Отключить чат',
'profile.blockFriendRequests': 'Отключить заявки в друзья', 'profile.blockFriendRequests': 'Отключить заявки в друзья',
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
'profile.email': 'Эл. почта', 'profile.email': 'Эл. почта',
'profile.bindEmail': 'Привязать почту', 'profile.bindEmail': 'Привязать почту',
'profile.emailCode': 'Код подтверждения', 'profile.emailCode': 'Код подтверждения',
@@ -181,6 +182,7 @@ export const ru: Record<MessageKey, string> = {
'friends.redeem': 'Добавить', 'friends.redeem': 'Добавить',
'friends.copy': 'Копировать', 'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.', 'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться через Telegram',
'friends.added': 'Добавлен(а) {name}.', 'friends.added': 'Добавлен(а) {name}.',
'friends.blockedList': 'Заблокированные', 'friends.blockedList': 'Заблокированные',
'friends.unblock': 'Разблокировать', 'friends.unblock': 'Разблокировать',
+3
View File
@@ -100,6 +100,9 @@ export class MockGateway implements GatewayClient {
} }
// --- auth --- // --- auth ---
async authTelegram(): Promise<Session> {
return { ...SESSION, isGuest: false };
}
async authGuest(): Promise<Session> { async authGuest(): Promise<Session> {
return { ...SESSION }; return { ...SESSION };
} }
+1
View File
@@ -36,6 +36,7 @@ export const PROFILE: Profile = {
blockChat: false, blockChat: false,
blockFriendRequests: false, blockFriendRequests: false,
isGuest: false, isGuest: false,
notificationsInAppOnly: true,
}; };
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile // Seed social/account data for the mock (pnpm start + Playwright). The mock profile
+3
View File
@@ -107,6 +107,8 @@ export interface Profile {
blockChat: boolean; blockChat: boolean;
blockFriendRequests: boolean; blockFriendRequests: boolean;
isGuest: 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). */ /** The full editable profile sent to profileUpdate (overwrites every field). */
@@ -118,6 +120,7 @@ export interface ProfileUpdate {
awayEnd: string; awayEnd: string;
blockChat: boolean; blockChat: boolean;
blockFriendRequests: boolean; blockFriendRequests: boolean;
notificationsInAppOnly: boolean;
} }
/** A referenced account with its display name (friend, blocked user, invitee). */ /** A referenced account with its display name (friend, blocked user, invitee). */
+39
View File
@@ -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');
});
});
+67
View File
@@ -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/');
}
+3
View File
@@ -54,6 +54,9 @@ export function createTransport(baseUrl: string): GatewayClient {
token = t; token = t;
}, },
async authTelegram(initData) {
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData)));
},
async authGuest(locale) { async authGuest(locale) {
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? ''))); return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
}, },
+5
View File
@@ -4,6 +4,7 @@
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte'; import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
import { gateway } from '../lib/gateway'; import { gateway } from '../lib/gateway';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink';
import type { AccountRef, FriendCode } from '../lib/model'; import type { AccountRef, FriendCode } from '../lib/model';
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
@@ -97,6 +98,7 @@
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button> <button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
</div> </div>
{#if code} {#if code}
{@const tg = shareLink(friendCodeParam(code.code))}
<div class="code" data-testid="friend-code"> <div class="code" data-testid="friend-code">
<div class="coderow"> <div class="coderow">
<button class="codeval" onclick={copyCode}>{code.code}</button> <button class="codeval" onclick={copyCode}>{code.code}</button>
@@ -105,6 +107,9 @@
<span class="codehint"> <span class="codehint">
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
{#if tg}
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a>
{/if}
</div> </div>
{:else} {:else}
<button class="link" onclick={getCode}>{t('friends.getCode')}</button> <button class="link" onclick={getCode}>{t('friends.getCode')}</button>
+7
View File
@@ -25,6 +25,7 @@
let endM = $state('00'); let endM = $state('00');
let blockChat = $state(false); let blockChat = $state(false);
let blockFriendRequests = $state(false); let blockFriendRequests = $state(false);
let notificationsInAppOnly = $state(true);
let emailInput = $state(''); let emailInput = $state('');
let codeInput = $state(''); let codeInput = $state('');
let emailSent = $state(false); let emailSent = $state(false);
@@ -47,6 +48,7 @@
[endH, endM] = splitTime(p.awayEnd); [endH, endM] = splitTime(p.awayEnd);
blockChat = p.blockChat; blockChat = p.blockChat;
blockFriendRequests = p.blockFriendRequests; blockFriendRequests = p.blockFriendRequests;
notificationsInAppOnly = p.notificationsInAppOnly;
editing = true; editing = true;
} }
@@ -68,6 +70,7 @@
awayEnd, awayEnd,
blockChat, blockChat,
blockFriendRequests, blockFriendRequests,
notificationsInAppOnly,
}); });
editing = false; editing = false;
showToast(t('profile.saved')); showToast(t('profile.saved'));
@@ -143,6 +146,10 @@
<input type="checkbox" bind:checked={blockFriendRequests} /> <input type="checkbox" bind:checked={blockFriendRequests} />
<span>{t('profile.blockFriendRequests')}</span> <span>{t('profile.blockFriendRequests')}</span>
</label> </label>
<label class="check">
<input type="checkbox" bind:checked={notificationsInAppOnly} />
<span>{t('profile.notificationsInAppOnly')}</span>
</label>
<div class="formacts"> <div class="formacts">
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button> <button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button> <button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
+3
View File
@@ -9,6 +9,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
const RPC_PREFIX = '/scrabble.edge.v1.Gateway'; const RPC_PREFIX = '/scrabble.edge.v1.Gateway';
export default defineConfig(({ mode }) => ({ 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()], plugins: [svelte()],
server: { server: {
port: 5173, port: 5173,