Compare commits

...

34 Commits

Author SHA1 Message Date
developer 5ce9f7e9b4 Merge pull request 'chore(release): promote development to master' (#258) from development into master 2026-07-13 22:21:59 +00:00
developer 522beec82e Merge pull request 'feat(tg-bot): unpin auto-forwarded channel posts' (#257) from feature/tg-unpin-linked-channel-post into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 10s
CI / integration (push) Successful in 22s
CI / ui (push) Has been skipped
CI / conformance (push) Successful in 10s
CI / changes (pull_request) Successful in 2s
CI / integration (pull_request) Successful in 20s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m54s
CI / unit (pull_request) Successful in 11s
CI / ui (pull_request) Successful in 1m15s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Has been skipped
2026-07-13 22:13:15 +00:00
Ilia Denisov 3b485883ee feat(tg-bot): unpin auto-forwarded channel posts
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
Telegram non-disableably auto-pins each channel post it auto-forwards
into the linked discussion group. The bot now detects that message by
Message.is_automatic_forward in the moderated chat (TELEGRAM_CHAT_ID)
and unpins it by id, so a pin set by a human admin — or by the bot for
another message — is never touched (no unpinAllChatMessages).

Needs the can_pin_messages right in the chat; the startup self-check
now also warns when it is missing. Bot-only; no wire/schema/DB change.
2026-07-14 00:07:46 +02:00
developer efeed17abc Merge pull request 'feat(vk): launch diagnostic on a direct /vk/ open instead of a guest' (#256) from feature/vk-launch-diagnostic into development
CI / changes (push) Successful in 2s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m14s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-13 21:42:51 +00:00
Ilia Denisov cc34622630 feat(vk): show a launch diagnostic on a direct /vk/ open instead of a guest
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
Opening the dedicated /vk/ entry directly in an ordinary browser (no signed VK
launch) fell through to the web flow and silently started a throwaway guest,
which is wrong for a VK-only entry. Mirror the existing /telegram/ launch-error
behaviour: render a compact, shareable, privacy-safe diagnostic screen instead.

- Boot: a new branch `onVKPath() && !insideVK()` sets app.launchError and stops
  the fall-through, the VK counterpart of the /telegram/ diagnostic guard.
- Diagnostic (passive, no VK Bridge round-trip): reads the URL launch-parameter
  NAMES (never the `sign` value — auth material), whether the URL was signed,
  `vk_platform`, the iframe/referrer context, plus the shared client-environment
  lines. VK signs the launch URL at load, so — unlike Telegram's initData — the
  parameters never arrive late; hence the VK screen offers Share only, no Retry.
- Generalise the screen: TelegramLaunchError.svelte -> LaunchError.svelte, which
  renders a neutral pre-formatted report and a per-platform title, with Retry
  gated on a `retry` flag (Telegram true, VK false). app.launchError is now a
  neutral LaunchDiag { platform, report, retry }.
- New lib/launchdiag.ts holds the shared pieces both platforms reuse: the
  LaunchDiag shape, the client-environment lines, and the query field-NAME
  reader (moved out of telegram.ts so VK does not duplicate them).

Tests: pure vkDiagLines units, incl. a guard that the `sign` value never leaks
into the report. i18n: launch.errorTitleVk (en/ru). Docs: FUNCTIONAL (+ru) user
story and ARCHITECTURE entry-path note.
2026-07-13 23:25:26 +02:00
developer f6d256215a Merge pull request 'fix(login): rate-limit no longer latches a phantom offline on the login screen' (#255) from feature/login-rate-limit-offline into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 20s
CI / ui (push) Successful in 1m18s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m47s
2026-07-13 20:59:22 +00:00
Ilia Denisov fe5a3d6d3b fix(login): stop a rate-limit from latching a phantom offline on the login screen
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m7s
A fresh email login that hit the per-IP email rate limit stranded the user in
an unrecoverable "offline" state that survived a full PWA restart, even though
the network was fine.

Root cause: the gateway email class (auth.email.request + auth.email.login,
keyed per IP, 5/10min burst 2) trips on the third event, which in the natural
"request code -> wrong code -> correct code" sequence is the correct-code login.
The gateway returns ResourceExhausted; the client mapped it to 'rate_limited',
which retry.ts classified as retryable + a connection code, so exec() called
reportOffline(). That pushes the net-state machine into connecting, whose
recovery probe is an authenticated profile.get -- but the login screen has no
session, so the probe can never succeed and the machine latches offline. The
transport kill switch (assertOnline) then refuses the very login that would fix
it, and because the IP's email bucket refills only 1 token / 120s, each fresh
attempt after a restart is rate-limited again -> offline again.

Fix (client, narrow -- the trigger):
- A rate-limit is no longer treated as connectivity. retry.ts no longer marks
  'rate_limited' retryable or a connection code, so exec() never reports it
  offline; it surfaces as the existing error.rate_limited "slow down" message.
  Not auto-retrying it also stops the ~20s button freeze and avoids feeding the
  gateway's ban tripwire with 6 extra rejections per attempt.

Fix (server):
- Raise the email-code burst 2 -> 4 so the honest request + a mistyped code +
  the correct one is not throttled mid-login. Defence-in-depth over the
  backend's own per-code guards (5-attempt cap + 15-min TTL + send throttle).

Tests: retry classification units updated to the new semantics; a gateway
regression guard asserts the honest three-event email flow passes under the
default policy. gateway/README.md rate-limit note updated.

The deeper gap (the net-state recovery probe is session-gated, so any real
transport failure on the session-less login/confirm screens still cannot
self-heal) is left as a known residual, deferred by the owner.
2026-07-13 22:48:59 +02:00
developer 3456a0b3ff Merge pull request 'release: v1.18.0 — /support command in the Telegram bot' (#252) from development into master 2026-07-13 07:39:06 +00:00
developer a41281c495 Merge pull request 'release: v1.17.0 — implicit offline model + two-tier client-version gate + unified lobby' (#250) from development into master 2026-07-13 01:06:35 +00:00
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer 0ca01133b5 Merge pull request 'Release v1.14.1 — ansible certs-dir fix + Robokassa go-live' (#239) from development into master 2026-07-10 10:58:38 +00:00
developer 45f0b34881 Merge pull request 'Release v1.14.0 — monetization launch (E5-E8)' (#237) from development into master 2026-07-10 10:11:02 +00:00
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
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
20 changed files with 408 additions and 118 deletions
+3 -1
View File
@@ -1369,7 +1369,9 @@ Single public origin, path-routed. The Vite build has two entries: a lightweight
`/app/` (web), `/telegram/` (the Telegram Mini App; on that path without sign-in data
— no `initData` — the client renders a compact, shareable launch-diagnostic screen instead
of redirecting away) and `/vk/` (the VK Mini App; the client reads the signed `vk_*` launch
parameters from the URL and the gateway verifies them in-process — §12); a stray hit on the
parameters from the URL and the gateway verifies them in-process — §12; on that path without a
signed launch the client renders the same shareable launch-diagnostic screen rather than starting
a throwaway guest); a stray hit on the
gateway's `/` 308-redirects to `/app/`. The **landing** ships in its own static container: the
`landing` target of `gateway/Dockerfile` (caddy:2-alpine + the same Vite build,
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
+6 -2
View File
@@ -72,7 +72,10 @@ A **VK Mini App** launch works the same way: it authenticates from VK's signed l
`vk_language` and its display name from the VK profile (read on the client, since VK does not put
the name in the signed launch). While the theme preference is "auto" the app follows the VK
client's light/dark scheme, and the layout clears the VK mobile home bar. The same quiet-retry
"couldn't load" screen applies inside VK.
"couldn't load" screen applies inside VK. Opening a Mini App link directly in an ordinary browser —
the `/vk/` entry with no signed VK launch, or `/telegram/` with no Telegram sign-in data — shows a
compact, shareable diagnostic screen instead of proceeding (as a throwaway guest on VK, or by
redirecting away on Telegram), so a player who ends up there wrongly can screenshot it for us.
**Email** sign-in (web) mails a branded six-digit confirmation code — localized
(en/ru), no images — to the address; entering it signs the player in. A new address
is provisioned on the first request but only becomes a durable account once the code
@@ -539,7 +542,8 @@ at any time (games already lost stay lost).
Where the bot manages a channel's **linked discussion chat**, everyone may write by default and the
bot **mutes** a player who is **not registered** or is **blocked**, un-muting them once they register
or are unblocked. So an unregistered newcomer who comments is muted (the promo bot points them at the
or are unblocked. It also clears the automatic pin Telegram puts on each channel post that is
auto-forwarded into the chat, so only deliberately pinned messages stay pinned. So an unregistered newcomer who comments is muted (the promo bot points them at the
game to register, after which the bot restores their voice), and a registered, unblocked player simply
writes. An operator can also **mute a player in the chat only** — a `chat_muted` role on the user card —
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
+7 -2
View File
@@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
тихого повтора «не удалось
загрузить» действует и внутри VK.
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
@@ -552,7 +555,9 @@ high-rate флага. С карточки пользователя операт
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
сообщения. То есть незарегистрированного новичка, написавшего в чат,
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
+2 -1
View File
@@ -121,7 +121,8 @@ web code exchange via `internal/vkid`, and both forward the trusted `external_id
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
300/min·user (burst 80, sized for multi-device play), admin
60/min·IP (burst 20, guarding the `/_gm` mount ahead of its Basic-Auth),
email-code 5/10 min·IP (burst 2).
email-code 5/10 min·IP (burst 4, covering request + a mistyped code + the
correct one; defence-in-depth over the backend's per-code 5-attempt/15-min cap).
Every rejection increments `gateway_rate_limited_total{class}`
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
+6 -1
View File
@@ -165,7 +165,12 @@ func DefaultRateLimit() RateLimitConfig {
// because multi-device play tripped the old 120/40.
UserPerMinute: 300, UserBurst: 80,
AdminPerMinute: 60, AdminBurst: 20,
EmailPer10Min: 5, EmailBurst: 2,
// Email-code path (per IP), defence-in-depth over the backend's own per-code guards
// (a 5-attempt cap + 15-min TTL + per-recipient send throttle). Burst 4 so the honest flow
// — request a code, mistype once or twice, then enter the right one — is not throttled
// mid-login: a request + wrong-code + right-code sequence exhausted the old burst of 2 and
// tripped the limit on the correct code, which the client mis-read as going offline.
EmailPer10Min: 5, EmailBurst: 4,
}
}
@@ -4,6 +4,7 @@ import (
"testing"
"time"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/ratelimit"
)
@@ -44,3 +45,20 @@ func TestPerWindow(t *testing.T) {
t.Fatalf("per-window burst = %v, want [true true false]", got)
}
}
// TestEmailBurstAllowsHonestLoginFlow guards the default email-code burst against being
// tightened back below the honest login sequence: requesting a code, submitting one wrong
// code, then the correct one are three immediate email-class events from one IP, and all
// must pass. A burst of 2 denied the correct-code login, which the client mis-read as going
// offline and could not recover from on the session-less login screen. This is defence in
// depth over the backend's own per-code attempt cap and code TTL.
func TestEmailBurstAllowsHonestLoginFlow(t *testing.T) {
rl := config.DefaultRateLimit()
p := ratelimit.Per(rl.EmailPer10Min, 10*time.Minute, rl.EmailBurst)
l := ratelimit.New()
for i, want := range []bool{true, true, true} { // request code, wrong code, right code
if got := l.Allow("email:198.51.100.7", p); got != want {
t.Fatalf("honest email event %d allowed = %v, want %v", i+1, got, want)
}
}
}
+6 -1
View File
@@ -70,7 +70,12 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the bot applies it — but only to a member currently in the chat (it probes one user with
`getChatMember`, since bots cannot list members). The bot must be an **administrator** there
with the **"Ban users"** right (the Bot API `can_restrict_members`), and it subscribes to
`chat_member` updates, which Telegram delivers only to a chat admin.
`chat_member` updates, which Telegram delivers only to a chat admin. The bot also **removes
the automatic pin** Telegram places on every channel post auto-forwarded into this linked
chat (the `Message.is_automatic_forward` marker): it unpins only that one message
(`unpinChatMessage` by id), so a pin set by a human admin — or by the bot for another
message — is never touched. This needs the **pin-messages** right (`can_pin_messages`) in
addition to "Ban users"; a startup self-check warns when it is missing.
- **Support relay (optional).** When `TELEGRAM_SUPPORT_CHAT_ID` names a private **forum
supergroup**, the bot runs a direct support channel for users who message it. A user's first
non-`/start` message opens a dedicated **forum topic** whose first message is an info card (the
+32
View File
@@ -285,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
return
}
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
// The same moderated chat also gets the linked channel's auto-forwarded posts un-pinned,
// which needs the pin-messages right; warn (separately from gating) when it is missing.
if !m.Administrator.CanPinMessages {
t.log.Warn("auto-unpin of linked channel posts WILL NOT WORK: the bot lacks the pin-messages right",
zap.Int64("chat_id", t.chatID))
}
}
// Notify sends a notification message with a Mini App launch button that opens the
@@ -405,6 +411,14 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleSuccessfulPayment(ctx, update.Message)
return
}
// A channel post automatically forwarded into the moderated discussion chat is
// auto-pinned by Telegram (a non-disableable behaviour). Unpin that one message so only
// deliberate pins remain; it targets this exact message id, so a pin set by a human admin
// — or by the bot for another message — is never touched.
if m := update.Message; m != nil && m.IsAutomaticForward && t.chatID != 0 && m.Chat.ID == t.chatID {
t.handleLinkedChannelPin(ctx, m)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply.
@@ -421,6 +435,24 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleStart(ctx, api, update)
}
// handleLinkedChannelPin removes Telegram's automatic pin from a linked channel's post
// that was auto-forwarded into the moderated discussion chat. It unpins only that exact
// message (by id), so a pin set by a human admin — or by the bot for another message — is
// left untouched. It needs the bot to hold the pin-messages right in the chat; a missing
// right surfaces as a warning (the unpin then no-ops), not a crash.
func (t *Bot) handleLinkedChannelPin(ctx context.Context, m *models.Message) {
if _, err := t.api.UnpinChatMessage(ctx, &tgbot.UnpinChatMessageParams{
ChatID: m.Chat.ID,
MessageID: m.ID,
}); err != nil {
t.log.Warn("could not unpin an auto-forwarded channel post; does the bot have the pin-messages right?",
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID), zap.Error(err))
return
}
t.log.Debug("unpinned an auto-forwarded channel post",
zap.Int64("chat_id", m.Chat.ID), zap.Int("message_id", m.ID))
}
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
// bot-link client backing it is built after the bot).
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
+53 -1
View File
@@ -19,10 +19,11 @@ const (
)
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
// scripted getChatMember status, and records restrictChatMember calls.
// scripted getChatMember status, and records restrictChatMember and unpinChatMessage calls.
type chatAPI struct {
memberStatus string // the status getChatMember reports (default "left")
restricts []restrictCall
unpins []unpinCall
}
type restrictCall struct {
@@ -30,6 +31,13 @@ type restrictCall struct {
canSend bool // can_send_messages in the applied permissions
}
// unpinCall records a single unpinChatMessage request (raw form values, mirroring the
// restrictCall style).
type unpinCall struct {
chatID string
messageID string
}
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/getMe"):
@@ -47,6 +55,9 @@ func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
io.WriteString(w, `{"ok":true,"result":true}`)
case strings.HasSuffix(r.URL.Path, "/unpinChatMessage"):
a.unpins = append(a.unpins, unpinCall{chatID: r.FormValue("chat_id"), messageID: r.FormValue("message_id")})
io.WriteString(w, `{"ok":true,"result":true}`)
default:
io.WriteString(w, `{"ok":true,"result":true}`)
}
@@ -237,3 +248,44 @@ func TestApplyChatGateSkipsAbsentAndAdmin(t *testing.T) {
})
}
}
// autoForwardUpdate builds a message update as it lands in the discussion chat: a post in
// chat chatID with id msgID and is_automatic_forward set to auto.
func autoForwardUpdate(chatID int64, msgID int, auto bool) *models.Update {
return &models.Update{Message: &models.Message{
ID: msgID,
Chat: models.Chat{ID: chatID, Type: models.ChatTypeSupergroup},
IsAutomaticForward: auto,
}}
}
func TestAutoForwardUnpinned(t *testing.T) {
// A channel post auto-forwarded into the moderated chat is unpinned by its exact id.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, true))
if len(api.unpins) != 1 || api.unpins[0].chatID != "555" || api.unpins[0].messageID != "42" {
t.Fatalf("unpins = %+v, want one {555, 42}", api.unpins)
}
}
func TestNonAutoForwardNotUnpinned(t *testing.T) {
// A normal message in the moderated chat (e.g. another admin's pinned message) is never
// unpinned — the is_automatic_forward filter is what guards every other pin.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(testChatID, 42, false))
if len(api.unpins) != 0 {
t.Fatalf("unpins = %+v, want none for a non-auto-forward message", api.unpins)
}
}
func TestAutoForwardOtherChatIgnored(t *testing.T) {
// An auto-forward in some other chat (not the configured moderated one) is ignored.
api := &chatAPI{}
b := newChatBot(t, api)
b.handleUpdate(context.Background(), b.api, autoForwardUpdate(999, 42, true))
if len(api.unpins) != 0 {
t.Fatalf("unpins = %+v, want none for a foreign chat", api.unpins)
}
}
+6 -5
View File
@@ -25,7 +25,7 @@
import Blocked from './screens/Blocked.svelte';
import AccountDeleted from './screens/AccountDeleted.svelte';
import BootError from './screens/BootError.svelte';
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
import LaunchError from './screens/LaunchError.svelte';
onMount(() => {
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
@@ -88,10 +88,11 @@
<div class="splash">{t('common.loading')}</div>
{/if}
{:else if app.launchError}
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
of bouncing to the marketing landing (also the probe for the empty-initData failure on
some Android clients). -->
<TelegramLaunchError />
<!-- A dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch: a compact,
shareable diagnostic screen instead of bouncing to the marketing landing (Telegram) or
silently proceeding as a guest (VK). Also the probe for the empty-initData failure on some
Android Telegram clients. -->
<LaunchError />
{:else if app.bootError}
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
show the retry screen instead of falling back to the web login. -->
+22 -12
View File
@@ -14,8 +14,7 @@ import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type
import {
insideTelegram,
telegramClose,
collectTelegramDiag,
type TelegramDiag,
telegramLaunchDiag,
onTelegramPath,
hasLaunchFragment,
loadTelegramSDK,
@@ -33,7 +32,8 @@ import {
telegramCloudGet,
telegramCloudSet,
} from './telegram';
import { onVKPath, insideVK, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import { onVKPath, insideVK, vkLaunchDiag, vkInit, vkClose, vkDisableSwipeBack, vkLaunchParams, vkUserName, vkStartParam, vkOnScheme, vkOnInsets, vkSetViewSettings, appearanceForBg } from './vk';
import type { LaunchDiag } from './launchdiag';
import { pendingVKLink, type VKLinkCallback } from './vkid';
import { isStandalone } from './pwa';
import { registerServiceWorker } from './pwa.svelte';
@@ -82,11 +82,12 @@ export const app = $state<{
* backend was down during a deploy). App.svelte then renders the boot-error retry screen
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
bootError: boolean;
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
launchError: TelegramDiag | null;
/** On a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch, set to a
* privacy-safe diagnostic snapshot. App.svelte then renders the compact, shareable launch-error
* screen (screens/LaunchError) — a probe for why Telegram delivered no initData (seen on some
* Android clients), or why /vk/ carried no signed launch — instead of bouncing to the landing
* (Telegram) or silently proceeding as a guest (VK). */
launchError: LaunchDiag | null;
/** Whether the hidden on-device debug panel (components/DebugPanel) is open — toggled by tapping
* the header title ten times in quick succession. A support aid; carries no secrets. */
debugOpen: boolean;
@@ -850,7 +851,7 @@ export async function bootstrap(): Promise<void> {
// clients), render the compact launch-error screen with a diagnostic snapshot the user can share
// with the developer, instead of bouncing the visitor to the marketing landing.
if (onTelegramPath() && !insideTelegram()) {
app.launchError = collectTelegramDiag();
app.launchError = telegramLaunchDiag();
app.ready = true;
return;
}
@@ -875,10 +876,19 @@ export async function bootstrap(): Promise<void> {
return;
}
// The dedicated /vk/ entry opened without a signed VK launch (a plain browser tab, or a VK client
// that delivered no launch parameters) shows the compact, shareable launch diagnostic instead of
// silently proceeding as a guest — the VK counterpart of the /telegram/ launch-error screen. VK puts
// its signed parameters in the launch URL at load, so there is nothing to wait for (hence no Retry).
if (onVKPath() && !insideVK()) {
app.launchError = vkLaunchDiag();
app.ready = true;
return;
}
// VK Mini App launch: signal readiness to the VK client (which dismisses its loading cover), then
// authenticate from the signed launch parameters in the URL — the display name comes from
// VKWebAppGetUserInfo, since VK omits it from the signed params. The /vk/ entry opened outside VK
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
// VKWebAppGetUserInfo, since VK omits it from the signed params.
if (onVKPath() && insideVK()) {
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
// VK mobile webview's prefers-color-scheme does not track it).
@@ -1072,7 +1082,7 @@ export async function retryTelegramLaunch(): Promise<void> {
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
if (!insideTelegram()) {
app.launchError = collectTelegramDiag();
app.launchError = telegramLaunchDiag();
return;
}
app.launchError = null;
+1
View File
@@ -27,6 +27,7 @@ export const en = {
'boot.errorBody': 'Please try again in a moment.',
'launch.errorTitle': "Can't open in Telegram",
'launch.errorTitleVk': "Can't open in VK",
'launch.errorBody': 'Screenshot or share this with the developer.',
'launch.share': 'Share',
'launch.copied': 'Copied',
+1
View File
@@ -28,6 +28,7 @@ export const ru: Record<MessageKey, string> = {
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
'launch.errorTitle': 'Не открывается в Telegram',
'launch.errorTitleVk': 'Не открывается в VK',
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
'launch.share': 'Поделиться',
'launch.copied': 'Скопировано',
+71
View File
@@ -0,0 +1,71 @@
// Shared launch-diagnostic plumbing for the dedicated Mini App entries (/telegram/, /vk/). When one of
// those is opened without a valid launch — a plain browser tab, or a client that delivered no sign-in
// data — the app renders a compact, privacy-safe diagnostic snapshot (the LaunchError screen) the user
// can screenshot or share, instead of silently proceeding (to the marketing landing, or as a guest).
// This module holds the pieces both platforms share: the neutral report shape the screen renders, the
// client environment lines, and a query-string field-NAME reader (names only — the values can be auth
// material). The platform-specific collectors live in telegram.ts / vk.ts.
/**
* LaunchDiag is the neutral, pre-formatted snapshot the LaunchError screen renders, so the screen is
* platform-agnostic: it shows the report text and, only when retry is set, a Retry button. The
* collectors (telegramLaunchDiag / vkLaunchDiag) own the platform-specific formatting.
*/
export interface LaunchDiag {
/** platform selects the screen's title copy ('telegram' | 'vk'). */
platform: 'telegram' | 'vk';
/** report is the compact multi-line "key: value" snapshot, sized to fit one screenshot. */
report: string;
/** retry is whether the screen shows a Retry button — true only where a late-arriving launch can
* still succeed (Telegram initData), false where the launch is fixed at load (VK's signed URL). */
retry: boolean;
}
interface uaBrand {
brand: string;
version: string;
}
interface uaDataValue {
platform?: string;
mobile?: boolean;
brands?: uaBrand[];
}
/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android in-app
* webviews), or undefined where it is unavailable (iOS / Safari / Firefox). */
export function uaData(): uaDataValue | undefined {
if (typeof navigator === 'undefined') return undefined;
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
}
/**
* clientEnvLines returns the shared client-environment lines of a launch diagnostic — the OS / mobile
* flag, the browser brands, and the full User-Agent — read from Client Hints (with a navigator.platform
* fallback). Both the Telegram and VK reports append these, so the environment readout stays identical
* across platforms. No secrets: it carries only client identification, never an IP.
*/
export function clientEnvLines(): string[] {
const ua = uaData();
const navPlatform =
typeof navigator === 'undefined' ? '' : ((navigator as unknown as { platform?: string }).platform ?? '');
const osPlatform = ua?.platform ?? navPlatform;
const mobile = ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no';
const browser = (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', ');
const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent;
return [`os: ${osPlatform || '—'} mobile: ${mobile || '—'}`, `browser: ${browser || '—'}`, `ua: ${userAgent || '—'}`];
}
/**
* queryFieldNames parses a URL query string (or any `key=value&…` form) and returns only its field
* NAMES, never the values — the values (a Telegram initData hash, a VK sign) are auth material that must
* never surface in a shareable diagnostic. Returns an empty array for empty or unparseable input.
*/
export function queryFieldNames(raw: string): string[] {
if (!raw) return [];
try {
return [...new URLSearchParams(raw).keys()];
} catch {
return [];
}
}
+14 -6
View File
@@ -28,10 +28,15 @@ describe('toGatewayError', () => {
});
describe('retryable', () => {
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
expect(retryable('rate_limited', 'games.list')).toBe(true);
expect(retryable('rate_limited', 'chat.post')).toBe(true);
it('does not auto-retry a rate-limit rejection (a deliberate throttle, surfaced not spun)', () => {
// A rate-limit is the server saying "slow down": auto-retrying cannot succeed until the bucket
// refills, only freezes the calling button for the whole backoff and feeds the gateway's ban
// tripwire with more rejections. It is surfaced to the caller instead. (Regression guard: the
// old retry path also called reportOffline, which on the session-less login screen — where the
// reachability probe can never heal — latched a stuck phantom offline.)
expect(retryable('rate_limited', 'game.submit_play')).toBe(false);
expect(retryable('rate_limited', 'games.list')).toBe(false);
expect(retryable('rate_limited', 'auth.email.login')).toBe(false);
});
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
@@ -53,9 +58,12 @@ describe('retryable', () => {
});
describe('isConnectionCode', () => {
it('flags the transport/rate-limit codes the indicator covers', () => {
it('flags only the transport connectivity code (a rate-limit is a surfaced message, not connectivity)', () => {
expect(isConnectionCode('unavailable')).toBe(true);
expect(isConnectionCode('rate_limited')).toBe(true);
// A rate-limit must NOT read as connectivity: isConnectionCode gates both reportOffline (the
// offline chrome) and handleError's toast suppression, so treating it as connectivity produced
// a silent, stuck phantom offline on the login screen. It surfaces as error.rate_limited.
expect(isConnectionCode('rate_limited')).toBe(false);
expect(isConnectionCode('not_your_turn')).toBe(false);
expect(isConnectionCode('internal')).toBe(false);
});
+16 -11
View File
@@ -2,11 +2,14 @@
// fails at the transport level the app retries it with capped exponential backoff while showing
// the "Connecting…" indicator, instead of flashing a red toast each time.
//
// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
// re-enables on reconnect, so the player re-issues it deliberately).
// Retry policy: a transport 'unavailable' is ambiguous for a mutation (its response could have been
// lost after the backend applied it), so only **read-only** ops are auto-retried on 'unavailable'; a
// mutation is surfaced instead (its button is disabled while offline and re-enables on reconnect, so
// the player re-issues it deliberately). A rate-limit rejection (ResourceExhausted) is NOT retried and
// is NOT a connectivity code: it is a deliberate "slow down", so auto-retrying cannot succeed until the
// bucket refills — it only freezes the calling button for the whole backoff and feeds the gateway's ban
// tripwire with more rejections. It is surfaced as its own message (error.rate_limited) and never flips
// the offline chrome, which the session-less login screen has no session-bearing probe to recover from.
import { Code, ConnectError } from '@connectrpc/connect';
import { GatewayError } from './client';
@@ -65,20 +68,22 @@ export const READ_OPS: ReadonlySet<string> = new Set([
]);
/**
* retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
* retryable reports whether a failed op should be auto-retried. A transport 'unavailable' is retried
* only for read-only ops, never a mutation; every other code — a rate-limit (a deliberate throttle,
* surfaced not spun), a domain rejection, not-found, … is final.
*/
export function retryable(code: string, op: string): boolean {
if (code === 'rate_limited') return true;
if (code === 'unavailable') return READ_OPS.has(op);
return false;
}
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
* indicator covers (so the UI suppresses its red toast). */
* indicator covers so the UI suppresses its red toast and reportOffline may flip the offline
* chrome. A rate-limit is deliberately NOT one: it is a genuine server signal, surfaced as its own
* "slow down" message, never the offline chrome (which the session-less login screen, whose
* reachability probe needs a bearer token, cannot recover from). */
export function isConnectionCode(code: string): boolean {
return code === 'unavailable' || code === 'rate_limited';
return code === 'unavailable';
}
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
+24 -46
View File
@@ -4,6 +4,7 @@
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
// theme params, and ready()/expand(). Every helper is safe to call outside Telegram.
import { clientEnvLines, queryFieldNames, type LaunchDiag } from './launchdiag';
import type { TelegramThemeParams } from './theme';
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
@@ -471,24 +472,6 @@ export function hasLaunchFragment(): boolean {
* the launch-error screen reports. Only field names are ever inspected, never their values. */
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
interface uaBrand {
brand: string;
version: string;
}
interface uaDataValue {
platform?: string;
mobile?: boolean;
brands?: uaBrand[];
}
/** uaData returns the User-Agent Client Hints object (Chromium only — notably the Android Telegram
* webview), or undefined where it is unavailable (iOS / Safari / Firefox). */
function uaData(): uaDataValue | undefined {
if (typeof navigator === 'undefined') return undefined;
return (navigator as unknown as { userAgentData?: uaDataValue }).userAgentData;
}
/** launchFragmentData returns the raw tgWebAppData carried in the URL fragment (the form Telegram
* appends on launch), or '' when absent. With no SDK present a non-empty value means Telegram
* delivered the data but telegram-web-app.js never ran to parse it. */
@@ -503,17 +486,6 @@ function launchFragmentData(): string {
}
}
/** initDataFieldNames parses a Telegram initData (or raw fragment data) query string and returns
* only its field NAMES — never the values, since the hash / signature are auth material. */
function initDataFieldNames(raw: string): string[] {
if (!raw) return [];
try {
return [...new URLSearchParams(raw).keys()];
} catch {
return [];
}
}
/**
* TelegramDiag is a privacy-safe snapshot of why a Mini App launch lacked sign-in data, taken at
* the moment of failure and rendered on the launch-error screen so a stuck user can share it with
@@ -542,30 +514,20 @@ export interface TelegramDiag {
fieldsPresent: string[];
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
fieldsMissing: string[];
/** The OS / platform per User-Agent Client Hints (else navigator.platform), e.g. 'Android', ''. */
osPlatform: string;
/** Whether the client reports itself mobile per Client Hints: 'yes' | 'no' | '' (unknown). */
mobile: string;
/** The browser brands + major versions per Client Hints (Chromium only), or ''. */
browser: string;
/** The full User-Agent string — the catch-all that also carries the OS version and webview build. */
userAgent: string;
}
/**
* collectTelegramDiag captures a TelegramDiag snapshot of the current launch state. Call it at the
* point a /telegram/ launch is found to lack sign-in data, so the snapshot reflects that moment —
* sign-in data arriving late would otherwise mask the failure.
* sign-in data arriving late would otherwise mask the failure. telegramLaunchDiag wraps it into the
* neutral LaunchDiag the shared LaunchError screen renders.
*/
export function collectTelegramDiag(): TelegramDiag {
const w = webApp();
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
const initData = w?.initData ?? '';
const fragData = initData ? '' : launchFragmentData();
const present = initDataFieldNames(initData || fragData);
const ua = uaData();
const navPlatform =
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
const present = queryFieldNames(initData || fragData);
return {
hasSDK: sdk,
hasWebApp: !!w,
@@ -576,13 +538,29 @@ export function collectTelegramDiag(): TelegramDiag {
hashHadTgData: fragData.length > 0,
fieldsPresent: present,
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
osPlatform: ua?.platform ?? navPlatform,
mobile: ua?.mobile === undefined ? '' : ua.mobile ? 'yes' : 'no',
browser: (ua?.brands ?? []).map((b) => `${b.brand} ${b.version}`).join(', '),
userAgent: typeof navigator === 'undefined' ? '' : navigator.userAgent,
};
}
/**
* telegramLaunchDiag captures the current Telegram launch state as a neutral LaunchDiag for the shared
* LaunchError screen: the platform-specific lines (SDK load, initData length, field names) plus the
* shared client-environment lines. retry is true — Telegram's initData can arrive late on a slow
* client, so the screen's Retry can still recover the launch.
*/
export function telegramLaunchDiag(): LaunchDiag {
const d = collectTelegramDiag();
const report = [
`sdk-load: ${d.sdkLoad}`,
`sdk: ${d.hasSDK ? 'yes' : 'no'} webapp: ${d.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${d.platform || '—'} tg-version: ${d.version || '—'}`,
`initData: ${d.initDataLen > 0 ? d.initDataLen : 'empty'} tgdata-in-url: ${d.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${d.fieldsPresent.join(',') || '—'}`,
`missing: ${d.fieldsMissing.join(',') || '—'}`,
...clientEnvLines(),
].join('\n');
return { platform: 'telegram', report, retry: true };
}
/**
* telegramChromeDiag returns a compact, privacy-safe readout of Telegram's viewport / chrome state
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
+41
View File
@@ -5,7 +5,9 @@ import {
routeExternalLinkInVK,
vkAndroidWebView,
vkAppId,
vkDiagLines,
vkExternalBrowserUrl,
vkLaunchDiag,
vkLaunchParams,
vkStartParam,
appearanceForBg,
@@ -119,6 +121,45 @@ describe('vk external links', () => {
});
});
describe('vkDiagLines (launch diagnostic)', () => {
it('reports an unsigned browser open: no sign, every VK param missing', () => {
const lines = vkDiagLines('utm_source=catalog', false, '');
expect(lines[0]).toBe('launch: unsigned iframe: no');
expect(lines[1]).toBe('vk-platform: —');
expect(lines[2]).toBe('params: utm_source');
expect(lines[3]).toBe('missing: vk_app_id,vk_user_id,vk_ts,vk_platform,sign');
expect(lines[4]).toBe('referrer: —');
});
it('reports a signed launch and never leaks the sign value, only its name', () => {
const lines = vkDiagLines(
'vk_app_id=6736218&vk_platform=mobile_android&vk_user_id=42&vk_ts=1700000000&sign=SECRETSIG',
true,
'vk.com',
);
expect(lines[0]).toBe('launch: signed iframe: yes');
expect(lines[1]).toBe('vk-platform: mobile_android');
expect(lines[3]).toBe('missing: —');
expect(lines[4]).toBe('referrer: vk.com');
// The signature is auth material: its NAME may appear, its VALUE must never.
const report = lines.join('\n');
expect(report).toContain('sign');
expect(report).not.toContain('SECRETSIG');
});
});
describe('vkLaunchDiag', () => {
afterEach(() => vi.unstubAllGlobals());
it('builds a VK launch diagnostic with no Retry (VK signs the URL at load, nothing to wait for)', () => {
vi.stubGlobal('location', { pathname: '/vk/', search: '?utm_source=catalog' });
const d = vkLaunchDiag();
expect(d.platform).toBe('vk');
expect(d.retry).toBe(false);
expect(d.report).toContain('launch: unsigned');
});
});
describe('appearanceForBg', () => {
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
expect(appearanceForBg('#0f1115')).toBe('dark');
+61
View File
@@ -6,6 +6,7 @@
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
// call outside VK.
import { clientEnvLines, queryFieldNames, type LaunchDiag } from './launchdiag';
import { isExternalHttpUrl, type Haptic } from './telegram';
async function bridge() {
@@ -108,6 +109,66 @@ export function vkPlatform(): string {
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? '';
}
// --- Launch diagnostics (the /vk/ entry without a signed launch) ---
/** The launch parameters a valid VK Mini App launch is expected to carry in the URL; their absence —
* notably `sign` — is the signal the launch-error screen reports. Only names are inspected. */
const expectedVKParams = ['vk_app_id', 'vk_user_id', 'vk_ts', 'vk_platform', 'sign'];
/**
* vkDiagLines formats the URL-derived lines of the VK launch diagnostic from a query string (no
* leading '?') and two runtime flags. It reports only the launch-parameter NAMES present and the
* expected ones missing — never a value, since `sign` is auth material — plus the non-secret
* `vk_platform` and whether the URL carried a signature at all. Pure, so it is unit-tested.
*/
export function vkDiagLines(search: string, inIframe: boolean, referrerHost: string): string[] {
const names = queryFieldNames(search);
const platform = new URLSearchParams(search).get('vk_platform') ?? '';
const missing = expectedVKParams.filter((p) => !names.includes(p));
return [
`launch: ${names.includes('sign') ? 'signed' : 'unsigned'} iframe: ${inIframe ? 'yes' : 'no'}`,
`vk-platform: ${platform || '—'}`,
`params: ${names.join(',') || '—'}`,
`missing: ${missing.join(',') || '—'}`,
`referrer: ${referrerHost || '—'}`,
];
}
/** vkInIframe reports whether the app runs inside an iframe (VK desktop frames the Mini App; a plain
* browser tab is top-level). A cross-origin top frame denies property access, which itself means we
* are framed, so that is caught as true. */
function vkInIframe(): boolean {
if (typeof window === 'undefined') return false;
try {
return window.self !== window.top;
} catch {
return true;
}
}
/** vkReferrerHost returns the host of document.referrer (a real VK launch is referred from vk.com; a
* direct type-in has none), or '' when there is no referrer or it does not parse. */
function vkReferrerHost(): string {
if (typeof document === 'undefined' || !document.referrer) return '';
try {
return new URL(document.referrer).host;
} catch {
return '';
}
}
/**
* vkLaunchDiag captures the current VK launch state as a neutral LaunchDiag for the shared LaunchError
* screen — a passive read of the URL launch parameters (names only), the iframe / referrer context and
* the shared client environment. retry is false: VK delivers its signed parameters in the launch URL at
* load, so — unlike Telegram's initData — they never arrive late, and a Retry could not recover them.
*/
export function vkLaunchDiag(): LaunchDiag {
const search = typeof location === 'undefined' ? '' : location.search.replace(/^\?/, '');
const report = [...vkDiagLines(search, vkInIframe(), vkReferrerHost()), ...clientEnvLines()].join('\n');
return { platform: 'vk', report, retry: false };
}
/**
* vkAndroidWebView reports whether the app runs inside the Android VK client's WebView
* (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView
@@ -1,34 +1,20 @@
<script lang="ts">
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
// Shown on a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch — instead of
// bouncing to the marketing landing (Telegram) or silently proceeding as a guest (VK). It states the
// problem plainly and renders a compact, privacy-safe diagnostic snapshot (app.launchError, taken at
// the moment of failure) sized to fit one screenshot, with a Share button (the OS share sheet, or a
// clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why the launch
// carried no sign-in data. The snapshot carries only presence / identification signals and field
// NAMES, never the signed launch data itself, nor an IP. Retry is shown only where a late-arriving
// launch can still succeed (Telegram initData); VK delivers its signed parameters in the URL at load,
// so it offers Share only.
import { app, retryTelegramLaunch } from '../lib/app.svelte';
import { shareText } from '../lib/share';
import { t } from '../lib/i18n/index.svelte';
const diag = $derived(app.launchError);
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
// prose), so the developer reads the same report in any locale. Kept terse to fit one screenshot.
const report = $derived(
diag
? [
`sdk-load: ${diag.sdkLoad}`,
`sdk: ${diag.hasSDK ? 'yes' : 'no'} webapp: ${diag.hasWebApp ? 'yes' : 'no'}`,
`tg-platform: ${diag.platform || '—'} tg-version: ${diag.version || '—'}`,
`initData: ${diag.initDataLen > 0 ? diag.initDataLen : 'empty'} tgdata-in-url: ${diag.hashHadTgData ? 'yes' : 'no'}`,
`fields: ${diag.fieldsPresent.join(',') || '—'}`,
`missing: ${diag.fieldsMissing.join(',') || '—'}`,
`os: ${diag.osPlatform || '—'} mobile: ${diag.mobile || '—'}`,
`browser: ${diag.browser || '—'}`,
`ua: ${diag.userAgent || '—'}`,
].join('\n')
: '',
);
// The title names the platform the entry belongs to; the body and the report are platform-neutral.
const title = $derived(t(diag?.platform === 'vk' ? 'launch.errorTitleVk' : 'launch.errorTitle'));
let retrying = $state(false);
async function retry(): Promise<void> {
@@ -44,7 +30,8 @@
let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;
async function share(): Promise<void> {
const r = await shareText(report, t('launch.errorTitle'));
if (!diag) return;
const r = await shareText(diag.report, title);
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
if (r === 'copied') {
copied = true;
@@ -57,12 +44,14 @@
{#if diag}
<div class="boot">
<div class="card">
<h1>{t('launch.errorTitle')}</h1>
<h1>{title}</h1>
<p class="msg">{t('launch.errorBody')}</p>
<pre class="diag">{report}</pre>
<pre class="diag">{diag.report}</pre>
<div class="actions">
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
{#if diag.retry}
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
{/if}
</div>
</div>
</div>