Compare commits

..

10 Commits

Author SHA1 Message Date
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
38 changed files with 140 additions and 1119 deletions
-89
View File
@@ -1,89 +0,0 @@
---
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").
-60
View File
@@ -101,66 +101,6 @@ func validateVariantPreferences(prefs []string) ([]string, error) {
return out, nil 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 // UpdateProfile validates and overwrites the editable fields of the account, then
// returns the stored row. It reports ErrInvalidProfile for a bad language, // returns the stored row. It reports ErrInvalidProfile for a bad language,
// timezone or display name and ErrNotFound when no account matches id. // timezone or display name and ErrNotFound when no account matches id.
@@ -1,37 +0,0 @@
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)
}
})
}
}
@@ -1,80 +0,0 @@
//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)
}
}
+1 -15
View File
@@ -6,7 +6,6 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account" "scrabble/backend/internal/account"
) )
@@ -20,16 +19,13 @@ import (
// telegramAuthRequest carries the identity the connector extracted from a // telegramAuthRequest carries the identity the connector extracted from a
// validated initData payload. Username, FirstName and LanguageCode seed a // validated initData payload. Username, FirstName and LanguageCode seed a
// brand-new account's display name and language; BrowserTZ (the client's detected // brand-new account's display name and language; BrowserTZ (the client's detected
// "±HH:MM" UTC offset) seeds its time zone; StartParam is the validated launch // "±HH:MM" UTC offset) seeds its time zone (first contact only).
// deep-link payload, which may seed the new account's variant preferences (first
// contact only).
type telegramAuthRequest struct { type telegramAuthRequest struct {
ExternalID string `json:"external_id"` ExternalID string `json:"external_id"`
Username string `json:"username"` Username string `json:"username"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"` LanguageCode string `json:"language_code"`
BrowserTZ string `json:"browser_tz"` BrowserTZ string `json:"browser_tz"`
StartParam string `json:"start_param"`
} }
// handleTelegramAuth provisions (or finds) the account bound to a Telegram // handleTelegramAuth provisions (or finds) the account bound to a Telegram
@@ -51,16 +47,6 @@ func (s *Server) handleTelegramAuth(c *gin.Context) {
// joined the chat before registering is granted on the spot (no chat_member // joined the chat before registering is granted on the spot (no chat_member
// event fires on registration). // event fires on registration).
s.publishChatAccessChange(acc.ID) 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) s.mintSession(c, acc)
} }
-1
View File
@@ -51,7 +51,6 @@ TELEGRAM_SUPPORT_CHAT_ID= # private forum supergroup for the suppor
TELEGRAM_PROMO_BOT_TOKEN= # optional standalone promo bot token; 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_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_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_MINIAPP_URL= # required
TELEGRAM_TEST_ENV=false TELEGRAM_TEST_ENV=false
TELEGRAM_API_BASE_URL= TELEGRAM_API_BASE_URL=
-1
View File
@@ -31,7 +31,6 @@ services:
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-} TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-} TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-} TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
TELEGRAM_PROMO_START_PARAM: ${TELEGRAM_PROMO_START_PARAM:-}
TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL} TELEGRAM_MINIAPP_URL: ${TELEGRAM_MINIAPP_URL:?set TELEGRAM_MINIAPP_URL}
# Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead). # Real Bot API in prod (the test contour pins TELEGRAM_TEST_ENV=true instead).
TELEGRAM_TEST_ENV: "false" TELEGRAM_TEST_ENV: "false"
+6 -14
View File
@@ -42,12 +42,7 @@ Three executables plus per-platform side-services:
users, a weighted fair rotation — §10), users, a weighted fair rotation — §10),
and a client **board-style** setting (bonus-label and a client **board-style** setting (bonus-label
mode). The visual/interaction design system is documented in mode). The visual/interaction design system is documented in
[`UI_DESIGN.md`](UI_DESIGN.md). Inside the Telegram Mini App the client additionally [`UI_DESIGN.md`](UI_DESIGN.md).
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 - **`platform/telegram`** — the Telegram side-service (module
`scrabble/platform/telegram`), split into two binaries that share the bot token `scrabble/platform/telegram`), split into two binaries that share the bot token
(**one bot**, one optional game channel, §3): (**one bot**, one optional game channel, §3):
@@ -157,8 +152,7 @@ arrive from a platform rather than completing a mandatory registration).
- **Single bot.** The platform side-service runs **one bot** (one token + one optional - **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 game channel), split into a home **validator** and a remote **bot** that share the
token. `ValidateInitData` (the validator) validates `initData` against that single token. `ValidateInitData` (the validator) validates `initData` against that single
token, **rejects a bot user** (the signed `is_bot` flag), and returns only the Telegram token and returns only the Telegram user identity — there is no per-bot "service
user identity — there is no per-bot "service
language" and no supported-languages set on the wire. The bot's chat messages and language" and no supported-languages set on the wire. The bot's chat messages and
out-of-app push are out-of-app push are
rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in rendered in the recipient's **interface language** (`preferred_language`, en/ru), not in
@@ -210,7 +204,7 @@ arrive from a platform rather than completing a mandatory registration).
> recipient's interface language (`preferred_language`), with no per-bot routing. New > 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 > 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 > `variant_preferences` (default Erudit only, server-enforced on the caller's create
> 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 > paths; an invited friend may still accept any variant). The per-bot env vars and
> `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped > `GATEWAY_DEFAULT_SUPPORTED_LANGUAGES` were removed; the wire dropped
> `service_language`/`supported_languages` and the push `language` routing field, and > `service_language`/`supported_languages` and the push `language` routing field, and
> gained `variant_preferences` on Profile/UpdateProfile. > gained `variant_preferences` on Profile/UpdateProfile.
@@ -978,7 +972,7 @@ edits take effect on the next `profile.get` (open/reconnect/foreground), not mid
| Concern | Enforced by | | 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) | | 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. The validator also **rejects a bot principal** (the signed `is_bot` flag) before any account is provisioned | | 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 |
| Session minting; email-code / guest validation | gateway (with backend) | | Session minting; email-code / guest validation | gateway (with backend) |
| Session → `user_id` resolution, `X-User-ID` injection | gateway | | Session → `user_id` resolution, `X-User-ID` injection | gateway |
| Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) | | Authorisation, ownership, state transitions | backend (`X-User-ID` is the sole identity input) |
@@ -1210,10 +1204,8 @@ migration.
**Telegram support relay.** Separate from the in-app Feedback above, the bot offers a direct **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 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 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 dedicated **forum topic** whose first message is an info card (name, @username, language, premium,
mention via a `text_mention` entity — which also keeps a name beginning with `/` from being read as id, profile deep-link) carrying a Block/Unblock toggle and a Clear button; every message is then
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). 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 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 back to that user; non-admins and the bot's own posts are ignored (the loop guard). Block drops the
+4 -9
View File
@@ -30,8 +30,7 @@ A player arrives from a platform (Telegram first), via email login, or as an
ephemeral guest. The gateway validates the credential once and mints a thin ephemeral guest. The gateway validates the credential once and mints a thin
session token; the backend resolves it to an internal `user_id`. A **Telegram Mini 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 App** launch authenticates from the platform's signed `initData`, themes the UI to
the Telegram colours (re-theming live if you switch Telegram's light/dark mode) and fits the Telegram colours, and — on first contact — seeds the new account's interface
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 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 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. **Retry** button, rather than dropping to the web sign-in, which has no place inside Telegram.
@@ -250,16 +249,12 @@ 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 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 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 & an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings** merge".
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 **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 variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
yourself to be matched into; a **new account starts with Erudite only** — unless it was created yourself to be matched into; a **new account starts with Erudite only**, and you must keep **at
through the **promo bot's deep link**, which also enables **English Scrabble** — and you must keep least one** selected. This list is exactly what **New Game** offers when you start a game
**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 (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 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 invited friend may accept an invitation in **any** variant, and you can always open and play
+4 -11
View File
@@ -31,9 +31,7 @@ top-1 подсказку, безлимитную проверку слова с
эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий эфемерный гость. Gateway один раз валидирует доступ и выдаёт тонкий
session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram session-токен; backend сопоставляет его с внутренним `user_id`. Запуск **Telegram
Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс Mini App** авторизует по подписанным `initData` платформы, перекрашивает интерфейс
в цвета Telegram (перекрашиваясь вживую при смене светлой/тёмной темы Telegram) и в цвета Telegram и — при первом контакте — задаёт язык интерфейса нового аккаунта по
вписывается в безопасные зоны экрана (safe-area), а — при первом контакте — задаёт язык
интерфейса нового аккаунта по
языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время языку Telegram-клиента. Если запуск не может достучаться до бэкенда (например, во время
деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось деплоя), Mini App тихо повторяет попытки, а затем показывает небольшой экран «не удалось
загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри загрузить» с кнопкой **Повторить**, вместо того чтобы сбрасывать на веб-вход, которому внутри
@@ -257,17 +255,12 @@ UTC; при создании аккаунта она подставляется
игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия игры с роботом таймились правильно ещё до открытия этой формы), суточного окна отсутствия
(away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется (away; сетка по 10 минут, не более 12 часов, с переходом через полночь) и переключателей блокировок. Форма профиля редактируется
сразу (без отдельного режима редактирования). Привязка email и Telegram, а также сразу (без отдельного режима редактирования). Привязка email и Telegram, а также
слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние». Внутри Telegram слияние аккаунтов вынесены в раздел «Аккаунты, привязка и слияние».
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты **Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом** — если только которые ты разрешаешь себя подбирать; **новый аккаунт стартует только с Эрудитом**, и нужно
он не создан по **диплинку промо-бота**, который дополнительно включает **английский Scrabble**, — оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
и нужно оставить выбранным **хотя бы один**. Именно этот список предлагает **Новая игра**, когда ты
запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не запускаешь партию (авто-подбор, игра с ИИ или приглашение друга, которое ты создаёшь), — не
включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя включённый вариант не предлагается, и сервер его отклоняет. На партии, в которые тебя
**приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте, **приглашают**, это не влияет: приглашённый друг может принять приглашение в **любом** варианте,
+4 -5
View File
@@ -184,10 +184,10 @@ type ChatResp struct {
} }
// TelegramAuth provisions/finds the Telegram account and mints a session, seeding a // 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, its // brand-new account's display name and language from the validated launch fields and
// time zone from browserTz (the client's detected "±HH:MM" UTC offset) and, from the // its time zone from browserTz (the client's detected "±HH:MM" UTC offset; first
// validated launch deep-link startParam, its variant preferences (first contact only). // contact only).
func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz, startParam string) (SessionResp, error) { func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, username, firstName, browserTz string) (SessionResp, error) {
var out SessionResp var out SessionResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "", err := c.do(ctx, http.MethodPost, "/api/v1/internal/sessions/telegram", "", "",
map[string]string{ map[string]string{
@@ -196,7 +196,6 @@ func (c *Client) TelegramAuth(ctx context.Context, externalID, languageCode, use
"username": username, "username": username,
"first_name": firstName, "first_name": firstName,
"browser_tz": browserTz, "browser_tz": browserTz,
"start_param": startParam,
}, &out) }, &out)
return out, err return out, err
} }
+2 -11
View File
@@ -9,7 +9,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"net/url"
"scrabble/gateway/internal/backendclient" "scrabble/gateway/internal/backendclient"
"scrabble/gateway/internal/connector" "scrabble/gateway/internal/connector"
@@ -155,19 +154,11 @@ func DomainCode(err error) (string, bool) {
func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler { func authTelegramHandler(backend *backendclient.Client, tg TelegramValidator) Handler {
return func(ctx context.Context, req Request) ([]byte, error) { return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0) in := fb.GetRootAsTelegramLoginRequest(req.Payload, 0)
initData := string(in.InitData()) user, err := tg.ValidateInitData(ctx, string(in.InitData()))
user, err := tg.ValidateInitData(ctx, initData)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// start_param rides inside the signed initData validated just above, so the launch sess, err := backend.TelegramAuth(ctx, user.ExternalID, user.LanguageCode, user.Username, user.FirstName, string(in.BrowserTz()))
// 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 { if err != nil {
return nil, err return nil, err
} }
@@ -54,7 +54,7 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
t.Fatal("auth.telegram not registered") t.Fatal("auth.telegram not registered")
} }
payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("start_param=verudit_ru-scrabble_en&query_id=abc")}) payload, err := op.Handler(context.Background(), transcode.Request{Payload: telegramLoginPayload("init")})
if err != nil { if err != nil {
t.Fatalf("handler: %v", err) t.Fatalf("handler: %v", err)
} }
@@ -66,11 +66,6 @@ func TestTelegramAuthForwardsSeedFields(t *testing.T) {
if gotBody["external_id"] != "42" || gotBody["language_code"] != "ru" || gotBody["first_name"] != "Иван" { 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) 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) { func TestTelegramAuthInvalidInitData(t *testing.T) {
+3 -10
View File
@@ -65,9 +65,8 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
`chat_member` updates, which Telegram delivers only to a chat admin. `chat_member` updates, which Telegram delivers only to a chat admin.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum - **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 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 non-`/start` message opens a dedicated **forum topic** whose first message is an info card (name,
name is a tappable profile mention via a `text_mention` entity — which also stops a name starting @username, language, premium, id, `tg://user` profile link) carrying a **Block/Unblock** toggle
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 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 — 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 in a user's topic has it copied back to that user; the bot's own posts and non-admins are ignored
@@ -83,12 +82,7 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the main bot's direct link (`TELEGRAM_BOT_LINK`, the same link the UI uses) with 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 `?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 would sign the initData), which the main bot's validator rejects. It is fully
self-contained: no bot-link, no gateway, no game. Its `?startapp` payload self-contained: no bot-link, no gateway, no game.
(`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`, - **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits. default 25) to respect the Bot API flood limits.
@@ -156,7 +150,6 @@ Bot (`cmd/bot`):
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it | | `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_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_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_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_SEND_RATE_PER_SECOND` | `25` | outbound Bot API send cap (0 disables) |
| `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway | | `TELEGRAM_INSTANCE_ID` | hostname | bot identity reported to the gateway |
-1
View File
@@ -131,7 +131,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
TestEnv: cfg.TestEnv, TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername, BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL, BotLinkURL: cfg.BotLinkURL,
StartParam: cfg.PromoStartParam,
SendRatePerSecond: cfg.SendRatePerSecond, SendRatePerSecond: cfg.SendRatePerSecond,
}, logger) }, logger)
if err != nil { if err != nil {
+28 -74
View File
@@ -3,17 +3,15 @@ package bot
import ( import (
"context" "context"
"fmt" "fmt"
"html"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf16"
tgbot "github.com/go-telegram/bot" tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models" "github.com/go-telegram/bot/models"
"go.uber.org/zap" "go.uber.org/zap"
"scrabble/platform/telegram/internal/support"
) )
// Support relay: the bot forwards a user's direct messages into a per-user forum // Support relay: the bot forwards a user's direct messages into a per-user forum
@@ -132,20 +130,26 @@ func (t *Bot) handleSupportUserMessage(ctx context.Context, m *models.Message) {
unlock := t.supportLocks.lock(uid) unlock := t.supportLocks.lock(uid)
defer unlock() defer unlock()
rec, _ := t.support.Get(uid) rec, ok := t.support.Get(uid)
if rec.Blocked { if ok && rec.Blocked {
return return
} }
topicID, err := t.ensureTopic(ctx, m.From, rec) topicID := 0
if err != nil { if ok {
t.log.Warn("support: ensure topic failed", zap.Int64("user_id", uid), zap.Error(err)) topicID = rec.TopicID
}
if topicID == 0 {
var err error
if topicID, err = t.openSupportTopic(ctx, m.From); err != nil {
t.log.Warn("support: open topic failed", zap.Int64("user_id", uid), zap.Error(err))
return return
} }
}
newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID) newID, err := t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
if err != nil && isTopicMissingErr(err) { if err != nil && isTopicMissingErr(err) {
// Backstop: the topic vanished between the liveness probe and the copy. // The operators deleted the whole topic; reopen it and retry once.
t.log.Info("support: topic gone on copy, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID)) t.log.Info("support: topic gone, reopening", zap.Int64("user_id", uid), zap.Int("topic_id", topicID))
if topicID, err = t.openSupportTopic(ctx, m.From); err == nil { if topicID, err = t.openSupportTopic(ctx, m.From); err == nil {
newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID) newID, err = t.copyToTopic(ctx, m.Chat.ID, m.ID, topicID)
} }
@@ -254,31 +258,6 @@ func (t *Bot) clearSupportTopic(ctx context.Context, cq *models.CallbackQuery, u
t.answerSupportCallback(ctx, cq.ID, "Очищено") 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 // 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 // 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. // persist is logged but not fatal: the in-memory mapping still lets the relay proceed.
@@ -291,12 +270,11 @@ func (t *Bot) openSupportTopic(ctx context.Context, u *models.User) (int, error)
return 0, fmt.Errorf("create forum topic: %w", err) return 0, fmt.Errorf("create forum topic: %w", err)
} }
headerID := 0 headerID := 0
cardText, cardEntities := supportCard(u)
header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{ header, err := t.api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: t.supportChatID, ChatID: t.supportChatID,
MessageThreadID: topic.MessageThreadID, MessageThreadID: topic.MessageThreadID,
Text: cardText, Text: supportCardText(u),
Entities: cardEntities, ParseMode: models.ParseModeHTML,
ReplyMarkup: supportCardMarkup(u.ID, false), ReplyMarkup: supportCardMarkup(u.ID, false),
}) })
if err != nil { if err != nil {
@@ -418,22 +396,16 @@ func supportTopicName(u *models.User) string {
return name return name
} }
// supportCard renders the topic info card describing the user and the message // supportCardText renders the info card describing the user. User-controlled fields
// entities for it. The display name is covered by a text_mention entity: it links to // are HTML-escaped because the card uses HTML parse mode for the profile link.
// the user's profile (the reliable way to mention a user who has no public username — func supportCardText(u *models.User) string {
// the bot has seen them, since they messaged it) and, because the name sits inside an name := html.EscapeString(strings.TrimSpace(u.FirstName + " " + u.LastName))
// 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 == "" { if name == "" {
name = "user " + strconv.FormatInt(u.ID, 10) name = "—"
} }
username := "—" username := "—"
if u.Username != "" { if u.Username != "" {
username = "@" + u.Username username = "@" + html.EscapeString(u.Username)
} }
lang := u.LanguageCode lang := u.LanguageCode
if lang == "" { if lang == "" {
@@ -444,30 +416,12 @@ func supportCard(u *models.User) (string, []models.MessageEntity) {
premium = "да" premium = "да"
} }
var b strings.Builder var b strings.Builder
b.WriteString(name) fmt.Fprintf(&b, "<b>%s</b>\n", name)
fmt.Fprintf(&b, "\nUsername: %s", username) fmt.Fprintf(&b, "Username: %s\n", username)
fmt.Fprintf(&b, "\nID: %d", u.ID) fmt.Fprintf(&b, "ID: <code>%d</code>\n", u.ID)
fmt.Fprintf(&b, "\nЯзык: %s · Premium: %s", lang, premium) fmt.Fprintf(&b, "Язык: %s · Premium: %s\n", html.EscapeString(lang), premium)
entities := []models.MessageEntity{{ fmt.Fprintf(&b, `<a href="tg://user?id=%d">Открыть профиль</a>`, u.ID)
Type: models.MessageEntityTypeTextMention, return b.String()
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 // isTopicMissingErr reports whether err is Telegram's deleted/absent forum-topic
+11 -80
View File
@@ -38,9 +38,6 @@ type supportAPI struct {
deleted [][]int deleted [][]int
editedMsgIDs []string editedMsgIDs []string
answers []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 { func newSupportAPI(adminIDs ...int64) *supportAPI {
@@ -80,10 +77,6 @@ func (s *supportAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.deleted = append(s.deleted, ids) s.deleted = append(s.deleted, ids)
io.WriteString(w, `{"ok":true,"result":true}`) io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(path, "/editMessageReplyMarkup"): 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")) s.editedMsgIDs = append(s.editedMsgIDs, r.FormValue("message_id"))
io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`) io.WriteString(w, `{"ok":true,"result":{"message_id":1}}`)
case strings.HasSuffix(path, "/answerCallbackQuery"): case strings.HasSuffix(path, "/answerCallbackQuery"):
@@ -188,36 +181,6 @@ func TestSupportSubsequentReusesTopic(t *testing.T) {
} }
} }
// 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) { func TestSupportBlockedUserDropped(t *testing.T) {
api := newSupportAPI() api := newSupportAPI()
b, st := newSupportBot(t, api) b, st := newSupportBot(t, api)
@@ -397,53 +360,21 @@ func TestParseSupportCallback(t *testing.T) {
} }
} }
func TestSupportCard(t *testing.T) { func TestSupportCardTextEscapes(t *testing.T) {
t.Run("name is a plain-text mention, not a command", func(t *testing.T) { u := &models.User{ID: 7, FirstName: "<b>Ann</b>", Username: "ann", LanguageCode: "ru", IsPremium: true}
u := &models.User{ID: 7, FirstName: "/start", Username: "ann", LanguageCode: "ru", IsPremium: true} card := supportCardText(u)
text, ents := supportCard(u) if strings.Contains(card, "<b>Ann</b>") {
// The name is rendered verbatim (no HTML, no escaping) at the start of the card. t.Error("user-supplied first name was not HTML-escaped in 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 if !strings.Contains(card, "&lt;b&gt;Ann&lt;/b&gt;") {
// suppresses the "/start" command auto-detection and links the profile. t.Errorf("card = %q, want the escaped name", card)
if len(ents) != 1 {
t.Fatalf("entities = %d, want 1", len(ents))
} }
e := ents[0] if !strings.Contains(card, "tg://user?id=7") {
if e.Type != models.MessageEntityTypeTextMention || e.Offset != 0 || e.Length != len("/start") { t.Error("card missing the profile deep link")
t.Errorf("entity = %+v, want a text_mention over the name", e)
} }
if e.User == nil || e.User.ID != 7 { if !strings.Contains(card, "Premium: да") {
t.Errorf("entity user = %+v, want id 7", e.User) t.Error("card missing the premium flag")
} }
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) { func TestSupportTopicName(t *testing.T) {
@@ -65,12 +65,6 @@ type BotConfig struct {
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the // 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. // promo bot runs). It is distinct from the BotLink mTLS dial config below.
BotLinkURL string 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 // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is
// the base of every launch button (TELEGRAM_MINIAPP_URL, required). // the base of every launch button (TELEGRAM_MINIAPP_URL, required).
MiniAppURL string MiniAppURL string
@@ -119,10 +113,6 @@ const (
defaultValidatorGRPCAddr = ":9091" defaultValidatorGRPCAddr = ":9091"
defaultBotReconnectDelay = 2 * time.Second defaultBotReconnectDelay = 2 * time.Second
defaultSendRatePerSecond = 25 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. // LoadValidator reads the validator configuration from the environment.
@@ -155,7 +145,6 @@ func LoadBot() (BotConfig, error) {
PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"), PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"),
BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"), BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"),
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"), SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{ BotLink: BotLinkClientConfig{
@@ -18,8 +18,7 @@ import (
) )
// ErrInvalidInitData is returned when initData fails HMAC validation, is missing // ErrInvalidInitData is returned when initData fails HMAC validation, is missing
// the hash, is malformed, is older than the freshness window, or identifies a bot // the hash, is malformed, or is older than the freshness window.
// user (is_bot), which is denied.
var ErrInvalidInitData = errors.New("initdata: invalid telegram init data") var ErrInvalidInitData = errors.New("initdata: invalid telegram init data")
// defaultMaxAge bounds how old a validated initData payload may be. // defaultMaxAge bounds how old a validated initData payload may be.
@@ -121,7 +120,6 @@ func parseUser(userJSON string) (User, error) {
} }
var u struct { var u struct {
ID int64 `json:"id"` ID int64 `json:"id"`
IsBot bool `json:"is_bot"`
Username string `json:"username"` Username string `json:"username"`
FirstName string `json:"first_name"` FirstName string `json:"first_name"`
LanguageCode string `json:"language_code"` LanguageCode string `json:"language_code"`
@@ -129,12 +127,6 @@ func parseUser(userJSON string) (User, error) {
if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 { if err := json.Unmarshal([]byte(userJSON), &u); err != nil || u.ID == 0 {
return User{}, ErrInvalidInitData 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{ return User{
ExternalID: strconv.FormatInt(u.ID, 10), ExternalID: strconv.FormatInt(u.ID, 10),
Username: u.Username, Username: u.Username,
@@ -83,28 +83,3 @@ 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,9 +9,7 @@
package promobot package promobot
import ( import (
"cmp"
"context" "context"
"html"
"net/url" "net/url"
"strings" "strings"
@@ -35,11 +33,6 @@ type Config struct {
// BotLinkURL is the main bot's Mini App direct link; the button appends // BotLinkURL is the main bot's Mini App direct link; the button appends
// ?startapp=<payload> to it. // ?startapp=<payload> to it.
BotLinkURL string 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 // SendRatePerSecond caps outbound sends to respect the Bot API flood limits; 0
// disables the limiter. The burst equals the per-second rate. // disables the limiter. The burst equals the per-second rate.
SendRatePerSecond int SendRatePerSecond int
@@ -50,7 +43,6 @@ type Bot struct {
api *tgbot.Bot api *tgbot.Bot
username string username string
linkURL string linkURL string
startParam string
log *zap.Logger log *zap.Logger
limiter *rate.Limiter limiter *rate.Limiter
} }
@@ -61,7 +53,7 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if log == nil { if log == nil {
log = zap.NewNop() log = zap.NewNop()
} }
t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, startParam: cfg.StartParam, log: log} t := &Bot{username: cfg.BotUsername, linkURL: cfg.BotLinkURL, log: log}
if cfg.SendRatePerSecond > 0 { if cfg.SendRatePerSecond > 0 {
t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond) t.limiter = rate.NewLimiter(rate.Limit(cfg.SendRatePerSecond), cfg.SendRatePerSecond)
} }
@@ -95,8 +87,7 @@ func (t *Bot) Run(ctx context.Context) {
} }
// handleStart replies to any message (typically /start) with the localized promo text // handleStart replies to any message (typically /start) with the localized promo text
// and a button that opens the main bot's Mini App at the configured campaign payload // and a button that opens the main bot's Mini App, forwarding any /start 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) { func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Update) {
if update.Message == nil { if update.Message == nil {
return return
@@ -113,16 +104,11 @@ func (t *Bot) handleStart(ctx context.Context, api *tgbot.Bot, update *models.Up
if update.Message.From != nil { if update.Message.From != nil {
lang = update.Message.From.LanguageCode lang = update.Message.From.LanguageCode
} }
// The configured campaign payload (a variant-seed deep link) takes precedence; absent text, button := promoText(lang, t.username)
// 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{ if _, err := api.SendMessage(ctx, &tgbot.SendMessageParams{
ChatID: update.Message.Chat.ID, ChatID: update.Message.Chat.ID,
Text: text, Text: text,
ParseMode: models.ParseModeHTML, ReplyMarkup: t.launchMarkup(button, startPayload(update.Message.Text)),
ReplyMarkup: t.launchMarkup(button, param),
}); err != nil { }); err != nil {
t.log.Warn("promo: reply to start failed", zap.Error(err)) t.log.Warn("promo: reply to start failed", zap.Error(err))
} }
@@ -173,15 +159,11 @@ func startPayload(text string) string {
return strings.TrimSpace(strings.TrimPrefix(text, cmd)) return strings.TrimSpace(strings.TrimPrefix(text, cmd))
} }
// promoText returns the localized message body and button label. The body names the main // promoText returns the localized message body and button label, naming the main bot
// bot as a clickable @username whose link is the Mini App deep link (launchURL, the same // (Russian for a "ru" language code, English otherwise).
// target as the button), so tapping the mention opens the seeded Mini App rather than the func promoText(lang, username string) (text, button string) {
// 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") { if strings.HasPrefix(strings.ToLower(lang), "ru") {
return "Откройте " + mention + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!" return "Откройте @" + username + " и выберите в настройках профиля нужный вариант игры.", "🤩 Хочу играть!"
} }
return "Open " + mention + " and choose your game variant in the profile settings.", "🤩 I want to play!" return "Open @" + username + " and choose your game variant in the profile settings.", "🤩 I want to play!"
} }
@@ -13,30 +13,25 @@ import (
) )
func TestPromoTextLocalization(t *testing.T) { func TestPromoTextLocalization(t *testing.T) {
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en" en, enBtn := promoText("en", "ScrabbleBot")
// The @username is rendered as a clickable deep link (the same target as the button), if !strings.Contains(en, "@ScrabbleBot") || !strings.Contains(en, "profile settings") {
// 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) t.Errorf("en text = %q", en)
} }
if enBtn != "🤩 I want to play!" { if enBtn != "🤩 I want to play!" {
t.Errorf("en button = %q", enBtn) t.Errorf("en button = %q", enBtn)
} }
ru, ruBtn := promoText("ru-RU", "ScrabbleBot", url) ru, ruBtn := promoText("ru-RU", "ScrabbleBot")
if !strings.Contains(ru, wantLink) || !strings.Contains(ru, "Откройте") { if !strings.Contains(ru, "@ScrabbleBot") || !strings.Contains(ru, "Откройте") {
t.Errorf("ru text = %q", ru) t.Errorf("ru text = %q", ru)
} }
if ruBtn != "🤩 Хочу играть!" { if ruBtn != "🤩 Хочу играть!" {
t.Errorf("ru button = %q", ruBtn) t.Errorf("ru button = %q", ruBtn)
} }
// An unknown language falls back to English, still with the linked mention. // An unknown language falls back to English.
if got, _ := promoText("de", "B", url); !strings.Contains(got, "Open ") || !strings.Contains(got, `">@B</a>`) { if got, _ := promoText("de", "B"); !strings.Contains(got, "Open @B") {
t.Errorf("fallback text = %q, want English with a linked mention", got) t.Errorf("fallback text = %q, want English", got)
} }
} }
@@ -98,8 +93,8 @@ func TestHandleStartReplies(t *testing.T) {
if api.chatID != "42" { if api.chatID != "42" {
t.Errorf("chat_id = %q, want 42", api.chatID) t.Errorf("chat_id = %q, want 42", api.chatID)
} }
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) { if !strings.Contains(api.text, "@ScrabbleBot") {
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text) t.Errorf("text = %q, want the @mention", api.text)
} }
if strings.Contains(api.replyMarkup, "web_app") { if strings.Contains(api.replyMarkup, "web_app") {
t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup) t.Errorf("reply_markup = %q has a web_app button; want a url button", api.replyMarkup)
-5
View File
@@ -49,11 +49,6 @@
/* Telegram device safe-area top (the notch); TG's own nav controls sit between it and /* 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-content-top, so the in-app header aligns to that band, 0 elsewhere. */
--tg-safe-top: 0px; --tg-safe-top: 0px;
/* Telegram device safe-area bottom / sides (home indicator; landscape notch), 0 elsewhere —
the screen pads its bottom and left/right edges by these so content clears the cut-outs. */
--tg-safe-bottom: 0px;
--tg-safe-left: 0px;
--tg-safe-right: 0px;
--font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, --font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
"Noto Sans", "Liberation Sans", sans-serif; "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); --shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 6px 16px rgba(0, 0, 0, 0.06);
+6 -6
View File
@@ -81,7 +81,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: var(--gap); gap: var(--gap);
padding: 5px var(--pad); padding: 10px var(--pad);
} }
h1 { h1 {
font-size: 1.05rem; font-size: 1.05rem;
@@ -139,15 +139,15 @@
back chevron is hidden), so min-height (the nav-band height) doesn't bind. Without the 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 (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 band. So **padding-top** is the lever: it drops the title clear of the band — the notch
plus an **8px** gap (halved from 16). A fixed px (not rem/em) gap so the clearance from plus a **10px** gap (was 6). A fixed px (not rem/em) gap so the clearance from Telegram's
Telegram's native controls stays constant if the user scales up the font (the title then native controls stays constant if the user scales up the font (the title then grows
grows downward and the bar with it). (Owner-tunable: the 8px.) */ downward and the bar with it). (Owner-tunable: the 10px.) */
min-height: var(--tg-content-top); min-height: var(--tg-content-top);
box-sizing: border-box; box-sizing: border-box;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding-top: calc(var(--tg-safe-top) + 8px); padding-top: calc(var(--tg-safe-top) + 16px);
padding-bottom: 3px; padding-bottom: 6px;
} }
:global(html.tg-fullscreen) .spacer { :global(html.tg-fullscreen) .spacer {
display: none; display: none;
-17
View File
@@ -101,12 +101,6 @@
bottom input — chat, word-check — stays above an open soft keyboard without the page 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. */ scrolling; falls back to the full height where the var is unset. */
height: var(--vvh, 100%); 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 { .content {
flex: 0 1 auto; flex: 0 1 auto;
@@ -122,18 +116,7 @@
display: flex; display: flex;
flex-direction: column; 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);
}
.tabbar { .tabbar {
flex: 0 0 auto; 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> </style>
+2 -25
View File
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { app, dismissStaleInvite } from '../lib/app.svelte'; import { app, dismissStaleInvite } from '../lib/app.svelte';
import { router } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink'; import { botUsername } from '../lib/deeplink';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; import { telegramOpenLink } from '../lib/telegram';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
// The single bot's @username, for the deep link. // The single bot's @username, for the deep link.
@@ -21,30 +19,9 @@
} }
dismissStaleInvite(); 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> </script>
{#if app.staleInvite && ready && !(insideTelegram() && telegramDialogsAvailable())} {#if app.staleInvite}
<Modal title={t('friends.staleInviteTitle')} onclose={dismissStaleInvite}> <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> <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> <button class="ok" onclick={dismissStaleInvite}>{t('common.ok')}</button>
+2 -25
View File
@@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import { app, dismissWelcomeRedeem } from '../lib/app.svelte'; import { app, dismissWelcomeRedeem } from '../lib/app.svelte';
import { router } from '../lib/router.svelte';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { botUsername } from '../lib/deeplink'; import { botUsername } from '../lib/deeplink';
import { insideTelegram, telegramDialogsAvailable, telegramOpenLink, telegramShowPopup } from '../lib/telegram'; import { telegramOpenLink } from '../lib/telegram';
import { BOT_BUTTON_ID, botInfoPopup } from '../lib/nativedialogs';
import Modal from './Modal.svelte'; import Modal from './Modal.svelte';
// The single bot's @username, for the deep link. // The single bot's @username, for the deep link.
@@ -24,30 +22,9 @@
} }
dismissWelcomeRedeem(); 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> </script>
{#if app.welcomeRedeem && ready && !(insideTelegram() && telegramDialogsAvailable())} {#if app.welcomeRedeem}
<Modal title={t('friends.welcomeRedeemTitle')} onclose={dismissWelcomeRedeem}> <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> <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> <button class="ok" onclick={dismissWelcomeRedeem}>{t('common.ok')}</button>
+2 -12
View File
@@ -24,7 +24,7 @@
import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache'; import { getCachedGame, setCachedGame, setCachedDraft, type CachedGame } from '../lib/gamecache';
import { patchLobbyGame } from '../lib/lobbycache'; import { patchLobbyGame } from '../lib/lobbycache';
import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta'; import { applyGameOver, applyMoveDelta, applyOpponentJoined, type DeltaResult } from '../lib/gamedelta';
import { insideTelegram, telegramDialogsAvailable, telegramHaptic, telegramShowConfirm } from '../lib/telegram'; import { telegramHaptic } from '../lib/telegram';
import { import {
BLANK, BLANK,
newPlacement, newPlacement,
@@ -712,16 +712,6 @@
busy = false; 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() { async function doResign() {
resignOpen = false; resignOpen = false;
busy = true; busy = true;
@@ -1159,7 +1149,7 @@
<button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button> <button class="hicon" onclick={exportGcg} aria-label={t('game.exportGcg')}>📤</button>
{/if} {/if}
{:else} {:else}
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button> <button class="hicon" onclick={() => (resignOpen = true)} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
{/if} {/if}
{#if !view.game.multipleWordsPerTurn}<span class="oneword-label">{t('game.oneWordRule')}</span>{/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 <!-- A finished AI game has no comms at all (no chat, and the dictionary closes with the
+17 -87
View File
@@ -9,7 +9,7 @@ import { GatewayError } from './client';
import { navigate, router } from './router.svelte'; import { navigate, router } from './router.svelte';
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte'; import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
import { languageNeedsServerSync } from './language'; import { languageNeedsServerSync } from './language';
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme'; import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref } from './theme';
import { import {
insideTelegram, insideTelegram,
collectTelegramDiag, collectTelegramDiag,
@@ -18,21 +18,15 @@ import {
hasLaunchFragment, hasLaunchFragment,
loadTelegramSDK, loadTelegramSDK,
telegramColorScheme, telegramColorScheme,
telegramThemeParams,
telegramContentSafeAreaTop, telegramContentSafeAreaTop,
telegramSafeAreaInset, telegramSafeAreaTop,
telegramDisableVerticalSwipes, telegramDisableVerticalSwipes,
telegramShowSettingsButton,
telegramHaptic, telegramHaptic,
telegramLaunch, telegramLaunch,
type TelegramLaunch, type TelegramLaunch,
telegramOnEvent, telegramOnEvent,
telegramSetChrome, telegramSetChrome,
telegramCloudAvailable,
telegramCloudGet,
telegramCloudSet,
} from './telegram'; } from './telegram';
import { CLOUD_PREFS_KEY, decodeClientPrefs, encodeClientPrefs } from './cloudprefs';
import { parseStartParam } from './deeplink'; import { parseStartParam } from './deeplink';
import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session';
import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte'; import { connection, reportOffline, reportOnline, resetConnection } from './connection.svelte';
@@ -533,25 +527,17 @@ function syncTelegramChrome(): void {
} }
/** /**
* syncTelegramSafeArea mirrors Telegram's safe-area insets into CSS vars: the content-safe-area top * syncTelegramSafeArea mirrors Telegram's content-safe-area top inset (the height its native
* (the height Telegram's native nav overlays the viewport in fullscreen) into --tg-content-top * nav overlays the viewport in fullscreen) into the --tg-content-top CSS var and toggles a
* (which also toggles the `tg-fullscreen` class so the header drops below the nav and centres the * `tg-fullscreen` class, so the header can drop below the nav and centre the title in its
* title in its band), and the device safe-area insets — notch / status bar (top), home indicator * band. Called on launch and on Telegram's safe-area / fullscreen change events.
* (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 { function syncTelegramSafeArea(): void {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const root = document.documentElement;
const top = telegramContentSafeAreaTop(); const top = telegramContentSafeAreaTop();
const safe = telegramSafeAreaInset(); document.documentElement.style.setProperty('--tg-content-top', `${top}px`);
root.style.setProperty('--tg-content-top', `${top}px`); document.documentElement.style.setProperty('--tg-safe-top', `${telegramSafeAreaTop()}px`);
root.style.setProperty('--tg-safe-top', `${safe.top}px`); document.documentElement.classList.toggle('tg-fullscreen', top > 0);
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);
} }
/** /**
@@ -568,31 +554,20 @@ function syncViewportHeight(): void {
} }
/** /**
* syncTelegramTheme re-applies Telegram's theme integration — the themeParams token overrides, * applyTelegramChrome applies a Mini App launch's visual integration: Telegram's authoritative
* Telegram's authoritative colour scheme, and the matching chrome — from theme, or from the SDK's * colour scheme and theme, the matching header / background / bottom chrome, the safe-area insets,
* current themeParams when omitted. Called on launch with the launch snapshot and live on the * the swipe-down guard, and immersive fullscreen on mobile. It is idempotent, so both the initial
* themeChanged event, so switching Telegram's light/dark theme while the app is open is picked up * bootstrap and a manual launch retry call it.
* without a relaunch.
*/ */
function syncTelegramTheme(theme: TelegramThemeParams | undefined = telegramThemeParams()): void { function applyTelegramChrome(launch: TelegramLaunch): void {
if (theme) applyTelegramTheme(theme); if (launch.theme) applyTelegramTheme(launch.theme);
// Inside Telegram the colour scheme is Telegram's to decide; force it explicitly so the OS // 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 // prefers-color-scheme (which leaks into the Telegram Desktop webview) cannot fight it. Falls
// back to the stored preference when the SDK omits it. // back to the stored preference when the SDK omits it.
applyTheme(telegramColorScheme() ?? app.theme); 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(); 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(); syncTelegramSafeArea();
telegramDisableVerticalSwipes(); telegramDisableVerticalSwipes();
} }
@@ -646,19 +621,10 @@ export async function bootstrap(): Promise<void> {
if (insideTelegram()) { if (insideTelegram()) {
const launch = telegramLaunch(); const launch = telegramLaunch();
applyTelegramChrome(launch); 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). // Re-sync the safe-area insets whenever Telegram's chrome changes (registered once per load).
telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea); telegramOnEvent('contentSafeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('safeAreaChanged', syncTelegramSafeArea); telegramOnEvent('safeAreaChanged', syncTelegramSafeArea);
telegramOnEvent('fullscreenChanged', 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); await bootTelegram(launch);
app.ready = true; app.ready = true;
return; return;
@@ -845,42 +811,6 @@ function persistPrefs(): void {
reduceMotion: app.reduceMotion, reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels, 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 { export function setTheme(theme: ThemePref): void {
-31
View File
@@ -1,31 +0,0 @@
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');
});
});
-49
View File
@@ -1,49 +0,0 @@
// 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;
}
+1 -1
View File
@@ -247,7 +247,7 @@ export const en = {
'friends.redeem': 'Add', 'friends.redeem': 'Add',
'friends.copy': 'Copy', 'friends.copy': 'Copy',
'friends.codeCopied': 'Code copied.', 'friends.codeCopied': 'Code copied.',
'friends.shareTelegram': 'Share', 'friends.shareTelegram': 'Share via Telegram',
'friends.inviteText': "Let's play Scrabble!", 'friends.inviteText': "Let's play Scrabble!",
'friends.linkCopied': 'Link copied.', 'friends.linkCopied': 'Link copied.',
'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️", 'friends.selfInvite': "Hopefully you've been friends with yourself for a while ☺️",
+1 -1
View File
@@ -248,7 +248,7 @@ export const ru: Record<MessageKey, string> = {
'friends.redeem': 'Добавить', 'friends.redeem': 'Добавить',
'friends.copy': 'Копировать', 'friends.copy': 'Копировать',
'friends.codeCopied': 'Код скопирован.', 'friends.codeCopied': 'Код скопирован.',
'friends.shareTelegram': 'Поделиться', 'friends.shareTelegram': 'Поделиться через Telegram',
'friends.inviteText': 'Давай играть в Эрудит!', 'friends.inviteText': 'Давай играть в Эрудит!',
'friends.linkCopied': 'Ссылка скопирована.', 'friends.linkCopied': 'Ссылка скопирована.',
'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️', 'friends.selfInvite': 'Надеюсь, что с собой Вы уже давно дружите ☺️',
-25
View File
@@ -1,25 +0,0 @@
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.');
});
});
-24
View File
@@ -1,24 +0,0 @@
// 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 };
}
-70
View File
@@ -7,12 +7,6 @@ import {
routeExternalLinkInTelegram, routeExternalLinkInTelegram,
telegramLaunch, telegramLaunch,
telegramOpenExternalLink, telegramOpenExternalLink,
telegramThemeParams,
telegramSafeAreaInset,
telegramShowSettingsButton,
telegramDialogsAvailable,
telegramShowConfirm,
telegramShowPopup,
} from './telegram'; } from './telegram';
function stubWebApp(initData: string, startParam?: string) { function stubWebApp(initData: string, startParam?: string) {
@@ -50,20 +44,6 @@ describe('telegram launch detection', () => {
expect(launch.startParam).toBe('g123'); expect(launch.startParam).toBe('g123');
expect(launch.theme?.bg_color).toBe('#101418'); expect(launch.theme?.bg_color).toBe('#101418');
}); });
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: { initData: 'x', safeAreaInset: { top: 59, bottom: 34, left: 0, right: 0 } } },
});
expect(telegramSafeAreaInset()).toEqual({ top: 59, bottom: 34, left: 0, right: 0 });
});
}); });
describe('telegramOpenExternalLink', () => { describe('telegramOpenExternalLink', () => {
@@ -81,56 +61,6 @@ 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', () => { describe('routeExternalLinkInTelegram', () => {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
+6 -115
View File
@@ -6,20 +6,6 @@
import type { TelegramThemeParams } from './theme'; 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 { interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe?: { start_param?: string }; initDataUnsafe?: { start_param?: string };
@@ -55,18 +41,6 @@ interface TelegramWebApp {
onClick?: (cb: () => void) => void; onClick?: (cb: () => void) => void;
offClick?: (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 { function webApp(): TelegramWebApp | undefined {
@@ -269,15 +243,6 @@ export function telegramColorScheme(): 'light' | 'dark' | undefined {
return webApp()?.colorScheme; 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 * 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 * app's colours, so the surrounding Telegram chrome does not clash with the UI. No-op
@@ -300,15 +265,13 @@ export function telegramContentSafeAreaTop(): number {
} }
/** /**
* telegramSafeAreaInset returns the device safe-area insets (px) — the notch / status bar (top), * telegramSafeAreaTop returns the device safe-area top inset (px) — the notch / status bar
* the home indicator (bottom) and, in landscape, the notch sides (left / right) — from the SDK's * (Bot API 8.0). Telegram's own nav controls sit in the band between it and
* safeAreaInset (Bot API 8.0). All 0 outside Telegram or on a client predating it, so callers can * telegramContentSafeAreaTop, so aligning our header to that band lines it up with them. 0
* pad defensively. Telegram's own nav controls sit in the band between the top inset and * outside Telegram or on older clients.
* telegramContentSafeAreaTop, so aligning our header to that band lines it up with them.
*/ */
export function telegramSafeAreaInset(): { top: number; bottom: number; left: number; right: number } { export function telegramSafeAreaTop(): number {
const i = webApp()?.safeAreaInset; return webApp()?.safeAreaInset?.top ?? 0;
return { top: i?.top ?? 0, bottom: i?.bottom ?? 0, left: i?.left ?? 0, right: i?.right ?? 0 };
} }
/** /**
@@ -319,78 +282,6 @@ export function telegramDisableVerticalSwipes(): void {
webApp()?.disableVerticalSwipes?.(); 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. */ /** Haptic is the set of feedbacks the app triggers. */
export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy'; export type Haptic = 'select' | 'success' | 'error' | 'warning' | 'light' | 'medium' | 'heavy';
+10 -26
View File
@@ -7,7 +7,7 @@
import { GatewayError } from '../lib/client'; import { GatewayError } from '../lib/client';
import { t } from '../lib/i18n/index.svelte'; import { t } from '../lib/i18n/index.svelte';
import { friendCodeParam, shareLink } from '../lib/deeplink'; import { friendCodeParam, shareLink } from '../lib/deeplink';
import { insideTelegram, shareTelegramLink, telegramDialogsAvailable, telegramShowConfirm } from '../lib/telegram'; import { shareTelegramLink } from '../lib/telegram';
import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model'; import type { AccountRef, FriendCode, RobotBlockEntry } from '../lib/model';
let friends = $state<AccountRef[]>([]); let friends = $state<AccountRef[]>([]);
@@ -66,36 +66,20 @@
// confirmBlock / confirmUnfriend run the pending action once its modal is // confirmBlock / confirmUnfriend run the pending action once its modal is
// accepted, then clear the target and the revealed row. // accepted, then clear the target and the revealed row.
function confirmBlock(target = blockTarget): void { function confirmBlock(): void {
const target = blockTarget;
blockTarget = null; blockTarget = null;
revealedId = null; revealedId = null;
if (target) void blockUser(target.accountId); if (target) void blockUser(target.accountId);
} }
function confirmUnfriend(target = unfriendTarget): void { function confirmUnfriend(): void {
const target = unfriendTarget;
unfriendTarget = null; unfriendTarget = null;
revealedId = null; revealedId = null;
if (target) void remove(target.accountId); 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 // 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. // closes it again. Taps on a kebab are skipped so its own toggle stays in charge.
$effect(() => { $effect(() => {
@@ -198,7 +182,7 @@
{t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })} {t('friends.codeHint')} · {t('friends.codeExpires', { time: codeTime(code.expiresAtUnix) })}
</span> </span>
{#if tg} {#if tg}
<button type="button" class="link" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button> <button type="button" class="link tgshare" onclick={() => shareInvite(tg)}>{t('friends.shareTelegram')}</button>
{/if} {/if}
</div> </div>
{:else} {:else}
@@ -232,8 +216,8 @@
{#each friends as f (f.accountId)} {#each friends as f (f.accountId)}
<div class="rowwrap" class:revealed={revealedId === f.accountId}> <div class="rowwrap" class:revealed={revealedId === f.accountId}>
<div class="acts"> <div class="acts">
<button class="iconbtn" onclick={() => onBlockClick(f)} disabled={!connection.online} aria-label={t('friends.block')}>🚫</button> <button class="iconbtn" onclick={() => (blockTarget = 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> <button class="iconbtn" onclick={() => (unfriendTarget = f)} disabled={!connection.online} aria-label={t('friends.unfriend')}>✖️</button>
</div> </div>
<div class="row"> <div class="row">
<span class="who">{f.displayName}</span> <span class="who">{f.displayName}</span>
@@ -280,7 +264,7 @@
<p class="confirm-name">{blockTarget.displayName}</p> <p class="confirm-name">{blockTarget.displayName}</p>
<div class="confirm-row"> <div class="confirm-row">
<button class="cancel" onclick={() => (blockTarget = null)}>{t('common.cancel')}</button> <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> </div>
</Modal> </Modal>
{/if} {/if}
@@ -289,7 +273,7 @@
<p class="confirm-name">{unfriendTarget.displayName}</p> <p class="confirm-name">{unfriendTarget.displayName}</p>
<div class="confirm-row"> <div class="confirm-row">
<button class="cancel" onclick={() => (unfriendTarget = null)}>{t('common.cancel')}</button> <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> </div>
</Modal> </Modal>
{/if} {/if}