Stage 9: Telegram integration (connector, Mini App, out-of-app push) #10
@@ -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
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'pkg/**'
|
||||
- 'platform/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
@@ -20,6 +21,7 @@ on:
|
||||
- 'backend/**'
|
||||
- 'gateway/**'
|
||||
- 'pkg/**'
|
||||
- 'platform/**'
|
||||
- 'go.work'
|
||||
- 'go.work.sum'
|
||||
- '.gitea/workflows/go-unit.yaml'
|
||||
@@ -56,10 +58,10 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: vet
|
||||
run: go vet ./backend/... ./pkg/... ./gateway/...
|
||||
run: go vet ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||
|
||||
- name: build
|
||||
run: go build ./backend/... ./pkg/... ./gateway/...
|
||||
run: go build ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||
|
||||
- name: test
|
||||
# -count=1 disables the test cache so a green run never depends on a
|
||||
@@ -67,4 +69,4 @@ jobs:
|
||||
# tests at the committed DAWGs in the sibling checkout.
|
||||
env:
|
||||
BACKEND_DICT_DIR: ${{ github.workspace }}/../scrabble-solver/dawg
|
||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/...
|
||||
run: go test -count=1 ./backend/... ./pkg/... ./gateway/... ./platform/telegram/...
|
||||
|
||||
@@ -111,7 +111,8 @@ backend/ # module scrabble/backend
|
||||
internal/server/ # gin engine, /api/v1 groups, X-User-ID, probes
|
||||
internal/inttest/ # //go:build integration Postgres-backed tests
|
||||
docs/ .gitea/workflows/ PLAN.md CLAUDE.md README.md
|
||||
gateway/ ui/ pkg/ platform/ # added by their stages
|
||||
gateway/ ui/ pkg/ # added by their stages
|
||||
platform/telegram/ # Telegram connector side-service (Stage 9): bot + gRPC API
|
||||
```
|
||||
|
||||
## Build & test
|
||||
@@ -121,6 +122,7 @@ go build ./backend/... # per module ('./...' from the root won't span t
|
||||
go vet ./backend/...
|
||||
gofmt -l . # must print nothing
|
||||
go test -count=1 ./backend/...
|
||||
go build ./platform/telegram/... && go test ./platform/telegram/... # Telegram connector (Stage 9)
|
||||
go run ./backend/cmd/backend # /healthz, /readyz on :8080
|
||||
|
||||
cd ui && pnpm install && pnpm check && pnpm test:unit && pnpm build # the UI (Stage 7+)
|
||||
|
||||
@@ -42,7 +42,7 @@ independent (see ARCHITECTURE §9.1).
|
||||
| 6 | Gateway edge (Connect/FB, platform auth, sessions, push bridge, admin) | **done** |
|
||||
| 7 | UI — playable slice + UX polish (Svelte+Vite, board, lobby, chat, hint/word-check, i18n) | **done** |
|
||||
| 8 | UI — social/account/history (friends, blocks, invitations, profile edit, stats, history/GCG) | **done** |
|
||||
| 9 | Telegram integration (bot side-service, deep-link, push) | todo |
|
||||
| 9 | Telegram integration (bot side-service, deep-link, push) | **done** |
|
||||
| 10 | Admin & dictionary ops (complaint review, version reload) | todo |
|
||||
| 11 | Account linking & merge | todo |
|
||||
| 12 | Polish (observability, perf with evidence, deploy) | todo |
|
||||
@@ -605,6 +605,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
|
||||
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:00–07: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)
|
||||
|
||||
- **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
|
||||
letter **indices** end-to-end. Caveat (as for the dictionaries, TODO-2): the wire table
|
||||
must stay pinned to the same `rules.Alphabet` the engine uses, or indices drift.
|
||||
- **TODO-5 — QR / deep-link friend codes (owner's idea, Stage 8).** The one-time
|
||||
friend code is entered by hand today. Once the Telegram/native deep-link scheme
|
||||
exists (Stage 9), wrap a code in a deep link and render it as a QR so a friend can
|
||||
add you by scanning rather than typing. The code semantics (12 h TTL, single use,
|
||||
one active per issuer) stay as-is; only the delivery changes.
|
||||
- **TODO-5 — QR friend codes (owner's idea, Stage 8).** *Partially done in Stage 9:*
|
||||
the deep-link scheme now exists (`f<code>`, shared Go ↔ TS), the bot redeems it on
|
||||
launch, and the UI shows a **share-to-Telegram** link for an issued code when
|
||||
`VITE_TELEGRAM_LINK` is configured. **Still open:** render the link as a **QR** so a
|
||||
friend can add you by scanning rather than tapping/typing. The code semantics
|
||||
(12 h TTL, single use, one active per issuer) stay as-is; only the delivery changes.
|
||||
- **TODO-6 — smart default for the friend-game "game type" (owner's idea, Stage 8).**
|
||||
The play-with-friends form has no preselected variant today (an empty, required
|
||||
pick). Default it from the player's history (the variant they play most, from
|
||||
|
||||
+5
-1
@@ -67,7 +67,11 @@ list/incoming, the one-time `code` issue/redeem), `blocks/*`, `invitations/*`
|
||||
`stats`, and `games/:id/gcg` (finished-only). A new `internal/notify` hub feeds a
|
||||
second listener — `internal/pushgrpc`, a gRPC server (`BACKEND_GRPC_ADDR`) streaming
|
||||
live events (your-turn, opponent-moved, chat, nudge, match-found, notify) to the
|
||||
gateway.
|
||||
gateway. Stage 9 adds the gateway-only `POST /api/v1/internal/push-target` (a user's
|
||||
Telegram `external_id`, language and `notifications_in_app_only` flag) that the gateway
|
||||
uses to route out-of-app push to the Telegram connector, extends the Telegram login to
|
||||
seed a new account's language and display name from the launch fields, and adds
|
||||
migration `00007` (`accounts.notifications_in_app_only`, default true).
|
||||
Migration `00005` adds `accounts.is_guest`: an ephemeral guest is a durable row
|
||||
with no identity, excluded from statistics. The shared wire contracts live in the
|
||||
sibling [`../pkg`](../pkg) module.
|
||||
|
||||
@@ -10,7 +10,9 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
"github.com/go-jet/jet/v2/qrm"
|
||||
@@ -56,9 +58,13 @@ type Account struct {
|
||||
BlockFriendRequests bool
|
||||
// IsGuest marks an ephemeral guest account: a durable row with no identity,
|
||||
// excluded from statistics, friends and history.
|
||||
IsGuest bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
IsGuest bool
|
||||
// NotificationsInAppOnly confines notifications to the in-app live stream when
|
||||
// true (the default): the platform side-service skips out-of-app push for the
|
||||
// account (Stage 9).
|
||||
NotificationsInAppOnly bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// Store is the Postgres-backed query surface for accounts and identities.
|
||||
@@ -77,6 +83,22 @@ func NewStore(db *sql.DB) *Store {
|
||||
// resolved by re-reading the winner's account. A platform identity is recorded
|
||||
// as confirmed; an email identity starts unconfirmed.
|
||||
func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
return s.provision(ctx, kind, externalID, provisionSeed{})
|
||||
}
|
||||
|
||||
// ProvisionTelegram provisions (or finds) the account bound to a Telegram
|
||||
// identity. On first contact only, it seeds the new account's preferred language
|
||||
// from the Telegram client languageCode (when it maps to a supported language) and
|
||||
// its display name from firstName (falling back to username); an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode, username, firstName string) (Account, error) {
|
||||
return s.provision(ctx, KindTelegram, externalID, telegramSeed(languageCode, username, firstName))
|
||||
}
|
||||
|
||||
// provision finds the account for (kind, externalID) or creates it with seed,
|
||||
// collapsing a concurrent-create race on the identity unique constraint into a
|
||||
// re-read of the winner's account.
|
||||
func (s *Store) provision(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
|
||||
acc, err := s.findByIdentity(ctx, kind, externalID)
|
||||
if err == nil {
|
||||
return acc, nil
|
||||
@@ -85,7 +107,7 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return Account{}, err
|
||||
}
|
||||
|
||||
acc, err = s.create(ctx, kind, externalID)
|
||||
acc, err = s.create(ctx, kind, externalID, seed)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
// A concurrent caller created the identity first; return theirs.
|
||||
@@ -96,6 +118,35 @@ func (s *Store) ProvisionByIdentity(ctx context.Context, kind, externalID string
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
// provisionSeed carries the optional create-time profile seed for a brand-new
|
||||
// account (Telegram first contact). Empty fields fall back to the accounts table
|
||||
// defaults, so an unknown language keeps the 'en' default and an empty name keeps
|
||||
// the ” default.
|
||||
type provisionSeed struct {
|
||||
preferredLanguage string
|
||||
displayName string
|
||||
}
|
||||
|
||||
// telegramSeed derives the create-time seed from Telegram launch fields: a
|
||||
// supported preferred language from languageCode (an ISO-639 code, possibly
|
||||
// region-tagged like "ru-RU"), and a display name from firstName or, failing that,
|
||||
// username (capped to maxDisplayName runes).
|
||||
func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := strings.TrimSpace(firstName)
|
||||
if name == "" {
|
||||
name = strings.TrimSpace(username)
|
||||
}
|
||||
if utf8.RuneCountInString(name) > maxDisplayName {
|
||||
name = string([]rune(name)[:maxDisplayName])
|
||||
}
|
||||
seed.displayName = name
|
||||
return seed
|
||||
}
|
||||
|
||||
// GetByID loads the account identified by id, or ErrNotFound when it is absent.
|
||||
func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||
stmt := postgres.SELECT(table.Accounts.AllColumns).
|
||||
@@ -113,6 +164,29 @@ func (s *Store) GetByID(ctx context.Context, id uuid.UUID) (Account, error) {
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// IdentityExternalID returns the external_id of the account's identity of the
|
||||
// given kind, or ErrNotFound when the account has no such identity. The Telegram
|
||||
// side-service uses it (through the gateway push-target lookup) to address an
|
||||
// out-of-app notification to a recipient's Telegram chat.
|
||||
func (s *Store) IdentityExternalID(ctx context.Context, accountID uuid.UUID, kind string) (string, error) {
|
||||
stmt := postgres.SELECT(table.Identities.ExternalID).
|
||||
FROM(table.Identities).
|
||||
WHERE(
|
||||
table.Identities.AccountID.EQ(postgres.UUID(accountID)).
|
||||
AND(table.Identities.Kind.EQ(postgres.String(kind))),
|
||||
).
|
||||
LIMIT(1)
|
||||
|
||||
var row model.Identities
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", fmt.Errorf("account: identity external id (%s, %s): %w", accountID, kind, err)
|
||||
}
|
||||
return row.ExternalID, nil
|
||||
}
|
||||
|
||||
// findByIdentity joins identities to accounts and returns the matching account,
|
||||
// or ErrNotFound.
|
||||
func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
@@ -137,9 +211,9 @@ func (s *Store) findByIdentity(ctx context.Context, kind, externalID string) (Ac
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// create inserts a new account and its first identity inside one transaction
|
||||
// and returns the persisted account row.
|
||||
func (s *Store) create(ctx context.Context, kind, externalID string) (Account, error) {
|
||||
// create inserts a new account (seeded from seed) and its first identity inside
|
||||
// one transaction and returns the persisted account row.
|
||||
func (s *Store) create(ctx context.Context, kind, externalID string, seed provisionSeed) (Account, error) {
|
||||
accountID, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
return Account{}, fmt.Errorf("account: new account id: %w", err)
|
||||
@@ -151,9 +225,16 @@ func (s *Store) create(ctx context.Context, kind, externalID string) (Account, e
|
||||
|
||||
var created Account
|
||||
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
|
||||
// Seed the new row's display name and language (Telegram first contact); an
|
||||
// empty seed reproduces the table defaults ('' and 'en') the other callers
|
||||
// relied on, so their behaviour is unchanged.
|
||||
lang := seed.preferredLanguage
|
||||
if lang == "" {
|
||||
lang = "en"
|
||||
}
|
||||
insertAccount := table.Accounts.
|
||||
INSERT(table.Accounts.AccountID).
|
||||
VALUES(accountID).
|
||||
INSERT(table.Accounts.AccountID, table.Accounts.DisplayName, table.Accounts.PreferredLanguage).
|
||||
VALUES(accountID, seed.displayName, lang).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
@@ -230,18 +311,19 @@ func (s *Store) SpendHint(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
// modelToAccount projects a generated model row into the public Account struct.
|
||||
func modelToAccount(row model.Accounts) Account {
|
||||
return Account{
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
ID: row.AccountID,
|
||||
DisplayName: row.DisplayName,
|
||||
PreferredLanguage: row.PreferredLanguage,
|
||||
TimeZone: row.TimeZone,
|
||||
AwayStart: row.AwayStart,
|
||||
AwayEnd: row.AwayEnd,
|
||||
HintBalance: int(row.HintBalance),
|
||||
BlockChat: row.BlockChat,
|
||||
BlockFriendRequests: row.BlockFriendRequests,
|
||||
IsGuest: row.IsGuest,
|
||||
NotificationsInAppOnly: row.NotificationsInAppOnly,
|
||||
CreatedAt: row.CreatedAt,
|
||||
UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,13 +39,14 @@ var ErrInvalidProfile = errors.New("account: invalid profile")
|
||||
// and AwayEnd carry only the hour and minute of the daily away window, in the
|
||||
// account's TimeZone.
|
||||
type ProfileUpdate struct {
|
||||
DisplayName string
|
||||
PreferredLanguage string // "en" or "ru"
|
||||
TimeZone string // an IANA location name
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
DisplayName string
|
||||
PreferredLanguage string // "en" or "ru"
|
||||
TimeZone string // an IANA location name
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
NotificationsInAppOnly bool
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
@@ -71,11 +72,13 @@ func (s *Store) UpdateProfile(ctx context.Context, id uuid.UUID, p ProfileUpdate
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.DisplayName, table.Accounts.PreferredLanguage, table.Accounts.TimeZone,
|
||||
table.Accounts.AwayStart, table.Accounts.AwayEnd,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests, table.Accounts.UpdatedAt,
|
||||
table.Accounts.BlockChat, table.Accounts.BlockFriendRequests,
|
||||
table.Accounts.NotificationsInAppOnly, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
postgres.String(name), postgres.String(lang), postgres.String(tz),
|
||||
postgres.TimeT(p.AwayStart), postgres.TimeT(p.AwayEnd),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests), postgres.TimestampzT(time.Now().UTC()),
|
||||
postgres.Bool(p.BlockChat), postgres.Bool(p.BlockFriendRequests),
|
||||
postgres.Bool(p.NotificationsInAppOnly), postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -104,3 +104,113 @@ func identityConfirmed(t *testing.T, kind, externalID string) bool {
|
||||
}
|
||||
return confirmed
|
||||
}
|
||||
|
||||
// TestProvisionTelegramSeedsNewAccountOnly checks that Telegram first contact
|
||||
// seeds the new account's language and display name from the launch fields,
|
||||
// defaults the in-app-only flag on, and never overwrites an existing account on a
|
||||
// later login (Stage 9 language seeding).
|
||||
func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "ru-RU", "thehandle", "Иван")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.PreferredLanguage != "ru" {
|
||||
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||
}
|
||||
if acc.DisplayName != "Иван" {
|
||||
t.Errorf("DisplayName = %q, want Иван", acc.DisplayName)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Error("NotificationsInAppOnly should default to true")
|
||||
}
|
||||
|
||||
// A later login with different fields returns the same account, unchanged.
|
||||
again, err := store.ProvisionTelegram(ctx, ext, "en", "other", "Other")
|
||||
if err != nil {
|
||||
t.Fatalf("re-provision telegram: %v", err)
|
||||
}
|
||||
if again.ID != acc.ID {
|
||||
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||
}
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q", again.PreferredLanguage, again.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionTelegramUnknownLanguageDefaults checks an unsupported Telegram
|
||||
// client language falls back to the account default rather than failing the
|
||||
// language CHECK.
|
||||
func TestProvisionTelegramUnknownLanguageDefaults(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
acc, err := account.NewStore(testDB).ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "fr", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if acc.PreferredLanguage != "en" {
|
||||
t.Errorf("PreferredLanguage = %q, want default en", acc.PreferredLanguage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdentityExternalID covers the reverse identity lookup the push-target route
|
||||
// uses: it returns the external_id for the matching kind and ErrNotFound otherwise,
|
||||
// including for a guest that carries no identity.
|
||||
func TestIdentityExternalID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "tg-" + uuid.NewString()
|
||||
acc, err := store.ProvisionTelegram(ctx, ext, "en", "", "Tg User")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
got, err := store.IdentityExternalID(ctx, acc.ID, account.KindTelegram)
|
||||
if err != nil {
|
||||
t.Fatalf("identity external id: %v", err)
|
||||
}
|
||||
if got != ext {
|
||||
t.Errorf("external id = %q, want %q", got, ext)
|
||||
}
|
||||
if _, err := store.IdentityExternalID(ctx, acc.ID, account.KindEmail); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("email lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
guest := provisionGuest(t)
|
||||
if _, err := store.IdentityExternalID(ctx, guest, account.KindTelegram); !errors.Is(err, account.ErrNotFound) {
|
||||
t.Errorf("guest lookup = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotificationsInAppOnlyRoundTrip checks the Stage 9 profile flag persists
|
||||
// through UpdateProfile and reads back through GetByID.
|
||||
func TestNotificationsInAppOnlyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
acc, err := store.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Player")
|
||||
if err != nil {
|
||||
t.Fatalf("provision telegram: %v", err)
|
||||
}
|
||||
if !acc.NotificationsInAppOnly {
|
||||
t.Fatal("default should be in-app-only true")
|
||||
}
|
||||
updated, err := store.UpdateProfile(ctx, acc.ID, account.ProfileUpdate{
|
||||
DisplayName: "Player",
|
||||
PreferredLanguage: "en",
|
||||
TimeZone: "UTC",
|
||||
NotificationsInAppOnly: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update profile: %v", err)
|
||||
}
|
||||
if updated.NotificationsInAppOnly {
|
||||
t.Error("update did not clear NotificationsInAppOnly")
|
||||
}
|
||||
got, err := store.GetByID(ctx, acc.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get by id: %v", err)
|
||||
}
|
||||
if got.NotificationsInAppOnly {
|
||||
t.Error("GetByID still reports in-app-only after clearing")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,13 @@ func TestTimeoutSweep(t *testing.T) {
|
||||
}
|
||||
backdate(t, g.ID, time.Now().UTC().Add(-2*time.Hour))
|
||||
|
||||
// Disable the to-move account's away window: with the default 00:00–07: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,
|
||||
// not the count, since other tests leave active games behind.
|
||||
if n, err := svc.SweepTimeouts(ctx, time.Now().UTC()); err != nil || n < 1 {
|
||||
|
||||
@@ -13,16 +13,17 @@ import (
|
||||
)
|
||||
|
||||
type Accounts struct {
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int32
|
||||
IsGuest bool
|
||||
AccountID uuid.UUID `sql:"primary_key"`
|
||||
DisplayName string
|
||||
PreferredLanguage string
|
||||
TimeZone string
|
||||
BlockChat bool
|
||||
BlockFriendRequests bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
AwayStart time.Time
|
||||
AwayEnd time.Time
|
||||
HintBalance int32
|
||||
IsGuest bool
|
||||
NotificationsInAppOnly bool
|
||||
}
|
||||
|
||||
@@ -17,18 +17,19 @@ type accountsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
AccountID postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
PreferredLanguage postgres.ColumnString
|
||||
TimeZone postgres.ColumnString
|
||||
BlockChat postgres.ColumnBool
|
||||
BlockFriendRequests postgres.ColumnBool
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
AwayStart postgres.ColumnTime
|
||||
AwayEnd postgres.ColumnTime
|
||||
HintBalance postgres.ColumnInteger
|
||||
IsGuest postgres.ColumnBool
|
||||
AccountID postgres.ColumnString
|
||||
DisplayName postgres.ColumnString
|
||||
PreferredLanguage postgres.ColumnString
|
||||
TimeZone postgres.ColumnString
|
||||
BlockChat postgres.ColumnBool
|
||||
BlockFriendRequests postgres.ColumnBool
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
AwayStart postgres.ColumnTime
|
||||
AwayEnd postgres.ColumnTime
|
||||
HintBalance postgres.ColumnInteger
|
||||
IsGuest postgres.ColumnBool
|
||||
NotificationsInAppOnly postgres.ColumnBool
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
@@ -70,39 +71,41 @@ func newAccountsTable(schemaName, tableName, alias string) *AccountsTable {
|
||||
|
||||
func newAccountsTableImpl(schemaName, tableName, alias string) accountsTable {
|
||||
var (
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
|
||||
TimeZoneColumn = postgres.StringColumn("time_zone")
|
||||
BlockChatColumn = postgres.BoolColumn("block_chat")
|
||||
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
AwayStartColumn = postgres.TimeColumn("away_start")
|
||||
AwayEndColumn = postgres.TimeColumn("away_end")
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn}
|
||||
AccountIDColumn = postgres.StringColumn("account_id")
|
||||
DisplayNameColumn = postgres.StringColumn("display_name")
|
||||
PreferredLanguageColumn = postgres.StringColumn("preferred_language")
|
||||
TimeZoneColumn = postgres.StringColumn("time_zone")
|
||||
BlockChatColumn = postgres.BoolColumn("block_chat")
|
||||
BlockFriendRequestsColumn = postgres.BoolColumn("block_friend_requests")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
AwayStartColumn = postgres.TimeColumn("away_start")
|
||||
AwayEndColumn = postgres.TimeColumn("away_end")
|
||||
HintBalanceColumn = postgres.IntegerColumn("hint_balance")
|
||||
IsGuestColumn = postgres.BoolColumn("is_guest")
|
||||
NotificationsInAppOnlyColumn = postgres.BoolColumn("notifications_in_app_only")
|
||||
allColumns = postgres.ColumnList{AccountIDColumn, DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
mutableColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
defaultColumns = postgres.ColumnList{DisplayNameColumn, PreferredLanguageColumn, TimeZoneColumn, BlockChatColumn, BlockFriendRequestsColumn, CreatedAtColumn, UpdatedAtColumn, AwayStartColumn, AwayEndColumn, HintBalanceColumn, IsGuestColumn, NotificationsInAppOnlyColumn}
|
||||
)
|
||||
|
||||
return accountsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
AccountID: AccountIDColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
PreferredLanguage: PreferredLanguageColumn,
|
||||
TimeZone: TimeZoneColumn,
|
||||
BlockChat: BlockChatColumn,
|
||||
BlockFriendRequests: BlockFriendRequestsColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
AwayStart: AwayStartColumn,
|
||||
AwayEnd: AwayEndColumn,
|
||||
HintBalance: HintBalanceColumn,
|
||||
IsGuest: IsGuestColumn,
|
||||
AccountID: AccountIDColumn,
|
||||
DisplayName: DisplayNameColumn,
|
||||
PreferredLanguage: PreferredLanguageColumn,
|
||||
TimeZone: TimeZoneColumn,
|
||||
BlockChat: BlockChatColumn,
|
||||
BlockFriendRequests: BlockFriendRequestsColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
AwayStart: AwayStartColumn,
|
||||
AwayEnd: AwayEndColumn,
|
||||
HintBalance: HintBalanceColumn,
|
||||
IsGuest: IsGuestColumn,
|
||||
NotificationsInAppOnly: NotificationsInAppOnlyColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
|
||||
@@ -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;
|
||||
@@ -35,16 +35,17 @@ type resolveResponse struct {
|
||||
// profileResponse is the authenticated account's own profile. AwayStart and AwayEnd
|
||||
// are the daily away window's "HH:MM" local-time bounds (in TimeZone).
|
||||
type profileResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// tileDTO is one placed (or to-place) tile.
|
||||
@@ -148,16 +149,17 @@ func sessionResponseFor(token string, acc account.Account) sessionResponse {
|
||||
// profileResponseFor projects an account into its profile DTO.
|
||||
func profileResponseFor(acc account.Account) profileResponse {
|
||||
return profileResponse{
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
UserID: acc.ID.String(),
|
||||
DisplayName: acc.DisplayName,
|
||||
PreferredLanguage: acc.PreferredLanguage,
|
||||
TimeZone: acc.TimeZone,
|
||||
AwayStart: acc.AwayStart.Format(awayTimeLayout),
|
||||
AwayEnd: acc.AwayEnd.Format(awayTimeLayout),
|
||||
HintBalance: acc.HintBalance,
|
||||
BlockChat: acc.BlockChat,
|
||||
BlockFriendRequests: acc.BlockFriendRequests,
|
||||
IsGuest: acc.IsGuest,
|
||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,10 @@ func (s *Server) registerRoutes() {
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
in.POST("/sessions/resolve", s.handleResolveSession)
|
||||
in.POST("/sessions/revoke", s.handleRevokeSession)
|
||||
// Out-of-app push routing for the platform side-service (Stage 9): the
|
||||
// gateway resolves a recipient's Telegram chat + language + in-app-only flag
|
||||
// before delivering an out-of-app notification.
|
||||
in.POST("/push-target", s.handlePushTarget)
|
||||
}
|
||||
u := s.user
|
||||
if s.accounts != nil {
|
||||
|
||||
@@ -18,13 +18,14 @@ import (
|
||||
// updateProfileRequest is the full editable profile. away_start/away_end are
|
||||
// "HH:MM" local-time bounds of the daily away window.
|
||||
type updateProfileRequest struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// statsDTO is a durable account's lifetime statistics (the derived games-played and
|
||||
@@ -80,13 +81,14 @@ func (s *Server) handleUpdateProfile(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.UpdateProfile(c.Request.Context(), uid, account.ProfileUpdate{
|
||||
DisplayName: req.DisplayName,
|
||||
PreferredLanguage: req.PreferredLanguage,
|
||||
TimeZone: req.TimeZone,
|
||||
AwayStart: awayStart,
|
||||
AwayEnd: awayEnd,
|
||||
BlockChat: req.BlockChat,
|
||||
BlockFriendRequests: req.BlockFriendRequests,
|
||||
DisplayName: req.DisplayName,
|
||||
PreferredLanguage: req.PreferredLanguage,
|
||||
TimeZone: req.TimeZone,
|
||||
AwayStart: awayStart,
|
||||
AwayEnd: awayEnd,
|
||||
BlockChat: req.BlockChat,
|
||||
BlockFriendRequests: req.BlockFriendRequests,
|
||||
NotificationsInAppOnly: req.NotificationsInAppOnly,
|
||||
})
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
@@ -14,21 +16,26 @@ import (
|
||||
// account and mint the opaque session. The backend trusts the gateway on this
|
||||
// segment (docs/ARCHITECTURE.md §12).
|
||||
|
||||
// telegramAuthRequest carries the platform user id the gateway extracted from a
|
||||
// validated initData payload.
|
||||
// telegramAuthRequest carries the identity the connector extracted from a
|
||||
// validated initData payload. Username, FirstName and LanguageCode seed a
|
||||
// brand-new account's display name and language (first contact only).
|
||||
type telegramAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
ExternalID string `json:"external_id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
}
|
||||
|
||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||
// identity and mints a session for it.
|
||||
// identity and mints a session for it, seeding a new account's display name and
|
||||
// language from the supplied Telegram fields.
|
||||
func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
var req telegramAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "external_id is required")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.ProvisionByIdentity(c.Request.Context(), account.KindTelegram, req.ExternalID)
|
||||
acc, err := s.accounts.ProvisionTelegram(c.Request.Context(), req.ExternalID, req.LanguageCode, req.Username, req.FirstName)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
@@ -36,6 +43,53 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// pushTargetRequest asks for a user's out-of-app push routing data by account id.
|
||||
type pushTargetRequest struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
// pushTargetResponse carries what the gateway needs to route an out-of-app push:
|
||||
// the recipient's Telegram external_id (empty when they have no Telegram
|
||||
// identity, e.g. a guest or email-only account), the preferred language for the
|
||||
// message template, and whether they confined notifications to the in-app stream.
|
||||
type pushTargetResponse struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Language string `json:"language"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// handlePushTarget resolves a user id to the data the gateway needs to deliver an
|
||||
// out-of-app Telegram notification — the gateway-only internal counterpart of the
|
||||
// in-app push stream. A user with no Telegram identity yields an empty external_id,
|
||||
// which the gateway treats as "no out-of-app channel".
|
||||
func (s *Server) handlePushTarget(c *gin.Context) {
|
||||
var req pushTargetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.UserID == "" {
|
||||
abortBadRequest(c, "user_id is required")
|
||||
return
|
||||
}
|
||||
uid, err := uuid.Parse(req.UserID)
|
||||
if err != nil {
|
||||
abortBadRequest(c, "user_id must be a uuid")
|
||||
return
|
||||
}
|
||||
acc, err := s.accounts.GetByID(c.Request.Context(), uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
ext, err := s.accounts.IdentityExternalID(c.Request.Context(), uid, account.KindTelegram)
|
||||
if err != nil && !errors.Is(err, account.ErrNotFound) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, pushTargetResponse{
|
||||
ExternalID: ext,
|
||||
Language: acc.PreferredLanguage,
|
||||
NotificationsInAppOnly: acc.NotificationsInAppOnly,
|
||||
})
|
||||
}
|
||||
|
||||
// handleGuestAuth provisions a fresh ephemeral guest account and mints a session.
|
||||
func (s *Server) handleGuestAuth(c *gin.Context) {
|
||||
acc, err := s.accounts.ProvisionGuest(c.Request.Context())
|
||||
|
||||
+42
-18
@@ -43,9 +43,15 @@ Three executables plus per-platform side-services:
|
||||
a server-driven channel later, §10), and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
- **`platform/<name>`** *(planned)* — per-platform side-services (Telegram bot
|
||||
first): deep-link invites and platform-native push notifications. They talk
|
||||
to `backend` over an internal API.
|
||||
- **`platform/telegram`** — the Telegram side-service (the "connector", module
|
||||
`scrabble/platform/telegram`). It is the only component holding the bot token: it
|
||||
runs the Bot API long-poll loop (Mini App launch + `/start` deep-links) and serves
|
||||
a gRPC API (`pkg/proto/telegram/v1`) that `gateway` (Mini App initData validation
|
||||
and out-of-app push) and `backend` (admin messaging — Stage 10) call over the
|
||||
trusted internal network. Its generic delivery methods are **platform-agnostic**
|
||||
(keyed by the identity `external_id`), so a future VK/MAX connector reuses them; only
|
||||
initData validation is Telegram-specific. It runs in its own container, egressing to
|
||||
Telegram through a VPN sidecar.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
@@ -55,7 +61,9 @@ flowchart LR
|
||||
Gateway -- in-app stream --> Client
|
||||
Backend -- pgx --> Postgres[(Postgres)]
|
||||
Backend -. embeds .- Solver[[scrabble-solver library]]
|
||||
Telegram[Telegram bot side-service] -- internal API --> Backend
|
||||
Gateway -- gRPC (validate initData, out-of-app push) --> Telegram[Telegram connector]
|
||||
Backend -. admin gRPC, Stage 10 .-> Telegram
|
||||
Telegram -- Bot API (via VPN sidecar) --> TgCloud((Telegram))
|
||||
```
|
||||
|
||||
The MVP runs `gateway` and `backend` as single-instance processes inside a
|
||||
@@ -92,10 +100,12 @@ Platform-native, deliberately simple: **no Ed25519 client keys, no per-request
|
||||
signing, no anti-replay crypto** (these were considered and dropped — players
|
||||
arrive from a platform rather than completing a mandatory registration).
|
||||
|
||||
- The gateway validates the originating credential **once** — the platform's
|
||||
signed launch data (e.g. Telegram `initData` HMAC), an email-code login, or a
|
||||
guest bootstrap — then mints a **thin opaque server session token**
|
||||
(`session_id`).
|
||||
- The gateway validates the originating credential **once** — Telegram `initData`
|
||||
(delegated to the connector's `ValidateInitData` RPC, which holds the bot token —
|
||||
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
|
||||
bootstrap — then mints a **thin opaque server session token** (`session_id`). First
|
||||
Telegram contact seeds the new account's language (from the launch `language_code`)
|
||||
and display name (§4).
|
||||
- The client holds `session_id` in memory for the app session (browser/OS
|
||||
storage is optional and may be unavailable; losing it means re-login).
|
||||
- The gateway caches `session → user_id` and injects `X-User-ID`. Session
|
||||
@@ -318,7 +328,8 @@ requires (there is no DM surface; chat is per-game).
|
||||
keys are application-generated **UUIDv7**.
|
||||
- Tables: `accounts` (durable internal accounts; Stage 3 added the away-window
|
||||
columns `away_start`/`away_end` and the hint wallet `hint_balance`; Stage 6's
|
||||
migration `00005` added the `is_guest` flag for ephemeral guest rows),
|
||||
migration `00005` added the `is_guest` flag for ephemeral guest rows; Stage 9's
|
||||
migration `00007` added the `notifications_in_app_only` out-of-app push toggle),
|
||||
`identities` (platform/email/robot identities, unique `(kind, external_id)`;
|
||||
Stage 5's migration `00004` admits the `robot` kind),
|
||||
`sessions` (revoke-only opaque-token hashes), the Stage 3 game tables
|
||||
@@ -387,9 +398,16 @@ the backend and forwarded verbatim. A client that is not currently streaming fal
|
||||
back to the matchmaker's `Poll` for match-found and, for the lobby **notification
|
||||
badge** (incoming friend requests + open invitations), the client polls on lobby
|
||||
open and on focus as well as re-polling on the `notify` event — covering a push
|
||||
missed while the app was hidden. Out-of-app platform push (your-turn, nudge) is
|
||||
wired in Stage 9; session-revocation events and cursor-based stream resume are
|
||||
deferred (single-instance MVP).
|
||||
missed while the app was hidden. **Out-of-app platform push** (Stage 9) is a fallback
|
||||
the **gateway** routes from the same firehose: for an event whose recipient has **no
|
||||
live in-app stream** it resolves the backend `/internal/push-target` (their Telegram
|
||||
`external_id`, language, and the `notifications_in_app_only` flag) and asks the
|
||||
**Telegram connector** to deliver a localized message with a Mini App deep-link
|
||||
button — only when the recipient has a Telegram identity and has not confined
|
||||
notifications to the app, so the two channels never duplicate. The out-of-app set is
|
||||
your-turn, nudge, match-found and the invitation / friend-request notify sub-kinds;
|
||||
the connector renders the message and skips the rest. Session-revocation events and
|
||||
cursor-based stream resume stay deferred (single-instance MVP).
|
||||
|
||||
A separate **announcements channel** feeds the client's one-line banner (UI_DESIGN.md).
|
||||
It is a client-side **mock** rotation today; a server-driven source (operational notices,
|
||||
@@ -417,11 +435,12 @@ promotions) is future work and would deliver short markdown messages (text + lin
|
||||
| Concern | Enforced by |
|
||||
| --- | --- |
|
||||
| Public rate limiting / anti-abuse | gateway |
|
||||
| Platform credential validation, session minting | gateway |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram connector; the gateway delegates it over gRPC, so the bot token lives only in the connector |
|
||||
| Session minting; email-code / guest validation | gateway (with backend) |
|
||||
| Session → `user_id` resolution, `X-User-ID` injection | gateway |
|
||||
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
|
||||
| Admin authentication | gateway validates HTTP Basic Auth (`GATEWAY_ADMIN_*`), then reverse-proxies to backend admin endpoints |
|
||||
| backend ↔ gateway trust | the network (only gateway may reach backend) |
|
||||
| backend ↔ gateway ↔ connector trust | the network (only gateway may reach backend; the connector serves unauthenticated gRPC on the internal segment) |
|
||||
|
||||
This is an explicit, accepted MVP risk: compromise of the gateway↔backend
|
||||
network segment defeats backend authentication. Mitigated by network isolation;
|
||||
@@ -438,10 +457,15 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
||||
|
||||
## 13. Deployment (informational)
|
||||
|
||||
Single public origin, path-routed: the UI, the gateway public surface and the
|
||||
admin surface share one host that terminates TLS. MVP runs one `gateway`, one
|
||||
`backend`, one Postgres. Docker/compose environments are introduced when there
|
||||
is something to deploy.
|
||||
Single public origin, path-routed: a mini-landing at the root, the **Telegram Mini
|
||||
App under `/telegram/`** (the gateway serves the static UI build; outside Telegram
|
||||
that path redirects to the root), the gateway public surface and the admin surface
|
||||
share one host that terminates TLS. The **Telegram connector** runs as a separate
|
||||
container with **no public ingress** — it long-polls Telegram and egresses through a
|
||||
VPN sidecar, answering only internal gRPC. MVP runs one `gateway`, one `backend`, one
|
||||
Postgres, plus the connector. The connector's Docker/compose ships now
|
||||
(`platform/telegram/deploy`, mirroring `../15-puzzle`); the full multi-service deploy
|
||||
is Stage 12.
|
||||
|
||||
## 14. CI & branches
|
||||
|
||||
|
||||
+14
-10
@@ -22,15 +22,19 @@ Settings also pick the board's bonus-label style (beginner / classic / none). A
|
||||
costs nothing when the rack has no legal move. The word-check accepts only the
|
||||
variant's alphabet, remembers answers within the session and rate-limits repeats.
|
||||
|
||||
### Identity & sessions *(Stage 1 / 6)*
|
||||
### Identity & sessions *(Stage 1 / 6 / 9)*
|
||||
A player arrives from a platform (Telegram first), via email login, or as an
|
||||
ephemeral guest. The gateway validates the credential once and mints a thin
|
||||
session token; the backend resolves it to an internal `user_id`. Guests are
|
||||
session-only with restricted features (auto-match only; no friends, stats or
|
||||
history). While the app is open the client keeps a live stream and receives
|
||||
in-app updates in real time — the opponent's move, your turn, chat, nudges and a
|
||||
found match; out-of-app push (your turn, nudge) is delivered by the platform
|
||||
later (Stage 9).
|
||||
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini
|
||||
App** launch authenticates from the platform's signed `initData`, themes the UI to
|
||||
the Telegram colours, and — on first contact — seeds the new account's interface
|
||||
language from the Telegram client. Guests are session-only with restricted features
|
||||
(auto-match only; no friends, stats or history). While the app is open the client
|
||||
keeps a live stream and receives in-app updates in real time — the opponent's move,
|
||||
your turn, chat, nudges and a found match. When the app is **closed**, the chosen
|
||||
out-of-app events (your turn, nudge, a found match, an invitation or friend request)
|
||||
arrive as a **Telegram notification** instead — unless the player keeps notifications
|
||||
in the app only (a profile setting, **on by default**).
|
||||
|
||||
### Accounts, linking & merge *(Stage 1 / 10)*
|
||||
First platform contact auto-provisions a durable account. From the profile a
|
||||
@@ -42,9 +46,9 @@ account (stats summed, games/friends transferred).
|
||||
Bottom tab menu: **my games**, **profile**. Auto-match (always 2 players) joins a
|
||||
per-variant pool and is paired with the next waiting human; after 10 s with no
|
||||
human the robot substitutes (the robot arrives in Stage 5). Friend games (2–4) are
|
||||
formed by inviting players from the friend list (deep-link invites arrive with the
|
||||
platform integration): the inviter chooses the settings and the game starts once
|
||||
every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||
formed by inviting players from the friend list (an invitation, like a friend code,
|
||||
is shareable as a Telegram deep link that opens it directly): the inviter chooses the
|
||||
settings and the game starts once every invitee has accepted — any decline cancels it, and an unanswered invitation
|
||||
expires after seven days.
|
||||
|
||||
### Playing a game *(Stage 3)*
|
||||
|
||||
+14
-10
@@ -23,15 +23,19 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
Проверка слова принимает только алфавит варианта, запоминает ответы в рамках сессии
|
||||
и ограничивает частоту повторов.
|
||||
|
||||
### Личность и сессии *(Stage 1 / 6)*
|
||||
### Личность и сессии *(Stage 1 / 6 / 9)*
|
||||
Игрок приходит с платформы (сначала Telegram), через email-вход или как
|
||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Гость —
|
||||
только сессия, с урезанными функциями (только авто-подбор; без друзей,
|
||||
статистики и истории). Пока приложение открыто, клиент держит живой стрим и
|
||||
получает обновления в реальном времени — ход соперника, ваш ход, чат, nudge и
|
||||
найденный матч; внеприложенческий push (ваш ход, nudge) платформа доставит
|
||||
позже (Stage 9).
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
||||
языку Telegram-клиента. Гость — только сессия, с урезанными функциями (только
|
||||
авто-подбор; без друзей, статистики и истории). Пока приложение открыто, клиент
|
||||
держит живой стрим и получает обновления в реальном времени — ход соперника, ваш ход,
|
||||
чат, nudge и найденный матч. Когда приложение **закрыто**, выбранные внеприложенческие
|
||||
события (ваш ход, nudge, найденный матч, приглашение или заявка в друзья) приходят
|
||||
вместо этого **уведомлением в Telegram** — если только игрок не оставил уведомления
|
||||
только в приложении (настройка профиля, **включена по умолчанию**).
|
||||
|
||||
### Аккаунты, привязка и слияние *(Stage 1 / 10)*
|
||||
Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок
|
||||
@@ -43,9 +47,9 @@ session-токен; backend сопоставляет его с внутренн
|
||||
Нижнее tab-меню: **мои игры**, **профиль**. Авто-подбор (всегда 2 игрока)
|
||||
встаёт в пул по варианту и сводится со следующим ожидающим человеком; через 10 с
|
||||
без человека подставляется робот (робот — в Stage 5). Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашения по deep-link
|
||||
появятся с платформенной интеграцией): инициатор выбирает настройки, и партия
|
||||
стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
### Игровой процесс *(Stage 3)*
|
||||
|
||||
+4
-2
@@ -5,8 +5,10 @@ Visual and interaction conventions for the `ui` client. Behaviour lives in
|
||||
points this doc references) lives in [`ARCHITECTURE.md`](ARCHITECTURE.md). The client
|
||||
is **pure HTML5/CSS + Unicode** — no image/font/SVG assets; icons are CSS shapes or
|
||||
emoji glyphs. Tokens are CSS custom properties (`ui/src/app.css`), light/dark via
|
||||
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themeParams-ready**
|
||||
(the tokens can be overridden at runtime).
|
||||
`prefers-color-scheme` or an explicit Settings choice, and **Telegram-themed** (Stage 9):
|
||||
on a Telegram Mini App launch — the app is served under `/telegram/` and detects the
|
||||
launch by `Telegram.WebApp.initData` — the SDK's `themeParams` override the tokens at
|
||||
runtime; opened outside Telegram, the `/telegram/` path redirects to the site root.
|
||||
|
||||
## Layout shell (`components/Screen.svelte`)
|
||||
|
||||
|
||||
+6
-1
@@ -19,7 +19,7 @@ internal/config/ # GATEWAY_* env config
|
||||
internal/backendclient/ # typed REST client (+ X-User-ID) and push gRPC client
|
||||
internal/session/ # in-memory session cache (LRU/TTL, backend fallback)
|
||||
internal/ratelimit/ # token-bucket limiter (golang.org/x/time/rate)
|
||||
internal/auth/ # Telegram initData HMAC validator (seam + fixtures)
|
||||
internal/connector/ # gRPC client to the Telegram connector (initData validate, out-of-app push) + routing
|
||||
internal/push/ # live-event fan-out hub (per-user client streams)
|
||||
internal/transcode/ # FlatBuffers<->REST bridge + message_type registry
|
||||
internal/connectsrv/ # the Connect Gateway service over h2c
|
||||
@@ -39,6 +39,11 @@ operations are unauthenticated and return the minted token. A unary domain
|
||||
outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge
|
||||
failures become Connect error codes.
|
||||
|
||||
`auth.telegram` validates the Mini App `initData` by calling the **Telegram connector**
|
||||
(`GATEWAY_CONNECTOR_ADDR`), which holds the bot token; the gateway also routes
|
||||
out-of-app push to that connector for recipients with no live in-app stream
|
||||
(ARCHITECTURE.md §10). When `GATEWAY_CONNECTOR_ADDR` is unset, both are disabled.
|
||||
|
||||
The Stage 6 message-type slice: `auth.telegram`, `auth.guest`,
|
||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`; live events
|
||||
|
||||
+43
-10
@@ -18,9 +18,9 @@ import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/gateway/internal/admin"
|
||||
"scrabble/gateway/internal/auth"
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/config"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/connectsrv"
|
||||
"scrabble/gateway/internal/push"
|
||||
"scrabble/gateway/internal/ratelimit"
|
||||
@@ -73,14 +73,20 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
limiter := ratelimit.New()
|
||||
hub := push.NewHub(0)
|
||||
|
||||
var tg auth.TelegramValidator
|
||||
if cfg.TelegramBotToken != "" {
|
||||
tg = auth.NewHMACValidator(cfg.TelegramBotToken)
|
||||
var conn *connector.Client
|
||||
var validator transcode.TelegramValidator
|
||||
if cfg.ConnectorAddr != "" {
|
||||
conn, err = connector.New(cfg.ConnectorAddr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
validator = conn
|
||||
} else {
|
||||
logger.Warn("telegram auth disabled (GATEWAY_TELEGRAM_BOT_TOKEN unset)")
|
||||
logger.Warn("telegram disabled (GATEWAY_CONNECTOR_ADDR unset)")
|
||||
}
|
||||
|
||||
registry := transcode.NewRegistry(backend, tg)
|
||||
registry := transcode.NewRegistry(backend, validator)
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
@@ -91,8 +97,9 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
Logger: logger,
|
||||
})
|
||||
|
||||
// Bridge the backend push stream into the fan-out hub.
|
||||
go runPushPump(ctx, backend, hub, logger)
|
||||
// Bridge the backend push stream into the fan-out hub (and the out-of-app
|
||||
// channel via the connector).
|
||||
go runPushPump(ctx, backend, hub, conn, logger)
|
||||
|
||||
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler()}
|
||||
servers := []*namedServer{{name: "public", srv: public}}
|
||||
@@ -153,8 +160,10 @@ func runServers(ctx context.Context, cancel context.CancelFunc, servers []*named
|
||||
}
|
||||
|
||||
// runPushPump keeps a backend push subscription open, forwarding every event to
|
||||
// the hub and re-subscribing after the stream ends, until the context is done.
|
||||
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, logger *zap.Logger) {
|
||||
// the hub and re-subscribing after the stream ends, until the context is done. For
|
||||
// the out-of-app push kinds it also routes events whose recipient has no live
|
||||
// in-app stream to the platform connector (a nil connector disables that channel).
|
||||
func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.Hub, conn *connector.Client, logger *zap.Logger) {
|
||||
for ctx.Err() == nil {
|
||||
stream, err := backend.SubscribePush(ctx, gatewayID)
|
||||
if err != nil {
|
||||
@@ -178,6 +187,12 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
|
||||
Payload: ev.GetPayload(),
|
||||
EventID: ev.GetEventId(),
|
||||
})
|
||||
// Out-of-app fallback: when the recipient has no live in-app stream,
|
||||
// deliver the event over the platform push channel. Done in a goroutine
|
||||
// so a slow connector never stalls the in-app firehose.
|
||||
if conn != nil && connector.OutOfAppKind(ev.GetKind()) && !hub.HasSubscribers(ev.GetUserId()) {
|
||||
go deliverOutOfApp(ctx, backend, conn, ev.GetUserId(), ev.GetKind(), ev.GetPayload(), logger)
|
||||
}
|
||||
}
|
||||
if !sleep(ctx, pushReconnectDelay) {
|
||||
return
|
||||
@@ -185,6 +200,24 @@ func runPushPump(ctx context.Context, backend *backendclient.Client, hub *push.H
|
||||
}
|
||||
}
|
||||
|
||||
// deliverOutOfApp resolves the recipient's push target and, when they have a
|
||||
// Telegram identity and have not confined notifications to the app, asks the
|
||||
// connector to deliver the event. It is best-effort: every failure is logged and
|
||||
// dropped (the in-app stream remains the primary channel).
|
||||
func deliverOutOfApp(ctx context.Context, backend *backendclient.Client, conn *connector.Client, userID, kind string, payload []byte, logger *zap.Logger) {
|
||||
target, err := backend.PushTarget(ctx, userID)
|
||||
if err != nil {
|
||||
logger.Warn("push target lookup failed", zap.String("user_id", userID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if !connector.DeliverToTarget(target.ExternalID, target.NotificationsInAppOnly) {
|
||||
return
|
||||
}
|
||||
if _, err := conn.Notify(ctx, target.ExternalID, kind, payload, target.Language); err != nil {
|
||||
logger.Warn("out-of-app notify failed", zap.String("kind", kind), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// sleep waits for d or until ctx is cancelled, reporting whether it waited the
|
||||
// full duration.
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -20,16 +20,17 @@ type SessionResp struct {
|
||||
|
||||
// ProfileResp is an account's own profile.
|
||||
type ProfileResp struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
UserID string `json:"user_id"`
|
||||
DisplayName string `json:"display_name"`
|
||||
PreferredLanguage string `json:"preferred_language"`
|
||||
TimeZone string `json:"time_zone"`
|
||||
AwayStart string `json:"away_start"`
|
||||
AwayEnd string `json:"away_end"`
|
||||
HintBalance int `json:"hint_balance"`
|
||||
BlockChat bool `json:"block_chat"`
|
||||
BlockFriendRequests bool `json:"block_friend_requests"`
|
||||
IsGuest bool `json:"is_guest"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// TileJSON is one placed tile, used in both play requests and move responses.
|
||||
@@ -109,11 +110,35 @@ type ChatResp struct {
|
||||
CreatedAtUnix int64 `json:"created_at_unix"`
|
||||
}
|
||||
|
||||
// TelegramAuth provisions/finds the Telegram account and mints a session.
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID string) (SessionResp, error) {
|
||||
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a
|
||||
// brand-new account's display name and language from the validated launch fields.
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||
map[string]string{"external_id": externalID}, &out)
|
||||
map[string]string{
|
||||
"external_id": externalID,
|
||||
"language_code": languageCode,
|
||||
"username": username,
|
||||
"first_name": firstName,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// PushTargetResp is a recipient's out-of-app push routing data: their Telegram
|
||||
// external_id (empty when they have no Telegram identity), preferred language, and
|
||||
// whether they confined notifications to the in-app stream.
|
||||
type PushTargetResp struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Language string `json:"language"`
|
||||
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
|
||||
}
|
||||
|
||||
// PushTarget resolves a user id to their out-of-app Telegram routing data (the
|
||||
// gateway uses it to decide whether to deliver an event over platform push).
|
||||
func (c *Client) PushTarget(ctx context.Context, userID string) (PushTargetResp, error) {
|
||||
var out PushTargetResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/push-target", "", "",
|
||||
map[string]string{"user_id": userID}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -215,13 +215,14 @@ func (c *Client) ListInvitations(ctx context.Context, userID string) (Invitation
|
||||
func (c *Client) UpdateProfile(ctx context.Context, userID string, p ProfileResp) (ProfileResp, error) {
|
||||
var out ProfileResp
|
||||
body := map[string]any{
|
||||
"display_name": p.DisplayName,
|
||||
"preferred_language": p.PreferredLanguage,
|
||||
"time_zone": p.TimeZone,
|
||||
"away_start": p.AwayStart,
|
||||
"away_end": p.AwayEnd,
|
||||
"block_chat": p.BlockChat,
|
||||
"block_friend_requests": p.BlockFriendRequests,
|
||||
"display_name": p.DisplayName,
|
||||
"preferred_language": p.PreferredLanguage,
|
||||
"time_zone": p.TimeZone,
|
||||
"away_start": p.AwayStart,
|
||||
"away_end": p.AwayEnd,
|
||||
"block_chat": p.BlockChat,
|
||||
"block_friend_requests": p.BlockFriendRequests,
|
||||
"notifications_in_app_only": p.NotificationsInAppOnly,
|
||||
}
|
||||
err := c.do(ctx, http.MethodPut, "/api/v1/user/profile", userID, "", body, &out)
|
||||
return out, err
|
||||
|
||||
@@ -28,9 +28,10 @@ type Config struct {
|
||||
// checks before proxying admin traffic to the backend. Empty disables admin.
|
||||
AdminUser string
|
||||
AdminPassword string
|
||||
// TelegramBotToken is the secret used to validate Telegram initData HMACs.
|
||||
// Empty disables the telegram auth path.
|
||||
TelegramBotToken string
|
||||
// ConnectorAddr is the gRPC address of the Telegram connector side-service. The
|
||||
// gateway calls it to validate Mini App initData and to deliver out-of-app push.
|
||||
// Empty disables the telegram auth path and the out-of-app push channel.
|
||||
ConnectorAddr string
|
||||
// SessionTTL bounds how long a resolved session stays cached; SessionCacheMax
|
||||
// caps the number of cached sessions.
|
||||
SessionTTL time.Duration
|
||||
@@ -83,16 +84,16 @@ func DefaultRateLimit() RateLimitConfig {
|
||||
func Load() (Config, error) {
|
||||
var err error
|
||||
c := Config{
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
TelegramBotToken: os.Getenv("GATEWAY_TELEGRAM_BOT_TOKEN"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
HTTPAddr: envOr("GATEWAY_HTTP_ADDR", defaultHTTPAddr),
|
||||
AdminAddr: envOr("GATEWAY_ADMIN_ADDR", defaultAdminAddr),
|
||||
LogLevel: envOr("GATEWAY_LOG_LEVEL", defaultLogLevel),
|
||||
BackendHTTPURL: envOr("GATEWAY_BACKEND_HTTP_URL", defaultBackendHTTPURL),
|
||||
BackendGRPCAddr: envOr("GATEWAY_BACKEND_GRPC_ADDR", defaultBackendGRPCAddr),
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ConnectorAddr: os.Getenv("GATEWAY_CONNECTOR_ADDR"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
}
|
||||
if c.BackendTimeout, err = envDuration("GATEWAY_BACKEND_TIMEOUT", defaultBackendTimeout); err != nil {
|
||||
return Config{}, err
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -86,3 +86,18 @@ func (h *Hub) SubscriberCount() int {
|
||||
defer h.mu.Unlock()
|
||||
return len(h.subs)
|
||||
}
|
||||
|
||||
// HasSubscribers reports whether any live client stream is registered for userID.
|
||||
// It gates out-of-app push: an online user is already reached in-app, so the
|
||||
// platform push (Telegram) is skipped for them — keeping the fallback channel free
|
||||
// of duplicates.
|
||||
func (h *Hub) HasSubscribers(userID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for _, s := range h.subs {
|
||||
if s.userID == userID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -54,3 +54,21 @@ func TestHubUnsubscribeClosesChannel(t *testing.T) {
|
||||
}
|
||||
h.Publish(push.Event{UserID: "u"}) // must not panic
|
||||
}
|
||||
|
||||
func TestHubHasSubscribers(t *testing.T) {
|
||||
h := push.NewHub(2)
|
||||
if h.HasSubscribers("u") {
|
||||
t.Fatal("no subscribers yet")
|
||||
}
|
||||
_, cancel := h.Subscribe("u")
|
||||
if !h.HasSubscribers("u") {
|
||||
t.Error("u should be reported online after Subscribe")
|
||||
}
|
||||
if h.HasSubscribers("other") {
|
||||
t.Error("other has no subscription")
|
||||
}
|
||||
cancel()
|
||||
if h.HasSubscribers("u") {
|
||||
t.Error("u should be offline after unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ func encodeProfile(p backendclient.ProfileResp) []byte {
|
||||
fb.ProfileAddIsGuest(b, p.IsGuest)
|
||||
fb.ProfileAddAwayStart(b, awayStart)
|
||||
fb.ProfileAddAwayEnd(b, awayEnd)
|
||||
fb.ProfileAddNotificationsInAppOnly(b, p.NotificationsInAppOnly)
|
||||
b.Finish(fb.ProfileEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"scrabble/gateway/internal/auth"
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/connector"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
@@ -63,10 +63,16 @@ type Registry struct {
|
||||
ops map[string]Op
|
||||
}
|
||||
|
||||
// TelegramValidator validates Mini App launch data via the connector side-service.
|
||||
// *connector.Client implements it; a nil value disables the telegram auth path.
|
||||
type TelegramValidator interface {
|
||||
ValidateInitData(ctx context.Context, initData string) (connector.User, error)
|
||||
}
|
||||
|
||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||
// The Telegram auth op is registered only when a validator is supplied (a bot
|
||||
// token is configured); otherwise auth.telegram is simply unknown.
|
||||
func NewRegistry(backend *backendclient.Client, tg auth.TelegramValidator) *Registry {
|
||||
// The Telegram auth op is registered only when a validator is supplied (the
|
||||
// connector is configured); otherwise auth.telegram is simply unknown.
|
||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry {
|
||||
r := &Registry{ops: make(map[string]Op)}
|
||||
if tg != nil {
|
||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||
@@ -109,20 +115,20 @@ func DomainCode(err error) (string, bool) {
|
||||
if errors.As(err, &apiErr) {
|
||||
return apiErr.Code, true
|
||||
}
|
||||
if errors.Is(err, auth.ErrInvalidInitData) {
|
||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||
return "invalid_init_data", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func authTelegramHandler(backend *backendclient.Client, tg auth.TelegramValidator) Handler {
|
||||
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
|
||||
user, err := tg.Validate(string(in.InitData()))
|
||||
user, err := tg.ValidateInitData(ctx, string(in.InitData()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.TelegramAuth(ctx, user.ID)
|
||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -233,13 +233,14 @@ func profileUpdateHandler(backend *backendclient.Client) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsUpdateProfileRequest(req.Payload, 0)
|
||||
p := backendclient.ProfileResp{
|
||||
DisplayName: string(in.DisplayName()),
|
||||
PreferredLanguage: string(in.PreferredLanguage()),
|
||||
TimeZone: string(in.TimeZone()),
|
||||
AwayStart: string(in.AwayStart()),
|
||||
AwayEnd: string(in.AwayEnd()),
|
||||
BlockChat: in.BlockChat(),
|
||||
BlockFriendRequests: in.BlockFriendRequests(),
|
||||
DisplayName: string(in.DisplayName()),
|
||||
PreferredLanguage: string(in.PreferredLanguage()),
|
||||
TimeZone: string(in.TimeZone()),
|
||||
AwayStart: string(in.AwayStart()),
|
||||
AwayEnd: string(in.AwayEnd()),
|
||||
BlockChat: in.BlockChat(),
|
||||
BlockFriendRequests: in.BlockFriendRequests(),
|
||||
NotificationsInAppOnly: in.NotificationsInAppOnly(),
|
||||
}
|
||||
out, err := backend.UpdateProfile(ctx, req.UserID, p)
|
||||
if err != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
@@ -202,11 +203,15 @@ func TestGcgRoundTrip(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProfileUpdateRoundTripAway(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
backend, cleanup := fakeBackend(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut || r.URL.Path != "/api/v1/user/profile" {
|
||||
t.Errorf("unexpected %s %q", r.Method, r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"ru","time_zone":"Europe/Moscow","away_start":"00:00","away_end":"07:30"}`))
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
// Respond with notifications_in_app_only=false to exercise the encode path
|
||||
// carrying a non-default value back to the client.
|
||||
_, _ = w.Write([]byte(`{"user_id":"u-1","display_name":"Kaya","preferred_language":"ru","time_zone":"Europe/Moscow","away_start":"00:00","away_end":"07:30","notifications_in_app_only":false}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
@@ -225,6 +230,7 @@ func TestProfileUpdateRoundTripAway(t *testing.T) {
|
||||
fb.UpdateProfileRequestAddTimeZone(b, tz)
|
||||
fb.UpdateProfileRequestAddAwayStart(b, as)
|
||||
fb.UpdateProfileRequestAddAwayEnd(b, ae)
|
||||
fb.UpdateProfileRequestAddNotificationsInAppOnly(b, true)
|
||||
b.Finish(fb.UpdateProfileRequestEnd(b))
|
||||
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{UserID: "u-1", Payload: b.FinishedBytes()})
|
||||
@@ -235,4 +241,12 @@ func TestProfileUpdateRoundTripAway(t *testing.T) {
|
||||
if string(p.AwayStart()) != "00:00" || string(p.AwayEnd()) != "07:30" || string(p.PreferredLanguage()) != "ru" {
|
||||
t.Fatalf("profile away round-trip wrong: start=%q end=%q lang=%q", p.AwayStart(), p.AwayEnd(), p.PreferredLanguage())
|
||||
}
|
||||
// The request's in-app-only flag (true) must reach the backend, and the backend's
|
||||
// value (false) must come back in the encoded Profile.
|
||||
if v, ok := gotBody["notifications_in_app_only"].(bool); !ok || v != true {
|
||||
t.Errorf("forwarded notifications_in_app_only = %v (ok=%v), want true", gotBody["notifications_in_app_only"], ok)
|
||||
}
|
||||
if p.NotificationsInAppOnly() {
|
||||
t.Error("response notifications_in_app_only = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ use ./backend
|
||||
use (
|
||||
./gateway
|
||||
./pkg
|
||||
./platform/telegram
|
||||
)
|
||||
|
||||
// The scrabble-solver engine is consumed in-process as a library. Its module
|
||||
|
||||
+2
-5
@@ -1,7 +1,5 @@
|
||||
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
|
||||
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.45.0/go.mod h1:giJfUVlMkcfUEPVfRpt51zZaGEx9i17gCos8gBl392c=
|
||||
@@ -23,6 +21,8 @@ github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6v
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-telegram/bot v1.21.0 h1:Va/PbGc2vBDdv57GCUEEVV6ROlHWiC6SklJY9Hvhzps=
|
||||
github.com/go-telegram/bot v1.21.0/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||
@@ -81,12 +81,9 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:p3MLuOwURrGBRoEyFHBT3GjUwaCQVKeNqqWxlcISGdw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260420184626-e10c466a9529/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
|
||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||
|
||||
@@ -104,7 +104,9 @@ table Ack {
|
||||
// --- profile (authenticated) ---
|
||||
|
||||
// Profile is the authenticated account's own profile view. away_start/away_end are
|
||||
// the "HH:MM" daily away-window bounds (added trailing — backward-compatible).
|
||||
// the "HH:MM" daily away-window bounds. notifications_in_app_only (default true)
|
||||
// suppresses out-of-app platform push, leaving only the in-app live stream (both
|
||||
// added trailing — backward-compatible).
|
||||
table Profile {
|
||||
user_id:string;
|
||||
display_name:string;
|
||||
@@ -116,6 +118,7 @@ table Profile {
|
||||
is_guest:bool;
|
||||
away_start:string;
|
||||
away_end:string;
|
||||
notifications_in_app_only:bool = true;
|
||||
}
|
||||
|
||||
// --- game (authenticated) ---
|
||||
@@ -256,6 +259,8 @@ table AccountRef {
|
||||
|
||||
// UpdateProfileRequest overwrites the full editable profile (the client sends the
|
||||
// complete desired profile). away_start/away_end are "HH:MM" bounds.
|
||||
// notifications_in_app_only (trailing — backward-compatible) toggles out-of-app
|
||||
// platform push off when set.
|
||||
table UpdateProfileRequest {
|
||||
display_name:string;
|
||||
preferred_language:string;
|
||||
@@ -264,6 +269,7 @@ table UpdateProfileRequest {
|
||||
away_end:string;
|
||||
block_chat:bool;
|
||||
block_friend_requests:bool;
|
||||
notifications_in_app_only:bool = true;
|
||||
}
|
||||
|
||||
// EmailBindRequest asks the backend to send a confirm-code binding email to the
|
||||
|
||||
@@ -137,8 +137,20 @@ func (rcv *Profile) AwayEnd() []byte {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *Profile) NotificationsInAppOnly() bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(24))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (rcv *Profile) MutateNotificationsInAppOnly(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(24, n)
|
||||
}
|
||||
|
||||
func ProfileStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(10)
|
||||
builder.StartObject(11)
|
||||
}
|
||||
func ProfileAddUserId(builder *flatbuffers.Builder, userId flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(userId), 0)
|
||||
@@ -170,6 +182,9 @@ func ProfileAddAwayStart(builder *flatbuffers.Builder, awayStart flatbuffers.UOf
|
||||
func ProfileAddAwayEnd(builder *flatbuffers.Builder, awayEnd flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(9, flatbuffers.UOffsetT(awayEnd), 0)
|
||||
}
|
||||
func ProfileAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
|
||||
builder.PrependBoolSlot(10, notificationsInAppOnly, true)
|
||||
}
|
||||
func ProfileEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -105,8 +105,20 @@ func (rcv *UpdateProfileRequest) MutateBlockFriendRequests(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(16, n)
|
||||
}
|
||||
|
||||
func (rcv *UpdateProfileRequest) NotificationsInAppOnly() bool {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(18))
|
||||
if o != 0 {
|
||||
return rcv._tab.GetBool(o + rcv._tab.Pos)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (rcv *UpdateProfileRequest) MutateNotificationsInAppOnly(n bool) bool {
|
||||
return rcv._tab.MutateBoolSlot(18, n)
|
||||
}
|
||||
|
||||
func UpdateProfileRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(7)
|
||||
builder.StartObject(8)
|
||||
}
|
||||
func UpdateProfileRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(displayName), 0)
|
||||
@@ -129,6 +141,9 @@ func UpdateProfileRequestAddBlockChat(builder *flatbuffers.Builder, blockChat bo
|
||||
func UpdateProfileRequestAddBlockFriendRequests(builder *flatbuffers.Builder, blockFriendRequests bool) {
|
||||
builder.PrependBoolSlot(6, blockFriendRequests, false)
|
||||
}
|
||||
func UpdateProfileRequestAddNotificationsInAppOnly(builder *flatbuffers.Builder, notificationsInAppOnly bool) {
|
||||
builder.PrependBoolSlot(7, notificationsInAppOnly, true)
|
||||
}
|
||||
func UpdateProfileRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
+36
-32
@@ -1,8 +1,8 @@
|
||||
// Package auth holds the gateway's credential validators. The only non-trivial
|
||||
// one is the Telegram Web App initData HMAC check; guest and email logins carry
|
||||
// no gateway-side secret and are validated by the backend. The validator is an
|
||||
// interface so handlers test against fixtures without a bot token.
|
||||
package auth
|
||||
// Package initdata validates Telegram Mini App launch data (initData). It lives in
|
||||
// the connector because the HMAC secret is the bot token, which is held only here
|
||||
// (ARCHITECTURE.md §12); the gateway calls the connector's ValidateInitData RPC
|
||||
// instead of validating the launch data itself.
|
||||
package initdata
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
@@ -19,23 +19,25 @@ import (
|
||||
|
||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||
// the hash, is malformed, or is older than the freshness window.
|
||||
var ErrInvalidInitData = errors.New("auth: invalid telegram init data")
|
||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||
|
||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||
const defaultMaxAge = 24 * time.Hour
|
||||
|
||||
// TelegramUser is the identity extracted from a validated initData payload. ID
|
||||
// is the platform user id used as the identity's external_id.
|
||||
type TelegramUser struct {
|
||||
ID string
|
||||
Username string
|
||||
FirstName string
|
||||
// User is the identity extracted from a validated initData payload. ExternalID is
|
||||
// the Telegram user id used as the identities external_id; LanguageCode seeds a
|
||||
// new account's preferred language (Stage 9).
|
||||
type User struct {
|
||||
ExternalID string
|
||||
Username string
|
||||
FirstName string
|
||||
LanguageCode string
|
||||
}
|
||||
|
||||
// TelegramValidator validates Telegram Web App launch data and returns the
|
||||
// authenticated user.
|
||||
type TelegramValidator interface {
|
||||
Validate(initData string) (TelegramUser, error)
|
||||
// Validator validates Telegram Web App launch data and returns the authenticated
|
||||
// user. It is an interface so the connector can be tested with a fixture.
|
||||
type Validator interface {
|
||||
Validate(initData string) (User, error)
|
||||
}
|
||||
|
||||
// HMACValidator validates initData against a bot token per Telegram's documented
|
||||
@@ -53,22 +55,22 @@ func NewHMACValidator(botToken string) *HMACValidator {
|
||||
}
|
||||
|
||||
// Validate parses and verifies initData, returning the authenticated user.
|
||||
func (v *HMACValidator) Validate(initData string) (TelegramUser, error) {
|
||||
func (v *HMACValidator) Validate(initData string) (User, error) {
|
||||
values, err := url.ParseQuery(initData)
|
||||
if err != nil {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
hash := values.Get("hash")
|
||||
if hash == "" {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
values.Del("hash")
|
||||
|
||||
if !v.checkSignature(values, hash) {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
if err := v.checkFreshness(values.Get("auth_date")); err != nil {
|
||||
return TelegramUser{}, err
|
||||
return User{}, err
|
||||
}
|
||||
return parseUser(values.Get("user"))
|
||||
}
|
||||
@@ -111,23 +113,25 @@ func (v *HMACValidator) checkFreshness(authDate string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseUser extracts the user id and names from the user JSON field.
|
||||
func parseUser(userJSON string) (TelegramUser, error) {
|
||||
// parseUser extracts the user id, names and language from the user JSON field.
|
||||
func parseUser(userJSON string) (User, error) {
|
||||
if userJSON == "" {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||
return TelegramUser{}, ErrInvalidInitData
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
return TelegramUser{
|
||||
ID: strconv.FormatInt(u.ID, 10),
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
return User{
|
||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||
Username: u.Username,
|
||||
FirstName: u.FirstName,
|
||||
LanguageCode: u.LanguageCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
// transport (no backend). These lock the round-1..4 interactions so future UI edits
|
||||
|
||||
@@ -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
|
||||
// seeded active game -> open it -> the board renders committed tiles -> place a rack
|
||||
|
||||
@@ -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).
|
||||
// The mock profile is a durable account, so friends, invitations, stats and the GCG
|
||||
|
||||
@@ -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
@@ -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).
|
||||
// cqw is sized against the zoom-scaled board, so the font grows with the cells.
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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
|
||||
to fight our two-state zoom. viewport-fit=cover for native (Capacitor). -->
|
||||
<meta
|
||||
|
||||
@@ -82,8 +82,13 @@ awayEnd(optionalEncoding?:any):string|Uint8Array|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) {
|
||||
builder.startObject(10);
|
||||
builder.startObject(11);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
|
||||
builder.addFieldInt8(10, +notificationsInAppOnly, +true);
|
||||
}
|
||||
|
||||
static endProfile(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
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.addUserId(builder, userIdOffset);
|
||||
Profile.addDisplayName(builder, displayNameOffset);
|
||||
@@ -143,6 +152,7 @@ static createProfile(builder:flatbuffers.Builder, userIdOffset:flatbuffers.Offse
|
||||
Profile.addIsGuest(builder, isGuest);
|
||||
Profile.addAwayStart(builder, awayStartOffset);
|
||||
Profile.addAwayEnd(builder, awayEndOffset);
|
||||
Profile.addNotificationsInAppOnly(builder, notificationsInAppOnly);
|
||||
return Profile.endProfile(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,13 @@ blockFriendRequests():boolean {
|
||||
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) {
|
||||
builder.startObject(7);
|
||||
builder.startObject(8);
|
||||
}
|
||||
|
||||
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||
@@ -97,12 +102,16 @@ static addBlockFriendRequests(builder:flatbuffers.Builder, blockFriendRequests:b
|
||||
builder.addFieldInt8(6, +blockFriendRequests, +false);
|
||||
}
|
||||
|
||||
static addNotificationsInAppOnly(builder:flatbuffers.Builder, notificationsInAppOnly:boolean) {
|
||||
builder.addFieldInt8(7, +notificationsInAppOnly, +true);
|
||||
}
|
||||
|
||||
static endUpdateProfileRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
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.addDisplayName(builder, displayNameOffset);
|
||||
UpdateProfileRequest.addPreferredLanguage(builder, preferredLanguageOffset);
|
||||
@@ -111,6 +120,7 @@ static createUpdateProfileRequest(builder:flatbuffers.Builder, displayNameOffset
|
||||
UpdateProfileRequest.addAwayEnd(builder, awayEndOffset);
|
||||
UpdateProfileRequest.addBlockChat(builder, blockChat);
|
||||
UpdateProfileRequest.addBlockFriendRequests(builder, blockFriendRequests);
|
||||
UpdateProfileRequest.addNotificationsInAppOnly(builder, notificationsInAppOnly);
|
||||
return UpdateProfileRequest.endUpdateProfileRequest(builder);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import { gateway } from './gateway';
|
||||
import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.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 type { BoardLabelMode } from './boardlabels';
|
||||
|
||||
@@ -144,6 +146,28 @@ export async function bootstrap(): Promise<void> {
|
||||
setLocale(guess);
|
||||
}
|
||||
|
||||
// Telegram Mini App launch: apply the platform theme, authenticate via initData,
|
||||
// and route any deep-link start parameter. On the dedicated /telegram/ entry path
|
||||
// outside Telegram (no initData), refuse to render and send the visitor to the
|
||||
// site root.
|
||||
if (onTelegramPath() && !insideTelegram()) {
|
||||
if (typeof location !== 'undefined') location.replace('/');
|
||||
return;
|
||||
}
|
||||
if (insideTelegram()) {
|
||||
const launch = telegramLaunch();
|
||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
||||
try {
|
||||
await adoptSession(await gateway.authTelegram(launch.initData));
|
||||
await routeStartParam(launch.startParam);
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
navigate('/login');
|
||||
}
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await loadSession();
|
||||
if (saved) {
|
||||
await adoptSession(saved);
|
||||
@@ -154,6 +178,32 @@ export async function bootstrap(): Promise<void> {
|
||||
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> {
|
||||
try {
|
||||
const s = await gateway.authGuest(app.locale);
|
||||
@@ -233,6 +283,7 @@ async function persistLanguageToServer(locale: Locale): Promise<void> {
|
||||
awayEnd: p.awayEnd,
|
||||
blockChat: p.blockChat,
|
||||
blockFriendRequests: p.blockFriendRequests,
|
||||
notificationsInAppOnly: p.notificationsInAppOnly,
|
||||
});
|
||||
} catch {
|
||||
// The client locale already changed; the server sync is best-effort.
|
||||
|
||||
@@ -52,6 +52,7 @@ export type Unsubscribe = () => void;
|
||||
|
||||
export interface GatewayClient {
|
||||
// --- auth (unauthenticated) ---
|
||||
authTelegram(initData: string): Promise<Session>;
|
||||
authGuest(locale?: string): Promise<Session>;
|
||||
authEmailRequest(email: string): Promise<void>;
|
||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||
|
||||
@@ -146,6 +146,14 @@ export function encodeChatPost(gameId: string, body: string): Uint8Array {
|
||||
return finish(b, fb.ChatPostRequest.endChatPostRequest(b));
|
||||
}
|
||||
|
||||
export function encodeTelegramLogin(initData: string): Uint8Array {
|
||||
const b = new Builder(512);
|
||||
const d = b.createString(initData);
|
||||
fb.TelegramLoginRequest.startTelegramLoginRequest(b);
|
||||
fb.TelegramLoginRequest.addInitData(b, d);
|
||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||
}
|
||||
|
||||
export function encodeGuestLogin(locale: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const l = b.createString(locale);
|
||||
@@ -264,6 +272,7 @@ export function decodeProfile(buf: Uint8Array): Profile {
|
||||
blockChat: p.blockChat(),
|
||||
blockFriendRequests: p.blockFriendRequests(),
|
||||
isGuest: p.isGuest(),
|
||||
notificationsInAppOnly: p.notificationsInAppOnly(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -444,6 +453,7 @@ export function encodeUpdateProfile(p: ProfileUpdate): Uint8Array {
|
||||
fb.UpdateProfileRequest.addAwayEnd(b, ae);
|
||||
fb.UpdateProfileRequest.addBlockChat(b, p.blockChat);
|
||||
fb.UpdateProfileRequest.addBlockFriendRequests(b, p.blockFriendRequests);
|
||||
fb.UpdateProfileRequest.addNotificationsInAppOnly(b, p.notificationsInAppOnly);
|
||||
return finish(b, fb.UpdateProfileRequest.endUpdateProfileRequest(b));
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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)}`;
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export const en = {
|
||||
'profile.to': 'To',
|
||||
'profile.blockChat': 'Disable chat',
|
||||
'profile.blockFriendRequests': 'Disable friend requests',
|
||||
'profile.notificationsInAppOnly': 'Notifications in the app only',
|
||||
'profile.email': 'Email',
|
||||
'profile.bindEmail': 'Bind email',
|
||||
'profile.emailCode': 'Confirmation code',
|
||||
@@ -180,6 +181,7 @@ export const en = {
|
||||
'friends.redeem': 'Add',
|
||||
'friends.copy': 'Copy',
|
||||
'friends.codeCopied': 'Code copied.',
|
||||
'friends.shareTelegram': 'Share via Telegram',
|
||||
'friends.added': 'Added {name}.',
|
||||
'friends.blockedList': 'Blocked players',
|
||||
'friends.unblock': 'Unblock',
|
||||
|
||||
@@ -111,6 +111,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'profile.to': 'До',
|
||||
'profile.blockChat': 'Отключить чат',
|
||||
'profile.blockFriendRequests': 'Отключить заявки в друзья',
|
||||
'profile.notificationsInAppOnly': 'Уведомления только в приложении',
|
||||
'profile.email': 'Эл. почта',
|
||||
'profile.bindEmail': 'Привязать почту',
|
||||
'profile.emailCode': 'Код подтверждения',
|
||||
@@ -181,6 +182,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'friends.redeem': 'Добавить',
|
||||
'friends.copy': 'Копировать',
|
||||
'friends.codeCopied': 'Код скопирован.',
|
||||
'friends.shareTelegram': 'Поделиться через Telegram',
|
||||
'friends.added': 'Добавлен(а) {name}.',
|
||||
'friends.blockedList': 'Заблокированные',
|
||||
'friends.unblock': 'Разблокировать',
|
||||
|
||||
@@ -100,6 +100,9 @@ export class MockGateway implements GatewayClient {
|
||||
}
|
||||
|
||||
// --- auth ---
|
||||
async authTelegram(): Promise<Session> {
|
||||
return { ...SESSION, isGuest: false };
|
||||
}
|
||||
async authGuest(): Promise<Session> {
|
||||
return { ...SESSION };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export const PROFILE: Profile = {
|
||||
blockChat: false,
|
||||
blockFriendRequests: false,
|
||||
isGuest: false,
|
||||
notificationsInAppOnly: true,
|
||||
};
|
||||
|
||||
// Seed social/account data for the mock (pnpm start + Playwright). The mock profile
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface Profile {
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
isGuest: boolean;
|
||||
/** Confine notifications to the in-app stream (no out-of-app platform push). */
|
||||
notificationsInAppOnly: boolean;
|
||||
}
|
||||
|
||||
/** The full editable profile sent to profileUpdate (overwrites every field). */
|
||||
@@ -118,6 +120,7 @@ export interface ProfileUpdate {
|
||||
awayEnd: string;
|
||||
blockChat: boolean;
|
||||
blockFriendRequests: boolean;
|
||||
notificationsInAppOnly: boolean;
|
||||
}
|
||||
|
||||
/** A referenced account with its display name (friend, blocked user, invitee). */
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/');
|
||||
}
|
||||
@@ -54,6 +54,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
token = t;
|
||||
},
|
||||
|
||||
async authTelegram(initData) {
|
||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData)));
|
||||
},
|
||||
async authGuest(locale) {
|
||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '')));
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { app, handleError, refreshNotifications, showToast } from '../lib/app.svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||
import type { AccountRef, FriendCode } from '../lib/model';
|
||||
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
@@ -97,6 +98,7 @@
|
||||
<button class="btn" onclick={redeem}>{t('friends.redeem')}</button>
|
||||
</div>
|
||||
{#if code}
|
||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||
<div class="code" data-testid="friend-code">
|
||||
<div class="coderow">
|
||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||
@@ -105,6 +107,9 @@
|
||||
<span class="codehint">
|
||||
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
||||
</span>
|
||||
{#if tg}
|
||||
<a class="link tgshare" href={tg} target="_blank" rel="noopener">{t('friends.shareTelegram')}</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<button class="link" onclick={getCode}>{t('friends.getCode')}</button>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
let endM = $state('00');
|
||||
let blockChat = $state(false);
|
||||
let blockFriendRequests = $state(false);
|
||||
let notificationsInAppOnly = $state(true);
|
||||
let emailInput = $state('');
|
||||
let codeInput = $state('');
|
||||
let emailSent = $state(false);
|
||||
@@ -47,6 +48,7 @@
|
||||
[endH, endM] = splitTime(p.awayEnd);
|
||||
blockChat = p.blockChat;
|
||||
blockFriendRequests = p.blockFriendRequests;
|
||||
notificationsInAppOnly = p.notificationsInAppOnly;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@
|
||||
awayEnd,
|
||||
blockChat,
|
||||
blockFriendRequests,
|
||||
notificationsInAppOnly,
|
||||
});
|
||||
editing = false;
|
||||
showToast(t('profile.saved'));
|
||||
@@ -143,6 +146,10 @@
|
||||
<input type="checkbox" bind:checked={blockFriendRequests} />
|
||||
<span>{t('profile.blockFriendRequests')}</span>
|
||||
</label>
|
||||
<label class="check">
|
||||
<input type="checkbox" bind:checked={notificationsInAppOnly} />
|
||||
<span>{t('profile.notificationsInAppOnly')}</span>
|
||||
</label>
|
||||
<div class="formacts">
|
||||
<button type="submit" class="btn" disabled={!formValid}>{t('common.save')}</button>
|
||||
<button type="button" class="ghost" onclick={() => (editing = false)}>{t('common.cancel')}</button>
|
||||
|
||||
@@ -9,6 +9,9 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
const RPC_PREFIX = '/scrabble.edge.v1.Gateway';
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
// Relative asset base so the one build serves under any path — the gateway maps the
|
||||
// Telegram Mini App to /telegram/ (the hash router is path-agnostic).
|
||||
base: './',
|
||||
plugins: [svelte()],
|
||||
server: {
|
||||
port: 5173,
|
||||
|
||||
Reference in New Issue
Block a user