Compare commits
51 Commits
v1.4.0
..
3a18e683ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a18e683ca | |||
| 00441e3657 | |||
| 3b7c7c077e | |||
| e900d592f8 | |||
| d76f1f4026 | |||
| 0ea9764a0d | |||
| 6a5ce12fab | |||
| da17c18895 | |||
| 303348ed39 | |||
| a06dfe00fc | |||
| a88678c5c6 | |||
| 500a01cf97 | |||
| d9ede77e2f | |||
| 65c194264c | |||
| 13c22734ee | |||
| 7dabcd1317 | |||
| 93d086a8a3 | |||
| b03e012011 | |||
| 2495446a47 | |||
| 35705f7d1e | |||
| 4f0cc81dfb | |||
| 2ba7cc3086 | |||
| 29b6c7e4d8 | |||
| 8de9fb1ecd | |||
| 8a5a5d6c4d | |||
| ea931c6680 | |||
| d0f60ee41d | |||
| b84bd1297e | |||
| 0fb6004a8b | |||
| 8fe1bdba6b | |||
| c1d1c1624b | |||
| 9207664fbd | |||
| a4581663f4 | |||
| 03dfc29a54 | |||
| c02262fcf7 | |||
| f8fab4a4c2 | |||
| 7923b3cc09 | |||
| 10264e10c8 | |||
| fc1715128e | |||
| bb18dc362b | |||
| 4891216749 | |||
| d86e022373 | |||
| 6a602aefae | |||
| f1b8769c89 | |||
| e6277dcd43 | |||
| 37070c3cb7 | |||
| 53d6883ffd | |||
| 93c57b3558 | |||
| 6f00c2f41d | |||
| 79766438a2 | |||
| 6aa5023b24 |
@@ -0,0 +1,89 @@
|
||||
---
|
||||
name: deploy-check
|
||||
description: "Use before any deploy-touching change to this repo — phrases like '/deploy-check', 'is this prod-safe', 'before we deploy', 'deploy safety review', 'проверь перед деплоем', 'это безопасно для прода'. Runs a pre-deploy checklist of this project's hard-won runtime constraints against the current diff, so the crash classes that have bitten live environments get caught before shipping instead of after."
|
||||
---
|
||||
|
||||
# Pre-deploy runtime-constraint check
|
||||
|
||||
Triggered before shipping anything that touches the deploy contour (Dockerfiles,
|
||||
`deploy/`, Caddyfile, compose, migrations, boot guards, the Telegram side-service,
|
||||
edge config). The worst frictions in this repo were never logic bugs — they were
|
||||
**environment mismatches that crashed a live env and forced a redesign**. Run this
|
||||
list against the diff first; turn crash-and-redesign into a single pass.
|
||||
|
||||
This checklist is a prompt, **not** the source of truth. The canonical detail
|
||||
lives in `deploy/README.md`, `docs/ARCHITECTURE.md`, `docs/EDGE_HTTP3.md`, and the
|
||||
agent memory files referenced below — read them when an item is in play, and add a
|
||||
new class here when a new incident teaches one.
|
||||
|
||||
## How to run it
|
||||
|
||||
1. `git diff <base>...HEAD --stat` to see what the change actually touches.
|
||||
2. For every risk class below that the diff touches, perform the **Check** and
|
||||
report PASS / FAIL with the exact file:line to fix. Skip classes the diff does
|
||||
not touch — say which you skipped and why.
|
||||
3. Remember: **deploy-job green ≠ healthy**. CI's deploy probe has historically
|
||||
passed with a dead backend (it only checked static landing+gateway). Verify the
|
||||
real feature live (`/readyz`, the actual flow) after deploy, not just the green.
|
||||
|
||||
## Risk classes
|
||||
|
||||
### 1. Container user — distroless nonroot UID 65532
|
||||
- **Bit us:** TLS keys `chmod 600` for the host owner crash-looped gateway + bot at
|
||||
boot with "permission denied" — service images run UID 65532.
|
||||
- **Check:** any new/changed mounted secret, key, or config file must be readable by
|
||||
UID 65532 (`0644`, not `0600`). Scan the diff for file modes, `chmod`, and new
|
||||
volume mounts. (memory: `distroless-nonroot-mounted-secrets`)
|
||||
|
||||
### 2. Caddy header pipeline ordering
|
||||
- **Bit us:** `header_up delete` after `set` nulled the value (the honeypot tag went
|
||||
empty); it passed CI and only showed up live.
|
||||
- **Check:** in any Caddyfile change, verify the `set` / `delete` / `header_up`
|
||||
ordering for every affected route, and test the tripwire/route on the live
|
||||
contour, not just CI.
|
||||
|
||||
### 3. Edge Alt-Svc / HTTP3
|
||||
- **Bit us:** edge advertised `Alt-Svc: h3` while UDP/443 was never exposed
|
||||
(docker tcp-only + ufw tcp-only); clients cached it 30 days and stalled on dead
|
||||
QUIC before falling back to h2 — Mini App "hangs on load".
|
||||
- **Check:** any edge/caddy change keeps `Alt-Svc: clear` (or only advertises h3 if
|
||||
UDP/443 is genuinely exposed). (memory: `tg-app-load-stall-dead-http3-altsvc`,
|
||||
`docs/EDGE_HTTP3.md`)
|
||||
|
||||
### 4. Prod caddy config recreate
|
||||
- **Bit us:** prod rolling deploy did **not** recreate caddy on a config-only change
|
||||
(pinned `caddy:2-alpine` + `admin off`), so a new Caddyfile deployed GREEN but
|
||||
stayed inert until a manual `docker restart`.
|
||||
- **Check:** a config-only edge change must `--force-recreate` caddy in
|
||||
`prod-deploy.sh` `roll()`; never trust deploy-green for edge config.
|
||||
(memory: `prod-deploy-caddy-config-recreate`)
|
||||
|
||||
### 5. DICT_VERSION / dictionary boot
|
||||
- **Bit us:** an early `DICT_VERSION` refuse-boot guard was wrong and crashed the
|
||||
live env when bumped on a seeded volume; it had to be redesigned to "marker-wins".
|
||||
- **Check:** any change touching `DICT_VERSION`, dict load, or the boot guard must
|
||||
keep marker-wins semantics and survive a seeded volume **and** an image rollback.
|
||||
`DICT_VERSION` is a required build-arg (no default), single-sourced. A new dict
|
||||
goes live via the admin console upload, not a redeploy. (memory:
|
||||
`dict-version-deploy-verify`, `contour-schema-change-wipe`)
|
||||
|
||||
### 6. Migrations — expand-contract + rollback safety
|
||||
- **Bit us / risk:** a non-backward-compatible migration breaks image rollback (DB
|
||||
ahead of rolled-back code).
|
||||
- **Check:** migrations must be **expand-contract** (backward-compatible). A schema
|
||||
change adds the maintenance window + a consistent `pg_dump` in prod-deploy. On the
|
||||
**test contour**, a schema/wire-label change needs `DROP SCHEMA backend CASCADE` +
|
||||
backend restart (new code vs old persisted DB), else the contour breaks. (memory:
|
||||
`contour-schema-change-wipe`)
|
||||
|
||||
### 7. Telegram permission model
|
||||
- **Bit us:** permissions are an **AND-intersection** — default-allow with explicit
|
||||
denies, not default-deny; inverting it broke access.
|
||||
- **Check:** any change to the Telegram permission / relay logic preserves the
|
||||
AND-intersection default-allow shape. (memory: `telegram-forum-relay-gotchas`)
|
||||
|
||||
## Output
|
||||
|
||||
A short PASS/FAIL table over the classes the diff touches, each FAIL with the exact
|
||||
file:line and the fix. If every touched class passes, say so plainly and name the
|
||||
post-deploy live check to run (not just "CI green").
|
||||
@@ -0,0 +1,173 @@
|
||||
# VK Mini App / VK Games — integration reference
|
||||
|
||||
Captured research + our implementation map, so a future session does not need to re-fetch
|
||||
the VK docs. Authoritative external source: <https://dev.vk.com/> (the `dev.vk.com` portal
|
||||
does not render via plain HTTP fetch; the facts below were cross-checked against the VKCOM
|
||||
reference repos cited at the end and verified against our own Go implementation).
|
||||
|
||||
A VK **game** is technically a **VK Mini App**: an HTML5 SPA VK loads in an **iframe inside
|
||||
vk.com** (desktop + mobile web) and in a **WebView** inside the VK mobile apps (iOS/Android).
|
||||
We serve our existing SPA under a dedicated `/vk/` path, mirroring the Telegram `/telegram/`
|
||||
entry — the single-origin, path-routed model.
|
||||
|
||||
## 1. Embedding model
|
||||
|
||||
- VK loads the app at the **Web iframe URL** configured in the app settings (HTTPS + valid
|
||||
cert required), appending the signed launch parameters as the **URL query string**.
|
||||
- An optional separate **Mobile iframe URL** is used by the VK mobile apps (we use the same).
|
||||
- No special `X-Frame-Options` / CSP `frame-ancestors` is required from us — VK frames the
|
||||
configured origin. (Our edge sets **no** framing headers today, so VK works as-is; see the
|
||||
clickjacking note in §Security.)
|
||||
- URL must match the settings exactly (scheme, host, no stray `www`/whitespace).
|
||||
|
||||
## 2. Launch parameters (URL query)
|
||||
|
||||
VK appends these to the iframe `src`. The `vk_*` set is what the signature covers.
|
||||
|
||||
| Param | Meaning |
|
||||
| --- | --- |
|
||||
| `vk_user_id` | signed-in VK user numeric id — **the identity** |
|
||||
| `vk_app_id` | our registered app id |
|
||||
| `vk_is_app_user` | 0/1 — user authorized/installed the app |
|
||||
| `vk_are_notifications_enabled` | 0/1 |
|
||||
| `vk_language` | 2-letter UI language (`ru`, `en`, …) |
|
||||
| `vk_platform` | `mobile_iphone` \| `mobile_android` \| `mobile_web` \| `desktop_web` \| … |
|
||||
| `vk_ts` | unix seconds when VK generated the params |
|
||||
| `vk_ref` | where the app was opened from (`catalog`, `feed`, …) |
|
||||
| `vk_access_token_settings` | comma-separated granted scopes (often empty) |
|
||||
| `vk_group_id`, `vk_viewer_group_role`, `vk_is_favorite`, `vk_client` | optional/contextual |
|
||||
| `sign` | **the signature** (see §3) — NOT part of the signed set |
|
||||
|
||||
Always present: `vk_user_id`, `vk_app_id`, `vk_platform`, `vk_ts`, `sign`.
|
||||
|
||||
The user's **name is NOT in the launch params** (only `vk_user_id`). Read it client-side via
|
||||
`VKWebAppGetUserInfo` (see §4) — unsigned, so treat it as a cosmetic display seed only.
|
||||
|
||||
## 3. Signature verification (`sign`) — CONFIRMED base64url, not hex
|
||||
|
||||
Algorithm (verified against our `gateway/internal/vkauth` + an independent Python reference):
|
||||
|
||||
1. Collect the query params whose key starts with `vk_` (exclude `sign`).
|
||||
2. Sort by key (alphabetical).
|
||||
3. Serialize as a URL-encoded query string `k=v&k=v…` (Go `url.Values.Encode()` matches VK's
|
||||
reference serialization for the constrained launch-param charset).
|
||||
4. `HMAC-SHA256(serialized, secret)` where `secret` = the app's **«Защищённый ключ»**
|
||||
(protected / secure key, a.k.a. client_secret) from the app settings.
|
||||
5. **base64url, no padding** (`+`→`-`, `/`→`_`, strip `=`).
|
||||
6. Constant-time compare against `sign`.
|
||||
|
||||
VK launch params have **no built-in expiry** (unlike Telegram's `auth_date`). We do NOT enforce
|
||||
freshness — the minted server session is the short-lived credential; a replay only
|
||||
re-authenticates the same `vk_user_id`.
|
||||
|
||||
Verified against the official doc <https://dev.vk.com/ru/mini-apps/development/launch-params-sign>
|
||||
(prose + PHP example: base64url = `strtr('+/','-_')` + `rtrim('=')`) and reproduced identically by
|
||||
independent Node `crypto` + Python references. **Doc-example caveat**: that page shows secret
|
||||
`wvl68m4dR1UpLrVRli` → sign `exTIBP…`, but the secret is a **placeholder** — recomputing with it does
|
||||
NOT yield the shown sign (it was made with the real, unshown key). Don't chase the mismatch; our
|
||||
`vkauth.Verify` is correct (`gateway/internal/vkauth/vkauth_test.go` carries cross-checked vectors,
|
||||
incl. the `%2C` comma case for `vk_access_token_settings`).
|
||||
|
||||
## 4. VK Bridge (client SDK)
|
||||
|
||||
`@vkontakte/vk-bridge` (npm, v3.x; bundled — `default` export `bridge`). Methods we use / may use:
|
||||
|
||||
- `VKWebAppInit` — **required**: tells VK the Mini App loaded (dismisses VK's loading cover).
|
||||
- `VKWebAppGetUserInfo` — `{ id, first_name, last_name, photo_200, … }`; no extra scope needed.
|
||||
- `VKWebAppGetLaunchParams` — parsed `vk_*` **without** `sign` (so NOT usable for our server
|
||||
verification — read `window.location.search` instead, which carries `sign`).
|
||||
- `VKWebAppGetAuthToken` — OAuth access token for VK API calls (only if we ever call VK API).
|
||||
- `VKWebAppShare` — native share dialog (the friend-code invite uses it; `navigator.share` is absent
|
||||
in the desktop VK iframe). **Used.**
|
||||
- `VKWebAppCopyText` — clipboard copy that works inside the VK iframe, where `navigator.clipboard` is
|
||||
blocked. **Used** as the copy-code / copy-link path.
|
||||
- `VKWebAppUpdateConfig` (subscribe) — light/dark scheme; the app follows it while the theme pref is
|
||||
"auto" (the VK webview's prefers-color-scheme does not track it). **Used.**
|
||||
- `VKWebAppSetViewSettings` / `VKWebAppSetSwipeSettings` — viewport / swipe-back (mobile); not used —
|
||||
the bottom home-bar safe area is handled by CSS `env(safe-area-inset-*)` (viewport-fit=cover).
|
||||
|
||||
The bridge talks to the embedding VK client over postMessage; it is NOT an external fetch, so
|
||||
it has no telegram.org-style load-hang risk. The SDK reads browser globals at import — we import
|
||||
it **lazily** so the pure URL helpers stay node-test-importable.
|
||||
|
||||
**Deep links — NOT possible on VK (confirmed on the contour).** The VK iframe receives ONLY the signed
|
||||
`vk_*` launch params (+ `sign`); VK strips any custom data from the app link. The documented
|
||||
`vk.com/app<id>#<payload>` form is eaten by the vk.com SPA (which owns the URL hash), and a
|
||||
`vk.com/app<id>?hash=<payload>` query is dropped (the diagnostic showed `rawSearch` with only `vk_*`
|
||||
and an empty `hash`). So the friend-code invite link is just `vk.com/app<id>` (`vkShareLink`, app id
|
||||
from `vk_app_id`); the recipient enters the **copied code by hand** (`VKWebAppCopyText` works). The
|
||||
`vkStartParam` reader + the `bootVK` routing stay as a no-op today, ready if a post-moderation VK
|
||||
channel (e.g. an invite API) ever delivers a payload.
|
||||
|
||||
## 5. Test mode (to verify before moderation)
|
||||
|
||||
1. App already registered (we have the App ID).
|
||||
2. In the app settings (dev.vk.com / `vk.com/editapp?act=settings&app_id=<id>`):
|
||||
- Category = **Игра** (Game).
|
||||
- **Web iframe URL** = our public HTTPS `/vk/` (the test-contour origin for contour testing,
|
||||
prod `https://erudit-game.ru/vk/` later). Mobile iframe URL = same.
|
||||
- Copy the **«Защищённый ключ»** → set as `GATEWAY_VK_APP_SECRET` (Gitea `TEST_`/`PROD_` secret).
|
||||
- Add own VK id to **testers**; open in test mode.
|
||||
3. Test mode = visible only to admins/testers, no payments processed.
|
||||
|
||||
## 6. Auth / identity (our model)
|
||||
|
||||
- `vk_user_id` (from verified params) → backend identity `kind='vk'`, `external_id=vk_user_id`,
|
||||
auto-confirmed (a platform identity). First contact seeds language from `vk_language` and the
|
||||
display name from the client-supplied `VKWebAppGetUserInfo` name (placeholder if empty).
|
||||
- No VK access token / VK API call needed for the launch+login MVP.
|
||||
|
||||
## 7. Payments / monetization
|
||||
|
||||
VK Pay / «голоса» (votes) are **optional**, not required to publish a free game. Not planned.
|
||||
|
||||
## 8. ToS / moderation (pre-publish, analyzed — no blocker for a free «Эрудит»)
|
||||
|
||||
- **Trademark**: "Scrabble" is trademarked. Our public brand is **«Эрудит»** (erudit-game.ru),
|
||||
a generic Russian word-game name → fine. Ensure the VK-registered app name is «Эрудит»/word-game,
|
||||
NOT "Scrabble". The repo name is internal and irrelevant to moderation.
|
||||
- **Pre-publish requirements**: public **Privacy Policy** + **ToS** URLs (disclose collected data:
|
||||
`vk_user_id`, language; mention VK), **age rating** (likely 6+/12+), icon, description.
|
||||
- **Dictionary**: standard word lists; VK may expect offensive-word filtering — likely fine for a
|
||||
dictionary game, flag if moderation asks.
|
||||
- **In-game chat (UGC)**: we already have a moderated chat + support relay → covered.
|
||||
- Moderation reviews after submission (commonly ~24–72h); rejects on violence/hate/sexual/illegal
|
||||
content or IP infringement — none apply.
|
||||
|
||||
## 9. Platforms
|
||||
|
||||
Desktop web (iframe), mobile web (iframe), VK iOS app (WKWebView), VK Android app (WebView). Bridge
|
||||
methods behave per-platform; the app's own back chevron + app-shell document-pin cover navigation
|
||||
without VK-specific code. Theme/viewport fine-tuning is best verified live in the real VK client
|
||||
(not reproducible in Playwright — like the iOS gesture caveats).
|
||||
|
||||
## 10. Our implementation map (what to touch for VK)
|
||||
|
||||
- **Wire**: `pkg/fbs/scrabble.fbs` → `VKLoginRequest{ params, browser_tz, display_name }`
|
||||
(regen: `make -C pkg fbs` + `pnpm -C ui codegen`).
|
||||
- **Gateway**: `internal/vkauth/` (the §3 verify), `internal/transcode` op `auth.vk`
|
||||
(registered via `WithVKAuth(secret)` option; `DomainCode` → `invalid_vk_params`),
|
||||
`internal/backendclient` `VKAuth` → `POST /api/v1/internal/sessions/vk`,
|
||||
config `GATEWAY_VK_APP_SECRET`, SPA mount `/vk/` in `internal/connectsrv/server.go`.
|
||||
- **Backend**: `internal/account` `KindVK` + `ProvisionVK`/`vkSeed` + `confirmed` for platform
|
||||
kinds; `internal/server/handlers_auth.go` `handleVKAuth` + route; migration
|
||||
`00005_vk_identity.sql` (widen `identities_kind_chk` to include `'vk'`, expand-contract).
|
||||
- **UI**: `src/lib/vk.ts` (`onVKPath`/`vkLaunchParams`/`insideVK`/`vkInit`/`vkUserName` plus `vkAppId`/
|
||||
`vkStartParam`/`vkShare`/`vkCopyText`/`vkOnScheme`), `app.svelte.ts` `bootVK` (+ deep-link routing
|
||||
and VK scheme→theme) + the `/vk/` dispatch branch + shared `retryMiniAppBoot`, `codec.ts`
|
||||
`encodeVKLogin`, `transport.ts`/`client.ts`/`mock/client.ts` `authVK`, `deeplink.ts` `vkShareLink`,
|
||||
`Friends.svelte` (VK share/copy), `app.css` `--tg-safe-*` defaulting to `env(safe-area-inset-*)`.
|
||||
- **Edge/deploy**: `deploy/caddy/Caddyfile` `/vk` path; `GATEWAY_VK_APP_SECRET` in
|
||||
`docker-compose.yml` + `.env.example` + `ci.yaml` (`TEST_…` secret) + `prod-deploy.yaml`
|
||||
(`PROD_…` secret, deploy-main).
|
||||
- **Deferred**: payments (VK Pay / votes), native (Capacitor) VK, account-linking a vk identity to an
|
||||
existing account, VK push. (Done after the launch+auth MVP — Group B: native share + clipboard via
|
||||
the bridge, the friend-code deep link, the auto-theme follow, and the home-bar safe area.)
|
||||
|
||||
## Sources
|
||||
|
||||
- VKCOM/vk-bridge — <https://github.com/VKCOM/vk-bridge>
|
||||
- VKCOM/vk-apps-launch-params (canonical signature examples) — <https://github.com/VKCOM/vk-apps-launch-params>
|
||||
- kravetsone/vk-launch-params — <https://github.com/kravetsone/vk-launch-params>
|
||||
- SevereCloud/vksdk `vkapps.ParamsVerify` (Go reference) — <https://pkg.go.dev/github.com/SevereCloud/vksdk/v2/vkapps>
|
||||
- VK Mini Apps API — <https://github.com/VKCOM/vk-mini-apps-api>
|
||||
@@ -31,7 +31,7 @@ on:
|
||||
# unit/integration jobs inherit it. The deploy job overrides it per contour with
|
||||
# vars.TEST_DICT_VERSION (the seed for a fresh volume), see deploy/README.md.
|
||||
env:
|
||||
DICT_VERSION: v1.3.0
|
||||
DICT_VERSION: v1.3.1
|
||||
|
||||
jobs:
|
||||
# changes detects which areas a PR/push touched, so the test jobs can skip when
|
||||
@@ -261,12 +261,16 @@ jobs:
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.TEST_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_BOT_TOKEN }}
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${{ secrets.TEST_TELEGRAM_PROMO_BOT_TOKEN }}
|
||||
# VK Mini App protected key (offline HMAC for the launch-param signature); empty
|
||||
# leaves the VK auth path (auth.vk) disabled until the operator sets the secret.
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.TEST_GATEWAY_VK_APP_SECRET }}
|
||||
GM_BASICAUTH_USER: ${{ vars.TEST_GM_BASICAUTH_USER }}
|
||||
GRAFANA_ROOT_URL: ${{ vars.TEST_GRAFANA_ROOT_URL }}
|
||||
CADDY_SITE_ADDRESS: ${{ vars.TEST_CADDY_SITE_ADDRESS }}
|
||||
TELEGRAM_MINIAPP_URL: ${{ vars.TEST_TELEGRAM_MINIAPP_URL }}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.TEST_TELEGRAM_GAME_CHANNEL_ID }}
|
||||
TELEGRAM_CHAT_ID: ${{ vars.TEST_TELEGRAM_CHAT_ID }}
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.TEST_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||
TELEGRAM_BOT_USERNAME: ${{ vars.TEST_TELEGRAM_BOT_USERNAME }}
|
||||
# The promo button reuses the UI's Mini App link variable.
|
||||
TELEGRAM_BOT_LINK: ${{ vars.TEST_VITE_TELEGRAM_LINK }}
|
||||
|
||||
@@ -82,6 +82,7 @@ jobs:
|
||||
GM_BASICAUTH_HASH: ${{ secrets.PROD_GM_BASICAUTH_HASH }}
|
||||
GRAFANA_ADMIN_PASSWORD: ${{ secrets.PROD_GRAFANA_ADMIN_PASSWORD }}
|
||||
TELEGRAM_BOT_TOKEN: ${{ secrets.PROD_TELEGRAM_BOT_TOKEN }}
|
||||
GATEWAY_VK_APP_SECRET: ${{ secrets.PROD_GATEWAY_VK_APP_SECRET }}
|
||||
PROD_BOTLINK_CA: ${{ secrets.PROD_BOTLINK_CA }}
|
||||
PROD_BOTLINK_GATEWAY_CERT: ${{ secrets.PROD_BOTLINK_GATEWAY_CERT }}
|
||||
PROD_BOTLINK_GATEWAY_KEY: ${{ secrets.PROD_BOTLINK_GATEWAY_KEY }}
|
||||
@@ -136,6 +137,7 @@ jobs:
|
||||
export APP_VERSION='$TAG'
|
||||
export TELEGRAM_BOT_TOKEN='$TELEGRAM_BOT_TOKEN'
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export GATEWAY_VK_APP_SECRET='$GATEWAY_VK_APP_SECRET'
|
||||
export GATEWAY_ABUSE_BAN_ENABLED='true'
|
||||
EOF
|
||||
printf '%s\n' "$PROD_BOTLINK_CA" > stage/certs-main/ca.crt
|
||||
@@ -179,6 +181,7 @@ jobs:
|
||||
TELEGRAM_MINIAPP_URL: ${{ vars.PROD_TELEGRAM_MINIAPP_URL }}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${{ vars.PROD_TELEGRAM_GAME_CHANNEL_ID }}
|
||||
TELEGRAM_CHAT_ID: ${{ vars.PROD_TELEGRAM_CHAT_ID }}
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${{ vars.PROD_TELEGRAM_SUPPORT_CHAT_ID }}
|
||||
TELEGRAM_BOT_USERNAME: ${{ vars.PROD_TELEGRAM_BOT_USERNAME }}
|
||||
TELEGRAM_BOT_LINK: ${{ vars.PROD_VITE_TELEGRAM_LINK }}
|
||||
steps:
|
||||
@@ -200,6 +203,7 @@ jobs:
|
||||
export TELEGRAM_MINIAPP_URL='$TELEGRAM_MINIAPP_URL'
|
||||
export TELEGRAM_GAME_CHANNEL_ID='$TELEGRAM_GAME_CHANNEL_ID'
|
||||
export TELEGRAM_CHAT_ID='$TELEGRAM_CHAT_ID'
|
||||
export TELEGRAM_SUPPORT_CHAT_ID='$TELEGRAM_SUPPORT_CHAT_ID'
|
||||
export TELEGRAM_PROMO_BOT_TOKEN='$TELEGRAM_PROMO_BOT_TOKEN'
|
||||
export TELEGRAM_BOT_USERNAME='$TELEGRAM_BOT_USERNAME'
|
||||
export TELEGRAM_BOT_LINK='$TELEGRAM_BOT_LINK'
|
||||
|
||||
@@ -22,12 +22,13 @@ import (
|
||||
"scrabble/backend/internal/postgres/jet/backend/table"
|
||||
)
|
||||
|
||||
// Identity kinds recognised by the backend. Email is modelled as an identity
|
||||
// alongside platform identities; its confirmed flag is driven by the email
|
||||
// confirm-code flow. Robot is a synthetic kind: each pooled
|
||||
// robot opponent is a durable account bound to one robot identity.
|
||||
// Identity kinds recognised by the backend. Telegram and VK are platform identities,
|
||||
// auto-confirmed on first contact. Email is modelled as an identity alongside them; its
|
||||
// confirmed flag is driven by the email confirm-code flow. Robot is a synthetic kind:
|
||||
// each pooled robot opponent is a durable account bound to one robot identity.
|
||||
const (
|
||||
KindTelegram = "telegram"
|
||||
KindVK = "vk"
|
||||
KindEmail = "email"
|
||||
KindRobot = "robot"
|
||||
)
|
||||
@@ -185,6 +186,28 @@ func (s *Store) ProvisionTelegram(ctx context.Context, externalID, languageCode,
|
||||
return acc, created, err
|
||||
}
|
||||
|
||||
// ProvisionVK provisions (or finds) the account bound to a VK identity, reporting
|
||||
// whether this call created it (first contact). On first contact only, it seeds the new
|
||||
// account's preferred language from the VK languageCode (vk_language, when it maps to a
|
||||
// supported language) and its display name sanitized from displayName — the name read
|
||||
// client-side via VKWebAppGetUserInfo, since VK omits it from the signed launch params —
|
||||
// falling back to a generated placeholder when it yields no letters; an already-existing
|
||||
// account is returned unchanged, so a later profile edit is never overwritten.
|
||||
func (s *Store) ProvisionVK(ctx context.Context, externalID, languageCode, displayName, browserTZ string) (Account, bool, error) {
|
||||
// Pre-check whether the identity already exists so the caller can act on first
|
||||
// contact (mirrors ProvisionTelegram); a create race only mis-reports created for
|
||||
// that one call.
|
||||
_, err := s.findByIdentity(ctx, KindVK, externalID)
|
||||
created := errors.Is(err, ErrNotFound)
|
||||
if err != nil && !created {
|
||||
return Account{}, false, err
|
||||
}
|
||||
seed := vkSeed(languageCode, displayName)
|
||||
seed.timeZone = seedZone(browserTZ)
|
||||
acc, err := s.provision(ctx, KindVK, externalID, seed)
|
||||
return acc, created, err
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -258,6 +281,24 @@ func telegramSeed(languageCode, username, firstName string) provisionSeed {
|
||||
return seed
|
||||
}
|
||||
|
||||
// vkSeed derives the create-time seed from VK launch fields: a supported preferred
|
||||
// language from languageCode (vk_language, normally a 2-letter code) and a display name
|
||||
// from displayName (sanitized to the editable format), falling back to a generated
|
||||
// placeholder in the seeded language when the name yields no usable letters. Unlike
|
||||
// telegramSeed there is no @username fallback — VK provides only the name.
|
||||
func vkSeed(languageCode, displayName string) provisionSeed {
|
||||
var seed provisionSeed
|
||||
if lang, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(languageCode)), "-"); lang == "en" || lang == "ru" {
|
||||
seed.preferredLanguage = lang
|
||||
}
|
||||
name := sanitizeDisplayName(displayName)
|
||||
if name == "" {
|
||||
name = placeholderDisplayName(seed.preferredLanguage)
|
||||
}
|
||||
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).
|
||||
@@ -421,7 +462,7 @@ func (s *Store) create(ctx context.Context, kind, externalID string, seed provis
|
||||
table.Identities.Kind,
|
||||
table.Identities.ExternalID,
|
||||
table.Identities.Confirmed,
|
||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram)
|
||||
).VALUES(identityID, accountID, kind, externalID, kind == KindTelegram || kind == KindVK)
|
||||
if _, err := insertIdentity.ExecContext(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -101,6 +101,66 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// variantSeedPrefix marks a Telegram start-param payload that seeds a brand-new
|
||||
// account's variant preferences (e.g. "verudit_ru-scrabble_en"): the prefix, then the
|
||||
// canonical variant labels joined by "-". It is deliberately distinct from the routing
|
||||
// deep links (g/i/f; see platform/telegram .../deeplink) so the client's start-param
|
||||
// router falls through to the lobby for it.
|
||||
const variantSeedPrefix = "v"
|
||||
|
||||
// SeedVariantsFromStartParam decodes a promo deep-link start-param into the variant
|
||||
// preference set to seed onto a brand-new account: the variantSeedPrefix followed by
|
||||
// the canonical variant labels joined by "-" (e.g. "verudit_ru-scrabble_en"). It
|
||||
// returns nil for any payload that is not a variant-seed link or that fails validation
|
||||
// against the known variants, so a malformed, empty or unrelated start-param simply
|
||||
// leaves the account on its default preferences rather than failing the login.
|
||||
func SeedVariantsFromStartParam(startParam string) []string {
|
||||
if !strings.HasPrefix(startParam, variantSeedPrefix) {
|
||||
return nil
|
||||
}
|
||||
body := strings.TrimPrefix(startParam, variantSeedPrefix)
|
||||
if body == "" {
|
||||
return nil
|
||||
}
|
||||
prefs, err := validateVariantPreferences(strings.Split(body, "-"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return prefs
|
||||
}
|
||||
|
||||
// SetVariantPreferences overwrites only the variant-preference set of the account,
|
||||
// cleaning it to a deduplicated, canonically ordered subset of the known variants
|
||||
// (rejecting an empty or unknown set with ErrInvalidProfile) and bumping updated_at; it
|
||||
// reports ErrNotFound when no account matches id. It is the narrow counterpart to
|
||||
// UpdateProfile used to seed a promo-onboarded account's variants at first contact
|
||||
// without disturbing its other profile fields.
|
||||
func (s *Store) SetVariantPreferences(ctx context.Context, id uuid.UUID, prefs []string) (Account, error) {
|
||||
clean, err := validateVariantPreferences(prefs)
|
||||
if err != nil {
|
||||
return Account{}, err
|
||||
}
|
||||
stmt := table.Accounts.UPDATE(
|
||||
table.Accounts.VariantPreferences, table.Accounts.UpdatedAt,
|
||||
).SET(
|
||||
// clean is validated against the closed knownVariants set; bind as a text[]
|
||||
// parameter (lib/pq encodes the array, the cast pins the column type), mirroring
|
||||
// UpdateProfile.
|
||||
postgres.Raw("#variant_prefs::text[]", map[string]interface{}{"#variant_prefs": pq.StringArray(clean)}),
|
||||
postgres.TimestampzT(time.Now().UTC()),
|
||||
).WHERE(table.Accounts.AccountID.EQ(postgres.UUID(id))).
|
||||
RETURNING(table.Accounts.AllColumns)
|
||||
|
||||
var row model.Accounts
|
||||
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
|
||||
if errors.Is(err, qrm.ErrNoRows) {
|
||||
return Account{}, ErrNotFound
|
||||
}
|
||||
return Account{}, fmt.Errorf("account: set variant preferences %s: %w", id, err)
|
||||
}
|
||||
return modelToAccount(row), nil
|
||||
}
|
||||
|
||||
// UpdateProfile validates and overwrites the editable fields of the account, then
|
||||
// returns the stored row. It reports ErrInvalidProfile for a bad language,
|
||||
// timezone or display name and ErrNotFound when no account matches id.
|
||||
|
||||
@@ -75,3 +75,55 @@ func TestTelegramSeedTruncatesLongName(t *testing.T) {
|
||||
t.Errorf("display name rune count = %d, want %d", n, maxDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKSeed covers the pure mapping from VK launch fields to the create-time account
|
||||
// seed: supported-language detection from vk_language (bare and region-tagged) and the
|
||||
// display name sanitized from the client-supplied name. Unlike Telegram there is no
|
||||
// @username fallback — VK provides only the name.
|
||||
func TestVKSeed(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, displayName string
|
||||
wantLang, wantName string
|
||||
}{
|
||||
"ru bare": {"ru", "Иван", "ru", "Иван"},
|
||||
"en region-tagged": {"en-US", "John", "en", "John"},
|
||||
"full name kept": {"ru", "Иван Петров", "ru", "Иван Петров"},
|
||||
"unknown language": {"uk", "Тарас", "", "Тарас"},
|
||||
"empty language": {"", "Neo", "", "Neo"},
|
||||
"trimmed": {" RU ", " Anna ", "ru", "Anna"},
|
||||
"emoji stripped": {"en", "🎮Kaya🎮", "en", "Kaya"},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := vkSeed(tc.languageCode, tc.displayName)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKSeedPlaceholder checks a VK name with no usable letters falls back to a
|
||||
// generated placeholder in the seeded language ("Player-NNNNN" / "Игрок-NNNNN").
|
||||
func TestVKSeedPlaceholder(t *testing.T) {
|
||||
cases := map[string]struct {
|
||||
languageCode, displayName string
|
||||
wantRe string
|
||||
}{
|
||||
"en empty": {"en", "", `^Player-\d{5}$`},
|
||||
"ru empty": {"ru", "", `^Игрок-\d{5}$`},
|
||||
"default en": {"uk", "", `^Player-\d{5}$`},
|
||||
"name garbage": {"ru", "123!@#", `^Игрок-\d{5}$`},
|
||||
}
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := vkSeed(tc.languageCode, tc.displayName).displayName
|
||||
if !regexp.MustCompile(tc.wantRe).MatchString(got) {
|
||||
t.Errorf("displayName = %q, want match %s", got, tc.wantRe)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestSeedVariantsFromStartParam covers decoding a promo deep-link start-param into the
|
||||
// variant-preference set to seed: a valid "v"-prefixed, "-"-joined label list is cleaned
|
||||
// to the canonical order and deduplicated, while anything that is not a variant-seed link
|
||||
// or that names an unknown variant yields nil (leaving the account on its defaults).
|
||||
func TestSeedVariantsFromStartParam(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
param string
|
||||
want []string
|
||||
}{
|
||||
{"english promo", "verudit_ru-scrabble_en", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"single variant", "vscrabble_en", []string{"scrabble_en"}},
|
||||
{"canonical order regardless of payload order", "vscrabble_en-erudit_ru", []string{"erudit_ru", "scrabble_en"}},
|
||||
{"deduplicated", "verudit_ru-erudit_ru", []string{"erudit_ru"}},
|
||||
{"empty", "", nil},
|
||||
{"prefix only", "v", nil},
|
||||
{"routing game link is not a seed", "g0190abcd", nil},
|
||||
{"friend code link is not a seed", "f123456", nil},
|
||||
{"unknown variant rejected", "vscrabble_de", nil},
|
||||
{"one unknown label rejects the whole set", "verudit_ru-scrabble_de", nil},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := SeedVariantsFromStartParam(tc.param)
|
||||
if !slices.Equal(got, tc.want) {
|
||||
t.Errorf("SeedVariantsFromStartParam(%q) = %v, want %v", tc.param, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ func TestRendererRendersEveryPage(t *testing.T) {
|
||||
{"dashboard", DashboardView{Accounts: 3, Variants: []VariantVersions{{Variant: "scrabble_en", Latest: "v1", Versions: []string{"v1"}}}}, "Dashboard"},
|
||||
{"users", UsersView{Items: []UserRow{{ID: "a1", DisplayName: "Kaya", FlaggedHighRate: true}}, Pager: NewPager(1, 50, 1)}, "high-rate"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", HasStats: true, Stats: StatsRow{Wins: 2}, TelegramID: "123", ConnectorEnabled: true}, "Send Telegram message"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", VKID: "494075"}, "vk.com/id494075"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", FlaggedHighRateAt: "2026-06-10 12:00"}, "Clear high-rate flag"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya", Roles: []string{"feedback_banned"}, KnownRoles: []string{"feedback_banned"}}, "feedback_banned"},
|
||||
{"user_detail", UserDetailView{ID: "a1", DisplayName: "Kaya",
|
||||
|
||||
@@ -142,6 +142,11 @@
|
||||
{{else}}<p class="note">connector not configured (set BACKEND_CONNECTOR_ADDR)</p>{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
{{if .VKID}}
|
||||
<section class="panel"><h2>VK</h2>
|
||||
<p>VK ID: <code>{{.VKID}}</code> · <a href="https://vk.com/id{{.VKID}}" target="_blank" rel="noopener">open profile</a></p>
|
||||
</section>
|
||||
{{end}}
|
||||
<section class="panel"><h2>Games</h2>
|
||||
<table class="list">
|
||||
<thead><tr><th>Game</th><th>Variant</th><th>Status</th><th class="num">Players</th><th>Updated</th></tr></thead>
|
||||
|
||||
@@ -162,7 +162,11 @@ type UserDetailView struct {
|
||||
Stats StatsRow
|
||||
Identities []IdentityRow
|
||||
Games []GameRow
|
||||
// TelegramID and VKID are the account's platform external ids (empty when absent).
|
||||
// TelegramID gates the "Send Telegram message" operator action; VKID surfaces the VK
|
||||
// user id with a link to the VK profile (there is no VK messaging to drive).
|
||||
TelegramID string
|
||||
VKID string
|
||||
ConnectorEnabled bool
|
||||
// MoveChart is the pre-rendered inline SVG of the account's per-move-number think
|
||||
// time (min/mean/max), empty when the account has no timed move.
|
||||
|
||||
@@ -154,6 +154,53 @@ func TestProvisionTelegramSeedsNewAccountOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionVKSeedsNewAccountOnly checks VK first contact seeds the new account's
|
||||
// language, display name and time zone from the launch fields / detected offset, records
|
||||
// the vk identity as confirmed (a platform identity), and never overwrites an existing
|
||||
// account on a later launch. It also exercises the widened identities.kind CHECK — a
|
||||
// 'vk' row must insert.
|
||||
func TestProvisionVKSeedsNewAccountOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := account.NewStore(testDB)
|
||||
ext := "vk-" + uuid.NewString()
|
||||
|
||||
acc, created, err := store.ProvisionVK(ctx, ext, "ru", "Иван Петров", "+03:00")
|
||||
if err != nil {
|
||||
t.Fatalf("provision vk: %v", err)
|
||||
}
|
||||
if !created {
|
||||
t.Error("created = false on first contact, want true")
|
||||
}
|
||||
if acc.PreferredLanguage != "ru" {
|
||||
t.Errorf("PreferredLanguage = %q, want ru", acc.PreferredLanguage)
|
||||
}
|
||||
if acc.DisplayName != "Иван Петров" {
|
||||
t.Errorf("DisplayName = %q, want Иван Петров", acc.DisplayName)
|
||||
}
|
||||
if acc.TimeZone != "+03:00" {
|
||||
t.Errorf("TimeZone = %q, want the seeded +03:00", acc.TimeZone)
|
||||
}
|
||||
// A VK identity is a platform identity: confirmed on insert.
|
||||
if !identityConfirmed(t, account.KindVK, ext) {
|
||||
t.Error("vk identity must be confirmed")
|
||||
}
|
||||
|
||||
// A later launch with different fields returns the same account, unchanged.
|
||||
again, created, err := store.ProvisionVK(ctx, ext, "en", "Other Name", "+09:00")
|
||||
if err != nil {
|
||||
t.Fatalf("re-provision vk: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("created = true on a repeat launch, want false")
|
||||
}
|
||||
if again.ID != acc.ID {
|
||||
t.Errorf("re-provision id = %s, want %s", again.ID, acc.ID)
|
||||
}
|
||||
if again.PreferredLanguage != "ru" || again.DisplayName != "Иван Петров" || again.TimeZone != "+03:00" {
|
||||
t.Errorf("existing account overwritten: lang=%q name=%q tz=%q", again.PreferredLanguage, again.DisplayName, again.TimeZone)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProvisionSeedsTimeZone checks the create-time time-zone seed across paths: a
|
||||
// valid detected offset is stored verbatim (even "+00:00", which is deliberately
|
||||
// distinct from the unset "UTC" default), a guest is seeded the same way, and a
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
//go:build integration
|
||||
|
||||
package inttest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap/zaptest"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
"scrabble/backend/internal/server"
|
||||
"scrabble/backend/internal/session"
|
||||
)
|
||||
|
||||
// TestTelegramAuthSeedsPromoVariantForNewUserOnly drives the sessions/telegram endpoint
|
||||
// to confirm a promo deep-link start-param seeds a brand-new account's variant
|
||||
// preferences (English Scrabble alongside the default Erudit), that a new account with no
|
||||
// such payload keeps the Erudit-only default, and that an existing account is never
|
||||
// re-seeded on a later login (the new-user-only contract).
|
||||
func TestTelegramAuthSeedsPromoVariantForNewUserOnly(t *testing.T) {
|
||||
srv := server.New(":0", server.Deps{
|
||||
Logger: zaptest.NewLogger(t),
|
||||
DB: testDB,
|
||||
Accounts: account.NewStore(testDB),
|
||||
Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
|
||||
Notifier: &captureNotifier{},
|
||||
})
|
||||
h := srv.Handler()
|
||||
|
||||
post := func(ext, startParam string) {
|
||||
body := `{"external_id":"` + ext + `","language_code":"en","first_name":"Promo"`
|
||||
if startParam != "" {
|
||||
body += `,"start_param":"` + startParam + `"`
|
||||
}
|
||||
body += `}`
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
store := account.NewStore(testDB)
|
||||
reload := func(ext string) []string {
|
||||
acc, err := store.AccountByIdentity(context.Background(), account.KindTelegram, ext)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup %s: %v", ext, err)
|
||||
}
|
||||
return acc.VariantPreferences
|
||||
}
|
||||
|
||||
// A brand-new account reached through a promo deep-link is seeded with English
|
||||
// Scrabble alongside the default Erudit.
|
||||
promoExt := "tg-" + uuid.NewString()
|
||||
post(promoExt, "verudit_ru-scrabble_en")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("promo new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A brand-new account with no promo payload keeps the Erudit-only default.
|
||||
plainExt := "tg-" + uuid.NewString()
|
||||
post(plainExt, "")
|
||||
if got, want := reload(plainExt), []string{"erudit_ru"}; !slices.Equal(got, want) {
|
||||
t.Errorf("plain new account variants = %v, want %v", got, want)
|
||||
}
|
||||
|
||||
// A later login of the promo account, even via a different payload, must not re-seed:
|
||||
// the seed is first-contact only.
|
||||
post(promoExt, "vscrabble_ru")
|
||||
if got, want := reload(promoExt), []string{"erudit_ru", "scrabble_en"}; !slices.Equal(got, want) {
|
||||
t.Errorf("existing account re-seeded = %v, want unchanged %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Admit the 'vk' platform identity (VK Mini App users) into the identities.kind check
|
||||
-- constraint, alongside telegram/email/robot. Expand-contract: widening the allowed set
|
||||
-- is backward-compatible, so a backend image rollback stays DB-safe — the older code
|
||||
-- simply never writes a 'vk' row. The table shape is unchanged (only the CHECK), so the
|
||||
-- generated go-jet model is not regenerated.
|
||||
|
||||
-- +goose Up
|
||||
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'vk'::text, 'email'::text, 'robot'::text])));
|
||||
|
||||
-- +goose Down
|
||||
ALTER TABLE backend.identities DROP CONSTRAINT identities_kind_chk;
|
||||
ALTER TABLE backend.identities ADD CONSTRAINT identities_kind_chk CHECK ((kind = ANY (ARRAY['telegram'::text, 'email'::text, 'robot'::text])));
|
||||
@@ -27,6 +27,7 @@ func (s *Server) registerRoutes() {
|
||||
if s.sessions != nil && s.accounts != nil {
|
||||
in := s.internal
|
||||
in.POST("/sessions/telegram", s.handleTelegramAuth)
|
||||
in.POST("/sessions/vk", s.handleVKAuth)
|
||||
in.POST("/sessions/guest", s.handleGuestAuth)
|
||||
in.POST("/sessions/email/request", s.handleEmailRequest)
|
||||
in.POST("/sessions/email/login", s.handleEmailLogin)
|
||||
|
||||
@@ -362,6 +362,9 @@ func (s *Server) consoleUserDetail(c *gin.Context) {
|
||||
if tg, err := s.accounts.IdentityExternalID(ctx, id, account.KindTelegram); err == nil {
|
||||
view.TelegramID = tg
|
||||
}
|
||||
if vk, err := s.accounts.IdentityExternalID(ctx, id, account.KindVK); err == nil {
|
||||
view.VKID = vk
|
||||
}
|
||||
if games, err := s.games.ListForAccount(ctx, id); err == nil {
|
||||
for _, g := range games {
|
||||
view.Games = append(view.Games, gameRow(g))
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/account"
|
||||
)
|
||||
@@ -19,13 +20,16 @@ import (
|
||||
// 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; BrowserTZ (the client's detected
|
||||
// "±HH:MM" UTC offset) seeds its time zone (first contact only).
|
||||
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch
|
||||
// deep-link payload, which may seed the new account's variant preferences (first
|
||||
// contact only).
|
||||
type telegramAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
BrowserTZ string `json:"browser_tz"`
|
||||
StartParam string `json:"start_param"`
|
||||
}
|
||||
|
||||
// handleTelegramAuth provisions (or finds) the account bound to a Telegram
|
||||
@@ -47,6 +51,47 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
|
||||
// joined the chat before registering is granted on the spot (no chat_member
|
||||
// event fires on registration).
|
||||
s.publishChatAccessChange(acc.ID)
|
||||
// A promo deep-link may seed this brand-new account's variant preferences (e.g.
|
||||
// English Scrabble alongside the default Erudit). Best-effort: an absent or
|
||||
// malformed payload leaves the account on its defaults, and a write failure must
|
||||
// not block the session mint.
|
||||
if seed := account.SeedVariantsFromStartParam(req.StartParam); len(seed) > 0 {
|
||||
if _, err := s.accounts.SetVariantPreferences(c.Request.Context(), acc.ID, seed); err != nil {
|
||||
s.log.Warn("telegram: seed variant preferences failed",
|
||||
zap.String("account", acc.ID.String()), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
// vkAuthRequest carries the identity the gateway extracted from verified VK launch
|
||||
// params. LanguageCode (vk_language) and DisplayName (read client-side via
|
||||
// VKWebAppGetUserInfo, since VK omits the name from the signed params) seed a brand-new
|
||||
// account's language and display name; BrowserTZ (the client's detected "±HH:MM" UTC
|
||||
// offset) seeds its time zone. All seeds apply on first contact only.
|
||||
type vkAuthRequest struct {
|
||||
ExternalID string `json:"external_id"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
DisplayName string `json:"display_name"`
|
||||
BrowserTZ string `json:"browser_tz"`
|
||||
}
|
||||
|
||||
// handleVKAuth provisions (or finds) the account bound to a VK identity and mints a
|
||||
// session for it, seeding a new account's language and display name from the supplied VK
|
||||
// fields (first contact only). Unlike Telegram there is no moderated-chat re-evaluation
|
||||
// or deep-link variant seed: a fresh VK account has no Telegram chat eligibility and the
|
||||
// MVP carries no launch deep link.
|
||||
func (s *Server) handleVKAuth(c *gin.Context) {
|
||||
var req vkAuthRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.ExternalID == "" {
|
||||
abortBadRequest(c, "external_id is required")
|
||||
return
|
||||
}
|
||||
acc, _, err := s.accounts.ProvisionVK(c.Request.Context(), req.ExternalID, req.LanguageCode, req.DisplayName, req.BrowserTZ)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
s.mintSession(c, acc)
|
||||
}
|
||||
|
||||
@@ -47,9 +47,17 @@ AWG_CONF= # required; AmneziaWG sidecar config (the
|
||||
TELEGRAM_BOT_TOKEN= # required
|
||||
TELEGRAM_GAME_CHANNEL_ID=
|
||||
TELEGRAM_CHAT_ID= # moderated discussion chat (channel's linked group); empty disables gating
|
||||
TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the support relay (topic per user); empty disables it
|
||||
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; empty disables it
|
||||
TELEGRAM_BOT_USERNAME= # main bot @username without the @ (promo message); required when the promo token is set
|
||||
TELEGRAM_BOT_LINK= # main bot Mini App link for the promo button (reuse VITE_TELEGRAM_LINK); required when the promo token is set
|
||||
TELEGRAM_PROMO_START_PARAM= # promo button startapp payload — a variant-seed deep link (default verudit_ru-scrabble_en) adding English Scrabble for new users; empty forwards the user's /start payload
|
||||
TELEGRAM_MINIAPP_URL= # required
|
||||
TELEGRAM_TEST_ENV=false
|
||||
TELEGRAM_API_BASE_URL=
|
||||
|
||||
# --- VK Mini App ------------------------------------------------------------
|
||||
# The VK app's "protected key" (client_secret): the gateway verifies the Mini App
|
||||
# launch-parameter signature in-process under it (a pure offline HMAC, no VK API call).
|
||||
# Empty disables the VK auth path (auth.vk). Set from the Gitea TEST_/PROD_ secret.
|
||||
GATEWAY_VK_APP_SECRET=
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Edge reverse proxy for the Scrabble contour. A single Basic-Auth gate covers
|
||||
# every operator surface under /_gm (the backend-rendered admin console and the
|
||||
# Grafana subpath); the game SPA (/app/, /telegram/) and the Connect edge go to
|
||||
# Grafana subpath); the game SPA (/app/, /telegram/, /vk/) and the Connect edge go to
|
||||
# the gateway; the catch-all — notably the public landing at / — goes to the
|
||||
# static landing container, so stray traffic never reaches the Go edge.
|
||||
# Mirrors ../galaxy-game's /_gm model.
|
||||
@@ -53,7 +53,7 @@
|
||||
# The game SPA and the Connect edge are served by the gateway. Strip any
|
||||
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
|
||||
# tag the honeypot block sets below (a client cannot self-tag a real request).
|
||||
@gateway path /app /app/* /telegram /telegram/* /scrabble.edge.v1.Gateway/*
|
||||
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /scrabble.edge.v1.Gateway/*
|
||||
handle @gateway {
|
||||
reverse_proxy gateway:8081 {
|
||||
header_up -X-Scrabble-Honeypot
|
||||
|
||||
@@ -23,9 +23,15 @@ services:
|
||||
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:?set TELEGRAM_BOT_TOKEN}
|
||||
TELEGRAM_GAME_CHANNEL_ID: ${TELEGRAM_GAME_CHANNEL_ID:-}
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
# Support relay: a private forum supergroup the bot relays direct user messages
|
||||
# into (one topic per user). 0/unset disables it; the bot needs admin there with
|
||||
# the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
||||
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
|
||||
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
|
||||
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
|
||||
TELEGRAM_TEST_ENV: "false"
|
||||
@@ -46,8 +52,13 @@ services:
|
||||
GOMAXPROCS: "1"
|
||||
volumes:
|
||||
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
||||
# Support relay state (topic mapping, block list); survives redeploys.
|
||||
- bot-state:/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: 256M
|
||||
|
||||
volumes:
|
||||
bot-state:
|
||||
|
||||
@@ -147,6 +147,10 @@ services:
|
||||
GATEWAY_BACKEND_GRPC_ADDR: backend:9090
|
||||
# Telegram auth validates against the home validator (plaintext, internal).
|
||||
GATEWAY_VALIDATOR_ADDR: validator:9091
|
||||
# VK Mini App auth verifies the launch-parameter signature in-process under the VK
|
||||
# app's protected key (a pure offline HMAC, no VK API round-trip). Empty disables
|
||||
# the VK auth path (auth.vk is then unregistered).
|
||||
GATEWAY_VK_APP_SECRET: ${GATEWAY_VK_APP_SECRET:-}
|
||||
# The reverse bot-link: the bot dials :9443 over mTLS; the backend admin relay
|
||||
# reaches the gateway at :9092 (plaintext, internal). In the test contour both
|
||||
# listeners stay on the internal network (the bot shares the VPN netns); in prod
|
||||
@@ -292,6 +296,11 @@ services:
|
||||
# only restricts (mutes the ineligible) — and the bot must be an admin there with the
|
||||
# "Ban users" right; chat_member updates are delivered only to a chat admin.
|
||||
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||
# The private forum supergroup the bot relays direct user messages into (one topic
|
||||
# per user) and reads operator replies from. Empty disables the support relay; when
|
||||
# set the bot must be an admin there with the manage-topics and delete-messages rights.
|
||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
||||
# The optional standalone promo bot (its own token) answering /start with a button
|
||||
# into the main bot's app. Empty disables it; when set it needs the main bot's
|
||||
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
|
||||
@@ -325,6 +334,8 @@ services:
|
||||
GOMAXPROCS: "1"
|
||||
volumes:
|
||||
- ${SCRABBLE_CONFIG_DIR:-.}/certs:/certs:ro
|
||||
# Support relay state (topic mapping, block list); survives redeploys.
|
||||
- bot-state:/data
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
@@ -496,3 +507,4 @@ volumes:
|
||||
prometheus-data:
|
||||
tempo-data:
|
||||
grafana-data:
|
||||
bot-state:
|
||||
|
||||
+50
-12
@@ -42,7 +42,12 @@ Three executables plus per-platform side-services:
|
||||
users, a weighted fair rotation — §10),
|
||||
and a client **board-style** setting (bonus-label
|
||||
mode). The visual/interaction design system is documented in
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md).
|
||||
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally
|
||||
tracks Telegram's live theme switch (`themeChanged`), fits the full device safe-area
|
||||
insets (the bottom/home-indicator strip taking the bottom bar's colour), exposes
|
||||
Telegram's native **Settings** button into the in-app settings, and syncs the
|
||||
device-independent display preferences (theme, reduce-motion, board labels — **not** the
|
||||
interface language) across the user's Telegram devices via **CloudStorage**.
|
||||
- **`platform/telegram`** — the Telegram side-service (module
|
||||
`scrabble/platform/telegram`), split into two binaries that share the bot token
|
||||
(**one bot**, one optional game channel, §3):
|
||||
@@ -144,15 +149,23 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
|
||||
- The gateway validates the originating credential **once** — Telegram `initData`
|
||||
(delegated to the **validator's** `ValidateInitData` RPC, which holds the bot token —
|
||||
the HMAC secret — so it never reaches the gateway), an email-code login, or a guest
|
||||
the HMAC secret — so it never reaches the gateway), a **VK Mini App** launch (verified
|
||||
**in-process** by `gateway/internal/vkauth`: HMAC-SHA256 over the signed `vk_*` params
|
||||
under the VK app's protected key `GATEWAY_VK_APP_SECRET`, base64url — a pure offline check,
|
||||
as VK signing needs no API round-trip, so no side-service), 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 validator runs on the main host and never reaches the Bot
|
||||
API, so login does not depend on Telegram or the remote bot being up (§10, §12).
|
||||
Telegram/VK contact seeds the new account's language (Telegram's `language_code` / VK's
|
||||
`vk_language`) and display name (§4; VK omits the name from the signed params, so the client
|
||||
reads it via `VKWebAppGetUserInfo` as an unsigned, cosmetic seed). The validator runs on the
|
||||
main host and never reaches the Bot API, so login does not depend on Telegram or the remote
|
||||
bot being up (§10, §12). VK launch params carry no built-in expiry (unlike Telegram's
|
||||
`auth_date`), so freshness is not enforced — the minted session is the short-lived credential,
|
||||
and a replay only re-authenticates the same `vk_user_id`.
|
||||
- **Single bot.** The platform side-service runs **one bot** (one token + one optional
|
||||
game channel), split into a home **validator** and a remote **bot** that share the
|
||||
token. `ValidateInitData` (the validator) validates `initData` against that single
|
||||
token and returns only the Telegram user identity — there is no per-bot "service
|
||||
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram
|
||||
user identity — there is no per-bot "service
|
||||
language" and no supported-languages set on the wire. The bot's chat messages and
|
||||
out-of-app push are
|
||||
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
|
||||
@@ -204,7 +217,7 @@ arrive from a platform rather than completing a mandatory registration).
|
||||
> recipient's interface language (`preferred_language`), with no per-bot routing. New
|
||||
> Game variant gating moved off the login language onto a per-user profile setting
|
||||
> `variant_preferences` (default Erudit only, server-enforced on the caller's create
|
||||
> paths; an invited friend may still accept any variant). The per-bot env vars and
|
||||
> paths; an invited friend may still accept any variant, and a Telegram **promo deep-link** seeds extra variants — e.g. English Scrabble — onto a brand-new account via the validated `start_param`). The per-bot env vars and
|
||||
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
|
||||
> `service_language`/`supported_languages` and the push `language` routing field, and
|
||||
> gained `variant_preferences` on Profile/UpdateProfile.
|
||||
@@ -972,7 +985,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
|
||||
| Concern | Enforced by |
|
||||
| --- | --- |
|
||||
| Public rate limiting / anti-abuse | gateway (per-IP public/email/admin classes, per-user authenticated class; a request body cap of `GATEWAY_MAX_BODY_BYTES`; rejections are metered, summarised to the backend and surfaced in the admin console with a conservative reversible auto-flag — §11). In prod a **temporary IP ban** (`GATEWAY_ABUSE_BAN_ENABLED`) blocks an IP that sustains rejections or trips a **honeypot** decoy path / **honeytoken**, refused with 429 before any work; operators lift bans from the console. Off in the shared-NAT test contour, where the client IP is not real (§11) |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway |
|
||||
| Telegram initData validation (bot-token HMAC) | the Telegram **validator**; the gateway delegates it over gRPC, so the bot token (the HMAC secret) lives only in the validator and the bot, never in the gateway. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned |
|
||||
| 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) |
|
||||
@@ -1040,10 +1053,11 @@ a dedicated redeem sub-limit or a longer code is the hardening step if abuse app
|
||||
Single public origin, path-routed. The Vite build has two entries: a lightweight
|
||||
**landing page** and the game **SPA**. The gateway **embeds** the SPA build
|
||||
(`go:embed`, baked in by a node stage in `gateway/Dockerfile`) and serves it at
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
|
||||
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
|
||||
of redirecting away); a stray hit on the gateway's `/`
|
||||
308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
|
||||
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
|
||||
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
|
||||
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
|
||||
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||
static file serving and never reaches the Go edge. Hash-named `/assets/*` are served
|
||||
@@ -1052,7 +1066,7 @@ static file serving and never reaches the Go edge. Hash-named `/assets/*` are se
|
||||
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
|
||||
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
|
||||
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
|
||||
**admin console**; `/app/`, `/telegram/` and the Connect path go to the gateway; the
|
||||
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
|
||||
catch-all — notably the landing at `/` — goes to the landing container. The
|
||||
**Telegram validator** runs as a separate container with **no public ingress**,
|
||||
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
|
||||
@@ -1200,3 +1214,27 @@ blocks **only** feedback submission (unlike a suspension, the whole-account bloc
|
||||
granted from the feedback section (the delete-with-block checkbox) and granted/revoked from the
|
||||
`/users` console card. Roles are validated against a known set in Go, so adding one needs no
|
||||
migration.
|
||||
|
||||
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct
|
||||
support channel for users who message it on Telegram. Any message other than `/start` is relayed
|
||||
into a private **forum supergroup** (`TELEGRAM_SUPPORT_CHAT_ID`): a user's first message opens a
|
||||
dedicated **forum topic** whose first message is an info card (the name is a tappable profile
|
||||
mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as
|
||||
a command — plus @username, language, premium, id) carrying a Block/Unblock toggle and a Clear
|
||||
button; every message is then
|
||||
copied into that topic (`copyMessage`, so any content — text, media, voice, files — carries over).
|
||||
Any **administrator** of the support chat who writes in a user's topic has their message copied
|
||||
back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
|
||||
user's incoming messages (the topic stays); Clear deletes the relayed messages, keeping the info
|
||||
card; a topic the operators delete is reopened on the next message. State (user→topic map, block
|
||||
list, relayed message ids) is a small JSON file on a writable volume — the bot host has no database
|
||||
and cannot reach Postgres. The relay is **bot-local**: it touches neither the backend, the gateway,
|
||||
nor `feedback_messages`. The bot must be an administrator in the forum group with the manage-topics
|
||||
and delete-messages rights.
|
||||
|
||||
> **Decision (2026-06-23) — bot-local support relay over forum topics.** The direct Telegram
|
||||
> support channel lives entirely in the bot (no backend, no bot-link command), because the bot host
|
||||
> has no database. One forum **topic per user** (not a reply-to-header thread) gives native per-user
|
||||
> separation and survives message deletion; the topic id, not a fragile header message, anchors the
|
||||
> mapping. Operators are the support chat's administrators (no separate owner id); messages relay
|
||||
> both ways with `copyMessage`. State persists as JSON on a dedicated volume.
|
||||
|
||||
+24
-6
@@ -22,7 +22,7 @@ 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.
|
||||
A public **landing page** at the site root introduces the game, switches language and
|
||||
theme, and links to the matching per-language Telegram channel; the game itself runs at
|
||||
`/app/` (web) and `/telegram/` (the Telegram Mini App). The landing's theme is ephemeral
|
||||
`/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App). The landing's theme is ephemeral
|
||||
(it follows the system scheme, not the saved preference); its language choice is saved.
|
||||
|
||||
### Identity & sessions
|
||||
@@ -30,10 +30,15 @@ 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`. 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
|
||||
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits
|
||||
the device safe-area, and — on first contact — seeds the new account's interface
|
||||
language from the Telegram client. If a launch cannot reach the backend (for example during a
|
||||
deployment), the Mini App retries quietly and then shows a small "couldn't load" screen with a
|
||||
**Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
|
||||
A **VK Mini App** launch works the same way: it authenticates from VK's signed launch parameters
|
||||
(verified by the gateway), and on first contact seeds the new account's interface language from
|
||||
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
|
||||
the name in the signed launch). The same quiet-retry "couldn't load" screen applies inside VK.
|
||||
Telegram runs a **single bot**: every player uses
|
||||
the same bot, and all of its chat and out-of-app notifications are written in the
|
||||
player's own **interface language** (en/ru). A separate optional **promo bot** can run alongside the
|
||||
@@ -120,7 +125,7 @@ and any incidental perpendicular words are ignored and not scored — while on i
|
||||
standard Scrabble. English games are always standard and show no such toggle. In auto-match
|
||||
the choice joins the pairing key, so a player only meets opponents who picked the same rule. Friend games (2–4) are
|
||||
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
|
||||
is shareable as a Telegram or VK 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.
|
||||
|
||||
@@ -249,12 +254,16 @@ is first created — so robot games are timed correctly before you ever open thi
|
||||
daily away window (on a 10-minute grid, at most 12 hours, wrapping midnight) and the
|
||||
block toggles. The profile form is edited inline (no separate edit mode). Linking
|
||||
an email or Telegram and merging accounts are covered under "Accounts, linking &
|
||||
merge".
|
||||
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
|
||||
entry that opens this screen, and your display preferences (theme, board-label style and
|
||||
reduce-motion — not the interface language, which follows your account) sync across your
|
||||
Telegram devices.
|
||||
|
||||
**Preferences (which variants you can be matched into).** A profile setting picks the game
|
||||
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
|
||||
yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
|
||||
least one** selected. This list is exactly what **New Game** offers when you start a game
|
||||
yourself to be matched into; a **new account starts with Erudite only** — unless it was created
|
||||
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep
|
||||
**at least one** selected. This list is exactly what **New Game** offers when you start a game
|
||||
(auto-match, an AI game, or a friend invitation you create) — a variant you have not enabled is
|
||||
not offered, and the server refuses it. It does not restrict games you are **invited** to: an
|
||||
invited friend may accept an invitation in **any** variant, and you can always open and play
|
||||
@@ -272,6 +281,15 @@ marked read once the screen shows it and disappears a week later. A badge on the
|
||||
unanswered reply. Guests cannot send feedback (the entry is hidden). A player the operator has
|
||||
barred from feedback (a role, not a full account block) sees the send control disabled.
|
||||
|
||||
### Telegram support chat
|
||||
A user can also reach the operators straight from the Telegram bot: anything they send the bot
|
||||
other than `/start` — text, a photo, a voice message, a file — is forwarded into the operators'
|
||||
private support group, grouped into a per-user thread. The user sees no automatic reply; an
|
||||
operator answers from that thread and the bot delivers the answer back as an ordinary bot message,
|
||||
so to the user it is a quiet one-to-one conversation. Operators can block a user (the bot then
|
||||
silently ignores their messages) or clear a thread's messages. This is independent of the in-app
|
||||
Feedback above — it serves people who write to the bot directly rather than through the app.
|
||||
|
||||
### History & statistics
|
||||
Finished games are archived in a dictionary-independent form and exportable to
|
||||
GCG; the export is offered **only once a game is finished**, and never for an
|
||||
|
||||
+27
-7
@@ -23,7 +23,7 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
и ограничивает частоту повторов.
|
||||
Публичная **посадочная страница** в корне сайта представляет игру, переключает язык и
|
||||
тему и ведёт в соответствующий по-язычный Telegram-канал; сама игра живёт по адресам
|
||||
`/app/` (веб) и `/telegram/` (Telegram Mini App). Тема на странице эфемерна (берётся из
|
||||
`/app/` (веб), `/telegram/` (Telegram Mini App) и `/vk/` (VK Mini App). Тема на странице эфемерна (берётся из
|
||||
системной настройки, а не из сохранённой), выбор языка сохраняется.
|
||||
|
||||
### Личность и сессии
|
||||
@@ -31,11 +31,17 @@ top-1 подсказку, безлимитную проверку слова с
|
||||
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
|
||||
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
|
||||
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
|
||||
в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
|
||||
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и
|
||||
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
|
||||
интерфейса нового аккаунта по
|
||||
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
|
||||
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
|
||||
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
|
||||
Telegram не место. Telegram держит **единого бота**: все игроки пользуются одним
|
||||
Telegram не место. Запуск **VK Mini App** работает так же: авторизует по подписанным
|
||||
launch-параметрам VK (их проверяет gateway), и при первом контакте задаёт язык интерфейса
|
||||
нового аккаунта по `vk_language`, а отображаемое имя — по профилю VK (читается на клиенте,
|
||||
так как VK не кладёт имя в подписанный запуск). Тот же экран тихого повтора «не удалось
|
||||
загрузить» действует и внутри VK. Telegram держит **единого бота**: все игроки пользуются одним
|
||||
и тем же ботом, а весь его чат и внеприложенческие уведомления пишутся на **языке
|
||||
интерфейса** самого игрока (en/ru). Рядом с основным может работать отдельный опциональный
|
||||
**промо-бот** — его единственная задача отвечать на `/start` коротким сообщением и кнопкой,
|
||||
@@ -125,7 +131,7 @@ _Вход сейчас только через провайдера, поэто
|
||||
показывают. В авто-подборе выбор входит в ключ подбора, поэтому игрок сводится только с теми,
|
||||
кто выбрал то же правило. Игры с друзьями (2–4)
|
||||
формируются приглашением игроков из списка друзей (приглашение, как и код друга,
|
||||
можно отправить deep-link'ом в Telegram, который откроет его сразу): инициатор
|
||||
можно отправить deep-link'ом в Telegram или VK, который откроет его сразу): инициатор
|
||||
выбирает настройки, и партия стартует, когда приняли все приглашённые — любой отказ отменяет приглашение, а без
|
||||
ответа приглашение протухает через семь дней.
|
||||
|
||||
@@ -255,12 +261,17 @@ UTC; при создании аккаунта она подставляется
|
||||
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
|
||||
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
|
||||
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
|
||||
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram
|
||||
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
|
||||
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
|
||||
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
|
||||
Telegram.
|
||||
|
||||
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
|
||||
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
|
||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
|
||||
оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только
|
||||
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, —
|
||||
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
|
||||
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
|
||||
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
|
||||
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
|
||||
@@ -278,6 +289,15 @@ UTC; при создании аккаунта она подставляется
|
||||
ответе. Гость отправлять обратную связь не может (пункт скрыт). Игрок, которому оператор запретил
|
||||
обратную связь (роль, а не полная блокировка аккаунта), видит кнопку отправки недоступной.
|
||||
|
||||
### Чат поддержки в Telegram
|
||||
С операторами можно поговорить и прямо из Telegram-бота: всё, что пользователь присылает боту,
|
||||
кроме `/start` — текст, фото, голосовое, файл, — пересылается в закрытую группу поддержки операторов
|
||||
и собирается в отдельную ветку на пользователя. Пользователь не получает автоответа; оператор
|
||||
отвечает из этой ветки, и бот доставляет ответ обратно обычным сообщением — для пользователя это
|
||||
тихий диалог один на один. Операторы могут заблокировать пользователя (тогда бот молча игнорирует
|
||||
его сообщения) или очистить сообщения ветки. Это независимо от встроенной «Обратной связи» выше —
|
||||
канал для тех, кто пишет боту напрямую, а не через приложение.
|
||||
|
||||
### История и статистика
|
||||
Завершённые партии архивируются в независимом от словаря виде и экспортируются
|
||||
в GCG; экспорт доступен **только после завершения партии** и никогда — для
|
||||
|
||||
+10
-8
@@ -97,14 +97,16 @@ dismisses as soon as the lobby is ready. The pure layout and timing live in `lib
|
||||
which leaks into the Telegram Desktop webview and otherwise fights it) and the Settings
|
||||
theme switcher is hidden; the nav bar takes Telegram's background and `setHeaderColor` /
|
||||
`setBackgroundColor` / `setBottomBarColor` paint Telegram's own chrome to match; the
|
||||
native header **BackButton** drives back-navigation (the app's chevron is hidden in
|
||||
Telegram); **HapticFeedback** fires on tile placement / commit / error; on **mobile**
|
||||
clients the app enters **immersive fullscreen** on launch (`requestFullscreen`, Bot API
|
||||
8.0+) like Telegram's own Mini Apps, while desktop keeps the bot's full-size window;
|
||||
**closing confirmation** is enabled while a game is open **on mobile only** (on desktop
|
||||
closing is deliberate and the "changes may not be saved" dialog is just noise — move drafts
|
||||
auto-save); **vertical swipes** (swipe-to-minimise)
|
||||
are disabled so they don't fight tile drag or the board scroll; **external links** (the word-check
|
||||
app's **own back chevron** (Header) drives back-navigation on every platform — the native
|
||||
Telegram BackButton is not used, as it does not render reliably in the windowed Mini App;
|
||||
**HapticFeedback** fires on tile placement / commit / error; the app calls `expand()` for the
|
||||
bot's full-size (max-height) window but **never `requestFullscreen`** — immersive fullscreen hid
|
||||
the native header (and its BackButton) and the Android system swipe-back then minimised the app,
|
||||
so it stays windowed with Telegram's thin native header (close) above the app's own header; move
|
||||
drafts auto-save, so there is **no closing-confirmation guard**; a hidden **debug panel** (ten
|
||||
quick taps on the header title) shows and shares a privacy-safe client diagnostic snapshot for
|
||||
support; **vertical swipes** (swipe-to-minimise) are disabled so they don't fight tile drag or
|
||||
the board scroll; **external links** (the word-check
|
||||
dictionary lookup, the rules link, operator-reply links) open through `Telegram.WebApp.openLink`
|
||||
so Telegram shows them in its in-app browser instead of the WebView's "open this link?"
|
||||
confirmation a plain `target=_blank` triggers; and a live stream dropped
|
||||
|
||||
+11
-3
@@ -7,7 +7,7 @@ thin opaque session, rate-limits, injects `X-User-ID` when forwarding to the
|
||||
backend over REST/JSON, and bridges the backend's gRPC push stream to each
|
||||
client's in-app live channel. It **embeds the static UI build** (`go:embed`, baked
|
||||
in by the gateway image's node stage) and serves a **landing page** at `/` and the game
|
||||
**SPA** at `/app/` (web) and `/telegram/` (the Mini App) — the single-origin model.
|
||||
**SPA** at `/app/` (web), `/telegram/` (the Telegram Mini App) and `/vk/` (the VK Mini App) — the single-origin model.
|
||||
Hash-named `/assets/*` are served `immutable`; the HTML shells are `no-cache`. It can also serve the
|
||||
backend's admin console at `/_gm` behind HTTP Basic-Auth for a local non-caddy run;
|
||||
in the deployed contour the front caddy owns `/_gm` (see
|
||||
@@ -29,7 +29,7 @@ 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 (+ the in-memory active_users gauge)
|
||||
internal/admin/ # Basic-Auth reverse proxy mounting the backend admin console at /_gm (verbatim)
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/
|
||||
internal/webui/ # embedded UI build (go:embed dist): landing at /, SPA at /app/ + /telegram/ + /vk/
|
||||
```
|
||||
|
||||
The FlatBuffers payloads and the backend push proto are the shared wire
|
||||
@@ -54,7 +54,14 @@ them down the same link and awaits the bot's ack (ARCHITECTURE.md §10/§12). Wh
|
||||
`GATEWAY_VALIDATOR_ADDR` is unset Telegram auth is disabled; when `GATEWAY_BOTLINK_ADDR`
|
||||
is unset the bot channel (out-of-app push + admin relay) is disabled.
|
||||
|
||||
The message-type catalog: `auth.telegram`, `auth.guest`,
|
||||
`auth.vk` validates a VK Mini App launch **in-process** (`internal/vkauth`): it verifies the
|
||||
signed `vk_*` launch parameters against the VK app's protected key (`GATEWAY_VK_APP_SECRET`) —
|
||||
HMAC-SHA256 over the sorted params, base64url — a pure offline check with no side-service or VK API
|
||||
round-trip, then forwards the trusted `vk_user_id` (and the client-read display name, which VK omits
|
||||
from the signed params) to the backend. When `GATEWAY_VK_APP_SECRET` is unset VK auth is disabled
|
||||
(`auth.vk` is unregistered).
|
||||
|
||||
The message-type catalog: `auth.telegram`, `auth.vk`, `auth.guest`,
|
||||
`auth.email.request`, `auth.email.login`, `profile.get`, `game.submit_play`,
|
||||
`game.state`, `lobby.enqueue`, `lobby.poll`, `chat.post`, `chat.read` and the play-loop ops;
|
||||
live events
|
||||
@@ -81,6 +88,7 @@ validator (`ValidateLoginWidget`) and forward the trusted `external_id`. These
|
||||
| `GATEWAY_BACKEND_TIMEOUT` | `5s` | per backend REST call |
|
||||
| `GATEWAY_ADMIN_USER` / `GATEWAY_ADMIN_PASSWORD` | unset | enable + guard the admin console at `/_gm` |
|
||||
| `GATEWAY_VALIDATOR_ADDR` | unset | Telegram validator gRPC address (enables initData / Login Widget validation) |
|
||||
| `GATEWAY_VK_APP_SECRET` | unset | VK app protected key; enables in-process VK Mini App launch-signature verification (`auth.vk`) |
|
||||
| `GATEWAY_BOTLINK_ADDR` | unset | reverse mTLS bot-link listener the remote bot dials (enables out-of-app push + admin relay) |
|
||||
| `GATEWAY_BOTLINK_RELAY_ADDR` | unset | plaintext internal listener serving the backend admin `SendToUser`/`SendToGameChannel` relay |
|
||||
| `GATEWAY_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | unset | gateway server cert, its key, and the CA that signs accepted bot client certs (required when `GATEWAY_BOTLINK_ADDR` is set) |
|
||||
|
||||
@@ -190,7 +190,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
||||
logger.Info("admin console disabled (set GATEWAY_ADMIN_USER and GATEWAY_ADMIN_PASSWORD)")
|
||||
}
|
||||
|
||||
registry := transcode.NewRegistry(backend, validator)
|
||||
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret))
|
||||
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||
Registry: registry,
|
||||
Sessions: sessions,
|
||||
|
||||
@@ -184,10 +184,10 @@ type ChatResp struct {
|
||||
}
|
||||
|
||||
// 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 and
|
||||
// its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
|
||||
// contact only).
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
|
||||
// brand-new account's display name and language from the validated launch fields, its
|
||||
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the
|
||||
// validated launch deep-link startParam, its variant preferences (first contact only).
|
||||
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
|
||||
map[string]string{
|
||||
@@ -196,6 +196,24 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
|
||||
"username": username,
|
||||
"first_name": firstName,
|
||||
"browser_tz": browserTz,
|
||||
"start_param": startParam,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
// VKAuth provisions/finds the VK account and mints a session, seeding a brand-new
|
||||
// account's preferred language from languageCode (the vk_language hint), its display
|
||||
// name from displayName (read client-side via VKWebAppGetUserInfo, since VK omits the
|
||||
// name from the signed launch params) and its time zone from browserTz (the client's
|
||||
// detected "±HH:MM" UTC offset). All seeds apply on first contact only.
|
||||
func (c *Client) VKAuth(ctx context.Context, externalID, languageCode, displayName, browserTz string) (SessionResp, error) {
|
||||
var out SessionResp
|
||||
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/vk", "", "",
|
||||
map[string]string{
|
||||
"external_id": externalID,
|
||||
"language_code": languageCode,
|
||||
"display_name": displayName,
|
||||
"browser_tz": browserTz,
|
||||
}, &out)
|
||||
return out, err
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ type Config struct {
|
||||
// plaintext, internal). The gateway calls it to validate Mini App initData and
|
||||
// Login Widget data. Empty disables the telegram auth path.
|
||||
ValidatorAddr string
|
||||
// VKAppSecret is the VK Mini App protected ("secure") key. The gateway verifies the
|
||||
// VK launch-parameter signature in-process under it (a pure offline HMAC, no VK API
|
||||
// round-trip). Empty disables the VK auth path (auth.vk is then unregistered).
|
||||
VKAppSecret string
|
||||
// BotLink configures the reverse mTLS channel to the remote Telegram bot. An
|
||||
// empty BotLink.Addr disables the bot channel (out-of-app push and admin relay).
|
||||
BotLink BotLinkConfig
|
||||
@@ -169,6 +173,7 @@ func Load() (Config, error) {
|
||||
AdminUser: os.Getenv("GATEWAY_ADMIN_USER"),
|
||||
AdminPassword: os.Getenv("GATEWAY_ADMIN_PASSWORD"),
|
||||
ValidatorAddr: os.Getenv("GATEWAY_VALIDATOR_ADDR"),
|
||||
VKAppSecret: os.Getenv("GATEWAY_VK_APP_SECRET"),
|
||||
SessionCacheMax: defaultSessionCacheMax,
|
||||
RateLimit: DefaultRateLimit(),
|
||||
Abuse: DefaultAbuse(),
|
||||
|
||||
@@ -183,14 +183,15 @@ func (s *Server) HTTPHandler() http.Handler {
|
||||
// does not serve the app shell at the operator path.
|
||||
mux.Handle("/_gm/", http.NotFoundHandler())
|
||||
}
|
||||
// The embedded UI: the game SPA under /app/ (web) and /telegram/ (the Telegram
|
||||
// Mini App) — the single-origin model (docs/ARCHITECTURE.md §13). Both sit below
|
||||
// the h2c wrap so the Connect edge (a more specific prefix) keeps priority, and
|
||||
// each mount falls back to the app shell (index.html) for the hash router. The
|
||||
// public landing lives in its own static container behind the contour caddy,
|
||||
// so the catch-all redirects a stray root hit to the app shell — which
|
||||
// keeps a local no-caddy run usable.
|
||||
// The embedded UI: the game SPA under /app/ (web), /telegram/ (the Telegram Mini
|
||||
// App) and /vk/ (the VK Mini App) — the single-origin model (docs/ARCHITECTURE.md
|
||||
// §13). All sit below the h2c wrap so the Connect edge (a more specific prefix) keeps
|
||||
// priority, and each mount falls back to the app shell (index.html) for the hash
|
||||
// router. The public landing lives in its own static container behind the contour
|
||||
// caddy, so the catch-all redirects a stray root hit to the app shell — which keeps a
|
||||
// local no-caddy run usable.
|
||||
mux.Handle("/telegram/", webui.Handler("/telegram/", "index.html"))
|
||||
mux.Handle("/vk/", webui.Handler("/vk/", "index.html"))
|
||||
mux.Handle("/app/", webui.Handler("/app/", "index.html"))
|
||||
mux.Handle("/", http.RedirectHandler("/app/", http.StatusPermanentRedirect))
|
||||
// abuseGuard is the outermost wrap (right under h2c) so a banned IP or a
|
||||
|
||||
@@ -9,15 +9,18 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/url"
|
||||
|
||||
"scrabble/gateway/internal/backendclient"
|
||||
"scrabble/gateway/internal/connector"
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
// Message types in the vertical slice.
|
||||
const (
|
||||
MsgAuthTelegram = "auth.telegram"
|
||||
MsgAuthVK = "auth.vk"
|
||||
MsgAuthGuest = "auth.guest"
|
||||
MsgAuthEmailReq = "auth.email.request"
|
||||
MsgAuthEmailLogin = "auth.email.login"
|
||||
@@ -88,8 +91,9 @@ type TelegramValidator interface {
|
||||
|
||||
// NewRegistry builds the slice's message-type catalog over the backend client.
|
||||
// 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 {
|
||||
// connector is configured); otherwise auth.telegram is simply unknown. Optional ops
|
||||
// (e.g. WithVKAuth) are applied last from opts.
|
||||
func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Option) *Registry {
|
||||
r := &Registry{ops: make(map[string]Op)}
|
||||
if tg != nil {
|
||||
r.ops[MsgAuthTelegram] = Op{Handler: authTelegramHandler(backend, tg)}
|
||||
@@ -125,9 +129,27 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator) *Registry
|
||||
r.ops[MsgFeedbackUnread] = Op{Handler: feedbackUnreadHandler(backend), Auth: true}
|
||||
registerSocialOps(r, backend)
|
||||
registerLinkOps(r, backend, tg)
|
||||
for _, opt := range opts {
|
||||
opt(r, backend)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// Option configures an optional registry operation at construction. It is kept out of
|
||||
// NewRegistry's positional signature so existing call sites stay unaffected.
|
||||
type Option func(r *Registry, backend *backendclient.Client)
|
||||
|
||||
// WithVKAuth registers the VK Mini App auth op (auth.vk), which verifies launch params
|
||||
// in-process under the VK app secret. A blank secret leaves auth.vk unregistered, so
|
||||
// the op is simply unknown wherever VK is not configured.
|
||||
func WithVKAuth(secret string) Option {
|
||||
return func(r *Registry, backend *backendclient.Client) {
|
||||
if secret != "" {
|
||||
r.ops[MsgAuthVK] = Op{Handler: authVKHandler(backend, secret)}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the operation for messageType, and whether it is registered.
|
||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||
op, ok := r.ops[messageType]
|
||||
@@ -145,6 +167,9 @@ func DomainCode(err error) (string, bool) {
|
||||
if errors.Is(err, connector.ErrInvalidInitData) {
|
||||
return "invalid_init_data", true
|
||||
}
|
||||
if errors.Is(err, vkauth.ErrInvalid) {
|
||||
return "invalid_vk_params", true
|
||||
}
|
||||
if errors.Is(err, connector.ErrInvalidLoginWidget) {
|
||||
return "invalid_login_widget", true
|
||||
}
|
||||
@@ -154,11 +179,38 @@ func DomainCode(err error) (string, bool) {
|
||||
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.ValidateInitData(ctx, string(in.InitData()))
|
||||
initData := string(in.InitData())
|
||||
user, err := tg.ValidateInitData(ctx, initData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
|
||||
// start_param rides inside the signed initData validated just above, so the launch
|
||||
// deep-link payload can be trusted here without a separate wire field; the backend
|
||||
// uses it to seed a brand-new account's variant preferences.
|
||||
startParam := ""
|
||||
if q, perr := url.ParseQuery(initData); perr == nil {
|
||||
startParam = q.Get("start_param")
|
||||
}
|
||||
sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()), startParam)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return encodeSession(sess), nil
|
||||
}
|
||||
}
|
||||
|
||||
// authVKHandler verifies a VK Mini App launch in-process (HMAC over the signed vk_*
|
||||
// params under the app secret) and provisions/finds the bound account. Unlike Telegram,
|
||||
// VK omits the user's name from the signed params, so the client-supplied display_name
|
||||
// rides the wire as a cosmetic seed for a brand-new account.
|
||||
func authVKHandler(backend *backendclient.Client, secret string) Handler {
|
||||
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||
in := fb.GetRootAsVKLoginRequest(req.Payload, 0)
|
||||
user, err := vkauth.Verify(string(in.Params()), secret)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sess, err := backend.VKAuth(ctx, user.ExternalID, user.Language, string(in.DisplayName()), string(in.BrowserTz()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
||||
t.Fatal("auth.telegram not registered")
|
||||
}
|
||||
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
@@ -66,6 +66,11 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
// start_param is parsed out of the validated initData and forwarded so the backend can
|
||||
// seed the new account's variant preferences from a promo deep link.
|
||||
if gotBody["start_param"] != "verudit_ru-scrabble_en" {
|
||||
t.Errorf("forwarded start_param = %q, want verudit_ru-scrabble_en", gotBody["start_param"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTelegramAuthInvalidInitData(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package transcode_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
|
||||
"scrabble/gateway/internal/transcode"
|
||||
fb "scrabble/pkg/fbs/scrabblefb"
|
||||
)
|
||||
|
||||
const (
|
||||
vkTestSecret = "wv527nm6mvf3rgomfhd6"
|
||||
// vkGoldenSign is the base64url HMAC-SHA256 of the unsigned params below under
|
||||
// vkTestSecret, computed independently (a Python reference) — so a green test
|
||||
// exercises VK's real algorithm end to end, not a self-consistent fake.
|
||||
vkGoldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||
)
|
||||
|
||||
// vkParams is the canonical VK launch-parameter set (without the sign) that
|
||||
// vkGoldenSign covers.
|
||||
func vkParams() url.Values {
|
||||
return url.Values{
|
||||
"vk_access_token_settings": {""},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_are_notifications_enabled": {"0"},
|
||||
"vk_is_app_user": {"1"},
|
||||
"vk_is_favorite": {"0"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"android"},
|
||||
"vk_ref": {"other"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
}
|
||||
}
|
||||
|
||||
func vkLoginPayload(params, browserTz, displayName string) []byte {
|
||||
b := flatbuffers.NewBuilder(0)
|
||||
p := b.CreateString(params)
|
||||
tz := b.CreateString(browserTz)
|
||||
dn := b.CreateString(displayName)
|
||||
fb.VKLoginRequestStart(b)
|
||||
fb.VKLoginRequestAddParams(b, p)
|
||||
fb.VKLoginRequestAddBrowserTz(b, tz)
|
||||
fb.VKLoginRequestAddDisplayName(b, dn)
|
||||
b.Finish(fb.VKLoginRequestEnd(b))
|
||||
return b.FinishedBytes()
|
||||
}
|
||||
|
||||
func TestVKAuthForwardsSeedFields(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/vk" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&gotBody)
|
||||
_, _ = w.Write([]byte(`{"token":"tok-vk","user_id":"u-vk","is_guest":false,"display_name":"Иван"}`))
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||
op, ok := reg.Lookup(transcode.MsgAuthVK)
|
||||
if !ok {
|
||||
t.Fatal("auth.vk not registered")
|
||||
}
|
||||
|
||||
signed := vkParams()
|
||||
signed.Set("sign", vkGoldenSign)
|
||||
payload, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(signed.Encode(), "+03:00", "Иван Петров")})
|
||||
if err != nil {
|
||||
t.Fatalf("handler: %v", err)
|
||||
}
|
||||
sess := fb.GetRootAsSession(payload, 0)
|
||||
if string(sess.Token()) != "tok-vk" || string(sess.UserId()) != "u-vk" {
|
||||
t.Fatalf("session decoded wrong: token=%q user=%q", sess.Token(), sess.UserId())
|
||||
}
|
||||
// The verified vk_user_id and vk_language plus the client-supplied display name are
|
||||
// forwarded so the backend can seed a brand-new account.
|
||||
if gotBody["external_id"] != "494075" || gotBody["language_code"] != "ru" || gotBody["display_name"] != "Иван Петров" {
|
||||
t.Errorf("forwarded body = %+v, want external_id=494075 language_code=ru display_name=Иван Петров", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKAuthInvalidSign confirms a bad signature is a domain failure (invalid_vk_params)
|
||||
// and the backend is never called.
|
||||
func TestVKAuthInvalidSign(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {
|
||||
t.Error("backend must not be called when the sign is invalid")
|
||||
})
|
||||
defer cleanup()
|
||||
|
||||
reg := transcode.NewRegistry(backend, nil, transcode.WithVKAuth(vkTestSecret))
|
||||
op, _ := reg.Lookup(transcode.MsgAuthVK)
|
||||
|
||||
tampered := vkParams()
|
||||
tampered.Set("sign", "deadbeef")
|
||||
_, err := op.Handler(context.Background(), transcode.Request{Payload: vkLoginPayload(tampered.Encode(), "", "")})
|
||||
if code, ok := transcode.DomainCode(err); !ok || code != "invalid_vk_params" {
|
||||
t.Errorf("DomainCode = (%q, %v), want (invalid_vk_params, true)", code, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVKAuthDisabledWithoutSecret confirms a blank VK app secret leaves auth.vk
|
||||
// unregistered.
|
||||
func TestVKAuthDisabledWithoutSecret(t *testing.T) {
|
||||
backend, cleanup := fakeBackend(t, func(http.ResponseWriter, *http.Request) {})
|
||||
defer cleanup()
|
||||
reg := transcode.NewRegistry(backend, nil)
|
||||
if _, ok := reg.Lookup(transcode.MsgAuthVK); ok {
|
||||
t.Error("auth.vk should be unregistered without a VK app secret")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Package vkauth verifies VK Mini App launch parameters in-process. VK signs the
|
||||
// launch query string with the app's protected ("secure") key; the gateway holds that
|
||||
// secret (GATEWAY_VK_APP_SECRET) and validates the `sign` itself rather than calling a
|
||||
// side-service, because the check is a pure offline HMAC with no VK API round-trip
|
||||
// (unlike the Telegram validator, which lives in a separate process to isolate the bot
|
||||
// token). See docs/ARCHITECTURE.md §12.
|
||||
package vkauth
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ErrInvalid is returned when launch params fail signature verification, are
|
||||
// malformed, carry no signed vk_* parameters, or lack the vk_user_id.
|
||||
var ErrInvalid = errors.New("vkauth: invalid vk launch params")
|
||||
|
||||
// Identity is the user extracted from verified VK launch params. ExternalID is the
|
||||
// vk_user_id used as the identities external_id; Language is the vk_language hint that
|
||||
// seeds a brand-new account's preferred language.
|
||||
type Identity struct {
|
||||
ExternalID string
|
||||
Language string
|
||||
}
|
||||
|
||||
// Verify checks the `sign` of a VK Mini App launch query string against secret and
|
||||
// returns the launching user's identity. Per VK's documented algorithm the signature
|
||||
// is HMAC-SHA256 over the vk_*-prefixed parameters — sorted by key and serialized as a
|
||||
// URL-encoded query string (url.Values.Encode mirrors VK's reference serialization for
|
||||
// the constrained launch-parameter charset) — under the app secret, then base64url
|
||||
// without padding; the comparison is constant-time.
|
||||
//
|
||||
// VK launch params carry no built-in expiry (unlike Telegram's auth_date), so freshness
|
||||
// is deliberately not enforced here: the gateway mints its own short-lived session, and
|
||||
// a replay only re-authenticates the same vk_user_id.
|
||||
func Verify(params, secret string) (Identity, error) {
|
||||
values, err := url.ParseQuery(params)
|
||||
if err != nil {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
sign := values.Get("sign")
|
||||
if sign == "" {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
// Only vk_*-prefixed parameters are signed; any other query parameter the client
|
||||
// appended is outside the signature and must be excluded from the check.
|
||||
signed := url.Values{}
|
||||
for k, v := range values {
|
||||
if strings.HasPrefix(k, "vk_") {
|
||||
signed[k] = v
|
||||
}
|
||||
}
|
||||
if len(signed) == 0 {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write([]byte(signed.Encode()))
|
||||
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
||||
if subtle.ConstantTimeCompare([]byte(want), []byte(sign)) != 1 {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
externalID := values.Get("vk_user_id")
|
||||
if externalID == "" {
|
||||
return Identity{}, ErrInvalid
|
||||
}
|
||||
return Identity{ExternalID: externalID, Language: values.Get("vk_language")}, nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package vkauth_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"scrabble/gateway/internal/vkauth"
|
||||
)
|
||||
|
||||
const (
|
||||
testSecret = "wv527nm6mvf3rgomfhd6"
|
||||
// goldenSign is HMAC-SHA256(sorted vk_* query, testSecret) base64url without
|
||||
// padding, computed independently from a Python reference over goldenParams — so a
|
||||
// green test proves Verify matches VK's documented algorithm, not merely itself.
|
||||
goldenSign = "x7RUe9sKN05Bigm-pBIl8gLmfMwZCloPrwZaZEQAdOA"
|
||||
)
|
||||
|
||||
// goldenParams is the canonical VK launch-parameter set the golden signature covers
|
||||
// (a representative real launch, including the empty vk_access_token_settings).
|
||||
func goldenParams() url.Values {
|
||||
return url.Values{
|
||||
"vk_access_token_settings": {""},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_are_notifications_enabled": {"0"},
|
||||
"vk_is_app_user": {"1"},
|
||||
"vk_is_favorite": {"0"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"android"},
|
||||
"vk_ref": {"other"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
}
|
||||
}
|
||||
|
||||
func signedParams() url.Values {
|
||||
p := goldenParams()
|
||||
p.Set("sign", goldenSign)
|
||||
return p
|
||||
}
|
||||
|
||||
func TestVerifyValid(t *testing.T) {
|
||||
id, err := vkauth.Verify(signedParams().Encode(), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" || id.Language != "ru" {
|
||||
t.Fatalf("identity = %+v, want ExternalID=494075 Language=ru", id)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyCommaValue locks the URL-encoding of the one realistic special-char VK
|
||||
// value: vk_access_token_settings can carry a comma (e.g. "email,phone"), which VK's
|
||||
// signer and our url.Values.Encode both escape to %2C. The golden sign was computed
|
||||
// independently (Node crypto) over these params under testSecret — so a green test
|
||||
// proves Go's encoding matches VK's for that character.
|
||||
func TestVerifyCommaValue(t *testing.T) {
|
||||
p := url.Values{
|
||||
"vk_access_token_settings": {"email,phone"},
|
||||
"vk_app_id": {"6736218"},
|
||||
"vk_language": {"ru"},
|
||||
"vk_platform": {"mobile_web"},
|
||||
"vk_ts": {"1546961916"},
|
||||
"vk_user_id": {"494075"},
|
||||
"sign": {"6g9FKCAfHfT-fBUdBAcJ5QK1xUm-vKMvGZrQ63aPgnQ"},
|
||||
}
|
||||
id, err := vkauth.Verify(p.Encode(), testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" {
|
||||
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestVerifyIgnoresForeignParams asserts a non-vk_ query parameter the client may
|
||||
// append (e.g. a tracking tag) is outside the signed set and does not break the check.
|
||||
func TestVerifyIgnoresForeignParams(t *testing.T) {
|
||||
id, err := vkauth.Verify(signedParams().Encode()+"&utm_source=catalog", testSecret)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: unexpected error %v", err)
|
||||
}
|
||||
if id.ExternalID != "494075" {
|
||||
t.Fatalf("ExternalID = %q, want 494075", id.ExternalID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
secret string
|
||||
}{
|
||||
{name: "tampered param", secret: testSecret, raw: func() string {
|
||||
p := signedParams()
|
||||
p.Set("vk_user_id", "1") // user id changed; the golden sign no longer matches
|
||||
return p.Encode()
|
||||
}()},
|
||||
{name: "wrong secret", secret: "not-the-secret", raw: signedParams().Encode()},
|
||||
{name: "missing sign", secret: testSecret, raw: goldenParams().Encode()},
|
||||
{name: "no vk params", secret: testSecret, raw: url.Values{"sign": {goldenSign}, "foo": {"bar"}}.Encode()},
|
||||
{name: "malformed query", secret: testSecret, raw: "%zz=bad"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := vkauth.Verify(tt.raw, tt.secret); !errors.Is(err, vkauth.ErrInvalid) {
|
||||
t.Fatalf("Verify error = %v, want ErrInvalid", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,6 +108,20 @@ table TelegramLoginRequest {
|
||||
browser_tz:string;
|
||||
}
|
||||
|
||||
// VKLoginRequest carries a VK Mini App launch. params is the raw query string of the
|
||||
// signed vk_* launch parameters plus the sign, which the gateway verifies in-process
|
||||
// (HMAC-SHA256 over the sorted vk_* params under the app secret, base64url) before
|
||||
// forwarding the extracted vk_user_id to the backend. display_name is the player's
|
||||
// name read client-side via VKWebAppGetUserInfo — VK omits it from the signed params,
|
||||
// so it is an untrusted, cosmetic seed for a brand-new account's display name.
|
||||
// browser_tz is the client's detected UTC offset ("±HH:MM"), seeded into a brand-new
|
||||
// account's time zone (see TelegramLoginRequest.browser_tz; first contact only).
|
||||
table VKLoginRequest {
|
||||
params:string;
|
||||
browser_tz:string;
|
||||
display_name:string;
|
||||
}
|
||||
|
||||
// GuestLoginRequest bootstraps an ephemeral guest session. locale is an optional
|
||||
// preferred-language hint; browser_tz is the detected UTC offset seeded into the
|
||||
// guest account's time zone (see TelegramLoginRequest.browser_tz).
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
|
||||
|
||||
package scrabblefb
|
||||
|
||||
import (
|
||||
flatbuffers "github.com/google/flatbuffers/go"
|
||||
)
|
||||
|
||||
type VKLoginRequest struct {
|
||||
_tab flatbuffers.Table
|
||||
}
|
||||
|
||||
func GetRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset:])
|
||||
x := &VKLoginRequest{}
|
||||
x.Init(buf, n+offset)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.Finish(offset)
|
||||
}
|
||||
|
||||
func GetSizePrefixedRootAsVKLoginRequest(buf []byte, offset flatbuffers.UOffsetT) *VKLoginRequest {
|
||||
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
|
||||
x := &VKLoginRequest{}
|
||||
x.Init(buf, n+offset+flatbuffers.SizeUint32)
|
||||
return x
|
||||
}
|
||||
|
||||
func FinishSizePrefixedVKLoginRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
|
||||
builder.FinishSizePrefixed(offset)
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
|
||||
rcv._tab.Bytes = buf
|
||||
rcv._tab.Pos = i
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) Table() flatbuffers.Table {
|
||||
return rcv._tab
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) Params() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) BrowserTz() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (rcv *VKLoginRequest) DisplayName() []byte {
|
||||
o := flatbuffers.UOffsetT(rcv._tab.Offset(8))
|
||||
if o != 0 {
|
||||
return rcv._tab.ByteVector(o + rcv._tab.Pos)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func VKLoginRequestStart(builder *flatbuffers.Builder) {
|
||||
builder.StartObject(3)
|
||||
}
|
||||
func VKLoginRequestAddParams(builder *flatbuffers.Builder, params flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(params), 0)
|
||||
}
|
||||
func VKLoginRequestAddBrowserTz(builder *flatbuffers.Builder, browserTz flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(browserTz), 0)
|
||||
}
|
||||
func VKLoginRequestAddDisplayName(builder *flatbuffers.Builder, displayName flatbuffers.UOffsetT) {
|
||||
builder.PrependUOffsetTSlot(2, flatbuffers.UOffsetT(displayName), 0)
|
||||
}
|
||||
func VKLoginRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
|
||||
return builder.EndObject()
|
||||
}
|
||||
@@ -24,6 +24,11 @@ ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/validator ./platform/telegram/cmd/validator
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags "-X scrabble/pkg/version.Version=${VERSION}" -o /out/bot ./platform/telegram/cmd/bot
|
||||
|
||||
# The bot's support relay writes its JSON state under /data. Stage an empty directory
|
||||
# owned by the distroless nonroot UID so a fresh named volume mounted there inherits
|
||||
# writable ownership (Docker copies the image dir's mode/owner into an empty volume).
|
||||
RUN mkdir -p /out/data
|
||||
|
||||
# --- validator (home) --------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot AS validator
|
||||
COPY --from=build /out/validator /usr/local/bin/validator
|
||||
@@ -32,4 +37,5 @@ ENTRYPOINT ["/usr/local/bin/validator"]
|
||||
# --- bot (remote) ------------------------------------------------------------
|
||||
FROM gcr.io/distroless/static-debian12:nonroot AS bot
|
||||
COPY --from=build /out/bot /usr/local/bin/bot
|
||||
COPY --from=build --chown=65532:65532 /out/data /data
|
||||
ENTRYPOINT ["/usr/local/bin/bot"]
|
||||
|
||||
@@ -63,13 +63,32 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
|
||||
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
|
||||
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
|
||||
`chat_member` updates, which Telegram delivers only to a chat admin.
|
||||
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
|
||||
supergroup**, the bot runs a direct support channel for users who message it. A user's first
|
||||
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
|
||||
name is a tappable profile mention via a `text_mention` entity — which also stops a name starting
|
||||
with `/` from rendering as a command — plus @username, language, premium, id) with a **Block/Unblock** toggle
|
||||
and a **Clear** button; each message is then copied into that topic (`copyMessage`, so any content
|
||||
— text, media, voice, files — carries over). Any **administrator** of the support chat who writes
|
||||
in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
|
||||
(the loop guard). **Block** drops a user's incoming messages (the topic stays); **Clear** deletes
|
||||
the relayed messages, keeping the info card; a topic the operators delete is reopened on the next
|
||||
message. State (user→topic map, block list, relayed ids) is a JSON file under
|
||||
`TELEGRAM_SUPPORT_STATE_DIR` on a persistent volume — the bot host has no database. The bot must
|
||||
be an **administrator** in the group with the **manage-topics** and **delete-messages** rights.
|
||||
The relay is bot-local (no backend, no bot-link) and the user gets no automatic reply.
|
||||
- **Promo bot (optional).** When `TELEGRAM_PROMO_BOT_TOKEN` is set, the container also
|
||||
runs a **second, standalone** bot whose only job is to answer `/start` with a localized
|
||||
message and a button that opens the **main** bot's Mini App. The button is a **URL** to
|
||||
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with
|
||||
`?startapp` — a `web_app` button would launch under the promo bot's identity (its token
|
||||
would sign the initData), which the main bot's validator rejects. It is fully
|
||||
self-contained: no bot-link, no gateway, no game.
|
||||
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload
|
||||
(`TELEGRAM_PROMO_START_PARAM`, default `verudit_ru-scrabble_en`) is a variant-seed deep
|
||||
link the backend decodes to seed a brand-new user's variant preferences (English Scrabble
|
||||
alongside the default Erudit). The message body also renders the bot's `@username` as that
|
||||
same deep link (an HTML `text_link`), so tapping the mention — not just the button — opens
|
||||
the seeded Mini App rather than the bot profile.
|
||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||
default 25) to respect the Bot API flood limits.
|
||||
|
||||
@@ -132,9 +151,12 @@ Bot (`cmd/bot`):
|
||||
| `TELEGRAM_BOTLINK_TLS_CERT` / `_KEY` / `_CA` | — (required) | the bot client cert, its key, and the CA that signs the gateway server cert |
|
||||
| `TELEGRAM_GAME_CHANNEL_ID` | — | the bot's game channel chat id for `SendToGameChannel` |
|
||||
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
|
||||
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
|
||||
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
|
||||
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
|
||||
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
|
||||
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
|
||||
| `TELEGRAM_PROMO_START_PARAM` | `verudit_ru-scrabble_en` | the promo button's `startapp` payload — a variant-seed deep link the backend decodes to seed a brand-new user's variant preferences; empty forwards the user's own `/start` payload |
|
||||
| `TELEGRAM_OWNS_UPDATES` | `true` | run the exclusive `getUpdates` long-poll (one bot per token) |
|
||||
| `TELEGRAM_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
|
||||
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"scrabble/platform/telegram/internal/botlink"
|
||||
"scrabble/platform/telegram/internal/config"
|
||||
"scrabble/platform/telegram/internal/promobot"
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
|
||||
@@ -65,6 +67,19 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
|
||||
}
|
||||
|
||||
// The support relay keeps its per-user state in a JSON file on a writable volume
|
||||
// (the bot host has no database). A corrupt file is logged and the relay starts
|
||||
// empty rather than crash-looping the bot.
|
||||
var supportStore *support.Store
|
||||
if cfg.SupportChatID != 0 {
|
||||
statePath := filepath.Join(cfg.SupportStateDir, "support.json")
|
||||
supportStore, err = support.Open(statePath)
|
||||
if err != nil {
|
||||
logger.Warn("support: state load failed, starting empty", zap.String("path", statePath), zap.Error(err))
|
||||
supportStore = support.New(statePath)
|
||||
}
|
||||
}
|
||||
|
||||
b, err := bot.New(bot.Config{
|
||||
Token: cfg.Token,
|
||||
APIBaseURL: cfg.APIBaseURL,
|
||||
@@ -73,6 +88,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||
ChatID: cfg.ChatID,
|
||||
GameChannelID: cfg.GameChannelID,
|
||||
SupportChatID: cfg.SupportChatID,
|
||||
SupportStore: supportStore,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -114,6 +131,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
TestEnv: cfg.TestEnv,
|
||||
BotUsername: cfg.BotUsername,
|
||||
BotLinkURL: cfg.BotLinkURL,
|
||||
StartParam: cfg.PromoStartParam,
|
||||
SendRatePerSecond: cfg.SendRatePerSecond,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
@@ -128,6 +146,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
||||
zap.Bool("owns_updates", cfg.OwnsUpdates),
|
||||
zap.Bool("test_env", cfg.TestEnv),
|
||||
zap.Bool("chat_gating", cfg.ChatID != 0),
|
||||
zap.Bool("support_relay", cfg.SupportChatID != 0),
|
||||
zap.Bool("promo_bot", promo != nil))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
// Config configures the bot wrapper.
|
||||
@@ -39,6 +41,14 @@ type Config struct {
|
||||
// GameChannelID is the game channel whose public @username the /start welcome links
|
||||
// to (resolved from this id via getChat at startup); 0 omits that follow link.
|
||||
GameChannelID int64
|
||||
// SupportChatID is the private forum supergroup the bot relays direct user messages
|
||||
// into (one topic per user) and reads operator replies from; 0 disables the support
|
||||
// relay. The bot must be an administrator there with the manage-topics and
|
||||
// delete-messages rights.
|
||||
SupportChatID int64
|
||||
// SupportStore persists the support relay's state (topic mapping, block list,
|
||||
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
||||
SupportStore *support.Store
|
||||
}
|
||||
|
||||
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||
@@ -66,11 +76,21 @@ type Bot struct {
|
||||
channelUsername string
|
||||
chatUsername string
|
||||
// botID is the bot's own Telegram user id (resolved at startup); it skips the
|
||||
// chat_member updates the bot's own restrict actions generate — the grant loop guard.
|
||||
// chat_member updates the bot's own restrict actions generate — the grant loop guard —
|
||||
// and the bot's own relayed copies in the support chat.
|
||||
botID int64
|
||||
// eligibility resolves a joining user's chat write eligibility; nil leaves a
|
||||
// joiner muted (fail-closed) until it is wired.
|
||||
eligibility EligibilityResolver
|
||||
// supportChatID is the support relay's forum supergroup (0 disables the relay).
|
||||
supportChatID int64
|
||||
// support persists the support relay's per-user state; nil when the relay is off.
|
||||
support *support.Store
|
||||
// supportLocks serialises per-user topic creation so a burst of a new user's
|
||||
// messages opens exactly one topic.
|
||||
supportLocks *keyedMutex
|
||||
// admins caches the support chat's administrator ids (who may reply and act).
|
||||
admins *adminCache
|
||||
}
|
||||
|
||||
// New builds the bot wrapper, registering the /start handler and a default handler
|
||||
@@ -80,7 +100,14 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{miniAppURL: cfg.MiniAppURL, log: log, chatID: cfg.ChatID, channelID: cfg.GameChannelID}
|
||||
t := &Bot{
|
||||
miniAppURL: cfg.MiniAppURL,
|
||||
log: log,
|
||||
chatID: cfg.ChatID,
|
||||
channelID: cfg.GameChannelID,
|
||||
supportChatID: cfg.SupportChatID,
|
||||
support: cfg.SupportStore,
|
||||
}
|
||||
if cfg.SendRatePerSecond > 0 {
|
||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||
}
|
||||
@@ -89,15 +116,26 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
tgbot.WithDefaultHandler(t.handleUpdate),
|
||||
tgbot.WithMessageTextHandler("/start", tgbot.MatchTypePrefix, t.handleStart),
|
||||
}
|
||||
if t.supportEnabled() {
|
||||
t.supportLocks = newKeyedMutex()
|
||||
t.admins = &adminCache{}
|
||||
// The info-card buttons are callback_query updates; route them to the support
|
||||
// callback handler by their "sup:" data prefix.
|
||||
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
||||
}
|
||||
// Allowed updates default to "all except chat_member". Specify an explicit set only
|
||||
// when we need chat_member (moderated chat) — and then re-add callback_query (which
|
||||
// the explicit set would otherwise drop) when the support relay needs it.
|
||||
if cfg.ChatID != 0 {
|
||||
// chat_member updates are off by default; subscribe explicitly (alongside
|
||||
// messages) so the bot sees joins in the moderated chat. The bot must also be an
|
||||
// administrator there for Telegram to deliver them.
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(tgbot.AllowedUpdates{
|
||||
allowed := tgbot.AllowedUpdates{
|
||||
models.AllowedUpdateMessage,
|
||||
models.AllowedUpdateMyChatMember,
|
||||
models.AllowedUpdateChatMember,
|
||||
}))
|
||||
}
|
||||
if t.supportEnabled() {
|
||||
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||
}
|
||||
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||
}
|
||||
if cfg.TestEnv {
|
||||
// Route to the Bot API test environment (.../bot<token>/test/METHOD).
|
||||
@@ -134,6 +172,9 @@ func (t *Bot) Run(ctx context.Context) {
|
||||
if t.chatID != 0 {
|
||||
t.logChatAdminStatus(ctx)
|
||||
}
|
||||
if t.supportEnabled() {
|
||||
t.initSupport(ctx)
|
||||
}
|
||||
t.resolveWelcomeHandles(ctx)
|
||||
t.api.Start(ctx)
|
||||
}
|
||||
@@ -305,6 +346,19 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
|
||||
t.handleChatMember(ctx, update.ChatMember)
|
||||
return
|
||||
}
|
||||
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||
// is either an operator's reply in the support chat or a user's direct message to
|
||||
// relay. Everything else falls through to the Mini App launch reply.
|
||||
if t.supportEnabled() && update.Message != nil {
|
||||
switch {
|
||||
case update.Message.Chat.ID == t.supportChatID:
|
||||
t.handleSupportGroupMessage(ctx, update.Message)
|
||||
return
|
||||
case update.Message.Chat.Type == models.ChatTypePrivate:
|
||||
t.handleSupportUserMessage(ctx, update.Message)
|
||||
return
|
||||
}
|
||||
}
|
||||
t.handleStart(ctx, api, update)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
tgbot "github.com/go-telegram/bot"
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
// Support relay: the bot forwards a user's direct messages into a per-user forum
|
||||
// topic in the operators' private support chat, and relays operator replies in that
|
||||
// topic back to the user. The info card opening each topic carries a Block/Unblock
|
||||
// toggle and a Clear button. State (topic mapping, block list, relayed message ids)
|
||||
// lives in a small JSON file via internal/support.
|
||||
const (
|
||||
// supportCallbackPrefix namespaces the info-card button callbacks
|
||||
// ("sup:<action>:<userID>").
|
||||
supportCallbackPrefix = "sup:"
|
||||
supportActionBlock = "block"
|
||||
supportActionUnblock = "unblock"
|
||||
supportActionClear = "clear"
|
||||
// supportAdminTTL is how long the support chat's administrator set is cached.
|
||||
supportAdminTTL = time.Minute
|
||||
// supportTopicNameMax bounds the forum topic name (the Telegram limit is 128).
|
||||
supportTopicNameMax = 128
|
||||
// supportDeleteBatch is the maximum message ids per deleteMessages call (the
|
||||
// Telegram limit is 100).
|
||||
supportDeleteBatch = 100
|
||||
)
|
||||
|
||||
// supportEnabled reports whether the support relay is configured.
|
||||
func (t *Bot) supportEnabled() bool {
|
||||
return t.supportChatID != 0 && t.support != nil
|
||||
}
|
||||
|
||||
// initSupport prepares the support relay at startup: it resolves the bot's own user
|
||||
// id (getMe) for the loop guard, unless the chat-gating self-check already did, and
|
||||
// logs readiness. The loop guard also tests From.IsBot, so an unresolved id is not
|
||||
// fatal.
|
||||
func (t *Bot) initSupport(ctx context.Context) {
|
||||
if t.botID == 0 {
|
||||
if me, err := t.api.GetMe(ctx); err != nil {
|
||||
t.log.Warn("support: getMe failed; relying on is_bot for the loop guard", zap.Error(err))
|
||||
} else {
|
||||
t.botID = me.ID
|
||||
}
|
||||
}
|
||||
t.log.Info("support relay ready", zap.Int64("support_chat_id", t.supportChatID))
|
||||
}
|
||||
|
||||
// keyedMutex serialises work per int64 key (here, per user id) so two concurrent
|
||||
// updates from the same user — the go-telegram/bot library runs handlers in
|
||||
// goroutines — cannot both open a forum topic.
|
||||
type keyedMutex struct {
|
||||
mu sync.Mutex
|
||||
m map[int64]*sync.Mutex
|
||||
}
|
||||
|
||||
func newKeyedMutex() *keyedMutex { return &keyedMutex{m: map[int64]*sync.Mutex{}} }
|
||||
|
||||
// lock acquires the per-key mutex and returns its unlock function.
|
||||
func (k *keyedMutex) lock(key int64) func() {
|
||||
k.mu.Lock()
|
||||
mu, ok := k.m[key]
|
||||
if !ok {
|
||||
mu = &sync.Mutex{}
|
||||
k.m[key] = mu
|
||||
}
|
||||
k.mu.Unlock()
|
||||
mu.Lock()
|
||||
return mu.Unlock
|
||||
}
|
||||
|
||||
// adminCache caches the support chat's administrator ids for a short TTL, so the bot
|
||||
// does not call getChatAdministrators on every operator action.
|
||||
type adminCache struct {
|
||||
mu sync.Mutex
|
||||
ids map[int64]bool
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
// isSupportAdmin reports whether userID administers the support chat, refreshing the
|
||||
// cache when stale. On a refresh failure it falls back to the last known set
|
||||
// (fail-closed when nothing is cached yet).
|
||||
func (t *Bot) isSupportAdmin(ctx context.Context, userID int64) bool {
|
||||
t.admins.mu.Lock()
|
||||
if t.admins.ids != nil && time.Since(t.admins.fetchedAt) < supportAdminTTL {
|
||||
ok := t.admins.ids[userID]
|
||||
t.admins.mu.Unlock()
|
||||
return ok
|
||||
}
|
||||
t.admins.mu.Unlock()
|
||||
|
||||
members, err := t.api.GetChatAdministrators(ctx, &tgbot.GetChatAdministratorsParams{ChatID: t.supportChatID})
|
||||
if err != nil {
|
||||
t.log.Warn("support: getChatAdministrators failed; using cached admin set", zap.Error(err))
|
||||
t.admins.mu.Lock()
|
||||
defer t.admins.mu.Unlock()
|
||||
return t.admins.ids[userID] // a nil map yields false (fail-closed)
|
||||
}
|
||||
ids := make(map[int64]bool, len(members))
|
||||
for _, m := range members {
|
||||
if u := chatMemberUser(m); u != nil {
|
||||
ids[u.ID] = true
|
||||
}
|
||||
}
|
||||
t.admins.mu.Lock()
|
||||
t.admins.ids = ids
|
||||
t.admins.fetchedAt = time.Now()
|
||||
t.admins.mu.Unlock()
|
||||
return ids[userID]
|
||||
}
|
||||
|
||||
// handleSupportUserMessage relays a user's direct message into their forum topic in
|
||||
// the support chat, opening the topic (and its info card) on first contact. A blocked
|
||||
// user's message is dropped silently, and the user receives no reply — operators
|
||||
// answer from the topic.
|
||||
func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
|
||||
if m.From == nil || m.From.IsBot {
|
||||
return
|
||||
}
|
||||
uid := m.From.ID
|
||||
unlock := t.supportLocks.lock(uid)
|
||||
defer unlock()
|
||||
|
||||
rec, _ := t.support.Get(uid)
|
||||
if rec.Blocked {
|
||||
return
|
||||
}
|
||||
topicID, err := t.ensureTopic(ctx, m.From, rec)
|
||||
if err != nil {
|
||||
t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
||||
if err != nil && isTopicMissingErr(err) {
|
||||
// Backstop: the topic vanished between the liveness probe and the copy.
|
||||
t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
|
||||
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
|
||||
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
t.log.Warn("support: relay to topic failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
return
|
||||
}
|
||||
if err := t.support.AppendMsg(uid, newID); err != nil {
|
||||
t.log.Warn("support: persist relayed id failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// handleSupportGroupMessage relays an operator's reply in a user's topic back to that
|
||||
// user. It ignores the bot's own posts (the loop guard), messages outside a topic,
|
||||
// unknown topics, and non-administrators. The operator's message is tracked too, so a
|
||||
// later clear removes it.
|
||||
func (t *Bot) handleSupportGroupMessage(ctx context.Context, m *models.Message) {
|
||||
if m.From == nil || m.From.IsBot {
|
||||
return // the bot's own relayed copies (and other bots) — never loop them back
|
||||
}
|
||||
if t.botID != 0 && m.From.ID == t.botID {
|
||||
return
|
||||
}
|
||||
if m.MessageThreadID == 0 {
|
||||
return // the chat's general area, not a user's topic
|
||||
}
|
||||
uid, ok := t.support.ByTopic(m.MessageThreadID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !t.isSupportAdmin(ctx, m.From.ID) {
|
||||
return // only operators reach the user
|
||||
}
|
||||
if err := t.support.AppendMsg(uid, m.ID); err != nil {
|
||||
t.log.Warn("support: persist operator msg id failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
}
|
||||
if _, err := t.copyToUser(ctx, m.ID, uid); err != nil {
|
||||
t.log.Warn("support: relay reply to user failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// handleSupportCallback handles the info-card buttons (block/unblock/clear). Only
|
||||
// support-chat administrators may act; others get a denial. It always answers the
|
||||
// callback query so the client's spinner clears.
|
||||
func (t *Bot) handleSupportCallback(ctx context.Context, _ *tgbot.Bot, update *models.Update) {
|
||||
cq := update.CallbackQuery
|
||||
if cq == nil {
|
||||
return
|
||||
}
|
||||
action, uid, ok := parseSupportCallback(cq.Data)
|
||||
if !ok {
|
||||
t.answerSupportCallback(ctx, cq.ID, "")
|
||||
return
|
||||
}
|
||||
if !t.isSupportAdmin(ctx, cq.From.ID) {
|
||||
t.answerSupportCallback(ctx, cq.ID, "Недостаточно прав")
|
||||
return
|
||||
}
|
||||
switch action {
|
||||
case supportActionBlock:
|
||||
t.setSupportBlocked(ctx, cq, uid, true)
|
||||
case supportActionUnblock:
|
||||
t.setSupportBlocked(ctx, cq, uid, false)
|
||||
case supportActionClear:
|
||||
t.clearSupportTopic(ctx, cq, uid)
|
||||
default:
|
||||
t.answerSupportCallback(ctx, cq.ID, "")
|
||||
}
|
||||
}
|
||||
|
||||
// setSupportBlocked sets the user's block state, flips the info-card toggle to match,
|
||||
// and answers the callback. Blocking does not delete the topic — it stays.
|
||||
func (t *Bot) setSupportBlocked(ctx context.Context, cq *models.CallbackQuery, uid int64, blocked bool) {
|
||||
if err := t.support.SetBlocked(uid, blocked); err != nil {
|
||||
t.log.Warn("support: set blocked failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
t.answerSupportCallback(ctx, cq.ID, "Ошибка")
|
||||
return
|
||||
}
|
||||
if rec, ok := t.support.Get(uid); ok && rec.HeaderMsgID != 0 {
|
||||
if _, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageID: rec.HeaderMsgID,
|
||||
ReplyMarkup: supportCardMarkup(uid, blocked),
|
||||
}); err != nil {
|
||||
t.log.Debug("support: update card markup failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
msg := "Разблокирован"
|
||||
if blocked {
|
||||
msg = "Заблокирован"
|
||||
}
|
||||
t.answerSupportCallback(ctx, cq.ID, msg)
|
||||
}
|
||||
|
||||
// clearSupportTopic deletes the messages relayed into the user's topic (keeping the
|
||||
// info card) and answers the callback.
|
||||
func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, uid int64) {
|
||||
ids, err := t.support.ClearMsgs(uid)
|
||||
if err != nil {
|
||||
t.log.Warn("support: clear failed", zap.Int64("user_id", uid), zap.Error(err))
|
||||
t.answerSupportCallback(ctx, cq.ID, "Ошибка")
|
||||
return
|
||||
}
|
||||
t.deleteSupportMessages(ctx, ids)
|
||||
t.answerSupportCallback(ctx, cq.ID, "Очищено")
|
||||
}
|
||||
|
||||
// ensureTopic returns a live forum-topic id for the user, opening a fresh topic (and
|
||||
// info card) when none exists or the previous one was deleted. Telegram silently
|
||||
// routes a copy aimed at a deleted topic into the chat's General topic (no error), so
|
||||
// the bot cannot rely on copyMessage failing; instead it probes the info card —
|
||||
// re-applying the card's reply markup is a no-op that errors only when the card (hence
|
||||
// the topic) is gone — and reopens on that signal. The probe also keeps the card's
|
||||
// block button in sync with the stored state.
|
||||
func (t *Bot) ensureTopic(ctx context.Context, u *models.User, rec support.User) (int, error) {
|
||||
if rec.TopicID == 0 || rec.HeaderMsgID == 0 {
|
||||
return t.openSupportTopic(ctx, u)
|
||||
}
|
||||
_, err := t.api.EditMessageReplyMarkup(ctx, &tgbot.EditMessageReplyMarkupParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageID: rec.HeaderMsgID,
|
||||
ReplyMarkup: supportCardMarkup(u.ID, rec.Blocked),
|
||||
})
|
||||
if isCardMissingErr(err) {
|
||||
t.log.Info("support: topic gone, reopening", zap.Int64("user_id", u.ID), zap.Int("topic_id", rec.TopicID))
|
||||
return t.openSupportTopic(ctx, u)
|
||||
}
|
||||
// Any other outcome (success, or a benign "message is not modified") means the
|
||||
// topic is alive; reuse it.
|
||||
return rec.TopicID, nil
|
||||
}
|
||||
|
||||
// openSupportTopic creates a forum topic for the user and posts its info card with
|
||||
// the block/clear buttons, persisting both. It returns the new topic id. A failure to
|
||||
// persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
|
||||
func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error) {
|
||||
topic, err := t.api.CreateForumTopic(ctx, &tgbot.CreateForumTopicParams{
|
||||
ChatID: t.supportChatID,
|
||||
Name: supportTopicName(u),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create forum topic: %w", err)
|
||||
}
|
||||
headerID := 0
|
||||
cardText, cardEntities := supportCard(u)
|
||||
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageThreadID: topic.MessageThreadID,
|
||||
Text: cardText,
|
||||
Entities: cardEntities,
|
||||
ReplyMarkup: supportCardMarkup(u.ID, false),
|
||||
})
|
||||
if err != nil {
|
||||
t.log.Warn("support: post info card failed (topic opened without it)", zap.Error(err))
|
||||
} else {
|
||||
headerID = header.ID
|
||||
}
|
||||
if err := t.support.SetTopic(u.ID, topic.MessageThreadID, headerID, u.FirstName, u.LastName, u.Username); err != nil {
|
||||
t.log.Warn("support: persist topic state failed (kept in memory)", zap.Int64("user_id", u.ID), zap.Error(err))
|
||||
}
|
||||
return topic.MessageThreadID, nil
|
||||
}
|
||||
|
||||
// copyToTopic copies a message into the user's forum topic and returns the new
|
||||
// message id.
|
||||
func (t *Bot) copyToTopic(ctx context.Context, fromChatID int64, messageID, topicID int) (int, error) {
|
||||
if err := t.throttle(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageThreadID: topicID,
|
||||
FromChatID: fromChatID,
|
||||
MessageID: messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.ID, nil
|
||||
}
|
||||
|
||||
// copyToUser copies a support-chat message to the user's private chat and returns the
|
||||
// new message id.
|
||||
func (t *Bot) copyToUser(ctx context.Context, messageID int, userID int64) (int, error) {
|
||||
if err := t.throttle(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
res, err := t.api.CopyMessage(ctx, &tgbot.CopyMessageParams{
|
||||
ChatID: userID,
|
||||
FromChatID: t.supportChatID,
|
||||
MessageID: messageID,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.ID, nil
|
||||
}
|
||||
|
||||
// deleteSupportMessages best-effort deletes the given support-chat messages in
|
||||
// batches; individual failures (for example a message already removed) are ignored.
|
||||
func (t *Bot) deleteSupportMessages(ctx context.Context, ids []int) {
|
||||
for start := 0; start < len(ids); start += supportDeleteBatch {
|
||||
end := min(start+supportDeleteBatch, len(ids))
|
||||
if _, err := t.api.DeleteMessages(ctx, &tgbot.DeleteMessagesParams{
|
||||
ChatID: t.supportChatID,
|
||||
MessageIDs: ids[start:end],
|
||||
}); err != nil {
|
||||
t.log.Debug("support: delete batch failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// answerSupportCallback answers a callback query, logging a failure at debug (the
|
||||
// only effect of a miss is a lingering client spinner).
|
||||
func (t *Bot) answerSupportCallback(ctx context.Context, id, text string) {
|
||||
if _, err := t.api.AnswerCallbackQuery(ctx, &tgbot.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: id,
|
||||
Text: text,
|
||||
}); err != nil {
|
||||
t.log.Debug("support: answer callback failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// parseSupportCallback parses "sup:<action>:<userID>" callback data.
|
||||
func parseSupportCallback(data string) (action string, userID int64, ok bool) {
|
||||
rest, found := strings.CutPrefix(data, supportCallbackPrefix)
|
||||
if !found {
|
||||
return "", 0, false
|
||||
}
|
||||
action, idStr, found := strings.Cut(rest, ":")
|
||||
if !found {
|
||||
return "", 0, false
|
||||
}
|
||||
userID, err := strconv.ParseInt(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return action, userID, true
|
||||
}
|
||||
|
||||
// supportCardMarkup builds the info-card buttons: a Block/Unblock toggle reflecting
|
||||
// the current state, and a Clear button.
|
||||
func supportCardMarkup(userID int64, blocked bool) *models.InlineKeyboardMarkup {
|
||||
toggleText, toggleAction := "Заблокировать", supportActionBlock
|
||||
if blocked {
|
||||
toggleText, toggleAction = "Разблокировать", supportActionUnblock
|
||||
}
|
||||
return &models.InlineKeyboardMarkup{
|
||||
InlineKeyboard: [][]models.InlineKeyboardButton{{
|
||||
{Text: toggleText, CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, toggleAction, userID)},
|
||||
{Text: "Очистить", CallbackData: fmt.Sprintf("%s%s:%d", supportCallbackPrefix, supportActionClear, userID)},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// supportTopicName builds the forum topic title for a user: full name and @username,
|
||||
// trimmed to the Telegram length limit.
|
||||
func supportTopicName(u *models.User) string {
|
||||
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if name == "" {
|
||||
name = "user " + strconv.FormatInt(u.ID, 10)
|
||||
}
|
||||
if u.Username != "" {
|
||||
name += " @" + u.Username
|
||||
}
|
||||
if r := []rune(name); len(r) > supportTopicNameMax {
|
||||
return string(r[:supportTopicNameMax])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// supportCard renders the topic info card describing the user and the message
|
||||
// entities for it. The display name is covered by a text_mention entity: it links to
|
||||
// the user's profile (the reliable way to mention a user who has no public username —
|
||||
// the bot has seen them, since they messaged it) and, because the name sits inside an
|
||||
// entity, Telegram does not auto-detect a leading "/" as a bot command or a stray
|
||||
// "@handle" inside the name as a mention. The remaining lines are plain text; a public
|
||||
// @username, when present, is left for Telegram to auto-link. Entity offsets are in
|
||||
// UTF-16 code units, as the Bot API requires; the name is at offset 0.
|
||||
func supportCard(u *models.User) (string, []models.MessageEntity) {
|
||||
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if name == "" {
|
||||
name = "user " + strconv.FormatInt(u.ID, 10)
|
||||
}
|
||||
username := "—"
|
||||
if u.Username != "" {
|
||||
username = "@" + u.Username
|
||||
}
|
||||
lang := u.LanguageCode
|
||||
if lang == "" {
|
||||
lang = "—"
|
||||
}
|
||||
premium := "нет"
|
||||
if u.IsPremium {
|
||||
premium = "да"
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(name)
|
||||
fmt.Fprintf(&b, "\nUsername: %s", username)
|
||||
fmt.Fprintf(&b, "\nID: %d", u.ID)
|
||||
fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium)
|
||||
entities := []models.MessageEntity{{
|
||||
Type: models.MessageEntityTypeTextMention,
|
||||
Offset: 0,
|
||||
Length: len(utf16.Encode([]rune(name))),
|
||||
User: &models.User{ID: u.ID},
|
||||
}}
|
||||
return b.String(), entities
|
||||
}
|
||||
|
||||
// isCardMissingErr reports whether err means the info-card message no longer exists
|
||||
// (the operators deleted the topic), as opposed to a benign "message is not modified"
|
||||
// no-op when the card is still there.
|
||||
func isCardMissingErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "message to edit not found") ||
|
||||
strings.Contains(s, "message can't be edited") ||
|
||||
strings.Contains(s, "message_id_invalid")
|
||||
}
|
||||
|
||||
// isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
|
||||
// error, signalling the topic must be reopened.
|
||||
func isTopicMissingErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "thread not found") ||
|
||||
strings.Contains(s, "thread_not_found") ||
|
||||
strings.Contains(s, "topic deleted") ||
|
||||
strings.Contains(s, "topic_deleted")
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/go-telegram/bot/models"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/platform/telegram/internal/support"
|
||||
)
|
||||
|
||||
const supportChatID = -1001000000000
|
||||
|
||||
// copyRec records one copyMessage call.
|
||||
type copyRec struct {
|
||||
chatID, fromChatID, threadID, messageID string
|
||||
}
|
||||
|
||||
// supportAPI is a fake Bot API for the support-relay handlers: it answers the
|
||||
// methods they call with incrementing ids and records the requests for assertions.
|
||||
type supportAPI struct {
|
||||
mu sync.Mutex
|
||||
adminIDs []int64
|
||||
nextThread int
|
||||
nextMsg int
|
||||
topicsOpened int
|
||||
copies []copyRec
|
||||
headerThread string // message_thread_id of the last header sendMessage
|
||||
deleted [][]int
|
||||
editedMsgIDs []string
|
||||
answers []string
|
||||
// editErr, when set, makes editMessageReplyMarkup return that Telegram error
|
||||
// description (simulating a deleted info card / topic).
|
||||
editErr string
|
||||
}
|
||||
|
||||
func newSupportAPI(adminIDs ...int64) *supportAPI {
|
||||
return &supportAPI{adminIDs: adminIDs, nextThread: 1000, nextMsg: 5000}
|
||||
}
|
||||
|
||||
func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/getMe"):
|
||||
io.WriteString(w, `{"ok":true,"result":{"id":1,"is_bot":true,"first_name":"bot","username":"b"}}`)
|
||||
case strings.HasSuffix(path, "/createForumTopic"):
|
||||
s.topicsOpened++
|
||||
tid := s.nextThread
|
||||
s.nextThread++
|
||||
fmt.Fprintf(w, `{"ok":true,"result":{"message_thread_id":%d,"name":%q}}`, tid, r.FormValue("name"))
|
||||
case strings.HasSuffix(path, "/sendMessage"):
|
||||
s.headerThread = r.FormValue("message_thread_id")
|
||||
mid := s.nextMsg
|
||||
s.nextMsg++
|
||||
fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid)
|
||||
case strings.HasSuffix(path, "/copyMessage"):
|
||||
s.copies = append(s.copies, copyRec{
|
||||
chatID: r.FormValue("chat_id"),
|
||||
fromChatID: r.FormValue("from_chat_id"),
|
||||
threadID: r.FormValue("message_thread_id"),
|
||||
messageID: r.FormValue("message_id"),
|
||||
})
|
||||
mid := s.nextMsg
|
||||
s.nextMsg++
|
||||
fmt.Fprintf(w, `{"ok":true,"result":{"message_id":%d}}`, mid)
|
||||
case strings.HasSuffix(path, "/deleteMessages"):
|
||||
var ids []int
|
||||
_ = json.Unmarshal([]byte(r.FormValue("message_ids")), &ids)
|
||||
s.deleted = append(s.deleted, ids)
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
case strings.HasSuffix(path, "/editMessageReplyMarkup"):
|
||||
if s.editErr != "" {
|
||||
fmt.Fprintf(w, `{"ok":false,"error_code":400,"description":%q}`, s.editErr)
|
||||
return
|
||||
}
|
||||
s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
|
||||
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
|
||||
case strings.HasSuffix(path, "/answerCallbackQuery"):
|
||||
s.answers = append(s.answers, r.FormValue("text"))
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
case strings.HasSuffix(path, "/getChatAdministrators"):
|
||||
var b strings.Builder
|
||||
b.WriteString(`{"ok":true,"result":[`)
|
||||
for i, id := range s.adminIDs {
|
||||
if i > 0 {
|
||||
b.WriteString(",")
|
||||
}
|
||||
fmt.Fprintf(&b, `{"status":"administrator","user":{"id":%d,"is_bot":false}}`, id)
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
io.WriteString(w, b.String())
|
||||
default:
|
||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *supportAPI) copyCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return len(s.copies)
|
||||
}
|
||||
|
||||
// newSupportBot builds a support-enabled bot against the fake API and returns it with
|
||||
// its backing store.
|
||||
func newSupportBot(t *testing.T, api http.Handler) (*Bot, *support.Store) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
st := support.New(filepath.Join(t.TempDir(), "support.json"))
|
||||
b, err := New(Config{
|
||||
Token: "123:ABC",
|
||||
APIBaseURL: srv.URL,
|
||||
MiniAppURL: "https://example.com/telegram/",
|
||||
SupportChatID: supportChatID,
|
||||
SupportStore: st,
|
||||
}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new support bot: %v", err)
|
||||
}
|
||||
return b, st
|
||||
}
|
||||
|
||||
// userMsg builds a private direct message from the given user.
|
||||
func userMsg(userID int64, text string) *models.Message {
|
||||
return &models.Message{
|
||||
ID: int(userID)*10 + 1,
|
||||
Chat: models.Chat{ID: userID, Type: models.ChatTypePrivate},
|
||||
From: &models.User{ID: userID, FirstName: "Ann", Username: "annlee", LanguageCode: "ru"},
|
||||
Text: text,
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportFirstContactOpensTopicAndRelays(t *testing.T) {
|
||||
api := newSupportAPI()
|
||||
b, st := newSupportBot(t, api)
|
||||
|
||||
b.handleSupportUserMessage(context.Background(), userMsg(7, "hello"))
|
||||
|
||||
if api.topicsOpened != 1 {
|
||||
t.Fatalf("topics opened = %d, want 1", api.topicsOpened)
|
||||
}
|
||||
rec, ok := st.Get(7)
|
||||
if !ok || rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
|
||||
t.Fatalf("store = %+v ok=%v, want topic 1000 / header 5000", rec, ok)
|
||||
}
|
||||
if api.headerThread != "1000" {
|
||||
t.Errorf("header posted to thread %q, want 1000", api.headerThread)
|
||||
}
|
||||
if len(api.copies) != 1 {
|
||||
t.Fatalf("copies = %d, want 1", len(api.copies))
|
||||
}
|
||||
c := api.copies[0]
|
||||
if c.threadID != "1000" || c.fromChatID != "7" || c.chatID != fmt.Sprint(supportChatID) {
|
||||
t.Errorf("copy = %+v, want thread 1000 from 7 into the support chat", c)
|
||||
}
|
||||
if len(rec.RelayedMsgIDs) != 1 {
|
||||
t.Errorf("relayed ids = %v, want 1 tracked", rec.RelayedMsgIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportSubsequentReusesTopic(t *testing.T) {
|
||||
api := newSupportAPI()
|
||||
b, st := newSupportBot(t, api)
|
||||
|
||||
b.handleSupportUserMessage(context.Background(), userMsg(7, "first"))
|
||||
b.handleSupportUserMessage(context.Background(), userMsg(7, "second"))
|
||||
|
||||
if api.topicsOpened != 1 {
|
||||
t.Errorf("topics opened = %d, want 1 (topic reused)", api.topicsOpened)
|
||||
}
|
||||
if len(api.copies) != 2 {
|
||||
t.Errorf("copies = %d, want 2", len(api.copies))
|
||||
}
|
||||
rec, _ := st.Get(7)
|
||||
if len(rec.RelayedMsgIDs) != 2 {
|
||||
t.Errorf("relayed ids = %v, want 2", rec.RelayedMsgIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSupportTopicRecreatedWhenDeleted covers the case the operator hit in prod:
|
||||
// after they delete a user's whole topic, Telegram routes a copy aimed at it into
|
||||
// General with no error, so the bot must proactively detect the dead topic (the card
|
||||
// probe fails) and reopen it instead of silently losing the message to General.
|
||||
func TestSupportTopicRecreatedWhenDeleted(t *testing.T) {
|
||||
api := newSupportAPI()
|
||||
api.editErr = "message to edit not found" // the operators deleted the topic + its card
|
||||
b, st := newSupportBot(t, api)
|
||||
// Seed a topic that has since been deleted in Telegram.
|
||||
if err := st.SetTopic(7, 999, 4999, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
|
||||
b.handleSupportUserMessage(context.Background(), userMsg(7, "still there?"))
|
||||
|
||||
if api.topicsOpened != 1 {
|
||||
t.Fatalf("topics opened = %d, want 1 (reopened after deletion)", api.topicsOpened)
|
||||
}
|
||||
rec, _ := st.Get(7)
|
||||
if rec.TopicID != 1000 || rec.HeaderMsgID != 5000 {
|
||||
t.Errorf("store = topic %d / header %d, want the reopened 1000 / 5000", rec.TopicID, rec.HeaderMsgID)
|
||||
}
|
||||
if len(api.copies) != 1 || api.copies[0].threadID != "1000" {
|
||||
t.Errorf("relay copies = %+v, want one into the reopened topic 1000", api.copies)
|
||||
}
|
||||
if _, ok := st.ByTopic(999); ok {
|
||||
t.Error("stale topic 999 still indexed after reopen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportBlockedUserDropped(t *testing.T) {
|
||||
api := newSupportAPI()
|
||||
b, st := newSupportBot(t, api)
|
||||
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
if err := st.SetBlocked(7, true); err != nil {
|
||||
t.Fatalf("block: %v", err)
|
||||
}
|
||||
|
||||
b.handleSupportUserMessage(context.Background(), userMsg(7, "hello?"))
|
||||
|
||||
if api.copyCount() != 0 {
|
||||
t.Errorf("a blocked user's message was relayed (%d copies)", api.copyCount())
|
||||
}
|
||||
if api.topicsOpened != 0 {
|
||||
t.Errorf("a blocked user opened a topic (%d)", api.topicsOpened)
|
||||
}
|
||||
}
|
||||
|
||||
// supportGroupMsg builds an operator message inside a topic of the support chat.
|
||||
func supportGroupMsg(fromID int64, threadID int, isBot bool) *models.Message {
|
||||
return &models.Message{
|
||||
ID: 700 + threadID,
|
||||
Chat: models.Chat{ID: supportChatID, Type: models.ChatTypeSupergroup},
|
||||
From: &models.User{ID: fromID, IsBot: isBot},
|
||||
MessageThreadID: threadID,
|
||||
Text: "operator reply",
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportOperatorReplyRelayed(t *testing.T) {
|
||||
api := newSupportAPI(50) // user 50 is an admin
|
||||
b, st := newSupportBot(t, api)
|
||||
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
|
||||
b.handleSupportGroupMessage(context.Background(), supportGroupMsg(50, 1000, false))
|
||||
|
||||
if len(api.copies) != 1 {
|
||||
t.Fatalf("copies = %d, want 1 (reply relayed to user)", len(api.copies))
|
||||
}
|
||||
if api.copies[0].chatID != "7" || api.copies[0].fromChatID != fmt.Sprint(supportChatID) {
|
||||
t.Errorf("copy = %+v, want from the support chat to user 7", api.copies[0])
|
||||
}
|
||||
rec, _ := st.Get(7)
|
||||
if len(rec.RelayedMsgIDs) != 1 {
|
||||
t.Errorf("operator message not tracked for clear: %v", rec.RelayedMsgIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportGroupMessageIgnored(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
msg *models.Message
|
||||
admins []int64
|
||||
}{
|
||||
{"bot's own copy (loop guard)", supportGroupMsg(1, 1000, true), []int64{50}},
|
||||
{"non-admin sender", supportGroupMsg(999, 1000, false), []int64{50}},
|
||||
{"outside any topic", supportGroupMsg(50, 0, false), []int64{50}},
|
||||
{"unknown topic", supportGroupMsg(50, 4242, false), []int64{50}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api := newSupportAPI(tc.admins...)
|
||||
b, st := newSupportBot(t, api)
|
||||
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
b.handleSupportGroupMessage(context.Background(), tc.msg)
|
||||
if api.copyCount() != 0 {
|
||||
t.Errorf("message relayed (%d copies); it should be ignored", api.copyCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// supportCallback builds a callback-query update for the given data and presser.
|
||||
func supportCallback(data string, fromID int64) *models.Update {
|
||||
return &models.Update{CallbackQuery: &models.CallbackQuery{
|
||||
ID: "cb1",
|
||||
From: models.User{ID: fromID},
|
||||
Data: data,
|
||||
}}
|
||||
}
|
||||
|
||||
func TestSupportCallbackBlockToggle(t *testing.T) {
|
||||
api := newSupportAPI(50)
|
||||
b, st := newSupportBot(t, api)
|
||||
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
|
||||
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 50))
|
||||
if !st.Blocked(7) {
|
||||
t.Error("user not blocked after sup:block")
|
||||
}
|
||||
if len(api.editedMsgIDs) != 1 || api.editedMsgIDs[0] != "5000" {
|
||||
t.Errorf("edited markup msg ids = %v, want [5000]", api.editedMsgIDs)
|
||||
}
|
||||
|
||||
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:unblock:7", 50))
|
||||
if st.Blocked(7) {
|
||||
t.Error("user still blocked after sup:unblock")
|
||||
}
|
||||
if len(api.answers) != 2 || api.answers[0] != "Заблокирован" || api.answers[1] != "Разблокирован" {
|
||||
t.Errorf("answers = %v, want [Заблокирован Разблокирован]", api.answers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportCallbackNonAdminDenied(t *testing.T) {
|
||||
api := newSupportAPI(50)
|
||||
b, st := newSupportBot(t, api)
|
||||
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
|
||||
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:block:7", 999))
|
||||
|
||||
if st.Blocked(7) {
|
||||
t.Error("a non-admin managed to block the user")
|
||||
}
|
||||
if len(api.answers) != 1 || api.answers[0] != "Недостаточно прав" {
|
||||
t.Errorf("answers = %v, want a permission denial", api.answers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportCallbackClearDeletesTracked(t *testing.T) {
|
||||
api := newSupportAPI(50)
|
||||
b, st := newSupportBot(t, api)
|
||||
if err := st.SetTopic(7, 1000, 5000, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("seed topic: %v", err)
|
||||
}
|
||||
for _, id := range []int{501, 502, 503} {
|
||||
if err := st.AppendMsg(7, id); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
b.handleSupportCallback(context.Background(), b.api, supportCallback("sup:clear:7", 50))
|
||||
|
||||
if len(api.deleted) != 1 || len(api.deleted[0]) != 3 {
|
||||
t.Fatalf("deleted batches = %v, want one batch of 3", api.deleted)
|
||||
}
|
||||
rec, _ := st.Get(7)
|
||||
if len(rec.RelayedMsgIDs) != 0 {
|
||||
t.Errorf("relayed ids after clear = %v, want empty", rec.RelayedMsgIDs)
|
||||
}
|
||||
if rec.HeaderMsgID != 5000 {
|
||||
t.Errorf("clear disturbed the header (%d)", rec.HeaderMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSupportCallback(t *testing.T) {
|
||||
cases := []struct {
|
||||
data string
|
||||
wantAction string
|
||||
wantUID int64
|
||||
wantOK bool
|
||||
}{
|
||||
{"sup:block:7", "block", 7, true},
|
||||
{"sup:clear:-100", "clear", -100, true},
|
||||
{"sup:unblock:42", "unblock", 42, true},
|
||||
{"block:7", "", 0, false},
|
||||
{"sup:block", "", 0, false},
|
||||
{"sup:block:notanumber", "", 0, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.data, func(t *testing.T) {
|
||||
action, uid, ok := parseSupportCallback(tc.data)
|
||||
if action != tc.wantAction || uid != tc.wantUID || ok != tc.wantOK {
|
||||
t.Errorf("parseSupportCallback(%q) = %q,%d,%v; want %q,%d,%v",
|
||||
tc.data, action, uid, ok, tc.wantAction, tc.wantUID, tc.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportCard(t *testing.T) {
|
||||
t.Run("name is a plain-text mention, not a command", func(t *testing.T) {
|
||||
u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true}
|
||||
text, ents := supportCard(u)
|
||||
// The name is rendered verbatim (no HTML, no escaping) at the start of the card.
|
||||
if !strings.HasPrefix(text, "/start\n") {
|
||||
t.Errorf("card = %q, want it to start with the raw name", text)
|
||||
}
|
||||
// A text_mention entity covers exactly the name with the user id — this both
|
||||
// suppresses the "/start" command auto-detection and links the profile.
|
||||
if len(ents) != 1 {
|
||||
t.Fatalf("entities = %d, want 1", len(ents))
|
||||
}
|
||||
e := ents[0]
|
||||
if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") {
|
||||
t.Errorf("entity = %+v, want a text_mention over the name", e)
|
||||
}
|
||||
if e.User == nil || e.User.ID != 7 {
|
||||
t.Errorf("entity user = %+v, want id 7", e.User)
|
||||
}
|
||||
if strings.Contains(text, "tg://") || strings.Contains(text, "<") {
|
||||
t.Errorf("card = %q, want no tg:// link and no HTML", text)
|
||||
}
|
||||
if !strings.Contains(text, "Premium: да") || !strings.Contains(text, "@ann") {
|
||||
t.Errorf("card = %q, want premium + @username", text)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entity length is UTF-16, not runes", func(t *testing.T) {
|
||||
// "😀A" is 2 runes but 3 UTF-16 code units (the emoji is a surrogate pair).
|
||||
u := &models.User{ID: 9, FirstName: "😀A"}
|
||||
_, ents := supportCard(u)
|
||||
if len(ents) != 1 || ents[0].Length != 3 {
|
||||
t.Fatalf("entity = %+v, want length 3 UTF-16 units", ents)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nameless user falls back to an id label", func(t *testing.T) {
|
||||
u := &models.User{ID: 42}
|
||||
text, ents := supportCard(u)
|
||||
if !strings.HasPrefix(text, "user 42") {
|
||||
t.Errorf("card = %q, want the id fallback name", text)
|
||||
}
|
||||
if ents[0].Length != len("user 42") {
|
||||
t.Errorf("entity length = %d, want it over the fallback name", ents[0].Length)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSupportTopicName(t *testing.T) {
|
||||
if got := supportTopicName(&models.User{ID: 7, FirstName: "Ann", LastName: "Lee", Username: "annlee"}); got != "Ann Lee @annlee" {
|
||||
t.Errorf("topic name = %q, want %q", got, "Ann Lee @annlee")
|
||||
}
|
||||
if got := supportTopicName(&models.User{ID: 7}); got != "user 7" {
|
||||
t.Errorf("nameless topic = %q, want %q", got, "user 7")
|
||||
}
|
||||
long := strings.Repeat("x", 200)
|
||||
if got := supportTopicName(&models.User{ID: 7, FirstName: long}); len([]rune(got)) != supportTopicNameMax {
|
||||
t.Errorf("topic name length = %d, want %d (trimmed)", len([]rune(got)), supportTopicNameMax)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupportDisabledFallsThrough(t *testing.T) {
|
||||
// With no SupportChatID the relay is off and supportEnabled is false.
|
||||
srv := httptest.NewServer(&supportAPI{nextThread: 1000, nextMsg: 5000})
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "123:ABC", APIBaseURL: srv.URL, MiniAppURL: "https://example.com/"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new bot: %v", err)
|
||||
}
|
||||
if b.supportEnabled() {
|
||||
t.Error("supportEnabled() = true without a SupportChatID")
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,16 @@ type BotConfig struct {
|
||||
// (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin
|
||||
// there with the "Ban users" right, and "chat_member" in its allowed updates.
|
||||
ChatID int64
|
||||
// SupportChatID is the chat id of the private forum supergroup the bot relays
|
||||
// direct user messages into — one forum topic per user — and reads operator
|
||||
// replies from (TELEGRAM_SUPPORT_CHAT_ID, optional; 0 disables the support relay).
|
||||
// The bot must be an administrator there with the manage-topics and
|
||||
// delete-messages rights. Distinct from ChatID (the moderated discussion chat).
|
||||
SupportChatID int64
|
||||
// SupportStateDir is the directory holding the support relay's JSON state file
|
||||
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
|
||||
// container user (UID 65532) and backed by a persistent volume.
|
||||
SupportStateDir string
|
||||
// PromoBotToken is the API token of the optional standalone promo bot run in this
|
||||
// container — a second bot whose only job is to answer /start with a button that
|
||||
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
|
||||
@@ -55,6 +65,12 @@ type BotConfig struct {
|
||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||
BotLinkURL string
|
||||
// PromoStartParam is the promo button's launch payload, appended as
|
||||
// ?startapp=<PromoStartParam> (TELEGRAM_PROMO_START_PARAM, default
|
||||
// "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to
|
||||
// seed a brand-new user's variant preferences; its labels must match the backend's
|
||||
// known variants. Empty falls back to forwarding the user's own /start payload.
|
||||
PromoStartParam string
|
||||
// MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
|
||||
// the base of every launch button (TELEGRAM_MINIAPP_URL, required).
|
||||
MiniAppURL string
|
||||
@@ -103,6 +119,10 @@ const (
|
||||
defaultValidatorGRPCAddr = ":9091"
|
||||
defaultBotReconnectDelay = 2 * time.Second
|
||||
defaultSendRatePerSecond = 25
|
||||
// defaultPromoStartParam is the promo button's default launch payload: a variant-seed
|
||||
// deep link the backend decodes to add English Scrabble to a brand-new user's variant
|
||||
// preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM.
|
||||
defaultPromoStartParam = "verudit_ru-scrabble_en"
|
||||
)
|
||||
|
||||
// LoadValidator reads the validator configuration from the environment.
|
||||
@@ -135,6 +155,8 @@ func LoadBot() (BotConfig, error) {
|
||||
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
|
||||
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
|
||||
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||
BotLink: BotLinkClientConfig{
|
||||
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
||||
@@ -152,6 +174,9 @@ func LoadBot() (BotConfig, error) {
|
||||
if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil {
|
||||
return BotConfig{}, err
|
||||
}
|
||||
if cfg.SupportChatID, err = envInt64("TELEGRAM_SUPPORT_CHAT_ID", 0); err != nil {
|
||||
return BotConfig{}, err
|
||||
}
|
||||
if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil {
|
||||
return BotConfig{}, err
|
||||
}
|
||||
|
||||
@@ -151,6 +151,48 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadBotSupportRelay verifies the support-relay chat id parses, the state
|
||||
// directory defaults to /data, and the relay is disabled (chat id 0) by default.
|
||||
func TestLoadBotSupportRelay(t *testing.T) {
|
||||
t.Run("parsed", func(t *testing.T) {
|
||||
setBotRequired(t)
|
||||
t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "-1001234567890")
|
||||
t.Setenv("TELEGRAM_SUPPORT_STATE_DIR", "/srv/state")
|
||||
c, err := LoadBot()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBot: %v", err)
|
||||
}
|
||||
if c.SupportChatID != -1001234567890 {
|
||||
t.Errorf("SupportChatID = %d, want -1001234567890", c.SupportChatID)
|
||||
}
|
||||
if c.SupportStateDir != "/srv/state" {
|
||||
t.Errorf("SupportStateDir = %q, want /srv/state", c.SupportStateDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("defaults", func(t *testing.T) {
|
||||
setBotRequired(t)
|
||||
c, err := LoadBot()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBot: %v", err)
|
||||
}
|
||||
if c.SupportChatID != 0 {
|
||||
t.Errorf("SupportChatID = %d, want 0 (relay disabled)", c.SupportChatID)
|
||||
}
|
||||
if c.SupportStateDir != "/data" {
|
||||
t.Errorf("SupportStateDir = %q, want /data", c.SupportStateDir)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed chat id rejected", func(t *testing.T) {
|
||||
setBotRequired(t)
|
||||
t.Setenv("TELEGRAM_SUPPORT_CHAT_ID", "not-a-number")
|
||||
if _, err := LoadBot(); err == nil {
|
||||
t.Fatal("LoadBot: expected an error for a malformed support chat id, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestLoadRejectsUnsupportedExporter verifies an exporter outside the supported set
|
||||
// fails validation (the validator path).
|
||||
func TestLoadRejectsUnsupportedExporter(t *testing.T) {
|
||||
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
)
|
||||
|
||||
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing
|
||||
// the hash, is malformed, or is older than the freshness window.
|
||||
// the hash, is malformed, is older than the freshness window, or identifies a bot
|
||||
// user (is_bot), which is denied.
|
||||
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
|
||||
|
||||
// defaultMaxAge bounds how old a validated initData payload may be.
|
||||
@@ -120,6 +121,7 @@ func parseUser(userJSON string) (User, error) {
|
||||
}
|
||||
var u struct {
|
||||
ID int64 `json:"id"`
|
||||
IsBot bool `json:"is_bot"`
|
||||
Username string `json:"username"`
|
||||
FirstName string `json:"first_name"`
|
||||
LanguageCode string `json:"language_code"`
|
||||
@@ -127,6 +129,12 @@ func parseUser(userJSON string) (User, error) {
|
||||
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
// Deny bot principals: the HMAC has already proved Telegram signed this payload, so is_bot==true
|
||||
// is Telegram itself attesting the launching user is a bot. A real user opening the Mini App
|
||||
// never carries it, so reject defensively rather than provision an account for a bot.
|
||||
if u.IsBot {
|
||||
return User{}, ErrInvalidInitData
|
||||
}
|
||||
return User{
|
||||
ExternalID: strconv.FormatInt(u.ID, 10),
|
||||
Username: u.Username,
|
||||
|
||||
@@ -83,3 +83,28 @@ func TestValidateRejects(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateBotUser(t *testing.T) {
|
||||
t.Run("is_bot true is denied", func(t *testing.T) {
|
||||
initData := signInitData(testToken, map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42,"is_bot":true,"first_name":"Robo"}`,
|
||||
})
|
||||
if _, err := NewHMACValidator(testToken).Validate(initData); !errors.Is(err, ErrInvalidInitData) {
|
||||
t.Errorf("err = %v, want ErrInvalidInitData", err)
|
||||
}
|
||||
})
|
||||
t.Run("is_bot false is allowed", func(t *testing.T) {
|
||||
initData := signInitData(testToken, map[string]string{
|
||||
"auth_date": strconv.FormatInt(time.Now().Unix(), 10),
|
||||
"user": `{"id":42,"is_bot":false,"first_name":"Thomas"}`,
|
||||
})
|
||||
u, err := NewHMACValidator(testToken).Validate(initData)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
if u.ExternalID != "42" || u.FirstName != "Thomas" {
|
||||
t.Errorf("user = %+v, want {42 Thomas}", u)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
package promobot
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"html"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
@@ -33,6 +35,11 @@ type Config struct {
|
||||
// BotLinkURL is the main bot's Mini App direct link; the button appends
|
||||
// ?startapp=<payload> to it.
|
||||
BotLinkURL string
|
||||
// StartParam is the campaign payload appended as ?startapp=<StartParam> to the launch
|
||||
// button — e.g. a variant-seed deep link ("verudit_ru-scrabble_en") the backend
|
||||
// decodes to seed a brand-new user's variant preferences. Empty falls back to
|
||||
// forwarding the user's own /start payload.
|
||||
StartParam string
|
||||
// SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
|
||||
// disables the limiter. The burst equals the per-second rate.
|
||||
SendRatePerSecond int
|
||||
@@ -43,6 +50,7 @@ type Bot struct {
|
||||
api *tgbot.Bot
|
||||
username string
|
||||
linkURL string
|
||||
startParam string
|
||||
log *zap.Logger
|
||||
limiter *rate.Limiter
|
||||
}
|
||||
@@ -53,7 +61,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
||||
if log == nil {
|
||||
log = zap.NewNop()
|
||||
}
|
||||
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
|
||||
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log}
|
||||
if cfg.SendRatePerSecond > 0 {
|
||||
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
|
||||
}
|
||||
@@ -87,7 +95,8 @@ func (t *Bot) Run(ctx context.Context) {
|
||||
}
|
||||
|
||||
// handleStart replies to any message (typically /start) with the localized promo text
|
||||
// and a button that opens the main bot's Mini App, forwarding any /start payload.
|
||||
// and a button that opens the main bot's Mini App at the configured campaign payload
|
||||
// (falling back to forwarding any /start payload the user arrived with).
|
||||
func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
|
||||
if update.Message == nil {
|
||||
return
|
||||
@@ -104,11 +113,16 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
|
||||
if update.Message.From != nil {
|
||||
lang = update.Message.From.LanguageCode
|
||||
}
|
||||
text, button := promoText(lang, t.username)
|
||||
// The configured campaign payload (a variant-seed deep link) takes precedence; absent
|
||||
// one, fall back to forwarding any /start payload the user arrived with. The same
|
||||
// payload backs both the inline button and the @username link in the body.
|
||||
param := cmp.Or(t.startParam, startPayload(update.Message.Text))
|
||||
text, button := promoText(lang, t.username, t.launchURL(param))
|
||||
if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||
ChatID: update.Message.Chat.ID,
|
||||
Text: text,
|
||||
ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
|
||||
ParseMode: models.ParseModeHTML,
|
||||
ReplyMarkup: t.launchMarkup(button, param),
|
||||
}); err != nil {
|
||||
t.log.Warn("promo: reply to start failed", zap.Error(err))
|
||||
}
|
||||
@@ -159,11 +173,15 @@ func startPayload(text string) string {
|
||||
return strings.TrimSpace(strings.TrimPrefix(text, cmd))
|
||||
}
|
||||
|
||||
// promoText returns the localized message body and button label, naming the main bot
|
||||
// (Russian for a "ru" language code, English otherwise).
|
||||
func promoText(lang, username string) (text, button string) {
|
||||
// promoText returns the localized message body and button label. The body names the main
|
||||
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same
|
||||
// target as the button), so tapping the mention opens the seeded Mini App rather than the
|
||||
// bot profile. The body is sent with ParseMode HTML; only the link carries markup, so the
|
||||
// static sentences need no escaping. Russian for a "ru" language code, English otherwise.
|
||||
func promoText(lang, username, launchURL string) (text, button string) {
|
||||
mention := `<a href="` + html.EscapeString(launchURL) + `">@` + html.EscapeString(username) + `</a>`
|
||||
if strings.HasPrefix(strings.ToLower(lang), "ru") {
|
||||
return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
|
||||
}
|
||||
return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!"
|
||||
}
|
||||
|
||||
@@ -13,25 +13,30 @@ import (
|
||||
)
|
||||
|
||||
func TestPromoTextLocalization(t *testing.T) {
|
||||
en, enBtn := promoText("en", "ScrabbleBot")
|
||||
if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
|
||||
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||
|
||||
en, enBtn := promoText("en", "ScrabbleBot", url)
|
||||
if !strings.Contains(en, wantLink) || !strings.Contains(en, "profile settings") {
|
||||
t.Errorf("en text = %q", en)
|
||||
}
|
||||
if enBtn != "🤩 I want to play!" {
|
||||
t.Errorf("en button = %q", enBtn)
|
||||
}
|
||||
|
||||
ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
|
||||
if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
|
||||
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url)
|
||||
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") {
|
||||
t.Errorf("ru text = %q", ru)
|
||||
}
|
||||
if ruBtn != "🤩 Хочу играть!" {
|
||||
t.Errorf("ru button = %q", ruBtn)
|
||||
}
|
||||
|
||||
// An unknown language falls back to English.
|
||||
if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
|
||||
t.Errorf("fallback text = %q, want English", got)
|
||||
// An unknown language falls back to English, still with the linked mention.
|
||||
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) {
|
||||
t.Errorf("fallback text = %q, want English with a linked mention", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,8 +98,8 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
if api.chatID != "42" {
|
||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||
}
|
||||
if !strings.Contains(api.text, "@ScrabbleBot") {
|
||||
t.Errorf("text = %q, want the @mention", api.text)
|
||||
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||
}
|
||||
if strings.Contains(api.replyMarkup, "web_app") {
|
||||
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
// Package support persists the Telegram bot's support-relay state. For each user
|
||||
// who direct-messages the bot it records the dedicated forum topic the bot opened
|
||||
// in the operators' support chat, whether the user is blocked, and the ids of the
|
||||
// messages relayed into that topic — so an operator's "clear" can delete them while
|
||||
// keeping the topic's info card. The state is a small JSON file on a writable
|
||||
// volume: the bot host has no database.
|
||||
//
|
||||
// The store is concurrency-safe. The go-telegram/bot library dispatches each update
|
||||
// in its own goroutine, so every exported method locks. Mutators update only their
|
||||
// own fields (SetTopic never touches Blocked, SetBlocked never touches the relayed
|
||||
// ids) so a topic recreate cannot clobber a concurrent block toggle.
|
||||
package support
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// User is one user's support-relay state.
|
||||
type User struct {
|
||||
// UserID is the user's Telegram user id (the private chat id the bot relays to).
|
||||
UserID int64 `json:"user_id"`
|
||||
// TopicID is the forum topic's message_thread_id in the support chat; 0 means no
|
||||
// topic has been created yet.
|
||||
TopicID int `json:"topic_id"`
|
||||
// HeaderMsgID is the info-card message that opens the topic and carries the
|
||||
// block/clear buttons; it is never deleted by a clear.
|
||||
HeaderMsgID int `json:"header_msg_id"`
|
||||
// Blocked reports whether the bot drops this user's incoming messages.
|
||||
Blocked bool `json:"blocked"`
|
||||
// FirstName, LastName and Username snapshot the user's Telegram profile at the
|
||||
// time the topic was created or last refreshed (for the topic name and info card).
|
||||
FirstName string `json:"first_name,omitempty"`
|
||||
LastName string `json:"last_name,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
// RelayedMsgIDs are the ids of the messages posted into the topic after the header
|
||||
// (the user's relayed messages and the operators' replies); a clear deletes them.
|
||||
RelayedMsgIDs []int `json:"relayed_msg_ids,omitempty"`
|
||||
// CreatedAt is when the topic was first opened for this user.
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Store is the concurrency-safe, JSON-backed support-relay state. The zero value is
|
||||
// not usable; build one with Open or New.
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
users map[int64]*User
|
||||
byTopic map[int]int64 // TopicID -> UserID, for resolving operator replies
|
||||
}
|
||||
|
||||
// file is the on-disk shape: a flat list of users. A JSON object keyed by user id
|
||||
// would force the int64 keys through strings; a list keeps the ids typed.
|
||||
type file struct {
|
||||
Users []*User `json:"users"`
|
||||
}
|
||||
|
||||
// New returns an empty store that persists to path, creating the parent directory
|
||||
// when missing. Use it as the fallback when Open reports a corrupt file.
|
||||
func New(path string) *Store {
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
return &Store{path: path, users: map[int64]*User{}, byTopic: map[int]int64{}}
|
||||
}
|
||||
|
||||
// Open loads the store from path. A missing file yields an empty store and no
|
||||
// error; an unreadable or malformed file returns an error so the caller can decide
|
||||
// whether to start empty.
|
||||
func Open(path string) (*Store, error) {
|
||||
s := New(path)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("support: read state %s: %w", path, err)
|
||||
}
|
||||
var f file
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
return nil, fmt.Errorf("support: parse state %s: %w", path, err)
|
||||
}
|
||||
for _, u := range f.Users {
|
||||
if u == nil || u.UserID == 0 {
|
||||
continue
|
||||
}
|
||||
s.users[u.UserID] = u
|
||||
if u.TopicID != 0 {
|
||||
s.byTopic[u.TopicID] = u.UserID
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get returns a detached copy of the user's state and whether it exists.
|
||||
func (s *Store) Get(userID int64) (User, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.users[userID]
|
||||
if !ok {
|
||||
return User{}, false
|
||||
}
|
||||
cp := *u
|
||||
cp.RelayedMsgIDs = append([]int(nil), u.RelayedMsgIDs...)
|
||||
return cp, true
|
||||
}
|
||||
|
||||
// ByTopic returns the user id owning the given forum topic, and whether one is known.
|
||||
func (s *Store) ByTopic(topicID int) (int64, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
uid, ok := s.byTopic[topicID]
|
||||
return uid, ok
|
||||
}
|
||||
|
||||
// Blocked reports whether the user's incoming messages are dropped.
|
||||
func (s *Store) Blocked(userID int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if u, ok := s.users[userID]; ok {
|
||||
return u.Blocked
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SetTopic records the forum topic and info-card message opened for the user and
|
||||
// refreshes the profile snapshot, creating the record on first contact. It rebuilds
|
||||
// the topic index when the topic changes (a recreate) and preserves the block state
|
||||
// and the relayed-message ids. It persists the result.
|
||||
func (s *Store) SetTopic(userID int64, topicID, headerMsgID int, firstName, lastName, username string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.users[userID]
|
||||
if !ok {
|
||||
u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
|
||||
s.users[userID] = u
|
||||
} else if u.TopicID != 0 && u.TopicID != topicID {
|
||||
delete(s.byTopic, u.TopicID)
|
||||
}
|
||||
u.TopicID = topicID
|
||||
u.HeaderMsgID = headerMsgID
|
||||
u.FirstName, u.LastName, u.Username = firstName, lastName, username
|
||||
if topicID != 0 {
|
||||
s.byTopic[topicID] = userID
|
||||
}
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// SetBlocked sets the user's blocked flag, creating a minimal record when none
|
||||
// exists, and persists the result.
|
||||
func (s *Store) SetBlocked(userID int64, blocked bool) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.users[userID]
|
||||
if !ok {
|
||||
u = &User{UserID: userID, CreatedAt: time.Now().UTC()}
|
||||
s.users[userID] = u
|
||||
}
|
||||
u.Blocked = blocked
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// AppendMsg records a message posted into the user's topic (for a later clear) and
|
||||
// persists the result. It is a no-op when the user has no record yet.
|
||||
func (s *Store) AppendMsg(userID int64, msgID int) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.users[userID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
u.RelayedMsgIDs = append(u.RelayedMsgIDs, msgID)
|
||||
return s.save()
|
||||
}
|
||||
|
||||
// ClearMsgs empties and returns the user's relayed-message ids (the info card is not
|
||||
// among them) and persists the result, so the caller can delete them from the chat.
|
||||
func (s *Store) ClearMsgs(userID int64) ([]int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.users[userID]
|
||||
if !ok || len(u.RelayedMsgIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ids := u.RelayedMsgIDs
|
||||
u.RelayedMsgIDs = nil
|
||||
if err := s.save(); err != nil {
|
||||
// Roll back so the ids are not lost if the write fails.
|
||||
u.RelayedMsgIDs = ids
|
||||
return nil, err
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// save writes the state atomically (temp file in the same directory, then rename).
|
||||
// The caller must hold s.mu.
|
||||
func (s *Store) save() error {
|
||||
f := file{Users: make([]*User, 0, len(s.users))}
|
||||
for _, u := range s.users {
|
||||
f.Users = append(f.Users, u)
|
||||
}
|
||||
sort.Slice(f.Users, func(i, j int) bool { return f.Users[i].UserID < f.Users[j].UserID })
|
||||
b, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("support: marshal state: %w", err)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), ".support-*.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("support: create temp state: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() { _ = os.Remove(tmpName) }() // no-op once the rename succeeds
|
||||
if _, err := tmp.Write(b); err != nil {
|
||||
_ = tmp.Close()
|
||||
return fmt.Errorf("support: write temp state: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return fmt.Errorf("support: close temp state: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, s.path); err != nil {
|
||||
return fmt.Errorf("support: replace state %s: %w", s.path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package support
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// statePath returns a path inside a fresh temp directory for a store file.
|
||||
func statePath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "support.json")
|
||||
}
|
||||
|
||||
func TestOpenMissingReturnsEmpty(t *testing.T) {
|
||||
s, err := Open(statePath(t))
|
||||
if err != nil {
|
||||
t.Fatalf("Open missing: %v", err)
|
||||
}
|
||||
if _, ok := s.Get(42); ok {
|
||||
t.Error("Get on an empty store returned a record")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenCorruptReturnsError(t *testing.T) {
|
||||
path := statePath(t)
|
||||
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
|
||||
t.Fatalf("write corrupt: %v", err)
|
||||
}
|
||||
if _, err := Open(path); err == nil {
|
||||
t.Fatal("Open on a corrupt file: expected an error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTopicPersistsAndReloads(t *testing.T) {
|
||||
path := statePath(t)
|
||||
s := New(path)
|
||||
if err := s.SetTopic(7, 100, 101, "Ann", "Lee", "annlee"); err != nil {
|
||||
t.Fatalf("SetTopic: %v", err)
|
||||
}
|
||||
|
||||
// In-memory view.
|
||||
got, ok := s.Get(7)
|
||||
if !ok || got.TopicID != 100 || got.HeaderMsgID != 101 || got.Username != "annlee" {
|
||||
t.Fatalf("Get = %+v ok=%v, want topic 100 / header 101 / annlee", got, ok)
|
||||
}
|
||||
if got.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt not set on first contact")
|
||||
}
|
||||
if uid, ok := s.ByTopic(100); !ok || uid != 7 {
|
||||
t.Errorf("ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
|
||||
}
|
||||
|
||||
// Reloaded from disk.
|
||||
reload, err := Open(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
got, ok = reload.Get(7)
|
||||
if !ok || got.TopicID != 100 || got.FirstName != "Ann" {
|
||||
t.Fatalf("reloaded Get = %+v ok=%v, want topic 100 / Ann", got, ok)
|
||||
}
|
||||
if uid, ok := reload.ByTopic(100); !ok || uid != 7 {
|
||||
t.Errorf("reloaded ByTopic(100) = %d ok=%v, want 7/true", uid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetTopicRecreateReindexes(t *testing.T) {
|
||||
s := New(statePath(t))
|
||||
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
|
||||
t.Fatalf("SetTopic: %v", err)
|
||||
}
|
||||
if err := s.SetTopic(7, 200, 201, "Ann", "", ""); err != nil {
|
||||
t.Fatalf("SetTopic recreate: %v", err)
|
||||
}
|
||||
if _, ok := s.ByTopic(100); ok {
|
||||
t.Error("ByTopic(100) still resolves after recreate; the stale topic was not dropped")
|
||||
}
|
||||
if uid, ok := s.ByTopic(200); !ok || uid != 7 {
|
||||
t.Errorf("ByTopic(200) = %d ok=%v, want 7/true", uid, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBlockSurvivesTopicRecreate is the field-isolation guarantee: a topic recreate
|
||||
// (SetTopic) must not clear a block set concurrently from the buttons.
|
||||
func TestBlockSurvivesTopicRecreate(t *testing.T) {
|
||||
s := New(statePath(t))
|
||||
if err := s.SetTopic(7, 100, 101, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("SetTopic: %v", err)
|
||||
}
|
||||
if err := s.SetBlocked(7, true); err != nil {
|
||||
t.Fatalf("SetBlocked: %v", err)
|
||||
}
|
||||
if err := s.SetTopic(7, 200, 201, "Ann", "", "annlee"); err != nil {
|
||||
t.Fatalf("SetTopic recreate: %v", err)
|
||||
}
|
||||
if !s.Blocked(7) {
|
||||
t.Error("block was cleared by a topic recreate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendAndClearMsgs(t *testing.T) {
|
||||
s := New(statePath(t))
|
||||
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
|
||||
t.Fatalf("SetTopic: %v", err)
|
||||
}
|
||||
for _, id := range []int{500, 501, 502} {
|
||||
if err := s.AppendMsg(7, id); err != nil {
|
||||
t.Fatalf("AppendMsg %d: %v", id, err)
|
||||
}
|
||||
}
|
||||
got, _ := s.Get(7)
|
||||
if len(got.RelayedMsgIDs) != 3 {
|
||||
t.Fatalf("RelayedMsgIDs = %v, want 3 ids", got.RelayedMsgIDs)
|
||||
}
|
||||
|
||||
ids, err := s.ClearMsgs(7)
|
||||
if err != nil {
|
||||
t.Fatalf("ClearMsgs: %v", err)
|
||||
}
|
||||
if len(ids) != 3 || ids[0] != 500 || ids[2] != 502 {
|
||||
t.Errorf("ClearMsgs = %v, want [500 501 502]", ids)
|
||||
}
|
||||
got, _ = s.Get(7)
|
||||
if len(got.RelayedMsgIDs) != 0 {
|
||||
t.Errorf("RelayedMsgIDs after clear = %v, want empty", got.RelayedMsgIDs)
|
||||
}
|
||||
if got.HeaderMsgID != 101 || got.TopicID != 100 {
|
||||
t.Errorf("clear disturbed the topic/header: %+v", got)
|
||||
}
|
||||
|
||||
// A second clear is a no-op.
|
||||
if ids, err := s.ClearMsgs(7); err != nil || ids != nil {
|
||||
t.Errorf("second ClearMsgs = %v, %v, want nil, nil", ids, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReturnsDetachedCopy(t *testing.T) {
|
||||
s := New(statePath(t))
|
||||
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
|
||||
t.Fatalf("SetTopic: %v", err)
|
||||
}
|
||||
if err := s.AppendMsg(7, 500); err != nil {
|
||||
t.Fatalf("AppendMsg: %v", err)
|
||||
}
|
||||
got, _ := s.Get(7)
|
||||
got.RelayedMsgIDs[0] = 999 // mutate the copy
|
||||
again, _ := s.Get(7)
|
||||
if again.RelayedMsgIDs[0] != 500 {
|
||||
t.Error("Get returned a slice that aliases the store's state")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentMutationsSafe exercises the mutex under parallel writers; run with
|
||||
// -race to catch data races.
|
||||
func TestConcurrentMutationsSafe(t *testing.T) {
|
||||
s := New(statePath(t))
|
||||
if err := s.SetTopic(7, 100, 101, "Ann", "", ""); err != nil {
|
||||
t.Fatalf("SetTopic: %v", err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := range 20 {
|
||||
wg.Add(1)
|
||||
go func(n int) {
|
||||
defer wg.Done()
|
||||
_ = s.AppendMsg(7, 1000+n)
|
||||
_ = s.SetBlocked(7, n%2 == 0)
|
||||
_, _ = s.Get(7)
|
||||
_, _ = s.ByTopic(100)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
got, ok := s.Get(7)
|
||||
if !ok || len(got.RelayedMsgIDs) != 20 {
|
||||
t.Errorf("after concurrent appends: %d ids, want 20", len(got.RelayedMsgIDs))
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
"@bufbuild/protobuf": "^2.12.0",
|
||||
"@connectrpc/connect": "^2.1.0",
|
||||
"@connectrpc/connect-web": "^2.1.0",
|
||||
"@vkontakte/vk-bridge": "^3.0.2",
|
||||
"flatbuffers": "^25.9.23"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
Generated
+22
@@ -17,6 +17,9 @@ importers:
|
||||
'@connectrpc/connect-web':
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.1(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.1.1(@bufbuild/protobuf@2.12.0))
|
||||
'@vkontakte/vk-bridge':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2
|
||||
flatbuffers:
|
||||
specifier: ^25.9.23
|
||||
version: 25.9.23
|
||||
@@ -413,6 +416,9 @@ packages:
|
||||
svelte: ^5.0.0
|
||||
vite: ^6.0.0
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@@ -462,6 +468,9 @@ packages:
|
||||
'@vitest/utils@3.2.6':
|
||||
resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
|
||||
|
||||
'@vkontakte/vk-bridge@3.0.2':
|
||||
resolution: {integrity: sha512-MTp+nl0/jH4Sa2TXDyxNfy9VkhOhRQR1oQ4yhvthVDA4aWUa26npns7bRgfTuR3xDVGFiqW7zAwLnwcv3mL+dA==}
|
||||
|
||||
acorn@8.16.0:
|
||||
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -689,6 +698,9 @@ packages:
|
||||
resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
typescript@5.4.5:
|
||||
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
|
||||
engines: {node: '>=14.17'}
|
||||
@@ -1022,6 +1034,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@swc/helpers@0.5.23':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@@ -1086,6 +1102,10 @@ snapshots:
|
||||
loupe: 3.2.1
|
||||
tinyrainbow: 2.0.0
|
||||
|
||||
'@vkontakte/vk-bridge@3.0.2':
|
||||
dependencies:
|
||||
'@swc/helpers': 0.5.23
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
aria-query@5.3.1: {}
|
||||
@@ -1318,6 +1338,8 @@ snapshots:
|
||||
|
||||
tinyspy@4.0.4: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@5.4.5: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
+6
-15
@@ -2,13 +2,13 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { cubicOut } from 'svelte/easing';
|
||||
import { app, bootstrap } from './lib/app.svelte';
|
||||
import { navigate, router, type RouteName } from './lib/router.svelte';
|
||||
import { router, type RouteName } from './lib/router.svelte';
|
||||
import { t } from './lib/i18n/index.svelte';
|
||||
import { insideTelegram, telegramBackButton } from './lib/telegram';
|
||||
import Toast from './components/Toast.svelte';
|
||||
import Splash from './components/Splash.svelte';
|
||||
import StaleInviteModal from './components/StaleInviteModal.svelte';
|
||||
import WelcomeRedeemModal from './components/WelcomeRedeemModal.svelte';
|
||||
import DebugPanel from './components/DebugPanel.svelte';
|
||||
import Login from './screens/Login.svelte';
|
||||
import Lobby from './screens/Lobby.svelte';
|
||||
import NewGame from './screens/NewGame.svelte';
|
||||
@@ -30,19 +30,6 @@
|
||||
// another screen is not covered.
|
||||
const routeIsLobby = $derived(router.route.name === 'lobby');
|
||||
|
||||
// Inside Telegram, drive its native header back button: show it on any sub-screen
|
||||
// (everything returns to the lobby root), hide it on the lobby/login. The app's own
|
||||
// back chevron is hidden in Telegram (Header.svelte) so only the native one shows.
|
||||
$effect(() => {
|
||||
if (!insideTelegram()) return;
|
||||
const r = router.route;
|
||||
// The chat / check sub-screens step back to their game; every other sub-screen to the lobby.
|
||||
let target = '/';
|
||||
if (r.name === 'gameChat' || r.name === 'gameCheck') target = `/game/${r.params.id}`;
|
||||
else if (r.name === 'feedback') target = '/about'; // back to the Settings → Info tab
|
||||
telegramBackButton(r.name !== 'lobby' && r.name !== 'login', () => navigate(target));
|
||||
});
|
||||
|
||||
// Screen transitions: the lobby is the navigation root. Entering a screen from the
|
||||
// lobby slides it in from the right (forward); returning to the lobby slides the
|
||||
// screen out to the right and reveals the lobby (back). Transitions are local, so
|
||||
@@ -138,6 +125,10 @@
|
||||
<Splash />
|
||||
{/if}
|
||||
|
||||
{#if app.debugOpen}
|
||||
<DebugPanel />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.splash {
|
||||
height: 100%;
|
||||
|
||||
+10
-3
@@ -46,9 +46,16 @@
|
||||
/* Height Telegram's native nav overlays at the top in fullscreen; set from the SDK's
|
||||
content-safe-area inset, 0 elsewhere. */
|
||||
--tg-content-top: 0px;
|
||||
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and
|
||||
--tg-content-top, so the in-app header aligns to that band, 0 elsewhere. */
|
||||
--tg-safe-top: 0px;
|
||||
/* Device safe-area top (the notch); inside Telegram TG's own nav controls sit between it and
|
||||
--tg-content-top, so the in-app header aligns to that band. Inside Telegram these are set from
|
||||
the SDK (lib/app.svelte.ts); elsewhere they fall back to the CSS env() safe area, so a VK Mini
|
||||
App / Capacitor / PWA webview clears the device cut-outs too (a plain browser tab reports 0). */
|
||||
--tg-safe-top: env(safe-area-inset-top, 0px);
|
||||
/* Device safe-area bottom / sides (home indicator; landscape notch) — the screen pads its bottom
|
||||
and left/right edges by these so content clears the cut-outs (e.g. the VK mobile home bar). */
|
||||
--tg-safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--tg-safe-left: env(safe-area-inset-left, 0px);
|
||||
--tg-safe-right: env(safe-area-inset-right, 0px);
|
||||
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
|
||||
"Noto Sans", "Liberation Sans", sans-serif;
|
||||
--shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
// Hidden on-device debug panel, opened by tapping the header title ten times (Header.svelte) and
|
||||
// closed by tapping anywhere except the Share control. It shows a privacy-safe client diagnostic
|
||||
// snapshot (no secrets, no initData values, no IP) and shares it through the OS share sheet (or a
|
||||
// clipboard copy on desktop) — a support aid for reproducing client-specific issues, e.g. the
|
||||
// Telegram Android presentation quirks. Drawn from the top, just under the app header.
|
||||
import { app, closeDebug } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { shareText } from '../lib/share';
|
||||
import { telegramChromeDiag } from '../lib/telegram';
|
||||
|
||||
const report = [
|
||||
`app: ${__APP_VERSION__}`,
|
||||
`locale: ${app.locale} theme: ${app.theme} reduceMotion: ${app.reduceMotion}`,
|
||||
`online: ${connection.online} streamAlive: ${app.streamAlive}`,
|
||||
`userId: ${app.session?.userId ?? '—'} guest: ${app.profile?.isGuest ?? '—'}`,
|
||||
telegramChromeDiag(),
|
||||
].join('\n');
|
||||
|
||||
let label = $state('Share');
|
||||
async function share(e: MouseEvent): Promise<void> {
|
||||
e.stopPropagation(); // a tap on Share shares; it must not also close the panel
|
||||
const r = await shareText(report, `Scrabble debug ${__APP_VERSION__}`);
|
||||
if (r === 'copied') {
|
||||
label = 'Copied';
|
||||
setTimeout(() => (label = 'Share'), 1500);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="overlay" onclick={closeDebug}>
|
||||
<button class="share" onclick={share}>{label}</button>
|
||||
<pre class="body">{report}</pre>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10000;
|
||||
/* Drawn from the top; the content clears the app header (~56px + the device safe-area). */
|
||||
padding: calc(var(--tg-safe-top, 0px) + 56px) 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.share {
|
||||
flex: 0 0 auto;
|
||||
padding: 7px 16px;
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
color: #d6e6ff;
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { navigate } from '../lib/router.svelte';
|
||||
import { insideTelegram } from '../lib/telegram';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { app } from '../lib/app.svelte';
|
||||
import { app, openDebug } from '../lib/app.svelte';
|
||||
import Spinner from './Spinner.svelte';
|
||||
import AdBanner from './AdBanner.svelte';
|
||||
|
||||
let { title, back, grow = false }: { title: string; back?: string; grow?: boolean } = $props();
|
||||
|
||||
// Inside Telegram the native header back button (App.svelte) is the back control, so
|
||||
// the app's own chevron is hidden to avoid two back affordances.
|
||||
const showBack = $derived(!!back && !insideTelegram());
|
||||
// The app always shows its own back chevron when there is a back target — on every platform, in
|
||||
// and out of Telegram. The native Telegram BackButton is not used: it does not render reliably in
|
||||
// the windowed Mini App (relying on it would lose back navigation there).
|
||||
const showBack = $derived(!!back);
|
||||
|
||||
// Ten quick taps on the title open the hidden debug panel (components/DebugPanel) — a support aid.
|
||||
let titleTaps = 0;
|
||||
let lastTitleTap = 0;
|
||||
function onTitleTap(): void {
|
||||
const now = Date.now();
|
||||
titleTaps = now - lastTitleTap < 400 ? titleTaps + 1 : 1;
|
||||
lastTitleTap = now;
|
||||
if (titleTaps >= 10) {
|
||||
titleTaps = 0;
|
||||
openDebug();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<header class="nav" class:grow>
|
||||
@@ -24,7 +37,9 @@
|
||||
<span class="spacer"></span>
|
||||
{/if}
|
||||
{#if connection.online}
|
||||
<h1>{title}</h1>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<h1 onclick={onTitleTap}>{title}</h1>
|
||||
{:else}
|
||||
<h1 class="connecting"><Spinner /> <span>{t('connection.connecting')}</span></h1>
|
||||
{/if}
|
||||
@@ -66,7 +81,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--gap);
|
||||
padding: 10px var(--pad);
|
||||
padding: 5px var(--pad);
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.05rem;
|
||||
@@ -124,15 +139,15 @@
|
||||
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the
|
||||
(removed) hamburger that content shrank and the bar sat flush under Telegram's native nav
|
||||
band. So **padding-top** is the lever: it drops the title clear of the band — the notch
|
||||
plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
|
||||
native controls stays constant if the user scales up the font (the title then grows
|
||||
downward and the bar with it). (Owner-tunable: the 10px.) */
|
||||
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from
|
||||
Telegram's native controls stays constant if the user scales up the font (the title then
|
||||
grows downward and the bar with it). (Owner-tunable: the 8px.) */
|
||||
min-height: var(--tg-content-top);
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-top: calc(var(--tg-safe-top) + 16px);
|
||||
padding-bottom: 6px;
|
||||
padding-top: calc(var(--tg-safe-top) + 8px);
|
||||
padding-bottom: 3px;
|
||||
}
|
||||
:global(html.tg-fullscreen) .spacer {
|
||||
display: none;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
scroll = true,
|
||||
growNav = false,
|
||||
column = false,
|
||||
selfInset = false,
|
||||
}: {
|
||||
title: string;
|
||||
back?: string;
|
||||
@@ -21,6 +22,10 @@
|
||||
children?: Snippet;
|
||||
scroll?: boolean;
|
||||
growNav?: boolean;
|
||||
// selfInset: the content paints the bottom home-indicator inset itself (a tab-bar-less screen
|
||||
// whose own bottom element owns the strip, e.g. the landscape game's left-panel controls), so
|
||||
// the shell does not add the detached .content padding-bottom below it.
|
||||
selfInset?: boolean;
|
||||
// column lays the content out as a flex column so a child can own the vertical fit
|
||||
// (the game makes only its board scroll while the score/rack/tab bar stay put).
|
||||
column?: boolean;
|
||||
@@ -87,7 +92,7 @@
|
||||
|
||||
<div class="screen">
|
||||
<Header {title} {back} grow={growNav} />
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column>{@render children?.()}</main>
|
||||
<main class="content" class:scroll class:fill={!growNav} class:column class:selfinset={selfInset}>{@render children?.()}</main>
|
||||
{#if tabbar}
|
||||
<nav class="tabbar">{@render tabbar()}</nav>
|
||||
{/if}
|
||||
@@ -101,6 +106,12 @@
|
||||
bottom input — chat, word-check — stays above an open soft keyboard without the page
|
||||
scrolling; falls back to the full height where the var is unset. */
|
||||
height: var(--vvh, 100%);
|
||||
/* Clear the landscape notch sides inside Telegram (0 elsewhere). The top inset is owned by the
|
||||
header; the home-indicator (bottom) inset is owned by the bottom bar — the .tabbar paints its
|
||||
own chrome into it, and a screen with no tab bar pads its content (.content:last-child) — so
|
||||
the strip takes the bar's colour rather than the detached content background. */
|
||||
padding-left: var(--tg-safe-left, 0px);
|
||||
padding-right: var(--tg-safe-right, 0px);
|
||||
}
|
||||
.content {
|
||||
flex: 0 1 auto;
|
||||
@@ -116,7 +127,23 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* No tab bar → the content is the bottom-most element: pad it by the device home-indicator inset
|
||||
so it clears the cut-out, the strip taking the content's own background. With a tab bar the
|
||||
.tabbar owns that inset instead (and content is not the last child, so this does not apply). */
|
||||
.content:last-child {
|
||||
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||
}
|
||||
/* selfInset: the content's own bottom element paints the home-indicator strip (the landscape
|
||||
game's left-panel controls), so the shell does not add a detached padding strip below it. */
|
||||
.content.selfinset:last-child {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
.tabbar {
|
||||
flex: 0 0 auto;
|
||||
/* Extend the bottom bar's chrome (the TabBar's --bg-elev) under the device home indicator
|
||||
inside Telegram (the inset is 0 elsewhere), so the safe-area strip reads as part of the bar
|
||||
instead of the content background showing through. */
|
||||
background: var(--bg-elev);
|
||||
padding-bottom: var(--tg-safe-bottom, 0px);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissStaleInvite } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { botUsername } from '../lib/deeplink';
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
|
||||
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// The single bot's @username, for the deep link.
|
||||
@@ -19,9 +21,30 @@
|
||||
}
|
||||
dismissStaleInvite();
|
||||
}
|
||||
|
||||
// Native path: when the notice fires inside the Mini App with native popups, present Telegram's
|
||||
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears the
|
||||
// notice; the `shown` guard fires it once per notice.
|
||||
// The notice is raised during boot; wait until the loading cover for the current route is gone —
|
||||
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||
// the native popup never appears over the splash.
|
||||
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||
let shown = false;
|
||||
$effect(() => {
|
||||
if (app.staleInvite && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||
shown = true;
|
||||
void telegramShowPopup(
|
||||
botInfoPopup(t('friends.staleInviteTitle'), t('friends.staleInvite'), username ?? '', t('common.ok')),
|
||||
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissStaleInvite()));
|
||||
} else if (!app.staleInvite) {
|
||||
shown = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if app.staleInvite}
|
||||
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}>
|
||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||
<button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
|
||||
import { router } from '../lib/router.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { botUsername } from '../lib/deeplink';
|
||||
import { telegramOpenLink } from '../lib/telegram';
|
||||
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram';
|
||||
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
|
||||
import Modal from './Modal.svelte';
|
||||
|
||||
// The single bot's @username, for the deep link.
|
||||
@@ -22,9 +24,30 @@
|
||||
}
|
||||
dismissWelcomeRedeem();
|
||||
}
|
||||
|
||||
// Native path: when the greeting fires inside the Mini App with native popups, present Telegram's
|
||||
// own popup instead of the in-app modal. insideTelegram()/dialogs are evaluated at fire time, not
|
||||
// captured at init: this component mounts before bootstrap loads the SDK, so a captured value
|
||||
// would be stale. The popup's "open bot" button opens the bot chat; any other dismissal clears it;
|
||||
// the `shown` guard fires it once per greeting.
|
||||
// The greeting is raised during boot; wait until the loading cover for the current route is gone —
|
||||
// the tile splash on the lobby (splashDone), the plain loading screen elsewhere (app.ready) — so
|
||||
// the native popup never appears over the splash.
|
||||
const ready = $derived(router.route.name === 'lobby' ? app.splashDone : app.ready);
|
||||
let shown = false;
|
||||
$effect(() => {
|
||||
if (app.welcomeRedeem && !shown && ready && insideTelegram() && telegramDialogsAvailable()) {
|
||||
shown = true;
|
||||
void telegramShowPopup(
|
||||
botInfoPopup(t('friends.welcomeRedeemTitle'), t('friends.welcomeRedeem', { name }), username ?? '', t('common.ok')),
|
||||
).then((id) => (id === BOT_BUTTON_ID ? openBot() : dismissWelcomeRedeem()));
|
||||
} else if (!app.welcomeRedeem) {
|
||||
shown = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if app.welcomeRedeem}
|
||||
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())}
|
||||
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}>
|
||||
<p class="msg">{parts[0]}{#if username}<button type="button" class="bot" onclick={openBot}>@{username}</button>{/if}{parts[1] ?? ''}</p>
|
||||
<button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
|
||||
|
||||
+86
-11
@@ -105,18 +105,27 @@
|
||||
prevZoom = on;
|
||||
prevRecenter = rc;
|
||||
if (landscape) {
|
||||
// Height-driven board in a wide viewport: pan so the focused cell is centred. The board
|
||||
// magnifies over the .scaler.land width/height transition, so the focus-centred scroll target
|
||||
// is only reachable once the board has grown — clamp it to the CURRENT scrollable size each
|
||||
// frame and ride the transition over a fixed time budget. (A scrollWidth-progress tween like
|
||||
// portrait's never settles here: zoom-out shrinks the board below the viewport, where it
|
||||
// stops overflowing.)
|
||||
// A wide viewport overflows VERTICALLY as soon as the square board grows past its height, but
|
||||
// HORIZONTALLY only once the board grows past the (much larger) width — so a per-axis scroll
|
||||
// moves the vertical axis early and the horizontal axis late (the browser pins scrollLeft to 0
|
||||
// until the board is wider than the viewport): the board positioning "in two steps". Drive
|
||||
// BOTH axes by one progress q — how far the board has grown past the viewport width, the point
|
||||
// at which a horizontal pan first becomes possible. Until then q is 0 and the board simply
|
||||
// zooms centred; past it both axes pan together in one diagonal motion. The full-zoom focus
|
||||
// target is finalSL/ST on zoom-in and the current position on zoom-out (where final is 0), so
|
||||
// q running 1→0 unwinds the scroll before the board shrinks back below the viewport.
|
||||
if (toggled || (recentered && on && f)) {
|
||||
const t0 = performance.now();
|
||||
let raf = requestAnimationFrame(function tick(now: number) {
|
||||
vp.scrollLeft = Math.min(finalSL, Math.max(0, vp.scrollWidth - vp.clientWidth));
|
||||
vp.scrollTop = Math.min(finalST, Math.max(0, vp.scrollHeight - vp.clientHeight));
|
||||
if (now - t0 < 320) raf = requestAnimationFrame(tick);
|
||||
const scaler = vp.firstElementChild as HTMLElement | null;
|
||||
const targetSL = on ? finalSL : vp.scrollLeft;
|
||||
const targetST = on ? finalST : vp.scrollTop;
|
||||
const span = Math.max(1, fullSide - clientW);
|
||||
let raf = requestAnimationFrame(function tick() {
|
||||
const side = scaler ? scaler.getBoundingClientRect().width : fullSide;
|
||||
const q = Math.max(0, Math.min(1, (side - clientW) / span));
|
||||
vp.scrollLeft = targetSL * q;
|
||||
vp.scrollTop = targetST * q;
|
||||
const settled = on ? side >= fullSide - 1 : side <= fitSide + 1;
|
||||
if (!settled) raf = requestAnimationFrame(tick);
|
||||
});
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}
|
||||
@@ -204,6 +213,69 @@
|
||||
};
|
||||
});
|
||||
|
||||
// Mouse drag-to-pan the zoomed board. Touch scrolls the overflow:auto viewport natively, but a
|
||||
// mouse cannot drag-scroll it, so on desktop / the landscape iframe the magnified board could not
|
||||
// be moved at all. Active only while zoomed, for a primary-button drag that does NOT start on a
|
||||
// pending tile (those own their pointer for relocation), and only once the pointer passes a small
|
||||
// threshold — so a plain click still places a rack tile or toggles zoom. A completed pan swallows
|
||||
// the trailing click so it does not also act on the cell under the release.
|
||||
$effect(() => {
|
||||
const vp = viewport;
|
||||
if (!vp) return;
|
||||
let armed = false;
|
||||
let panning = false;
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let sl = 0;
|
||||
let st = 0;
|
||||
const onDown = (e: PointerEvent) => {
|
||||
if (e.pointerType === 'touch' || e.button !== 0 || !zoomed) return;
|
||||
if ((e.target as HTMLElement | null)?.closest('.cell.pending')) return;
|
||||
armed = true;
|
||||
panning = false;
|
||||
sx = e.clientX;
|
||||
sy = e.clientY;
|
||||
sl = vp.scrollLeft;
|
||||
st = vp.scrollTop;
|
||||
};
|
||||
const onMove = (e: PointerEvent) => {
|
||||
if (!armed) return;
|
||||
const dx = e.clientX - sx;
|
||||
const dy = e.clientY - sy;
|
||||
if (!panning) {
|
||||
if (Math.hypot(dx, dy) < 4) return; // a small move is still a click, not a pan
|
||||
panning = true;
|
||||
vp.setPointerCapture(e.pointerId);
|
||||
}
|
||||
vp.scrollLeft = sl - dx;
|
||||
vp.scrollTop = st - dy;
|
||||
};
|
||||
const onUp = (e: PointerEvent) => {
|
||||
armed = false;
|
||||
if (!panning) return;
|
||||
panning = false;
|
||||
if (vp.hasPointerCapture(e.pointerId)) vp.releasePointerCapture(e.pointerId);
|
||||
// Swallow the click the drag would otherwise produce (capture phase, before it reaches the
|
||||
// cell); self-remove on that click, and clean up on the next tick if none fired.
|
||||
const swallow = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
vp.removeEventListener('click', swallow, true);
|
||||
};
|
||||
vp.addEventListener('click', swallow, true);
|
||||
setTimeout(() => vp.removeEventListener('click', swallow, true), 0);
|
||||
};
|
||||
vp.addEventListener('pointerdown', onDown);
|
||||
vp.addEventListener('pointermove', onMove);
|
||||
vp.addEventListener('pointerup', onUp);
|
||||
vp.addEventListener('pointercancel', onUp);
|
||||
return () => {
|
||||
vp.removeEventListener('pointerdown', onDown);
|
||||
vp.removeEventListener('pointermove', onMove);
|
||||
vp.removeEventListener('pointerup', onUp);
|
||||
vp.removeEventListener('pointercancel', onUp);
|
||||
};
|
||||
});
|
||||
|
||||
// Double-tap a pending tile recalls it; double-tap any other cell toggles zoom toward
|
||||
// it. A single tap places a selected rack tile (handled by oncell). Drag also auto-zooms
|
||||
// toward a cell the held tile hovers over (handled in Game), so the one-finger native
|
||||
@@ -287,6 +359,9 @@
|
||||
/* Clamp the pan at the board edge: kill the native rubber-band/overscroll so the zoomed
|
||||
board cannot be dragged past its content into empty space — it just stops at the edge. */
|
||||
overscroll-behavior: none;
|
||||
/* The zoom effect drives scrollLeft/Top itself while the board grows/shrinks; turn off the
|
||||
browser's scroll-anchoring so it does not fight those writes mid-transition. */
|
||||
overflow-anchor: none;
|
||||
}
|
||||
/* The query container is the (zoom-scaled) board, so cqw labels scale WITH the board
|
||||
— a magnifying-glass zoom. */
|
||||
|
||||
+32
-7
@@ -24,7 +24,7 @@
|
||||
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
|
||||
import { patchLobbyGame } from '../lib/lobbycache';
|
||||
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
|
||||
import { telegramClosingConfirmation, telegramHaptic } from '../lib/telegram';
|
||||
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram';
|
||||
import {
|
||||
BLANK,
|
||||
newPlacement,
|
||||
@@ -228,8 +228,6 @@
|
||||
placement = tiles.length ? placementFromHint(tiles, rack) : newPlacement(rack);
|
||||
}
|
||||
onMount(() => {
|
||||
// Guard against an accidental swipe-close losing the open game (Telegram).
|
||||
telegramClosingConfirmation(true);
|
||||
// Render instantly from the cache (a game opened before), then refresh in the
|
||||
// background. A cold open shows the loading state until load() resolves.
|
||||
const cached = getCachedGame(id);
|
||||
@@ -579,7 +577,6 @@
|
||||
clearTimeout(draftSaveTimer);
|
||||
void gateway.draftSave(id, serializeDraft(rackIds, placement.pending)).catch(() => {});
|
||||
}
|
||||
telegramClosingConfirmation(false);
|
||||
});
|
||||
|
||||
function onCell(row: number, col: number) {
|
||||
@@ -715,6 +712,16 @@
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
// onResignClick: inside the Mini App (and online) confirm with Telegram's native dialog and resign
|
||||
// on accept; otherwise open the in-app confirm modal (which also carries the offline-disabled action).
|
||||
async function onResignClick(): Promise<void> {
|
||||
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||
if (await telegramShowConfirm(t('game.confirmResign'))) doResign();
|
||||
return;
|
||||
}
|
||||
resignOpen = true;
|
||||
}
|
||||
|
||||
async function doResign() {
|
||||
resignOpen = false;
|
||||
busy = true;
|
||||
@@ -1067,6 +1074,7 @@
|
||||
column
|
||||
scroll={false}
|
||||
tabbar={landscape ? undefined : bottomBar}
|
||||
selfInset={landscape}
|
||||
>
|
||||
{#if view}
|
||||
{#if landscape}
|
||||
@@ -1152,7 +1160,7 @@
|
||||
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
|
||||
{/if}
|
||||
{:else}
|
||||
<button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/if}
|
||||
<!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
|
||||
@@ -1537,6 +1545,14 @@
|
||||
.make:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
|
||||
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
|
||||
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
|
||||
so it sits inside the freed slot with its right edge level with the rack's right edge — the
|
||||
mirror of the first tile's left edge. */
|
||||
.game-land .make {
|
||||
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
|
||||
}
|
||||
/* The move-history header: leave (active) / export (finished) on the left, comms on the
|
||||
right, icon-only. Sticky so it stays atop the scrolling move list. */
|
||||
.hhead {
|
||||
@@ -1723,8 +1739,10 @@
|
||||
grid-template-columns: clamp(260px, 32%, 360px) 1fr;
|
||||
gap: 4px;
|
||||
/* The left-column children carry their own horizontal --pad (matching portrait), so the
|
||||
grid only pads its right edge; the board centres in the right pane. */
|
||||
padding: 4px var(--pad) 6px 0;
|
||||
grid only pads its right edge; the board centres in the right pane. The bottom is flush so the
|
||||
controls bar and the board reach the screen edge — the controls bar owns the home-indicator
|
||||
inset (see the .leftpane .tabbar rule), the board runs under the thin indicator. */
|
||||
padding: 4px var(--pad) 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.leftpane {
|
||||
@@ -1732,6 +1750,13 @@
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
/* The home-indicator inset on the controls side: the left panel's controls bar paints its own
|
||||
chrome (--bg-elev) into it so the buttons clear the cut-out (the shell's detached content strip
|
||||
is suppressed here via Screen selfInset); the board pane runs to the edge under the thin
|
||||
indicator. */
|
||||
.game-land .leftpane :global(.tabbar) {
|
||||
padding-bottom: calc(8px + var(--tg-safe-bottom, 0px));
|
||||
}
|
||||
.rightpane {
|
||||
/* A size container spanning the whole right area: the board (Board.svelte's .viewport.land)
|
||||
fills it and fits the square board by HEIGHT via min(100cqw,100cqh); on zoom-in the board
|
||||
|
||||
+23
-3
@@ -112,12 +112,32 @@
|
||||
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
|
||||
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
|
||||
viewport-width measure that would overflow seven tiles in a column. */
|
||||
.rack.landscape {
|
||||
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
|
||||
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
|
||||
resolves unreliably, so the rack — which has a definite width — is the query container. A
|
||||
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
|
||||
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
|
||||
container-type: inline-size;
|
||||
}
|
||||
.rack.landscape .tile {
|
||||
flex: 1 1 0;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
|
||||
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
|
||||
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
|
||||
tiles leave. */
|
||||
flex: 0 0 auto;
|
||||
width: calc((100% - 6 * 5px) / 7);
|
||||
max-width: 46px;
|
||||
}
|
||||
.rack.landscape .letter {
|
||||
font-size: 6cqw;
|
||||
}
|
||||
.rack.landscape .val {
|
||||
font-size: 3.1cqw;
|
||||
}
|
||||
.rack.landscape .star {
|
||||
font-size: 7.6cqw;
|
||||
}
|
||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||
tile width plus the rack gap), so the drop position is visible. */
|
||||
.rack.reordering .tile {
|
||||
|
||||
@@ -71,5 +71,6 @@ export { TargetRequest } from './scrabblefb/target-request.js';
|
||||
export { TelegramLoginRequest } from './scrabblefb/telegram-login-request.js';
|
||||
export { TileRecord } from './scrabblefb/tile-record.js';
|
||||
export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
|
||||
export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
|
||||
export { WordCheckResult } from './scrabblefb/word-check-result.js';
|
||||
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
// automatically generated by the FlatBuffers compiler, do not modify
|
||||
|
||||
import * as flatbuffers from 'flatbuffers';
|
||||
|
||||
export class VKLoginRequest {
|
||||
bb: flatbuffers.ByteBuffer|null = null;
|
||||
bb_pos = 0;
|
||||
__init(i:number, bb:flatbuffers.ByteBuffer):VKLoginRequest {
|
||||
this.bb_pos = i;
|
||||
this.bb = bb;
|
||||
return this;
|
||||
}
|
||||
|
||||
static getRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest {
|
||||
return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
static getSizePrefixedRootAsVKLoginRequest(bb:flatbuffers.ByteBuffer, obj?:VKLoginRequest):VKLoginRequest {
|
||||
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
|
||||
return (obj || new VKLoginRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
|
||||
}
|
||||
|
||||
params():string|null
|
||||
params(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
params(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 4);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
browserTz():string|null
|
||||
browserTz(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
browserTz(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 6);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
displayName():string|null
|
||||
displayName(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
|
||||
displayName(optionalEncoding?:any):string|Uint8Array|null {
|
||||
const offset = this.bb!.__offset(this.bb_pos, 8);
|
||||
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
|
||||
}
|
||||
|
||||
static startVKLoginRequest(builder:flatbuffers.Builder) {
|
||||
builder.startObject(3);
|
||||
}
|
||||
|
||||
static addParams(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(0, paramsOffset, 0);
|
||||
}
|
||||
|
||||
static addBrowserTz(builder:flatbuffers.Builder, browserTzOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(1, browserTzOffset, 0);
|
||||
}
|
||||
|
||||
static addDisplayName(builder:flatbuffers.Builder, displayNameOffset:flatbuffers.Offset) {
|
||||
builder.addFieldOffset(2, displayNameOffset, 0);
|
||||
}
|
||||
|
||||
static endVKLoginRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
|
||||
const offset = builder.endObject();
|
||||
return offset;
|
||||
}
|
||||
|
||||
static createVKLoginRequest(builder:flatbuffers.Builder, paramsOffset:flatbuffers.Offset, browserTzOffset:flatbuffers.Offset, displayNameOffset:flatbuffers.Offset):flatbuffers.Offset {
|
||||
VKLoginRequest.startVKLoginRequest(builder);
|
||||
VKLoginRequest.addParams(builder, paramsOffset);
|
||||
VKLoginRequest.addBrowserTz(builder, browserTzOffset);
|
||||
VKLoginRequest.addDisplayName(builder, displayNameOffset);
|
||||
return VKLoginRequest.endVKLoginRequest(builder);
|
||||
}
|
||||
}
|
||||
+175
-30
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
|
||||
import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { languageNeedsServerSync } from './language';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||
import {
|
||||
insideTelegram,
|
||||
collectTelegramDiag,
|
||||
@@ -18,16 +18,22 @@ import {
|
||||
hasLaunchFragment,
|
||||
loadTelegramSDK,
|
||||
telegramColorScheme,
|
||||
telegramThemeParams,
|
||||
telegramContentSafeAreaTop,
|
||||
telegramSafeAreaTop,
|
||||
telegramSafeAreaInset,
|
||||
telegramDisableVerticalSwipes,
|
||||
telegramShowSettingsButton,
|
||||
telegramHaptic,
|
||||
telegramLaunch,
|
||||
type TelegramLaunch,
|
||||
telegramOnEvent,
|
||||
telegramRequestFullscreen,
|
||||
telegramSetChrome,
|
||||
telegramCloudAvailable,
|
||||
telegramCloudGet,
|
||||
telegramCloudSet,
|
||||
} from './telegram';
|
||||
import { onVKPath, insideVK, vkInit, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets } from './vk';
|
||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||
import { parseStartParam } from './deeplink';
|
||||
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
|
||||
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
|
||||
@@ -56,6 +62,9 @@ export const app = $state<{
|
||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
||||
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
|
||||
launchError: TelegramDiag | null;
|
||||
/** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping
|
||||
* the header title ten times in quick succession. A support aid; carries no secrets. */
|
||||
debugOpen: boolean;
|
||||
/** Whether the lobby's first cold load has settled (success or error). The loading splash
|
||||
* (components/Splash.svelte) watches it to know when to dismiss; set by screens/Lobby. */
|
||||
lobbyReady: boolean;
|
||||
@@ -106,6 +115,7 @@ export const app = $state<{
|
||||
ready: false,
|
||||
bootError: false,
|
||||
launchError: null,
|
||||
debugOpen: false,
|
||||
lobbyReady: false,
|
||||
splashDone: false,
|
||||
streamAlive: false,
|
||||
@@ -197,6 +207,16 @@ export function dismissWelcomeRedeem(): void {
|
||||
app.welcomeRedeem = false;
|
||||
}
|
||||
|
||||
/** openDebug / closeDebug toggle the hidden on-device debug panel (components/DebugPanel), opened
|
||||
* by tapping the header title ten times — a support aid that shows and shares client diagnostics. */
|
||||
export function openDebug(): void {
|
||||
app.debugOpen = true;
|
||||
}
|
||||
|
||||
export function closeDebug(): void {
|
||||
app.debugOpen = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* seedChatUnread sets a game's unread flags from an authoritative per-viewer REST view (the lobby
|
||||
* list, a game's state, or a move result): unread is any unread entry, message whether one of them
|
||||
@@ -514,17 +534,25 @@ function syncTelegramChrome(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
|
||||
* nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
|
||||
* `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
|
||||
* band. Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||
* syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top
|
||||
* (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top
|
||||
* (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the
|
||||
* title in its band), and the device safe-area insets — notch / status bar (top), home indicator
|
||||
* (bottom) and the landscape notch sides (left / right) — into --tg-safe-top / --tg-safe-bottom /
|
||||
* --tg-safe-left / --tg-safe-right, so the header, rack and screen edges clear the device cut-outs.
|
||||
* Called on launch and on Telegram's safe-area / fullscreen change events.
|
||||
*/
|
||||
function syncTelegramSafeArea(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
const root = document.documentElement;
|
||||
const top = telegramContentSafeAreaTop();
|
||||
document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
|
||||
document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
|
||||
document.documentElement.classList.toggle('tg-fullscreen', top > 0);
|
||||
const safe = telegramSafeAreaInset();
|
||||
root.style.setProperty('--tg-content-top', `${top}px`);
|
||||
root.style.setProperty('--tg-safe-top', `${safe.top}px`);
|
||||
root.style.setProperty('--tg-safe-bottom', `${safe.bottom}px`);
|
||||
root.style.setProperty('--tg-safe-left', `${safe.left}px`);
|
||||
root.style.setProperty('--tg-safe-right', `${safe.right}px`);
|
||||
root.classList.toggle('tg-fullscreen', top > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,26 +569,33 @@ function syncViewportHeight(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
|
||||
* colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
|
||||
* the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
|
||||
* bootstrap and a manual launch retry call it.
|
||||
* syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides,
|
||||
* Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's
|
||||
* current themeParams when omitted. Called on launch with the launch snapshot and live on the
|
||||
* themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up
|
||||
* without a relaunch.
|
||||
*/
|
||||
function applyTelegramChrome(launch: TelegramLaunch): void {
|
||||
if (launch.theme) applyTelegramTheme(launch.theme);
|
||||
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void {
|
||||
if (theme) applyTelegramTheme(theme);
|
||||
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS
|
||||
// prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
|
||||
// back to the stored preference when the SDK omits it.
|
||||
applyTheme(telegramColorScheme() ?? app.theme);
|
||||
// Match Telegram's chrome to the app and stop its swipe-down-to-minimise from fighting tile
|
||||
// drag / board scroll.
|
||||
syncTelegramChrome();
|
||||
}
|
||||
|
||||
/**
|
||||
* applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
|
||||
* colour scheme and theme (syncTelegramTheme), the matching header / background / bottom chrome,
|
||||
* the safe-area insets, and the swipe-down-to-minimise guard. It is idempotent, so both the
|
||||
* initial bootstrap and a manual launch retry call it.
|
||||
*/
|
||||
function applyTelegramChrome(launch: TelegramLaunch): void {
|
||||
syncTelegramTheme(launch.theme);
|
||||
// Mirror the safe-area insets and stop Telegram's swipe-down-to-minimise from fighting tile drag
|
||||
// / board scroll.
|
||||
syncTelegramSafeArea();
|
||||
telegramDisableVerticalSwipes();
|
||||
// On mobile, go immersive fullscreen like Telegram's own Mini Apps; the fullscreenChanged
|
||||
// listener (registered at bootstrap) then re-syncs the safe-area insets. Desktop keeps the bot's
|
||||
// full-size window. No-op on clients predating Bot API 8.0.
|
||||
telegramRequestFullscreen();
|
||||
}
|
||||
|
||||
/** How long to wait for the dynamically loaded Telegram Mini App SDK before giving up and showing
|
||||
@@ -612,15 +647,49 @@ export async function bootstrap(): Promise<void> {
|
||||
if (insideTelegram()) {
|
||||
const launch = telegramLaunch();
|
||||
applyTelegramChrome(launch);
|
||||
// Pull the device-independent display prefs (theme / reduce-motion / board labels) from
|
||||
// CloudStorage in the background so a change on another device follows the user here; the local
|
||||
// values applied above render instantly, so this reconciles without blocking launch.
|
||||
void reconcileCloudPrefs();
|
||||
// Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
|
||||
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
|
||||
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
|
||||
telegramOnEvent('fullscreenChanged', syncTelegramSafeArea);
|
||||
// Re-apply the theme live when the user switches Telegram's light/dark mode while the app is open.
|
||||
telegramOnEvent('themeChanged', () => syncTelegramTheme());
|
||||
// Telegram's native Settings button (Bot API 7.0) opens our Settings screen; the in-app gear
|
||||
// entry stays the primary path. No-op on clients predating the button.
|
||||
telegramShowSettingsButton(() => navigate('/settings'));
|
||||
await bootTelegram(launch);
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
|
||||
// authenticate from the signed launch parameters in the URL — the display name comes from
|
||||
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
|
||||
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
|
||||
if (onVKPath() && insideVK()) {
|
||||
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
|
||||
// VK mobile webview's prefers-color-scheme does not track it).
|
||||
void vkOnScheme((scheme) => {
|
||||
if (app.theme === 'auto') applyTheme(scheme);
|
||||
});
|
||||
// Clear the device safe area from VK's insets — the VK mobile webview does not surface them via
|
||||
// CSS env() (notably the Android home bar). max() keeps whichever of env() / the VK value is real.
|
||||
void vkOnInsets((i) => {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--tg-safe-top', `max(env(safe-area-inset-top, 0px), ${i.top}px)`);
|
||||
root.style.setProperty('--tg-safe-bottom', `max(env(safe-area-inset-bottom, 0px), ${i.bottom}px)`);
|
||||
root.style.setProperty('--tg-safe-left', `max(env(safe-area-inset-left, 0px), ${i.left}px)`);
|
||||
root.style.setProperty('--tg-safe-right', `max(env(safe-area-inset-right, 0px), ${i.right}px)`);
|
||||
});
|
||||
await vkInit();
|
||||
await bootVK();
|
||||
app.ready = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const saved = await loadSession();
|
||||
if (saved) {
|
||||
await adoptSession(saved);
|
||||
@@ -631,10 +700,10 @@ export async function bootstrap(): Promise<void> {
|
||||
app.ready = true;
|
||||
}
|
||||
|
||||
// Inside a Mini App the only identity is the Telegram session, so a failed launch must never fall
|
||||
// back to the web login screen. A transient backend outage (a deploy rolling over) is retried a
|
||||
// few times in silence; only then does the boot-error screen surface, from which Retry re-runs the
|
||||
// same path (retryTelegramBoot).
|
||||
// Inside a Mini App the only identity is the platform session (Telegram or VK), so a failed launch
|
||||
// must never fall back to the web login screen. A transient backend outage (a deploy rolling over)
|
||||
// is retried a few times in silence; only then does the boot-error screen surface, from which Retry
|
||||
// re-runs the same path (retryMiniAppBoot).
|
||||
const TELEGRAM_BOOT_RETRIES = 2;
|
||||
const TELEGRAM_BOOT_RETRY_MS = 1200;
|
||||
|
||||
@@ -671,14 +740,54 @@ async function bootTelegram(launch: TelegramLaunch): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* retryTelegramBoot re-attempts the Mini App launch from the boot-error screen's Retry button. It
|
||||
* clears the error and shows the loading state again, then runs the same retrying boot; on success
|
||||
* the app renders normally, otherwise the boot-error screen returns.
|
||||
* bootVK authenticates a VK Mini App launch from the signed launch parameters in the URL, seeding a
|
||||
* brand-new account's display name from VKWebAppGetUserInfo. Like bootTelegram it retries a few
|
||||
* times on a transient failure before raising the boot-error screen, and a blocked account is
|
||||
* terminal. This MVP carries no VK deep-link routing.
|
||||
*/
|
||||
export async function retryTelegramBoot(): Promise<void> {
|
||||
async function bootVK(): Promise<void> {
|
||||
const params = vkLaunchParams();
|
||||
const displayName = await vkUserName();
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
await adoptSession(await gateway.authVK(params, displayName));
|
||||
// VK forwards only the signed vk_* params to the iframe — a custom payload on the app link
|
||||
// (whether after '#', eaten by the vk.com SPA, or as a '?' query, dropped) never reaches it,
|
||||
// confirmed on the contour. So there is no friend-code auto-redeem on VK in test mode: the
|
||||
// launch lands on the lobby. vkStartParam stays as the reader for a future (post-moderation)
|
||||
// deep-link channel, and routes the lobby for an empty payload today.
|
||||
if (!app.blocked) await routeStartParam(vkStartParam());
|
||||
app.bootError = false;
|
||||
return;
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayError && err.code === 'account_blocked') {
|
||||
await enterBlocked();
|
||||
return;
|
||||
}
|
||||
if (attempt >= TELEGRAM_BOOT_RETRIES) {
|
||||
app.bootError = true;
|
||||
return;
|
||||
}
|
||||
await delay(TELEGRAM_BOOT_RETRY_MS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* retryMiniAppBoot re-attempts a Mini App launch from the boot-error screen's Retry button — the VK
|
||||
* boot on the /vk/ entry, the Telegram boot otherwise. It clears the error and shows the loading
|
||||
* state again, then runs the same retrying boot; on success the app renders normally, otherwise the
|
||||
* boot-error screen returns.
|
||||
*/
|
||||
export async function retryMiniAppBoot(): Promise<void> {
|
||||
app.bootError = false;
|
||||
app.ready = false;
|
||||
if (onVKPath()) {
|
||||
await vkInit();
|
||||
await bootVK();
|
||||
} else {
|
||||
await bootTelegram(telegramLaunch());
|
||||
}
|
||||
app.ready = true;
|
||||
}
|
||||
|
||||
@@ -802,6 +911,42 @@ function persistPrefs(): void {
|
||||
reduceMotion: app.reduceMotion,
|
||||
boardLabels: app.boardLabels,
|
||||
});
|
||||
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
|
||||
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
|
||||
// syncs via the durable account (Profile.preferredLanguage) instead.
|
||||
void telegramCloudSet(
|
||||
CLOUD_PREFS_KEY,
|
||||
encodeClientPrefs({ theme: app.theme, reduceMotion: app.reduceMotion, boardLabels: app.boardLabels }),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* reconcileCloudPrefs pulls the device-independent display prefs (theme / reduce-motion / board
|
||||
* labels) from Telegram CloudStorage and applies any that differ from the current values, so a
|
||||
* change made on another Telegram device follows the user here. The local store is the
|
||||
* instant-render cache (read synchronously at boot); this runs once on launch after it and persists
|
||||
* what it applied. Theme is not re-applied visually — inside Telegram the colour scheme is
|
||||
* Telegram's to decide — only its stored value is updated. A no-op outside Telegram or when
|
||||
* CloudStorage is unavailable; locale is never synced this way (it has its own server reconciler).
|
||||
*/
|
||||
async function reconcileCloudPrefs(): Promise<void> {
|
||||
if (!telegramCloudAvailable()) return;
|
||||
const cloud = decodeClientPrefs(await telegramCloudGet(CLOUD_PREFS_KEY));
|
||||
let changed = false;
|
||||
if (cloud.theme !== undefined && cloud.theme !== app.theme) {
|
||||
app.theme = cloud.theme;
|
||||
changed = true;
|
||||
}
|
||||
if (cloud.reduceMotion !== undefined && cloud.reduceMotion !== app.reduceMotion) {
|
||||
app.reduceMotion = cloud.reduceMotion;
|
||||
applyReduceMotion(app.reduceMotion);
|
||||
changed = true;
|
||||
}
|
||||
if (cloud.boardLabels !== undefined && cloud.boardLabels !== app.boardLabels) {
|
||||
app.boardLabels = cloud.boardLabels;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) persistPrefs();
|
||||
}
|
||||
|
||||
export function setTheme(theme: ThemePref): void {
|
||||
|
||||
@@ -58,6 +58,10 @@ export type Unsubscribe = () => void;
|
||||
export interface GatewayClient {
|
||||
// --- auth (unauthenticated) ---
|
||||
authTelegram(initData: string): Promise<Session>;
|
||||
/** Authenticate a VK Mini App launch: params is the signed vk_* launch query string (the gateway
|
||||
* verifies its sign); displayName is the client-read VKWebAppGetUserInfo name (an unsigned,
|
||||
* cosmetic seed for a brand-new account). */
|
||||
authVK(params: string, displayName: string): Promise<Session>;
|
||||
authGuest(locale?: string): Promise<Session>;
|
||||
authEmailRequest(email: string): Promise<void>;
|
||||
authEmailLogin(email: string, code: string): Promise<Session>;
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
|
||||
|
||||
describe('cloudprefs', () => {
|
||||
it('round-trips the synced client prefs', () => {
|
||||
const p = { theme: 'dark', reduceMotion: true, boardLabels: 'classic' } as const;
|
||||
expect(decodeClientPrefs(encodeClientPrefs(p))).toEqual(p);
|
||||
});
|
||||
|
||||
it('never encodes the locale (it syncs via the durable account instead)', () => {
|
||||
const raw = encodeClientPrefs({ theme: 'light', reduceMotion: false, boardLabels: 'none' });
|
||||
expect(raw).not.toContain('locale');
|
||||
});
|
||||
|
||||
it('returns an empty partial for missing or malformed input', () => {
|
||||
expect(decodeClientPrefs(null)).toEqual({});
|
||||
expect(decodeClientPrefs(undefined)).toEqual({});
|
||||
expect(decodeClientPrefs('')).toEqual({});
|
||||
expect(decodeClientPrefs('not json')).toEqual({});
|
||||
expect(decodeClientPrefs('[1,2,3]')).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps only valid fields and drops unknown or mistyped ones', () => {
|
||||
const raw = JSON.stringify({ theme: 'neon', reduceMotion: 'yes', boardLabels: 'classic', locale: 'ru' });
|
||||
expect(decodeClientPrefs(raw)).toEqual({ boardLabels: 'classic' });
|
||||
});
|
||||
|
||||
it('exposes the CloudStorage key', () => {
|
||||
expect(CLOUD_PREFS_KEY).toBe('prefs');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
// Telegram CloudStorage sync for the device-independent client display preferences — theme,
|
||||
// reduce-motion and board labels — so they follow the user across their Telegram devices. The
|
||||
// interface language is intentionally excluded: it has its own server-side sync
|
||||
// (Profile.preferredLanguage) plus an on-launch reconciler, and mixing it in here would fight that.
|
||||
// The pure encode/decode is kept free of the SDK and the DOM so it unit-tests in the node
|
||||
// environment; the CloudStorage transport wrappers live in telegram.ts and the wiring (mirror on
|
||||
// save, reconcile on launch) in app.svelte.ts.
|
||||
|
||||
import type { ThemePref } from './theme';
|
||||
import type { BoardLabelMode } from './boardlabels';
|
||||
|
||||
/** ClientPrefs is the subset of preferences synced across devices via Telegram CloudStorage. */
|
||||
export interface ClientPrefs {
|
||||
theme: ThemePref;
|
||||
reduceMotion: boolean;
|
||||
boardLabels: BoardLabelMode;
|
||||
}
|
||||
|
||||
/** CLOUD_PREFS_KEY is the Telegram CloudStorage key holding the JSON-encoded ClientPrefs. */
|
||||
export const CLOUD_PREFS_KEY = 'prefs';
|
||||
|
||||
/** encodeClientPrefs serialises the synced client prefs (and only those — never the locale). */
|
||||
export function encodeClientPrefs(p: ClientPrefs): string {
|
||||
return JSON.stringify({ theme: p.theme, reduceMotion: p.reduceMotion, boardLabels: p.boardLabels });
|
||||
}
|
||||
|
||||
/**
|
||||
* decodeClientPrefs parses a CloudStorage payload into a partial ClientPrefs, keeping only valid
|
||||
* fields and dropping anything unknown, mistyped or malformed — so a value written by a newer or
|
||||
* older build, or a corrupt entry, never throws and never applies a bad setting. A missing field
|
||||
* stays absent, so the caller leaves the corresponding local value untouched.
|
||||
*/
|
||||
export function decodeClientPrefs(raw: string | null | undefined): Partial<ClientPrefs> {
|
||||
if (!raw) return {};
|
||||
let o: Record<string, unknown>;
|
||||
try {
|
||||
o = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
if (!o || typeof o !== 'object') return {};
|
||||
const out: Partial<ClientPrefs> = {};
|
||||
if (o.theme === 'auto' || o.theme === 'light' || o.theme === 'dark') out.theme = o.theme;
|
||||
if (typeof o.reduceMotion === 'boolean') out.reduceMotion = o.reduceMotion;
|
||||
if (o.boardLabels === 'beginner' || o.boardLabels === 'classic' || o.boardLabels === 'none') {
|
||||
out.boardLabels = o.boardLabels;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
encodeTarget,
|
||||
encodeTelegramLogin,
|
||||
encodeUpdateProfile,
|
||||
encodeVKLogin,
|
||||
} from './codec';
|
||||
|
||||
describe('codec', () => {
|
||||
@@ -94,6 +95,13 @@ describe('codec', () => {
|
||||
);
|
||||
expect(email.email()).toBe('a@example.com');
|
||||
expect(email.browserTz()).toBe('+00:00');
|
||||
|
||||
const vk = fb.VKLoginRequest.getRootAsVKLoginRequest(
|
||||
new ByteBuffer(encodeVKLogin('vk_user_id=494075&vk_ts=1&sign=abc', '+03:00', 'Иван Петров')),
|
||||
);
|
||||
expect(vk.params()).toBe('vk_user_id=494075&vk_ts=1&sign=abc');
|
||||
expect(vk.browserTz()).toBe('+03:00');
|
||||
expect(vk.displayName()).toBe('Иван Петров');
|
||||
});
|
||||
|
||||
it('round-trips a feedback submit and decodes state + unread', () => {
|
||||
|
||||
@@ -189,6 +189,18 @@ export function encodeTelegramLogin(initData: string, browserTz: string): Uint8A
|
||||
return finish(b, fb.TelegramLoginRequest.endTelegramLoginRequest(b));
|
||||
}
|
||||
|
||||
export function encodeVKLogin(params: string, browserTz: string, displayName: string): Uint8Array {
|
||||
const b = new Builder(512);
|
||||
const p = b.createString(params);
|
||||
const tz = b.createString(browserTz);
|
||||
const dn = b.createString(displayName);
|
||||
fb.VKLoginRequest.startVKLoginRequest(b);
|
||||
fb.VKLoginRequest.addParams(b, p);
|
||||
fb.VKLoginRequest.addBrowserTz(b, tz);
|
||||
fb.VKLoginRequest.addDisplayName(b, dn);
|
||||
return finish(b, fb.VKLoginRequest.endVKLoginRequest(b));
|
||||
}
|
||||
|
||||
export function encodeGuestLogin(locale: string, browserTz: string): Uint8Array {
|
||||
const b = new Builder(64);
|
||||
const l = b.createString(locale);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink } from './deeplink';
|
||||
import { botUsername, friendCodeParam, gameParam, invitationParam, parseStartParam, shareLink, vkShareLink } from './deeplink';
|
||||
|
||||
describe('parseStartParam', () => {
|
||||
it('classifies game / invitation / friend code', () => {
|
||||
@@ -37,6 +37,20 @@ describe('shareLink', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('vkShareLink', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('returns null outside a VK launch (no vk_app_id)', () => {
|
||||
vi.stubGlobal('location', { search: '' });
|
||||
expect(vkShareLink()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the plain vk.com/app link (VK drops a custom query payload, so the code is not carried)', () => {
|
||||
vi.stubGlobal('location', { search: '?vk_app_id=6736218&vk_user_id=1&sign=x' });
|
||||
expect(vkShareLink()).toBe('https://vk.com/app6736218');
|
||||
});
|
||||
});
|
||||
|
||||
describe('botUsername', () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// f<6-digit code> redeem that friend code
|
||||
// An empty or unrecognised parameter opens the lobby.
|
||||
|
||||
import { vkAppId } from './vk';
|
||||
|
||||
export type DeepLink =
|
||||
| { kind: 'lobby' }
|
||||
| { kind: 'game'; id: string }
|
||||
@@ -74,3 +76,16 @@ export function shareLink(param: string): string | null {
|
||||
const sep = base.includes('?') ? '&' : '?';
|
||||
return `${base}${sep}startapp=${encodeURIComponent(param)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShareLink returns the VK Mini App link (https://vk.com/app<id>) to share on VK, or null when the
|
||||
* VK app id is unknown (outside a VK launch). VK forwards no custom payload through the app link to
|
||||
* the iframe — the '#' form is eaten by the vk.com SPA and a '?' query is dropped (confirmed on the
|
||||
* contour: only the signed vk_* params arrive) — so the link cannot carry the friend code; the
|
||||
* recipient enters the copied code by hand.
|
||||
*/
|
||||
export function vkShareLink(): string | null {
|
||||
const id = vkAppId();
|
||||
if (!id) return null;
|
||||
return `https://vk.com/app${id}`;
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ export const en = {
|
||||
'friends.redeem': 'Add',
|
||||
'friends.copy': 'Copy',
|
||||
'friends.codeCopied': 'Code copied.',
|
||||
'friends.shareTelegram': 'Share via Telegram',
|
||||
'friends.shareTelegram': 'Share',
|
||||
'friends.inviteText': "Let's play Scrabble!",
|
||||
'friends.linkCopied': 'Link copied.',
|
||||
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
|
||||
|
||||
@@ -248,7 +248,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'friends.redeem': 'Добавить',
|
||||
'friends.copy': 'Копировать',
|
||||
'friends.codeCopied': 'Код скопирован.',
|
||||
'friends.shareTelegram': 'Поделиться через Telegram',
|
||||
'friends.shareTelegram': 'Поделиться',
|
||||
'friends.inviteText': 'Давай играть в Эрудит!',
|
||||
'friends.linkCopied': 'Ссылка скопирована.',
|
||||
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
|
||||
@@ -301,7 +301,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.exportGcg': 'Экспорт GCG',
|
||||
'game.gcgActiveOnly': 'Доступно после завершения игры.',
|
||||
'game.addFriendShort': 'В друзья?',
|
||||
'game.blockShort': 'Блокируем?',
|
||||
'game.blockShort': 'В бан?',
|
||||
|
||||
'time.minutes': '{n} мин',
|
||||
'time.hours': '{n} ч',
|
||||
|
||||
@@ -142,6 +142,9 @@ export class MockGateway implements GatewayClient {
|
||||
if (initData.includes('bootfail')) throw new GatewayError('unavailable');
|
||||
return { ...SESSION, isGuest: false };
|
||||
}
|
||||
async authVK(): Promise<Session> {
|
||||
return { ...SESSION, isGuest: false };
|
||||
}
|
||||
async authGuest(): Promise<Session> {
|
||||
return { ...SESSION };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BOT_BUTTON_ID, botInfoPopup } from './nativedialogs';
|
||||
|
||||
describe('botInfoPopup', () => {
|
||||
it('inlines the bot handle and adds an open-bot button', () => {
|
||||
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', 'erudit_bot', 'OK');
|
||||
expect(p.title).toBe('Title');
|
||||
expect(p.message).toBe('Open the bot @erudit_bot to play.');
|
||||
expect(p.buttons).toEqual([
|
||||
{ id: BOT_BUTTON_ID, text: '@erudit_bot' },
|
||||
{ id: 'ok', text: 'OK' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops the token and the open-bot button when no username is known', () => {
|
||||
const p = botInfoPopup('Title', 'Open the bot {bot} to play.', '', 'OK');
|
||||
expect(p.message).toBe('Open the bot to play.');
|
||||
expect(p.buttons).toEqual([{ id: 'ok', text: 'OK' }]);
|
||||
});
|
||||
|
||||
it('preserves newlines in the message', () => {
|
||||
const p = botInfoPopup('Hi', 'Welcome!\n\nUse {bot} now.', 'b', 'OK');
|
||||
expect(p.message).toBe('Welcome!\n\nUse @b now.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
// Pure builder for the Telegram native popup (showPopup) used by the deep-link info modals
|
||||
// (StaleInviteModal / WelcomeRedeemModal) inside the Mini App. Kept free of the SDK and the DOM so
|
||||
// it unit-tests in the node environment; the showPopup transport wrapper lives in telegram.ts and
|
||||
// the wiring (native inside Telegram, the in-app Modal elsewhere) in the modal components.
|
||||
|
||||
import type { TelegramPopupParams } from './telegram';
|
||||
|
||||
/** BOT_BUTTON_ID is the showPopup button id that means "open the bot chat". */
|
||||
export const BOT_BUTTON_ID = 'bot';
|
||||
|
||||
/**
|
||||
* botInfoPopup builds the native popup for a deep-link info modal: the message with the `{bot}`
|
||||
* token replaced by the `@username` as plain text (a native popup has no inline link, unlike the
|
||||
* in-app Modal), plus an "open bot" button when a username is known and a closing OK button. With
|
||||
* no username it is the message (token removed) and OK only.
|
||||
*/
|
||||
export function botInfoPopup(title: string, message: string, username: string, okText: string): TelegramPopupParams {
|
||||
const handle = username ? `@${username}` : '';
|
||||
const text = (username ? message.replace('{bot}', handle) : message.replace('{bot}', '').replace(' ', ' ')).trim();
|
||||
const buttons = username
|
||||
? [{ id: BOT_BUTTON_ID, text: handle }, { id: 'ok', text: okText }]
|
||||
: [{ id: 'ok', text: okText }];
|
||||
return { title, message: text, buttons };
|
||||
}
|
||||
+66
-61
@@ -5,10 +5,14 @@ import {
|
||||
loadTelegramSDK,
|
||||
telegramSdkOutcome,
|
||||
routeExternalLinkInTelegram,
|
||||
telegramClosingConfirmation,
|
||||
telegramLaunch,
|
||||
telegramOpenExternalLink,
|
||||
telegramRequestFullscreen,
|
||||
telegramThemeParams,
|
||||
telegramSafeAreaInset,
|
||||
telegramShowSettingsButton,
|
||||
telegramDialogsAvailable,
|
||||
telegramShowConfirm,
|
||||
telegramShowPopup,
|
||||
} from './telegram';
|
||||
|
||||
function stubWebApp(initData: string, startParam?: string) {
|
||||
@@ -46,68 +50,19 @@ describe('telegram launch detection', () => {
|
||||
expect(launch.startParam).toBe('g123');
|
||||
expect(launch.theme?.bg_color).toBe('#101418');
|
||||
});
|
||||
});
|
||||
|
||||
// stubClient stands up a fake WebApp on the given platform with spies for the mobile-gated
|
||||
// chrome toggles, so the platform gate can be asserted without a real Telegram client.
|
||||
function stubClient(platform?: string) {
|
||||
const enable = vi.fn();
|
||||
const disable = vi.fn();
|
||||
const requestFullscreen = vi.fn();
|
||||
it('telegramThemeParams reads the live palette (undefined outside Telegram)', () => {
|
||||
expect(telegramThemeParams()).toBeUndefined();
|
||||
stubWebApp('query_id=abc');
|
||||
expect(telegramThemeParams()?.bg_color).toBe('#101418');
|
||||
});
|
||||
|
||||
it('telegramSafeAreaInset returns zeros outside Telegram and the SDK insets inside', () => {
|
||||
expect(telegramSafeAreaInset()).toEqual({ top: 0, bottom: 0, left: 0, right: 0 });
|
||||
vi.stubGlobal('window', {
|
||||
Telegram: {
|
||||
WebApp: { platform, enableClosingConfirmation: enable, disableClosingConfirmation: disable, requestFullscreen },
|
||||
},
|
||||
Telegram: { WebApp: { initData: 'x', safeAreaInset: { top: 59, bottom: 34, left: 0, right: 0 } } },
|
||||
});
|
||||
return { enable, disable, requestFullscreen };
|
||||
}
|
||||
|
||||
const mobilePlatforms = ['ios', 'android', 'android_x'];
|
||||
const desktopPlatforms = ['tdesktop', 'macos', 'web', undefined];
|
||||
|
||||
describe('telegramClosingConfirmation', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('arms the close guard on mobile clients', () => {
|
||||
for (const p of mobilePlatforms) {
|
||||
const { enable } = stubClient(p);
|
||||
telegramClosingConfirmation(true);
|
||||
expect(enable, `platform=${p}`).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('skips the close guard on desktop clients (the dialog there is just noise)', () => {
|
||||
for (const p of desktopPlatforms) {
|
||||
const { enable } = stubClient(p);
|
||||
telegramClosingConfirmation(true);
|
||||
expect(enable, `platform=${p}`).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('always lifts the guard on leave, regardless of platform', () => {
|
||||
const { disable } = stubClient('tdesktop');
|
||||
telegramClosingConfirmation(false);
|
||||
expect(disable).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramRequestFullscreen', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('goes immersive fullscreen on mobile clients', () => {
|
||||
for (const p of mobilePlatforms) {
|
||||
const { requestFullscreen } = stubClient(p);
|
||||
telegramRequestFullscreen();
|
||||
expect(requestFullscreen, `platform=${p}`).toHaveBeenCalledOnce();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves desktop clients as a standard window (the bot full-size setting fills it)', () => {
|
||||
for (const p of desktopPlatforms) {
|
||||
const { requestFullscreen } = stubClient(p);
|
||||
telegramRequestFullscreen();
|
||||
expect(requestFullscreen, `platform=${p}`).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(telegramSafeAreaInset()).toEqual({ top: 59, bottom: 34, left: 0, right: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -126,6 +81,56 @@ describe('telegramOpenExternalLink', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('telegramShowSettingsButton', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('shows the native Settings button and wires its click inside Telegram', () => {
|
||||
const onClick = vi.fn();
|
||||
const show = vi.fn();
|
||||
const handler = vi.fn();
|
||||
vi.stubGlobal('window', { Telegram: { WebApp: { SettingsButton: { onClick, show } } } });
|
||||
telegramShowSettingsButton(handler);
|
||||
expect(onClick).toHaveBeenCalledWith(handler);
|
||||
expect(show).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op without the SDK button (older client / outside Telegram)', () => {
|
||||
expect(() => telegramShowSettingsButton(() => {})).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('native dialogs', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('telegramDialogsAvailable reflects showPopup presence', () => {
|
||||
expect(telegramDialogsAvailable()).toBe(false);
|
||||
vi.stubGlobal('window', { Telegram: { WebApp: { showPopup: () => {} } } });
|
||||
expect(telegramDialogsAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('telegramShowConfirm resolves the user choice inside Telegram', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
Telegram: { WebApp: { showConfirm: (_m: string, cb: (ok: boolean) => void) => cb(true) } },
|
||||
});
|
||||
await expect(telegramShowConfirm('Sure?')).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('telegramShowConfirm resolves false without the SDK dialog', async () => {
|
||||
await expect(telegramShowConfirm('Sure?')).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('telegramShowPopup resolves the pressed button id', async () => {
|
||||
vi.stubGlobal('window', {
|
||||
Telegram: { WebApp: { showPopup: (_p: unknown, cb: (id: string) => void) => cb('bot') } },
|
||||
});
|
||||
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBe('bot');
|
||||
});
|
||||
|
||||
it('telegramShowPopup resolves null without the SDK', async () => {
|
||||
await expect(telegramShowPopup({ message: 'Hi' })).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('routeExternalLinkInTelegram', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
|
||||
+148
-69
@@ -6,6 +6,20 @@
|
||||
|
||||
import type { TelegramThemeParams } from './theme';
|
||||
|
||||
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
|
||||
export interface TelegramPopupButton {
|
||||
id?: string;
|
||||
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** TelegramPopupParams configures a native showPopup (title optional, message required, up to 3 buttons). */
|
||||
export interface TelegramPopupParams {
|
||||
title?: string;
|
||||
message: string;
|
||||
buttons?: TelegramPopupButton[];
|
||||
}
|
||||
|
||||
interface TelegramWebApp {
|
||||
initData: string;
|
||||
initDataUnsafe?: { start_param?: string };
|
||||
@@ -14,11 +28,14 @@ interface TelegramWebApp {
|
||||
themeParams?: TelegramThemeParams;
|
||||
colorScheme?: 'light' | 'dark';
|
||||
isFullscreen?: boolean;
|
||||
isExpanded?: boolean;
|
||||
viewportHeight?: number;
|
||||
viewportStableHeight?: number;
|
||||
exitFullscreen?: () => void;
|
||||
safeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||
contentSafeAreaInset?: { top: number; bottom: number; left: number; right: number };
|
||||
ready?: () => void;
|
||||
expand?: () => void;
|
||||
requestFullscreen?: () => void;
|
||||
openTelegramLink?: (url: string) => void;
|
||||
openLink?: (url: string) => void;
|
||||
onEvent?: (event: string, handler: () => void) => void;
|
||||
@@ -26,19 +43,30 @@ interface TelegramWebApp {
|
||||
setBackgroundColor?: (color: string) => void;
|
||||
setBottomBarColor?: (color: string) => void;
|
||||
disableVerticalSwipes?: () => void;
|
||||
enableClosingConfirmation?: () => void;
|
||||
disableClosingConfirmation?: () => void;
|
||||
HapticFeedback?: {
|
||||
impactOccurred?: (style: string) => void;
|
||||
notificationOccurred?: (type: string) => void;
|
||||
selectionChanged?: () => void;
|
||||
};
|
||||
BackButton?: {
|
||||
isVisible?: boolean;
|
||||
show?: () => void;
|
||||
hide?: () => void;
|
||||
onClick?: (cb: () => void) => void;
|
||||
offClick?: (cb: () => void) => void;
|
||||
};
|
||||
SettingsButton?: {
|
||||
show?: () => void;
|
||||
hide?: () => void;
|
||||
onClick?: (cb: () => void) => void;
|
||||
offClick?: (cb: () => void) => void;
|
||||
};
|
||||
CloudStorage?: {
|
||||
getItem?: (key: string, cb: (err: string | null, value?: string) => void) => void;
|
||||
setItem?: (key: string, value: string, cb?: (err: string | null, ok?: boolean) => void) => void;
|
||||
};
|
||||
showConfirm?: (message: string, cb?: (ok: boolean) => void) => void;
|
||||
showPopup?: (params: TelegramPopupParams, cb?: (buttonId: string) => void) => void;
|
||||
}
|
||||
|
||||
function webApp(): TelegramWebApp | undefined {
|
||||
@@ -241,6 +269,15 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
|
||||
return webApp()?.colorScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramThemeParams returns Telegram's current theme palette (WebApp.themeParams), or undefined
|
||||
* outside Telegram. It reads the live value rather than a launch snapshot, so the themeChanged
|
||||
* event can re-apply the palette when the user switches Telegram's light/dark theme mid-session.
|
||||
*/
|
||||
export function telegramThemeParams(): TelegramThemeParams | undefined {
|
||||
return webApp()?.themeParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramSetChrome paints Telegram's own header, background and bottom bar to match the
|
||||
* app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
|
||||
@@ -263,13 +300,15 @@ export function telegramContentSafeAreaTop(): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramSafeAreaTop returns the device safe-area top inset (px) — the notch / status bar
|
||||
* (Bot API 8.0). Telegram's own nav controls sit in the band between it and
|
||||
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
|
||||
* outside Telegram or on older clients.
|
||||
* telegramSafeAreaInset returns the device safe-area insets (px) — the notch / status bar (top),
|
||||
* the home indicator (bottom) and, in landscape, the notch sides (left / right) — from the SDK's
|
||||
* safeAreaInset (Bot API 8.0). All 0 outside Telegram or on a client predating it, so callers can
|
||||
* pad defensively. Telegram's own nav controls sit in the band between the top inset and
|
||||
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them.
|
||||
*/
|
||||
export function telegramSafeAreaTop(): number {
|
||||
return webApp()?.safeAreaInset?.top ?? 0;
|
||||
export function telegramSafeAreaInset(): { top: number; bottom: number; left: number; right: number } {
|
||||
const i = webApp()?.safeAreaInset;
|
||||
return { top: i?.top ?? 0, bottom: i?.bottom ?? 0, left: i?.left ?? 0, right: i?.right ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,6 +319,78 @@ export function telegramDisableVerticalSwipes(): void {
|
||||
webApp()?.disableVerticalSwipes?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramShowSettingsButton reveals Telegram's native Settings button (in the Mini App's ⋮ menu,
|
||||
* Bot API 7.0) and routes its taps to handler. A no-op outside Telegram or on a client predating
|
||||
* the button, so the app's own in-app settings entry stays the primary path. The app registers it
|
||||
* once per launch (Telegram hides the button when the Mini App closes), so there is no offClick.
|
||||
*/
|
||||
export function telegramShowSettingsButton(handler: () => void): void {
|
||||
const b = webApp()?.SettingsButton;
|
||||
if (!b?.show) return;
|
||||
b.onClick?.(handler);
|
||||
b.show();
|
||||
}
|
||||
|
||||
/** telegramCloudAvailable reports whether Telegram CloudStorage (Bot API 6.9) is usable. */
|
||||
export function telegramCloudAvailable(): boolean {
|
||||
return !!webApp()?.CloudStorage?.getItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramCloudGet reads a value from Telegram CloudStorage, resolving null when the key is absent,
|
||||
* CloudStorage is unavailable (outside Telegram / a client predating Bot API 6.9), or the read
|
||||
* errors — so the caller can fall back to the local value.
|
||||
*/
|
||||
export function telegramCloudGet(key: string): Promise<string | null> {
|
||||
const cs = webApp()?.CloudStorage;
|
||||
if (!cs?.getItem) return Promise.resolve(null);
|
||||
return new Promise((resolve) => {
|
||||
cs.getItem!(key, (err, value) => resolve(err ? null : (value ?? null)));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramCloudSet writes a value to Telegram CloudStorage, resolving once the write settles. It is
|
||||
* best-effort: a no-op outside Telegram / on an older client, and it swallows write errors, since
|
||||
* the local store remains the source of truth.
|
||||
*/
|
||||
export function telegramCloudSet(key: string, value: string): Promise<void> {
|
||||
const cs = webApp()?.CloudStorage;
|
||||
if (!cs?.setItem) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
cs.setItem!(key, value, () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/** telegramDialogsAvailable reports whether Telegram's native dialogs (showConfirm / showPopup, Bot
|
||||
* API 6.2) are usable, so a caller can choose the native path over its own modal. */
|
||||
export function telegramDialogsAvailable(): boolean {
|
||||
return !!webApp()?.showPopup;
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramShowConfirm shows Telegram's native confirm dialog and resolves true when the user
|
||||
* accepts. Resolves false outside Telegram or on a client predating the dialog, so callers should
|
||||
* gate on telegramDialogsAvailable and fall back to their own modal otherwise.
|
||||
*/
|
||||
export function telegramShowConfirm(message: string): Promise<boolean> {
|
||||
const w = webApp();
|
||||
if (!w?.showConfirm) return Promise.resolve(false);
|
||||
return new Promise((resolve) => w.showConfirm!(message, (ok) => resolve(!!ok)));
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramShowPopup shows Telegram's native popup and resolves the pressed button id (the empty
|
||||
* string when dismissed without pressing a button). Resolves null outside Telegram or on a client
|
||||
* predating the popup, so callers can fall back to their own modal.
|
||||
*/
|
||||
export function telegramShowPopup(params: TelegramPopupParams): Promise<string | null> {
|
||||
const w = webApp();
|
||||
if (!w?.showPopup) return Promise.resolve(null);
|
||||
return new Promise((resolve) => w.showPopup!(params, (id) => resolve(id ?? '')));
|
||||
}
|
||||
|
||||
/** Haptic is the set of feedbacks the app triggers. */
|
||||
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
|
||||
|
||||
@@ -292,66 +403,6 @@ export function telegramHaptic(kind: Haptic): void {
|
||||
else h.impactOccurred?.(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* isMobilePlatform reports whether the Mini App runs on a Telegram mobile client — iOS or
|
||||
* Android (the latter reported as 'android' by Telegram for Android and 'android_x' by
|
||||
* Telegram X). Desktop clients (tdesktop, macOS, web) report other values. Used to limit
|
||||
* mobile-only chrome such as the close guard and immersive fullscreen.
|
||||
*/
|
||||
function isMobilePlatform(): boolean {
|
||||
const p = webApp()?.platform;
|
||||
return p === 'ios' || p === 'android' || p === 'android_x';
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramRequestFullscreen asks Telegram to open the Mini App in fullscreen (Bot API 8.0+),
|
||||
* but only on mobile clients — mirroring how Telegram's own Mini Apps go immersive on phones
|
||||
* while staying a standard window on desktop (where the bot's full-size setting already fills
|
||||
* the window). A no-op outside Telegram, on desktop, or on clients predating the method.
|
||||
*/
|
||||
export function telegramRequestFullscreen(): void {
|
||||
if (isMobilePlatform()) webApp()?.requestFullscreen?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramClosingConfirmation toggles the confirmation Telegram shows when the user swipes
|
||||
* the Mini App closed — enabled during an active game so it is not lost by accident. The
|
||||
* guard is only armed on mobile clients: on desktop, closing a window is deliberate and
|
||||
* Telegram surfaces a "changes may not be saved" dialog that is just noise here (drafts
|
||||
* auto-save), so the confirmation is skipped there.
|
||||
*/
|
||||
export function telegramClosingConfirmation(on: boolean): void {
|
||||
const w = webApp();
|
||||
if (on) {
|
||||
if (isMobilePlatform()) w?.enableClosingConfirmation?.();
|
||||
} else {
|
||||
w?.disableClosingConfirmation?.();
|
||||
}
|
||||
}
|
||||
|
||||
let backHandler: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* telegramBackButton shows or hides Telegram's native header back button, wiring its
|
||||
* click to onClick (replacing any previous handler). The app hides its own back chevron
|
||||
* inside Telegram so only the native control shows.
|
||||
*/
|
||||
export function telegramBackButton(show: boolean, onClick?: () => void): void {
|
||||
const b = webApp()?.BackButton;
|
||||
if (!b) return;
|
||||
if (backHandler) b.offClick?.(backHandler);
|
||||
backHandler = null;
|
||||
if (show) {
|
||||
if (onClick) {
|
||||
backHandler = onClick;
|
||||
b.onClick?.(onClick);
|
||||
}
|
||||
b.show?.();
|
||||
} else {
|
||||
b.hide?.();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* startParamFromURL reads a startapp parameter from the page URL — a bot web_app
|
||||
* launch button carries the deep-link there rather than in initDataUnsafe.
|
||||
@@ -496,6 +547,34 @@ export function collectTelegramDiag(): TelegramDiag {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
|
||||
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
|
||||
* back-button state, UA). It feeds the hidden debug panel (components/DebugPanel), opened by tapping
|
||||
* the header title ten times. No secrets: no initData values, no IP.
|
||||
*/
|
||||
export function telegramChromeDiag(): string {
|
||||
const w = webApp();
|
||||
if (!w) return 'no telegram';
|
||||
const win = typeof window === 'undefined' ? undefined : window;
|
||||
const scr = typeof screen === 'undefined' ? undefined : screen;
|
||||
const vv = win?.visualViewport ?? undefined;
|
||||
const bar = typeof document === 'undefined' ? null : (document.querySelector('.bar')?.getBoundingClientRect() ?? null);
|
||||
const sa = w.safeAreaInset;
|
||||
const csa = w.contentSafeAreaInset;
|
||||
const n = (v: number | undefined): string => (v === undefined ? '—' : String(Math.round(v)));
|
||||
return [
|
||||
`platform: ${w.platform ?? '—'} version: ${w.version ?? '—'} scheme: ${w.colorScheme ?? '—'}`,
|
||||
`isFullscreen: ${w.isFullscreen} isExpanded: ${w.isExpanded}`,
|
||||
`inTG: ${insideTelegram()} sdkLoad: ${sdkLoadOutcome} backPresent: ${!!w.BackButton} backVisible: ${w.BackButton?.isVisible}`,
|
||||
`innerH: ${n(win?.innerHeight)} outerH: ${n(win?.outerHeight)} screenH: ${n(scr?.height)} availH: ${n(scr?.availHeight)}`,
|
||||
`screenY: ${n(win?.screenY)} vv.offTop: ${n(vv?.offsetTop)} vv.h: ${n(vv?.height)}`,
|
||||
`tgViewportH: ${n(w.viewportHeight)} stableH: ${n(w.viewportStableHeight)}`,
|
||||
`safeArea T/B: ${n(sa?.top)}/${n(sa?.bottom)} contentSafe T/B: ${n(csa?.top)}/${n(csa?.bottom)}`,
|
||||
`appHeader top/h: ${bar ? Math.round(bar.top) : '—'}/${bar ? Math.round(bar.height) : '—'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// --- Login Widget (web sign-in for account linking) ---
|
||||
|
||||
// The Login Widget is the web (non-Mini-App) Telegram sign-in. It is used only to
|
||||
|
||||
@@ -65,6 +65,9 @@ export function createTransport(baseUrl: string): GatewayClient {
|
||||
async authTelegram(initData) {
|
||||
return codec.decodeSession(await exec('auth.telegram', codec.encodeTelegramLogin(initData, browserOffset())));
|
||||
},
|
||||
async authVK(params, displayName) {
|
||||
return codec.decodeSession(await exec('auth.vk', codec.encodeVKLogin(params, browserOffset(), displayName)));
|
||||
},
|
||||
async authGuest(locale) {
|
||||
return codec.decodeSession(await exec('auth.guest', codec.encodeGuestLogin(locale ?? '', browserOffset())));
|
||||
},
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { insideVK, onVKPath, vkAppId, vkLaunchParams, vkStartParam } from './vk';
|
||||
|
||||
describe('vk launch detection', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('is not inside VK and not on the VK path without a location (node / SSR)', () => {
|
||||
expect(onVKPath()).toBe(false);
|
||||
expect(insideVK()).toBe(false);
|
||||
expect(vkLaunchParams()).toBe('');
|
||||
});
|
||||
|
||||
it('detects the dedicated /vk/ entry path', () => {
|
||||
vi.stubGlobal('location', { pathname: '/vk/', search: '' });
|
||||
expect(onVKPath()).toBe(true);
|
||||
vi.stubGlobal('location', { pathname: '/app/', search: '' });
|
||||
expect(onVKPath()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the signed launch query only when a sign is present', () => {
|
||||
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&vk_ts=2&sign=abc' });
|
||||
expect(vkLaunchParams()).toBe('vk_user_id=1&vk_ts=2&sign=abc');
|
||||
expect(insideVK()).toBe(true);
|
||||
});
|
||||
|
||||
it('treats a URL carrying no sign as not a VK launch', () => {
|
||||
vi.stubGlobal('location', { pathname: '/vk/', search: '?utm_source=catalog' });
|
||||
expect(vkLaunchParams()).toBe('');
|
||||
expect(insideVK()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vk launch params', () => {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('reads the VK app id from the launch query', () => {
|
||||
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_app_id=6736218&sign=x' });
|
||||
expect(vkAppId()).toBe('6736218');
|
||||
});
|
||||
|
||||
it('reads the direct-link deep-link payload from the hash query param', () => {
|
||||
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x&hash=f123456' });
|
||||
expect(vkStartParam()).toBe('f123456');
|
||||
});
|
||||
|
||||
it('returns empty app id / start param when absent', () => {
|
||||
vi.stubGlobal('location', { pathname: '/vk/', search: '?vk_user_id=1&sign=x' });
|
||||
expect(vkAppId()).toBe('');
|
||||
expect(vkStartParam()).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// VK Mini App SDK access via @vkontakte/vk-bridge. The bridge is imported lazily inside the
|
||||
// functions that need it — not at module top level — because the SDK reads browser globals on
|
||||
// import: the lazy import keeps the pure URL helpers below importable in the node test environment,
|
||||
// and code-splits the bridge into a chunk loaded only on the /vk/ entry. This wraps the subset the
|
||||
// app uses: launch detection, the signed launch parameters (for auth.vk) and the user's display name
|
||||
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
||||
// call outside VK.
|
||||
|
||||
async function bridge() {
|
||||
return (await import('@vkontakte/vk-bridge')).default;
|
||||
}
|
||||
|
||||
/**
|
||||
* onVKPath reports whether the app is served under the dedicated VK entry path (/vk/).
|
||||
*/
|
||||
export function onVKPath(): boolean {
|
||||
if (typeof location === 'undefined') return false;
|
||||
return location.pathname.startsWith('/vk/');
|
||||
}
|
||||
|
||||
/**
|
||||
* vkLaunchParams returns the raw signed VK launch query string (the vk_* parameters plus sign) from
|
||||
* the page URL — the exact form the gateway verifies — or '' when the URL carries no signed launch
|
||||
* (an ordinary browser tab, or the /vk/ path opened directly).
|
||||
*/
|
||||
export function vkLaunchParams(): string {
|
||||
if (typeof location === 'undefined') return '';
|
||||
const query = location.search.replace(/^\?/, '');
|
||||
return new URLSearchParams(query).has('sign') ? query : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* insideVK reports whether the app launched as a VK Mini App — the URL carries signed launch
|
||||
* parameters (an ordinary browser tab has none).
|
||||
*/
|
||||
export function insideVK(): boolean {
|
||||
return vkLaunchParams() !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* vkInit signals to the VK client that the Mini App has loaded (VKWebAppInit), dismissing VK's own
|
||||
* loading cover. Best-effort: it resolves even if the bridge is unavailable (outside VK), so the
|
||||
* caller can await it unconditionally.
|
||||
*/
|
||||
export async function vkInit(): Promise<void> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppInit', {});
|
||||
} catch {
|
||||
// Outside VK there is no client to receive it; the launch continues regardless.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkUserName fetches the launching user's display name via VKWebAppGetUserInfo, since VK omits it
|
||||
* from the signed launch params. Returns '' on any failure or outside VK, so the backend falls back
|
||||
* to a generated placeholder. The value is a cosmetic seed only — being unsigned, the gateway never
|
||||
* trusts it for identity.
|
||||
*/
|
||||
export async function vkUserName(): Promise<string> {
|
||||
try {
|
||||
const u = await (await bridge()).send('VKWebAppGetUserInfo', {});
|
||||
return [u.first_name, u.last_name].filter(Boolean).join(' ').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkAppId returns the launching VK app id (vk_app_id) from the signed launch parameters in the URL —
|
||||
* so the app can build its own vk.com/app<id> links without a VK API call — or '' outside a VK launch.
|
||||
*/
|
||||
export function vkAppId(): string {
|
||||
if (typeof location === 'undefined') return '';
|
||||
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_app_id') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* vkStartParam returns the VK direct-link deep-link payload. VK passes everything after the '#' in a
|
||||
* vk.com/app<id>#<payload> link to the app as the `hash` query parameter (it is also in location.hash,
|
||||
* but that collides with the app's hash router, so the query parameter is the safe source). Empty when
|
||||
* the launch carried no deep link.
|
||||
*/
|
||||
export function vkStartParam(): string {
|
||||
if (typeof location === 'undefined') return '';
|
||||
return new URLSearchParams(location.search.replace(/^\?/, '')).get('hash') ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* vkShare opens VK's native share dialog for link (VKWebAppShare) — the in-iframe replacement for
|
||||
* navigator.share, which is unavailable in the desktop VK iframe. Resolves true when the share was
|
||||
* handled, false on any failure or outside VK.
|
||||
*/
|
||||
export async function vkShare(link: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppShare', { link });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkCopyText copies text to the clipboard via VKWebAppCopyText — which works inside the VK iframe,
|
||||
* where navigator.clipboard is blocked. Resolves true on success, false on any failure or outside VK.
|
||||
*/
|
||||
export async function vkCopyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await (await bridge()).send('VKWebAppCopyText', { text });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* vkOnScheme subscribes to VK's appearance (VKWebAppUpdateConfig) and calls handler with the mapped
|
||||
* 'light' | 'dark' scheme on launch and whenever the user switches the VK client theme — so the app's
|
||||
* "auto" theme can follow the VK client instead of the (often wrong) webview prefers-color-scheme.
|
||||
* A no-op outside VK.
|
||||
*/
|
||||
export async function vkOnScheme(handler: (scheme: 'light' | 'dark') => void): Promise<void> {
|
||||
try {
|
||||
const b = await bridge();
|
||||
b.subscribe((e) => {
|
||||
const detail = (e as { detail?: { type?: string; data?: { scheme?: string; appearance?: string } } }).detail;
|
||||
if (detail?.type !== 'VKWebAppUpdateConfig') return;
|
||||
const scheme = detail.data?.scheme ?? detail.data?.appearance ?? '';
|
||||
handler(/dark|space_gray/i.test(scheme) ? 'dark' : 'light');
|
||||
});
|
||||
} catch {
|
||||
// Outside VK / bridge unavailable: leave the app on its own theme.
|
||||
}
|
||||
}
|
||||
|
||||
/** VKInsets is the device safe-area the VK client reports (px). */
|
||||
export interface VKInsets {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* vkOnInsets subscribes to VK's safe-area insets and calls handler with them on launch and on change.
|
||||
* VK reports them via VKWebAppUpdateConfig (iOS) and the dedicated VKWebAppUpdateInsets event; the VK
|
||||
* mobile webview does not expose them through CSS env() the way iOS Safari does, so the app reads them
|
||||
* from the bridge to clear the home bar (notably on Android). A no-op outside VK.
|
||||
*/
|
||||
export async function vkOnInsets(handler: (insets: VKInsets) => void): Promise<void> {
|
||||
try {
|
||||
const b = await bridge();
|
||||
b.subscribe((e) => {
|
||||
const d = (e as { detail?: { type?: string; data?: { insets?: Partial<VKInsets> } } }).detail;
|
||||
if (d?.type !== 'VKWebAppUpdateConfig' && d?.type !== 'VKWebAppUpdateInsets') return;
|
||||
const i = d.data?.insets;
|
||||
if (!i) return;
|
||||
handler({ top: i.top ?? 0, bottom: i.bottom ?? 0, left: i.left ?? 0, right: i.right ?? 0 });
|
||||
});
|
||||
} catch {
|
||||
// Outside VK / bridge unavailable: the CSS env() safe-area fallback applies.
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
// The Mini App launch failed to authenticate after its silent retries (e.g. the backend was
|
||||
// briefly down during a deploy). Inside Telegram there is no web login to fall back to, so this
|
||||
// terminal screen offers a manual Retry that re-runs the launch (app.svelte retryTelegramBoot).
|
||||
import { retryTelegramBoot } from '../lib/app.svelte';
|
||||
// briefly down during a deploy). Inside a Mini App (Telegram or VK) there is no web login to fall
|
||||
// back to, so this terminal screen offers a manual Retry that re-runs the launch (app.svelte
|
||||
// retryMiniAppBoot).
|
||||
import { retryMiniAppBoot } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
|
||||
let retrying = $state(false);
|
||||
@@ -10,7 +11,7 @@
|
||||
if (retrying) return;
|
||||
retrying = true;
|
||||
try {
|
||||
await retryTelegramBoot();
|
||||
await retryMiniAppBoot();
|
||||
} finally {
|
||||
retrying = false;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { friendCodeParam, shareLink } from '../lib/deeplink';
|
||||
import { shareTelegramLink } from '../lib/telegram';
|
||||
import { friendCodeParam, shareLink, vkShareLink } from '../lib/deeplink';
|
||||
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram';
|
||||
import { insideVK, vkCopyText, vkShare } from '../lib/vk';
|
||||
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
|
||||
|
||||
let friends = $state<AccountRef[]>([]);
|
||||
@@ -66,20 +67,36 @@
|
||||
|
||||
// confirmBlock / confirmUnfriend run the pending action once its modal is
|
||||
// accepted, then clear the target and the revealed row.
|
||||
function confirmBlock(): void {
|
||||
const target = blockTarget;
|
||||
function confirmBlock(target = blockTarget): void {
|
||||
blockTarget = null;
|
||||
revealedId = null;
|
||||
if (target) void blockUser(target.accountId);
|
||||
}
|
||||
|
||||
function confirmUnfriend(): void {
|
||||
const target = unfriendTarget;
|
||||
function confirmUnfriend(target = unfriendTarget): void {
|
||||
unfriendTarget = null;
|
||||
revealedId = null;
|
||||
if (target) void remove(target.accountId);
|
||||
}
|
||||
|
||||
// onBlockClick / onUnfriendClick: inside the Mini App (and online) confirm with Telegram's native
|
||||
// dialog and act on accept; otherwise open the in-app confirm modal (the offline / web path).
|
||||
async function onBlockClick(f: AccountRef): Promise<void> {
|
||||
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||
if (await telegramShowConfirm(`${t('friends.blockConfirm')}\n${f.displayName}`)) confirmBlock(f);
|
||||
return;
|
||||
}
|
||||
blockTarget = f;
|
||||
}
|
||||
|
||||
async function onUnfriendClick(f: AccountRef): Promise<void> {
|
||||
if (connection.online && insideTelegram() && telegramDialogsAvailable()) {
|
||||
if (await telegramShowConfirm(`${t('friends.unfriendConfirm')}\n${f.displayName}`)) confirmUnfriend(f);
|
||||
return;
|
||||
}
|
||||
unfriendTarget = f;
|
||||
}
|
||||
|
||||
// While a friend row is slid open, a tap anywhere outside its action buttons
|
||||
// closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
|
||||
$effect(() => {
|
||||
@@ -124,6 +141,11 @@
|
||||
|
||||
async function copyCode() {
|
||||
if (!code) return;
|
||||
// Inside the VK iframe navigator.clipboard is blocked, so copy via VK Bridge there.
|
||||
if (insideVK()) {
|
||||
if (await vkCopyText(code.code)) showToast(t('friends.codeCopied'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.code);
|
||||
showToast(t('friends.codeCopied'));
|
||||
@@ -137,6 +159,13 @@
|
||||
// copies the link to the clipboard.
|
||||
async function shareInvite(url: string) {
|
||||
const text = t('friends.inviteText');
|
||||
// Inside VK navigator.share is absent (desktop iframe) and the clipboard is blocked: share via VK
|
||||
// Bridge, falling back to a VK-Bridge clipboard copy if the share is dismissed or unavailable.
|
||||
if (insideVK()) {
|
||||
if (await vkShare(url)) return;
|
||||
if (await vkCopyText(url)) showToast(t('friends.linkCopied'));
|
||||
return;
|
||||
}
|
||||
if (shareTelegramLink(url, text)) return;
|
||||
if (typeof navigator !== 'undefined' && navigator.share) {
|
||||
try {
|
||||
@@ -172,7 +201,7 @@
|
||||
<button class="btn" onclick={redeem} disabled={!connection.online}>{t('friends.redeem')}</button>
|
||||
</div>
|
||||
{#if code}
|
||||
{@const tg = shareLink(friendCodeParam(code.code))}
|
||||
{@const shareUrl = insideVK() ? vkShareLink() : shareLink(friendCodeParam(code.code))}
|
||||
<div class="code" data-testid="friend-code">
|
||||
<div class="coderow">
|
||||
<button class="codeval" onclick={copyCode}>{code.code}</button>
|
||||
@@ -181,8 +210,8 @@
|
||||
<span class="codehint">
|
||||
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
|
||||
</span>
|
||||
{#if tg}
|
||||
<button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
|
||||
{#if shareUrl}
|
||||
<button type="button" class="link" onclick={() => shareInvite(shareUrl)}>{t('friends.shareTelegram')}</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -216,8 +245,8 @@
|
||||
{#each friends as f (f.accountId)}
|
||||
<div class="rowwrap" class:revealed={revealedId === f.accountId}>
|
||||
<div class="acts">
|
||||
<button class="iconbtn" onclick={() => (blockTarget = f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
||||
<button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
||||
<button class="iconbtn" onclick={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button>
|
||||
<button class="iconbtn" onclick={() => onUnfriendClick(f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="who">{f.displayName}</span>
|
||||
@@ -264,7 +293,7 @@
|
||||
<p class="confirm-name">{blockTarget.displayName}</p>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={confirmBlock} disabled={!connection.online}>{t('friends.block')}</button>
|
||||
<button class="danger" onclick={() => confirmBlock()} disabled={!connection.online}>{t('friends.block')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
@@ -273,7 +302,7 @@
|
||||
<p class="confirm-name">{unfriendTarget.displayName}</p>
|
||||
<div class="confirm-row">
|
||||
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button>
|
||||
<button class="danger" onclick={confirmUnfriend} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
||||
<button class="danger" onclick={() => confirmUnfriend()} disabled={!connection.online}>{t('friends.unfriend')}</button>
|
||||
</div>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user