Compare commits
34 Commits
7e142d471c
...
v1.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ce9f7e9b4 | |||
| 522beec82e | |||
| 3b485883ee | |||
| efeed17abc | |||
| cc34622630 | |||
| f6d256215a | |||
| fe5a3d6d3b | |||
| 3456a0b3ff | |||
| a41281c495 | |||
| 516ffbe5f0 | |||
| 6badc20078 | |||
| 0ca01133b5 | |||
| 45f0b34881 | |||
| 18785efc8c | |||
| 780ff68ec2 | |||
| 57ff2d03f8 | |||
| a9d0986e74 | |||
| 45957bdcd6 | |||
| 829e29a726 | |||
| 399508f2f0 | |||
| 3a18e683ca | |||
| 93d086a8a3 | |||
| 8fe1bdba6b | |||
| 7923b3cc09 | |||
| 4891216749 | |||
| f1b8769c89 | |||
| b6f28a2423 | |||
| e32ee9ce68 | |||
| dc946a1faf | |||
| 384bd143d0 | |||
| c5d22fceca | |||
| deaa7a29c5 | |||
| 24017bcb7f | |||
| 2c4f4b10dc |
@@ -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
|
`/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
|
— 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
|
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
|
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,
|
`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
|
`deploy/landing/Caddyfile`) serves it at `/`, so stray public traffic is absorbed by
|
||||||
|
|||||||
+6
-2
@@ -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
|
`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
|
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
|
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
|
**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
|
(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
|
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
|
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
|
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
|
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 —
|
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
|
without a full account block; an account block mutes them in the chat regardless. Muting and unmuting
|
||||||
|
|||||||
@@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
|||||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||||
тихого повтора «не удалось
|
тихого повтора «не удалось
|
||||||
загрузить» действует и внутри VK.
|
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||||
|
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||||
|
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||||
|
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||||
@@ -552,7 +555,9 @@ high-rate флага. С карточки пользователя операт
|
|||||||
|
|
||||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||||
|
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||||
|
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||||
|
|||||||
+2
-1
@@ -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
|
Rate-limit defaults (built-in): public 30/min·IP (burst 10), authenticated
|
||||||
300/min·user (burst 80, sized for multi-device play), admin
|
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),
|
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}`
|
Every rejection increments `gateway_rate_limited_total{class}`
|
||||||
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
(`user`/`public`/`email`/`admin`) and logs one Debug line; a reporter drains the
|
||||||
|
|||||||
@@ -165,7 +165,12 @@ func DefaultRateLimit() RateLimitConfig {
|
|||||||
// because multi-device play tripped the old 120/40.
|
// because multi-device play tripped the old 120/40.
|
||||||
UserPerMinute: 300, UserBurst: 80,
|
UserPerMinute: 300, UserBurst: 80,
|
||||||
AdminPerMinute: 60, AdminBurst: 20,
|
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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"scrabble/gateway/internal/config"
|
||||||
"scrabble/gateway/internal/ratelimit"
|
"scrabble/gateway/internal/ratelimit"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -44,3 +45,20 @@ func TestPerWindow(t *testing.T) {
|
|||||||
t.Fatalf("per-window burst = %v, want [true true false]", got)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
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
|
`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
|
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
|
- **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 (the
|
||||||
|
|||||||
@@ -285,6 +285,12 @@ func (t *Bot) logChatAdminStatus(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
t.log.Info("chat gating ready: bot is an admin with the restrict-members right", zap.Int64("chat_id", t.chatID))
|
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
|
// 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)
|
t.handleSuccessfulPayment(ctx, update.Message)
|
||||||
return
|
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 —
|
// 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
|
// 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.
|
// 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)
|
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
|
// SetEligibilityResolver wires the chat-eligibility resolver after construction (the
|
||||||
// bot-link client backing it is built after the bot).
|
// bot-link client backing it is built after the bot).
|
||||||
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
func (t *Bot) SetEligibilityResolver(resolve EligibilityResolver) {
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// chatAPI is a fake Bot API for the chat-gating tests: it answers getMe, returns a
|
// 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 {
|
type chatAPI struct {
|
||||||
memberStatus string // the status getChatMember reports (default "left")
|
memberStatus string // the status getChatMember reports (default "left")
|
||||||
restricts []restrictCall
|
restricts []restrictCall
|
||||||
|
unpins []unpinCall
|
||||||
}
|
}
|
||||||
|
|
||||||
type restrictCall struct {
|
type restrictCall struct {
|
||||||
@@ -30,6 +31,13 @@ type restrictCall struct {
|
|||||||
canSend bool // can_send_messages in the applied permissions
|
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) {
|
func (a *chatAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
switch {
|
switch {
|
||||||
case strings.HasSuffix(r.URL.Path, "/getMe"):
|
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)
|
_ = json.Unmarshal([]byte(r.FormValue("permissions")), &perms)
|
||||||
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
a.restricts = append(a.restricts, restrictCall{userID: r.FormValue("user_id"), canSend: perms.CanSendMessages})
|
||||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
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:
|
default:
|
||||||
io.WriteString(w, `{"ok":true,"result":true}`)
|
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
@@ -25,7 +25,7 @@
|
|||||||
import Blocked from './screens/Blocked.svelte';
|
import Blocked from './screens/Blocked.svelte';
|
||||||
import AccountDeleted from './screens/AccountDeleted.svelte';
|
import AccountDeleted from './screens/AccountDeleted.svelte';
|
||||||
import BootError from './screens/BootError.svelte';
|
import BootError from './screens/BootError.svelte';
|
||||||
import TelegramLaunchError from './screens/TelegramLaunchError.svelte';
|
import LaunchError from './screens/LaunchError.svelte';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Tell the index.html boot guard that startup completed, so its reactive net does not raise the
|
// 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>
|
<div class="splash">{t('common.loading')}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if app.launchError}
|
{:else if app.launchError}
|
||||||
<!-- The /telegram/ entry without sign-in data: a compact, shareable diagnostic screen instead
|
<!-- A dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch: a compact,
|
||||||
of bouncing to the marketing landing (also the probe for the empty-initData failure on
|
shareable diagnostic screen instead of bouncing to the marketing landing (Telegram) or
|
||||||
some Android clients). -->
|
silently proceeding as a guest (VK). Also the probe for the empty-initData failure on some
|
||||||
<TelegramLaunchError />
|
Android Telegram clients. -->
|
||||||
|
<LaunchError />
|
||||||
{:else if app.bootError}
|
{:else if app.bootError}
|
||||||
<!-- A Mini App launch that failed to authenticate (e.g. the backend was down mid-deploy):
|
<!-- 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. -->
|
show the retry screen instead of falling back to the web login. -->
|
||||||
|
|||||||
+22
-12
@@ -14,8 +14,7 @@ import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type
|
|||||||
import {
|
import {
|
||||||
insideTelegram,
|
insideTelegram,
|
||||||
telegramClose,
|
telegramClose,
|
||||||
collectTelegramDiag,
|
telegramLaunchDiag,
|
||||||
type TelegramDiag,
|
|
||||||
onTelegramPath,
|
onTelegramPath,
|
||||||
hasLaunchFragment,
|
hasLaunchFragment,
|
||||||
loadTelegramSDK,
|
loadTelegramSDK,
|
||||||
@@ -33,7 +32,8 @@ import {
|
|||||||
telegramCloudGet,
|
telegramCloudGet,
|
||||||
telegramCloudSet,
|
telegramCloudSet,
|
||||||
} from './telegram';
|
} 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 { pendingVKLink, type VKLinkCallback } from './vkid';
|
||||||
import { isStandalone } from './pwa';
|
import { isStandalone } from './pwa';
|
||||||
import { registerServiceWorker } from './pwa.svelte';
|
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
|
* 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. */
|
* instead of the web login — a Mini App has no manual sign-in to fall back to. */
|
||||||
bootError: boolean;
|
bootError: boolean;
|
||||||
/** On the dedicated /telegram/ entry, set to a privacy-safe diagnostic snapshot when a Mini App
|
/** On a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch, set to a
|
||||||
* launch carried no sign-in data (empty initData). App.svelte then renders the compact
|
* privacy-safe diagnostic snapshot. App.svelte then renders the compact, shareable launch-error
|
||||||
* launch-error screen (screens/TelegramLaunchError) — a shareable probe for why Telegram
|
* screen (screens/LaunchError) — a probe for why Telegram delivered no initData (seen on some
|
||||||
* delivered no initData (seen on some Android clients) — instead of bouncing to the landing. */
|
* Android clients), or why /vk/ carried no signed launch — instead of bouncing to the landing
|
||||||
launchError: TelegramDiag | null;
|
* (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
|
/** 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. */
|
* the header title ten times in quick succession. A support aid; carries no secrets. */
|
||||||
debugOpen: boolean;
|
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
|
// 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.
|
// with the developer, instead of bouncing the visitor to the marketing landing.
|
||||||
if (onTelegramPath() && !insideTelegram()) {
|
if (onTelegramPath() && !insideTelegram()) {
|
||||||
app.launchError = collectTelegramDiag();
|
app.launchError = telegramLaunchDiag();
|
||||||
app.ready = true;
|
app.ready = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -875,10 +876,19 @@ export async function bootstrap(): Promise<void> {
|
|||||||
return;
|
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
|
// 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
|
// 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
|
// VKWebAppGetUserInfo, since VK omits it from the signed params.
|
||||||
// (no signed params — e.g. a developer hitting the URL directly) falls through to the web flow.
|
|
||||||
if (onVKPath() && insideVK()) {
|
if (onVKPath() && insideVK()) {
|
||||||
// Follow the VK client's light/dark appearance while the user keeps the app's "auto" theme (the
|
// 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).
|
// 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.
|
// Re-attempt the SDK load — the network may have recovered since the launch-error screen showed.
|
||||||
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
|
await loadTelegramSDK(TELEGRAM_SDK_TIMEOUT_MS);
|
||||||
if (!insideTelegram()) {
|
if (!insideTelegram()) {
|
||||||
app.launchError = collectTelegramDiag();
|
app.launchError = telegramLaunchDiag();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
app.launchError = null;
|
app.launchError = null;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const en = {
|
|||||||
'boot.errorBody': 'Please try again in a moment.',
|
'boot.errorBody': 'Please try again in a moment.',
|
||||||
|
|
||||||
'launch.errorTitle': "Can't open in Telegram",
|
'launch.errorTitle': "Can't open in Telegram",
|
||||||
|
'launch.errorTitleVk': "Can't open in VK",
|
||||||
'launch.errorBody': 'Screenshot or share this with the developer.',
|
'launch.errorBody': 'Screenshot or share this with the developer.',
|
||||||
'launch.share': 'Share',
|
'launch.share': 'Share',
|
||||||
'launch.copied': 'Copied',
|
'launch.copied': 'Copied',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
||||||
|
|
||||||
'launch.errorTitle': 'Не открывается в Telegram',
|
'launch.errorTitle': 'Не открывается в Telegram',
|
||||||
|
'launch.errorTitleVk': 'Не открывается в VK',
|
||||||
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
|
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
|
||||||
'launch.share': 'Поделиться',
|
'launch.share': 'Поделиться',
|
||||||
'launch.copied': 'Скопировано',
|
'launch.copied': 'Скопировано',
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,10 +28,15 @@ describe('toGatewayError', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('retryable', () => {
|
describe('retryable', () => {
|
||||||
it('retries any op on a rate-limit rejection (it never reached the backend)', () => {
|
it('does not auto-retry a rate-limit rejection (a deliberate throttle, surfaced not spun)', () => {
|
||||||
expect(retryable('rate_limited', 'game.submit_play')).toBe(true);
|
// A rate-limit is the server saying "slow down": auto-retrying cannot succeed until the bucket
|
||||||
expect(retryable('rate_limited', 'games.list')).toBe(true);
|
// refills, only freezes the calling button for the whole backoff and feeds the gateway's ban
|
||||||
expect(retryable('rate_limited', 'chat.post')).toBe(true);
|
// 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)', () => {
|
it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => {
|
||||||
@@ -53,9 +58,12 @@ describe('retryable', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('isConnectionCode', () => {
|
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('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('not_your_turn')).toBe(false);
|
||||||
expect(isConnectionCode('internal')).toBe(false);
|
expect(isConnectionCode('internal')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
+16
-11
@@ -2,11 +2,14 @@
|
|||||||
// fails at the transport level the app retries it with capped exponential backoff while showing
|
// 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.
|
// 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
|
// Retry policy: a transport 'unavailable' is ambiguous for a mutation (its response could have been
|
||||||
// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have
|
// lost after the backend applied it), so only **read-only** ops are auto-retried on 'unavailable'; a
|
||||||
// been lost after the backend applied it), so only **read-only** ops are auto-retried on
|
// mutation is surfaced instead (its button is disabled while offline and re-enables on reconnect, so
|
||||||
// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and
|
// the player re-issues it deliberately). A rate-limit rejection (ResourceExhausted) is NOT retried and
|
||||||
// re-enables on reconnect, so the player re-issues it deliberately).
|
// 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 { Code, ConnectError } from '@connectrpc/connect';
|
||||||
import { GatewayError } from './client';
|
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
|
* retryable reports whether a failed op should be auto-retried. A transport 'unavailable' is retried
|
||||||
* safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for
|
* only for read-only ops, never a mutation; every other code — a rate-limit (a deliberate throttle,
|
||||||
* read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final.
|
* surfaced not spun), a domain rejection, not-found, … — is final.
|
||||||
*/
|
*/
|
||||||
export function retryable(code: string, op: string): boolean {
|
export function retryable(code: string, op: string): boolean {
|
||||||
if (code === 'rate_limited') return true;
|
|
||||||
if (code === 'unavailable') return READ_OPS.has(op);
|
if (code === 'unavailable') return READ_OPS.has(op);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting
|
/** 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 {
|
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
|
/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a
|
||||||
|
|||||||
+24
-46
@@ -4,6 +4,7 @@
|
|||||||
// the app uses: launch detection, initData (for auth.telegram), the deep-link start parameter,
|
// 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.
|
// 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';
|
import type { TelegramThemeParams } from './theme';
|
||||||
|
|
||||||
/** TelegramPopupButton is one button of a native showPopup (Bot API 6.2). */
|
/** 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. */
|
* the launch-error screen reports. Only field names are ever inspected, never their values. */
|
||||||
const expectedInitDataFields = ['user', 'auth_date', 'hash', 'signature'];
|
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
|
/** 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
|
* 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. */
|
* 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
|
* 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
|
* 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[];
|
fieldsPresent: string[];
|
||||||
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
|
/** The expected field names absent from the launch data (a subset of expectedInitDataFields). */
|
||||||
fieldsMissing: string[];
|
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
|
* 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 —
|
* 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 {
|
export function collectTelegramDiag(): TelegramDiag {
|
||||||
const w = webApp();
|
const w = webApp();
|
||||||
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
|
const sdk = typeof window !== 'undefined' && !!(window as unknown as { Telegram?: unknown }).Telegram;
|
||||||
const initData = w?.initData ?? '';
|
const initData = w?.initData ?? '';
|
||||||
const fragData = initData ? '' : launchFragmentData();
|
const fragData = initData ? '' : launchFragmentData();
|
||||||
const present = initDataFieldNames(initData || fragData);
|
const present = queryFieldNames(initData || fragData);
|
||||||
const ua = uaData();
|
|
||||||
const navPlatform =
|
|
||||||
typeof navigator === 'undefined' ? '' : (navigator as unknown as { platform?: string }).platform ?? '';
|
|
||||||
return {
|
return {
|
||||||
hasSDK: sdk,
|
hasSDK: sdk,
|
||||||
hasWebApp: !!w,
|
hasWebApp: !!w,
|
||||||
@@ -576,13 +538,29 @@ export function collectTelegramDiag(): TelegramDiag {
|
|||||||
hashHadTgData: fragData.length > 0,
|
hashHadTgData: fragData.length > 0,
|
||||||
fieldsPresent: present,
|
fieldsPresent: present,
|
||||||
fieldsMissing: expectedInitDataFields.filter((f) => !present.includes(f)),
|
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
|
* 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,
|
* (platform, version, fullscreen/expanded, viewport geometry, safe-area insets, SDK-load outcome,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
routeExternalLinkInVK,
|
routeExternalLinkInVK,
|
||||||
vkAndroidWebView,
|
vkAndroidWebView,
|
||||||
vkAppId,
|
vkAppId,
|
||||||
|
vkDiagLines,
|
||||||
vkExternalBrowserUrl,
|
vkExternalBrowserUrl,
|
||||||
|
vkLaunchDiag,
|
||||||
vkLaunchParams,
|
vkLaunchParams,
|
||||||
vkStartParam,
|
vkStartParam,
|
||||||
appearanceForBg,
|
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', () => {
|
describe('appearanceForBg', () => {
|
||||||
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
|
it('maps a dark background to the dark appearance (light status-bar icons)', () => {
|
||||||
expect(appearanceForBg('#0f1115')).toBe('dark');
|
expect(appearanceForBg('#0f1115')).toBe('dark');
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
// (VKWebAppGetUserInfo, since VK omits it from the signed launch params). Every helper is safe to
|
||||||
// call outside VK.
|
// call outside VK.
|
||||||
|
|
||||||
|
import { clientEnvLines, queryFieldNames, type LaunchDiag } from './launchdiag';
|
||||||
import { isExternalHttpUrl, type Haptic } from './telegram';
|
import { isExternalHttpUrl, type Haptic } from './telegram';
|
||||||
|
|
||||||
async function bridge() {
|
async function bridge() {
|
||||||
@@ -108,6 +109,66 @@ export function vkPlatform(): string {
|
|||||||
return new URLSearchParams(location.search.replace(/^\?/, '')).get('vk_platform') ?? '';
|
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
|
* 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
|
* (vk_platform mobile_android / mobile_android_messenger) — the one environment whose WebView
|
||||||
|
|||||||
@@ -1,34 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
// Shown on the dedicated /telegram/ entry when a Mini App launch carried no sign-in data (empty
|
// Shown on a dedicated Mini App entry (/telegram/ or /vk/) opened without a valid launch — instead of
|
||||||
// initData) — instead of bouncing the visitor to the marketing landing. It states the problem
|
// bouncing to the marketing landing (Telegram) or silently proceeding as a guest (VK). It states the
|
||||||
// plainly and renders a compact, privacy-safe diagnostic snapshot (taken at the moment of
|
// problem plainly and renders a compact, privacy-safe diagnostic snapshot (app.launchError, taken at
|
||||||
// failure, app.launchError) sized to fit one screenshot, with a Share button (the OS share sheet,
|
// the moment of failure) sized to fit one screenshot, with a Share button (the OS share sheet, or a
|
||||||
// or a clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why
|
// clipboard copy on desktop) so a stuck user can send it to the developer to pinpoint why the launch
|
||||||
// Telegram provided no initData — notably on some Android clients. The snapshot carries only
|
// carried no sign-in data. The snapshot carries only presence / identification signals and field
|
||||||
// presence / identification signals and field NAMES, never the signed initData itself, nor an IP.
|
// 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 { app, retryTelegramLaunch } from '../lib/app.svelte';
|
||||||
import { shareText } from '../lib/share';
|
import { shareText } from '../lib/share';
|
||||||
import { t } from '../lib/i18n/index.svelte';
|
import { t } from '../lib/i18n/index.svelte';
|
||||||
|
|
||||||
const diag = $derived(app.launchError);
|
const diag = $derived(app.launchError);
|
||||||
|
// The title names the platform the entry belongs to; the body and the report are platform-neutral.
|
||||||
// One compact "key: value" line per fact; the field labels are fixed diagnostic tokens (not UI
|
const title = $derived(t(diag?.platform === 'vk' ? 'launch.errorTitleVk' : 'launch.errorTitle'));
|
||||||
// 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')
|
|
||||||
: '',
|
|
||||||
);
|
|
||||||
|
|
||||||
let retrying = $state(false);
|
let retrying = $state(false);
|
||||||
async function retry(): Promise<void> {
|
async function retry(): Promise<void> {
|
||||||
@@ -44,7 +30,8 @@
|
|||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
let copyTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
async function share(): Promise<void> {
|
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.
|
// The OS share sheet is its own feedback; a desktop clipboard copy is silent, so confirm it.
|
||||||
if (r === 'copied') {
|
if (r === 'copied') {
|
||||||
copied = true;
|
copied = true;
|
||||||
@@ -57,12 +44,14 @@
|
|||||||
{#if diag}
|
{#if diag}
|
||||||
<div class="boot">
|
<div class="boot">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>{t('launch.errorTitle')}</h1>
|
<h1>{title}</h1>
|
||||||
<p class="msg">{t('launch.errorBody')}</p>
|
<p class="msg">{t('launch.errorBody')}</p>
|
||||||
<pre class="diag">{report}</pre>
|
<pre class="diag">{diag.report}</pre>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
|
<button class="share" onclick={share}>{copied ? t('launch.copied') : t('launch.share')}</button>
|
||||||
|
{#if diag.retry}
|
||||||
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
|
<button class="retry" onclick={retry} disabled={retrying}>{t('common.retry')}</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
Reference in New Issue
Block a user