Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39e03d22e4 | |||
| d8d1b06eee | |||
| c16008e67a | |||
| ff55d5de83 | |||
| aaf162de40 | |||
| 7cc2b50d23 | |||
| eb7fa98426 | |||
| 81d5383c42 | |||
| b6af682381 | |||
| ed79c30d2a | |||
| 471fadc6a4 | |||
| 564abdcf88 | |||
| c522e77599 | |||
| 28afcff551 | |||
| c34e2a8dbf | |||
| 25381f70a3 | |||
| 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 |
@@ -21,7 +21,17 @@
|
||||
<div><button type="submit">Add</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Rewarded ads (watch for chips)</h2>
|
||||
<p class="note">The chips a player earns per rewarded-video view (VK only), plus the per-day and per-hour caps that bound free chips — <strong>0 payout turns rewarded off</strong>. This is the shared reward config, not a product.</p>
|
||||
<form class="form col" method="post" action="/_gm/catalog/reward">
|
||||
<label>Chips per view <input type="number" name="reward_payout" min="0" value="{{.RewardPayout}}"></label>
|
||||
<label>Daily cap <input type="number" name="reward_daily" min="0" value="{{.RewardDailyCap}}"></label>
|
||||
<label>Hourly cap <input type="number" name="reward_hourly" min="0" value="{{.RewardHourlyCap}}"></label>
|
||||
<div><button type="submit">Save</button></div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="panel"><h2>Products</h2>
|
||||
<p class="note">Showing {{if .ShowAll}}<strong>all</strong> products (active + archived) — <a href="/_gm/catalog">active only</a>{{else}}<strong>active</strong> products — <a href="/_gm/catalog?all=1">show all (incl. archived)</a>{{end}}.</p>
|
||||
<table class="list">
|
||||
<thead><tr><th>Title</th><th>Status</th><th>Atoms</th><th>Prices</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
|
||||
@@ -677,6 +677,15 @@ type FeedbackDetailView struct {
|
||||
// CatalogView is the product-catalog list page.
|
||||
type CatalogView struct {
|
||||
Products []ProductRow
|
||||
// ShowAll reports whether archived products are listed alongside active ones (the active/all
|
||||
// toggle); it drives the toggle link and the list heading.
|
||||
ShowAll bool
|
||||
// RewardPayout / RewardDailyCap / RewardHourlyCap are the rewarded-video config (chips earned per
|
||||
// view and the per-day / per-hour anti-abuse caps), shown in and edited from the page's
|
||||
// rewarded-ads form. A 0 payout means rewarded is inert.
|
||||
RewardPayout int
|
||||
RewardDailyCap int
|
||||
RewardHourlyCap int
|
||||
}
|
||||
|
||||
// ProductRow is one product in the catalog list: its composition, prices, the archived flag
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
// productByTitle finds a catalog product by its (unique in the test) title.
|
||||
func productByTitle(t *testing.T, pay *payments.Service, title string) payments.AdminProduct {
|
||||
t.Helper()
|
||||
all, err := pay.AdminCatalog(context.Background())
|
||||
all, err := pay.AdminCatalog(context.Background(), true)
|
||||
if err != nil {
|
||||
t.Fatalf("admin catalog: %v", err)
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
||||
if code, body := consoleDo(h, http.MethodPost, catalog, "title=cat-bad&chips=100&active=true", origin); code != http.StatusOK || !strings.Contains(body, "Invalid product") {
|
||||
t.Fatalf("bad pack = %d, has 'Invalid product' = %v", code, strings.Contains(body, "Invalid product"))
|
||||
}
|
||||
if _, err := pay.AdminCatalog(ctx); err != nil {
|
||||
if _, err := pay.AdminCatalog(ctx, true); err != nil {
|
||||
t.Fatalf("catalog: %v", err)
|
||||
}
|
||||
|
||||
@@ -95,3 +95,59 @@ func TestConsoleCatalogEditor(t *testing.T) {
|
||||
t.Fatalf("delete transacted = %d, has 'Cannot delete' = %v", code, strings.Contains(body, "Cannot delete"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleCatalogActiveToggle checks the catalog console lists only active products by default and
|
||||
// includes the archived ones under ?all=1 (the active/all toggle).
|
||||
func TestConsoleCatalogActiveToggle(t *testing.T) {
|
||||
srv, _, _ := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const catalog = "http://admin.test/_gm/catalog"
|
||||
|
||||
// An active value and an archived one (created without the active flag).
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-active&hints=5&price_chip=50&active=true", origin); code != http.StatusOK {
|
||||
t.Fatal("create active failed")
|
||||
}
|
||||
if code, _ := consoleDo(h, http.MethodPost, catalog, "title=toggle-archived&hints=9&price_chip=90", origin); code != http.StatusOK {
|
||||
t.Fatal("create archived failed")
|
||||
}
|
||||
|
||||
// Default view: active only.
|
||||
if _, body := consoleDo(h, http.MethodGet, catalog, "", ""); !strings.Contains(body, "toggle-active") || strings.Contains(body, "toggle-archived") {
|
||||
t.Errorf("default view: active listed=%v, archived listed=%v (want true/false)",
|
||||
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||
}
|
||||
|
||||
// ?all=1: active and archived.
|
||||
if _, body := consoleDo(h, http.MethodGet, catalog+"?all=1", "", ""); !strings.Contains(body, "toggle-active") || !strings.Contains(body, "toggle-archived") {
|
||||
t.Errorf("all view: active listed=%v, archived listed=%v (want true/true)",
|
||||
strings.Contains(body, "toggle-active"), strings.Contains(body, "toggle-archived"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleRewardConfig checks the rewarded-ads config form on the catalog page: a POST updates the
|
||||
// payout and caps, the page pre-fills the current values, and a negative value is refused.
|
||||
func TestConsoleRewardConfig(t *testing.T) {
|
||||
srv, _, pay := bannerServer(t)
|
||||
h := srv.Handler()
|
||||
const origin = "http://admin.test"
|
||||
const reward = "http://admin.test/_gm/catalog/reward"
|
||||
|
||||
// Set the payout and caps.
|
||||
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=7&reward_daily=40&reward_hourly=9", origin); code != http.StatusOK || !strings.Contains(body, "Saved") {
|
||||
t.Fatalf("set reward = %d, has 'Saved' = %v", code, strings.Contains(body, "Saved"))
|
||||
}
|
||||
if payout, daily, hourly, err := pay.RewardConfig(context.Background()); err != nil || payout != 7 || daily != 40 || hourly != 9 {
|
||||
t.Fatalf("reward config = %d/%d/%d (err %v), want 7/40/9", payout, daily, hourly, err)
|
||||
}
|
||||
|
||||
// The catalog page renders the form pre-filled with the current payout.
|
||||
if _, body := consoleDo(h, http.MethodGet, "http://admin.test/_gm/catalog", "", ""); !strings.Contains(body, "reward_payout") || !strings.Contains(body, `value="7"`) {
|
||||
t.Error("catalog page did not render the reward form with the current payout")
|
||||
}
|
||||
|
||||
// A negative value is refused (the service validates non-negativity).
|
||||
if code, body := consoleDo(h, http.MethodPost, reward, "reward_payout=-1", origin); code != http.StatusOK || !strings.Contains(body, "non-negative") {
|
||||
t.Errorf("negative payout = %d, has 'non-negative' = %v", code, strings.Contains(body, "non-negative"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,11 @@ const atomChips = "chips"
|
||||
// method are omitted (misconfigured or unavailable there). An untrusted context (empty Kind) has
|
||||
// no method, so only values show; buying is gated server-side regardless.
|
||||
func projectCatalog(entries []catalogEntry, cxt Context) CatalogView {
|
||||
// Present products in the canonical listing order shared with the offer and the admin console
|
||||
// (packs first by rouble price, then values grouped hints → no-ads → combo and by chip price); the
|
||||
// client renders them as received. Ordered in place — the caller passes a fresh loadCatalog result
|
||||
// it does not reuse.
|
||||
sortCatalogEntries(entries)
|
||||
var out CatalogView
|
||||
for _, e := range entries {
|
||||
isPack := false
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -144,10 +143,12 @@ func validateProduct(in ProductInput, sellable bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdminCatalog lists every product (active and archived) with its composition, prices and whether it
|
||||
// has been transacted, for the admin editor. Read uncached, straight from the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx)
|
||||
// AdminCatalog lists products with their composition, prices and whether each has been transacted,
|
||||
// for the admin editor. includeInactive adds the archived products to the active ones (the console's
|
||||
// active/all toggle); with it false only active products are returned. Read uncached, straight from
|
||||
// the catalog tables.
|
||||
func (s *Service) AdminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||
return s.store.adminCatalog(ctx, includeInactive)
|
||||
}
|
||||
|
||||
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
|
||||
@@ -157,18 +158,7 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
// products the same as active ones (the admin list shows both).
|
||||
func SortAdminCatalog(products []AdminProduct) {
|
||||
slices.SortStableFunc(products, func(a, b AdminProduct) int {
|
||||
if pa, pb := adminIsPack(a), adminIsPack(b); pa != pb {
|
||||
if pa {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
} else if pa {
|
||||
return cmp.Compare(adminPriceAmount(a, string(SourceDirect), CurrencyRUB), adminPriceAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
}
|
||||
if d := cmp.Compare(adminValueGroup(a), adminValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(adminPriceAmount(a, "", CurrencyChip), adminPriceAmount(b, "", CurrencyChip))
|
||||
return compareCatalogRank(adminRank(a), adminRank(b))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// catalogRank is the canonical listing key for a catalog product: whether it is a chip pack, its
|
||||
// value subgroup (see [valueGroup]; unused for a pack) and the minor-unit amount its section sorts by
|
||||
// (a pack's direct rouble price, a value's chip price; math.MaxInt64 when absent, so a misconfigured
|
||||
// row sorts last rather than leading).
|
||||
type catalogRank struct {
|
||||
pack bool
|
||||
group int
|
||||
amount int64
|
||||
}
|
||||
|
||||
// compareCatalogRank is the single canonical product order, shared by the storefront ([projectCatalog]),
|
||||
// the public offer ([projectOfferPricing]) and the admin catalog ([SortAdminCatalog]): chip packs
|
||||
// first, ascending by rouble price; then chip-priced values grouped hints → no-ads → combo →
|
||||
// tournament and, within a group, ascending by chip price. A pack's group is unused (all packs share
|
||||
// one section). Callers use it through [entryRank] / [adminRank], which build the key from each
|
||||
// product shape, so the subgroup and ordering rules live in exactly one place.
|
||||
func compareCatalogRank(a, b catalogRank) int {
|
||||
if a.pack != b.pack {
|
||||
if a.pack {
|
||||
return -1 // packs (sales) before values (chip exchange)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !a.pack {
|
||||
if d := cmp.Compare(a.group, b.group); d != 0 {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return cmp.Compare(a.amount, b.amount)
|
||||
}
|
||||
|
||||
// entryRank builds the canonical rank of a storefront / offer catalog entry.
|
||||
func entryRank(e catalogEntry) catalogRank {
|
||||
if isPackEntry(e) {
|
||||
return catalogRank{pack: true, amount: offerSortAmount(e, string(SourceDirect), CurrencyRUB)}
|
||||
}
|
||||
return catalogRank{group: offerValueGroup(e), amount: offerSortAmount(e, "", CurrencyChip)}
|
||||
}
|
||||
|
||||
// adminRank builds the canonical rank of an admin catalog product.
|
||||
func adminRank(p AdminProduct) catalogRank {
|
||||
if adminIsPack(p) {
|
||||
return catalogRank{pack: true, amount: adminPriceAmount(p, string(SourceDirect), CurrencyRUB)}
|
||||
}
|
||||
return catalogRank{group: adminValueGroup(p), amount: adminPriceAmount(p, "", CurrencyChip)}
|
||||
}
|
||||
|
||||
// sortCatalogEntries orders storefront / offer entries in place into the canonical listing order
|
||||
// ([compareCatalogRank]). Stable, so equal-keyed products keep their incoming (creation) order.
|
||||
func sortCatalogEntries(entries []catalogEntry) {
|
||||
slices.SortStableFunc(entries, func(a, b catalogEntry) int {
|
||||
return compareCatalogRank(entryRank(a), entryRank(b))
|
||||
})
|
||||
}
|
||||
@@ -135,6 +135,56 @@ func TestProjectCatalog_Empty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjectCatalog_CanonicalOrder checks the storefront lists products in the shared canonical order
|
||||
// (compareCatalogRank): chip packs first ascending by rouble price, then chip-priced values grouped
|
||||
// hints → no-ads → combo and ascending by chip price — the same order as the offer and the admin
|
||||
// console, regardless of catalog creation order.
|
||||
func TestProjectCatalog_CanonicalOrder(t *testing.T) {
|
||||
pack := func(title string, rub int64) catalogEntry {
|
||||
return catalogEntry{
|
||||
id: uuid.New(),
|
||||
title: title,
|
||||
atoms: []atomQty{{atomType: atomChips, quantity: 1}},
|
||||
prices: []priceRow{{method: "direct", currency: CurrencyRUB, amount: rub}},
|
||||
}
|
||||
}
|
||||
value := func(title string, chips int64, atoms ...string) catalogEntry {
|
||||
e := catalogEntry{id: uuid.New(), title: title, prices: []priceRow{{method: "", currency: CurrencyChip, amount: chips}}}
|
||||
for _, a := range atoms {
|
||||
e.atoms = append(e.atoms, atomQty{atomType: a, quantity: 1})
|
||||
}
|
||||
return e
|
||||
}
|
||||
// Deliberately shuffled on input; the projection must impose the canonical order.
|
||||
entries := []catalogEntry{
|
||||
pack("packDear", 30000),
|
||||
value("bundle", 500, "hints", "noads_days"),
|
||||
pack("packCheap", 10000),
|
||||
value("adsOnly", 150, "noads_days"),
|
||||
value("hintsBig", 200, "hints"),
|
||||
value("hintsSmall", 50, "hints"),
|
||||
}
|
||||
got := projectCatalog(entries, Context{Kind: SourceDirect})
|
||||
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
|
||||
if len(got.Products) != len(want) {
|
||||
t.Fatalf("projected %d products, want %d", len(got.Products), len(want))
|
||||
}
|
||||
for i, p := range got.Products {
|
||||
if p.Title != want[i] {
|
||||
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], productTitles(got.Products))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// productTitles extracts the projected product titles for a failure message.
|
||||
func productTitles(products []CatalogProduct) []string {
|
||||
out := make([]string, len(products))
|
||||
for i, p := range products {
|
||||
out[i] = p.Title
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSortAdminCatalog checks the admin list is ordered like the public offer: chip packs first,
|
||||
// ascending by rouble price; then values, grouped (hints only -> no-ads only -> no-ads + hints) and
|
||||
// ascending by chip price within a group. Stable and applied to active + archived alike.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package payments
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
@@ -60,8 +59,13 @@ func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
|
||||
// omitted. Amounts are rendered through [Money] so no floating point ever reaches the page (roubles
|
||||
// show kopecks as "200.00", whole-unit rails as integers); a missing rail price shows an em dash.
|
||||
func projectOfferPricing(entries []catalogEntry) string {
|
||||
// Order the whole catalog once by the shared canonical rank, then split it into the two offer
|
||||
// tables (packs first, then the chip-exchange values); each keeps that order. Sort a copy so the
|
||||
// caller's slice is left untouched.
|
||||
sorted := slices.Clone(entries)
|
||||
sortCatalogEntries(sorted)
|
||||
var packs, values []catalogEntry
|
||||
for _, e := range entries {
|
||||
for _, e := range sorted {
|
||||
if isPackEntry(e) {
|
||||
packs = append(packs, e)
|
||||
} else {
|
||||
@@ -69,18 +73,6 @@ func projectOfferPricing(entries []catalogEntry) string {
|
||||
}
|
||||
}
|
||||
|
||||
// Packs: ascending by the rouble price (the offer's base currency); a pack with no rouble price
|
||||
// sorts last. Values: by group, then ascending chip price. Stable, so the catalog order breaks ties.
|
||||
slices.SortStableFunc(packs, func(a, b catalogEntry) int {
|
||||
return cmp.Compare(offerSortAmount(a, string(SourceDirect), CurrencyRUB), offerSortAmount(b, string(SourceDirect), CurrencyRUB))
|
||||
})
|
||||
slices.SortStableFunc(values, func(a, b catalogEntry) int {
|
||||
if d := cmp.Compare(offerValueGroup(a), offerValueGroup(b)); d != 0 {
|
||||
return d
|
||||
}
|
||||
return cmp.Compare(offerSortAmount(a, "", CurrencyChip), offerSortAmount(b, "", CurrencyChip))
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
if len(packs) > 0 {
|
||||
b.WriteString("Приобретение внутриигровой валюты «Фишка»:\n\n")
|
||||
|
||||
@@ -157,6 +157,22 @@ func (s *Service) RewardPayout(ctx context.Context, cxt Context, present []Sourc
|
||||
return payout, err
|
||||
}
|
||||
|
||||
// RewardConfig reads the rewarded-video config for the admin editor: the payout (chips per view) and
|
||||
// the per-day and per-hour caps.
|
||||
func (s *Service) RewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap int, err error) {
|
||||
return s.store.rewardConfig(ctx)
|
||||
}
|
||||
|
||||
// SetRewardConfig updates the rewarded-video config (the payout and the two caps). All three must be
|
||||
// non-negative; a 0 payout leaves rewarded inert. It is not a catalog product, so it does not mark the
|
||||
// offer stale.
|
||||
func (s *Service) SetRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||
if payout < 0 || dailyCap < 0 || hourlyCap < 0 {
|
||||
return errors.New("payments: reward payout and caps must be non-negative")
|
||||
}
|
||||
return s.store.setRewardConfig(ctx, payout, dailyCap, hourlyCap)
|
||||
}
|
||||
|
||||
// CreditReward credits a rewarded-video view's chips to the VK segment, client-attested (VK Mini App
|
||||
// ads expose no server verify — the client's watch result is trusted for an honest user; a forger who
|
||||
// skips the ad and calls the endpoint is bounded by the config daily cap). It is idempotent on the
|
||||
|
||||
@@ -19,11 +19,17 @@ import (
|
||||
// row references — it must be archived instead, to keep the append-only ledger resolvable.
|
||||
var ErrProductTransacted = errors.New("payments: product has transactions; archive instead of delete")
|
||||
|
||||
// adminCatalog lists every product (active and archived) with its atoms, prices and transacted flag.
|
||||
func (s *Store) adminCatalog(ctx context.Context) ([]AdminProduct, error) {
|
||||
// adminCatalog lists products with their atoms, prices and transacted flag. includeInactive adds the
|
||||
// archived products to the active ones; with it false only active products are returned.
|
||||
func (s *Store) adminCatalog(ctx context.Context, includeInactive bool) ([]AdminProduct, error) {
|
||||
where := table.Product.Active.IS_TRUE()
|
||||
if includeInactive {
|
||||
where = postgres.Bool(true)
|
||||
}
|
||||
var prods []model.Product
|
||||
if err := postgres.SELECT(table.Product.AllColumns).
|
||||
FROM(table.Product).
|
||||
WHERE(where).
|
||||
ORDER_BY(table.Product.CreatedAt.ASC()).
|
||||
QueryContext(ctx, s.db, &prods); err != nil {
|
||||
return nil, fmt.Errorf("payments: admin list products: %w", err)
|
||||
|
||||
@@ -413,6 +413,18 @@ func (s *Store) rewardConfig(ctx context.Context) (payout, dailyCap, hourlyCap i
|
||||
return int(cfg.RewardedPayoutChips), int(cfg.RewardDailyCap), int(cfg.RewardHourlyCap), nil
|
||||
}
|
||||
|
||||
// setRewardConfig updates the rewarded payout and the per-day and per-hour caps on the singleton
|
||||
// config row (no WHERE — the table holds exactly one row). Non-negativity is validated by the caller
|
||||
// and also enforced by the config CHECK constraints.
|
||||
func (s *Store) setRewardConfig(ctx context.Context, payout, dailyCap, hourlyCap int) error {
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE payments.config SET rewarded_payout_chips=$1, reward_daily_cap=$2, reward_hourly_cap=$3`,
|
||||
payout, dailyCap, hourlyCap); err != nil {
|
||||
return fmt.Errorf("payments: set reward config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// creditReward credits a rewarded-video view's chips to the funded segment, client-attested (VK Mini
|
||||
// App ads expose no server verify). It reads the payout and daily cap from config: a 0 payout
|
||||
// (unconfigured) or a reached cap credits nothing. It is idempotent on the client nonce (dedup on the
|
||||
|
||||
@@ -33,10 +33,12 @@ var priceFields = []struct {
|
||||
{"price_chip", "", payments.CurrencyChip},
|
||||
}
|
||||
|
||||
// consoleCatalog lists every product (active and archived) with its composition, prices, transacted
|
||||
// flag, and the inline create form.
|
||||
// consoleCatalog lists the catalog products with their composition, prices, transacted flag, and the
|
||||
// inline create form. It shows active products by default; ?all=1 also lists the archived ones (the
|
||||
// active/all toggle).
|
||||
func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
showAll := c.Query("all") == "1"
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context(), showAll)
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
@@ -44,13 +46,32 @@ func (s *Server) consoleCatalog(c *gin.Context) {
|
||||
// Order the list like the public offer: sales (chip packs) first, then the chip-exchange values,
|
||||
// grouped and price-sorted the same way, so the console mirrors what a buyer sees.
|
||||
payments.SortAdminCatalog(products)
|
||||
var view adminconsole.CatalogView
|
||||
payout, daily, hourly, err := s.payments.RewardConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
}
|
||||
view := adminconsole.CatalogView{ShowAll: showAll, RewardPayout: payout, RewardDailyCap: daily, RewardHourlyCap: hourly}
|
||||
for _, p := range products {
|
||||
view.Products = append(view.Products, catalogRow(p))
|
||||
}
|
||||
s.renderConsole(c, "catalog", "catalog", "Catalog", view)
|
||||
}
|
||||
|
||||
// consoleSetReward updates the rewarded-video config (chips per view plus the per-day and per-hour
|
||||
// caps) from the catalog page's rewarded-ads form. A blank field reads as 0; a negative value is
|
||||
// refused by the service.
|
||||
func (s *Server) consoleSetReward(c *gin.Context) {
|
||||
payout, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_payout")))
|
||||
daily, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_daily")))
|
||||
hourly, _ := strconv.Atoi(strings.TrimSpace(c.PostForm("reward_hourly")))
|
||||
if err := s.payments.SetRewardConfig(c.Request.Context(), payout, daily, hourly); err != nil {
|
||||
s.renderConsoleMessage(c, "Update failed", err.Error(), catalogBack)
|
||||
return
|
||||
}
|
||||
s.renderConsoleMessage(c, "Saved", "the rewarded-ads config was updated", catalogBack)
|
||||
}
|
||||
|
||||
// catalogRow projects a product into its list row.
|
||||
func catalogRow(p payments.AdminProduct) adminconsole.ProductRow {
|
||||
row := adminconsole.ProductRow{ID: p.ID.String(), Title: p.Title, Active: p.Active, Transacted: p.Transacted}
|
||||
@@ -69,7 +90,7 @@ func (s *Server) consoleCatalogDetail(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context())
|
||||
products, err := s.payments.AdminCatalog(c.Request.Context(), true) // include archived: the detail form edits any product
|
||||
if err != nil {
|
||||
s.consoleError(c, err)
|
||||
return
|
||||
@@ -242,7 +263,7 @@ func (s *Server) consoleGrantProduct(c *gin.Context) {
|
||||
// bundles, including archived ones — chips and tournament products are excluded).
|
||||
func (s *Server) grantForm(ctx context.Context) adminconsole.GrantFormView {
|
||||
fv := adminconsole.GrantFormView{Present: true, Origins: []string{"direct", "vk", "telegram"}}
|
||||
products, err := s.payments.AdminCatalog(ctx)
|
||||
products, err := s.payments.AdminCatalog(ctx, true)
|
||||
if err != nil {
|
||||
return fv
|
||||
}
|
||||
|
||||
@@ -112,6 +112,7 @@ func (s *Server) registerConsole(router *gin.Engine) {
|
||||
if s.payments != nil {
|
||||
gm.GET("/catalog", s.consoleCatalog)
|
||||
gm.POST("/catalog", s.consoleCreateProduct)
|
||||
gm.POST("/catalog/reward", s.consoleSetReward)
|
||||
gm.GET("/catalog/:id", s.consoleCatalogDetail)
|
||||
gm.POST("/catalog/:id", s.consoleUpdateProduct)
|
||||
gm.POST("/catalog/:id/archive", s.consoleArchiveProduct)
|
||||
|
||||
@@ -56,6 +56,9 @@ func Middleware(logger *zap.Logger) gin.HandlerFunc {
|
||||
zap.String("path", route),
|
||||
zap.Int("status", status),
|
||||
zap.Duration("latency", elapsed),
|
||||
// The gateway forwards the real caller as X-Forwarded-For (and Caddy does for /_gm), which
|
||||
// gin resolves here — so the access log carries the client IP, not the gateway's connection.
|
||||
zap.String("client_ip", c.ClientIP()),
|
||||
}
|
||||
fields = append(fields, TraceFieldsFromContext(ctx)...)
|
||||
|
||||
|
||||
+1
-1
@@ -86,7 +86,7 @@ GM_BASICAUTH_HASH= # required; `caddy hash-password` bcrypt
|
||||
|
||||
# --- UI build args (baked into the gateway image) ---------------------------
|
||||
VITE_TELEGRAM_BOT_ID=
|
||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://t.me/<bot>/<app>)
|
||||
VITE_TELEGRAM_LINK= # friend-invite Mini App link (full URL, e.g. https://telegram.me/<bot>/<app>)
|
||||
VITE_TELEGRAM_GAME_CHANNEL_NAME= # landing "Play in Telegram" link, the bot's game channel
|
||||
VITE_VK_APP_LINK= # landing "Play on VK" link (full URL, https://vk.com/app<id>)
|
||||
VITE_VK_APP_ID= # VK ID "Web" app id (client_id) for VK web-login linking; also the gateway's GATEWAY_VK_ID_APP_ID
|
||||
|
||||
+2
-2
@@ -107,8 +107,8 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
|
||||
| `TELEGRAM_TEST_ENV` | _pinned_ | `false` | `true` routes the bot through Telegram's test environment (`.../bot<token>/test/METHOD`). **The CI test contour pins this to `true` in `ci.yaml`** (the contour is the test environment) — it is not a Gitea variable. Set it in `.env` for a local run; prod leaves it `false`. |
|
||||
| `TELEGRAM_API_BASE_URL` | variable | _(empty)_ | Override the Bot API host (a mock/self-hosted server); empty = `https://api.telegram.org`. |
|
||||
| `VITE_TELEGRAM_BOT_ID` | variable | _(empty)_ | UI build-arg: numeric bot id for the web Login Widget. |
|
||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://t.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://t.me/Erudit_Game`). |
|
||||
| `VITE_TELEGRAM_LINK` | variable | _(empty)_ | UI build-arg: friend-invite Mini App link (full URL, `https://telegram.me/<bot>/<app>` — `<app>` is the Mini App short name from BotFather). |
|
||||
| `VITE_TELEGRAM_GAME_CHANNEL_NAME` | variable | _(empty)_ | UI build-arg: the landing "Play in Telegram" link, the bot's game channel (e.g. `https://telegram.me/Erudit_Game`). |
|
||||
| `VITE_VK_APP_LINK` | variable (shared) | _(empty)_ | UI build-arg: the landing "Play on VK" link, the VK Mini App (full URL, `https://vk.com/app<id>`). One VK Mini App for all contours. |
|
||||
| `VITE_VK_APP_ID` | variable (shared) | _(empty)_ | VK ID "Web" app id (`client_id`) for VK web-login linking. Baked into the SPA authorize URL and reused as the gateway's `GATEWAY_VK_ID_APP_ID`. Empty disables the `link.vk.*` ops. One VK ID "Web" app for all contours. |
|
||||
| `VITE_VK_ID_REDIRECT_URL` | derived | _(empty)_ | VK ID trusted redirect URL — must match the app's registered redirect exactly. The deploy derives `PUBLIC_BASE_URL + /app/` (used by the SPA and as the gateway's exchange `redirect_uri`); set it only for a local run. |
|
||||
|
||||
@@ -150,6 +150,13 @@
|
||||
enabled: true
|
||||
state: started
|
||||
|
||||
# Pin every host to UTC so host-level timestamps (journald, file mtimes, cron) line up
|
||||
# across the fleet — some VPS images ship a local zone (the tg host came up on MSK). The
|
||||
# services themselves run in UTC regardless; this is about host-side log correlation.
|
||||
- name: Set the system timezone to UTC
|
||||
community.general.timezone:
|
||||
name: Etc/UTC
|
||||
|
||||
# --- Swap file (OOM cushion) ---------------------------------------------------
|
||||
# The prod main host is tight (1.9 GiB) and the per-container memory caps
|
||||
# (docker-compose.prod.yml) sum to more than RAM, so a simultaneous spike could hit
|
||||
|
||||
+24
-9
@@ -107,8 +107,10 @@ dropped). Horizontal scaling is explicit future work.
|
||||
the header **"Connecting…"** spinner, chrome stays online); `offlineNoNetwork` (sustained loss — blue
|
||||
chrome, a local-only lobby, the transport **kill switch**); and `offlineVersionLocked` (the gateway is
|
||||
reachable but the build is below the hard minimum — the *update* notice, the client-version gate below). **Offline is
|
||||
implicit** — detected from failed calls, a reachability probe and the OS hint (`navigator` /
|
||||
`@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
implicit** — detected from failed calls, a reachability probe, a **live-stream heartbeat watchdog**
|
||||
(a silence past ~25 s of the gateway's 10 s keep-alive `heartbeat` means a silently-dead stream — e.g.
|
||||
the radio was cut with no stream error; `ui/src/lib/streamwatchdog.ts`, background-aware) and the OS hint
|
||||
(`navigator` / `@capacitor/network`), **never a user toggle** — with **hysteresis** so a brief blip lives entirely in
|
||||
`connecting` (K consecutive probe failures *or* a debounce window trips `offlineNoNetwork`; the first
|
||||
probe/call success heals back, with a toast). The transport **auto-retries with capped exponential
|
||||
backoff** — every op on a rate-limit (the gateway rejected it before processing, so it is safe), but
|
||||
@@ -116,10 +118,17 @@ dropped). Horizontal scaling is explicit future work.
|
||||
one whose response was lost — its button is disabled while offline and re-issued on reconnect). A
|
||||
reachability watcher (a lightweight `profile.get` probe; a session-less native guest reconciles a
|
||||
server guest instead — that reconcile IS the probe) drives recovery, and the live `Subscribe` stream's
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt** — always online: they are never fed an
|
||||
offline signal, and `offlineMode.active` is additionally hard-gated on `offlineCapable()` (false in
|
||||
those mini-apps), so nothing — not even a version lock — puts them in offline mode: no blue chrome, no
|
||||
local lobby, no transport kill switch and no device-local create paths.
|
||||
drop/recovery feeds the same machine. **Telegram/VK are exempt from the offline-*play* model** —
|
||||
`offlineMode.active` is hard-gated on `offlineCapable()` (false in those mini-apps), so nothing — not
|
||||
even a version lock — puts them in offline mode: no blue chrome, no local lobby, no transport kill
|
||||
switch and no device-local create paths. The machine still tracks reachability there, though: a
|
||||
connection lost **inside an online game** is surfaced on **every platform**, driven off the machine's
|
||||
confirmed-offline state (`netState.offline`, not `offlineMode.active`) — the game screen shows a
|
||||
*connection lost* banner, **freezes** the tray and move controls, and hides the resign, add-friend/block
|
||||
and chat/dictionary controls (a started move stays a draft, re-committed by the player on reconnect), and
|
||||
the word-check tool falls back to the game's pinned on-device dictionary (`ui/src/lib/dict/check.ts`,
|
||||
exact when that dawg is cached, else *unavailable*) with its network-only complaint and external
|
||||
look-up hidden.
|
||||
**Edge hardening:** every request body on the public listener is capped at
|
||||
`GATEWAY_MAX_BODY_BYTES` (default 1 MiB — far above any legitimate payload), both at the HTTP
|
||||
layer (`http.MaxBytesReader`) and as the Connect per-message read limit, so an oversized
|
||||
@@ -142,7 +151,10 @@ dropped). Horizontal scaling is explicit future work.
|
||||
and GCG are unaffected** (they stay decoded concrete characters, §9.1).
|
||||
- **gateway ↔ backend (sync)**: plain HTTP REST/JSON. The gateway injects
|
||||
`X-User-ID` (and the session's trusted `X-Platform`, §3) for authenticated
|
||||
requests; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
requests, plus the caller's real IP as **`X-Forwarded-For`** on **every** call
|
||||
(carried on the request context), so the backend records the real caller — the
|
||||
account's last-login IP, chat/feedback moderation, the access log — rather than
|
||||
the gateway's own connection address; `backend` never re-derives identity or platform from the body. Because every sync call targets the one backend host, the
|
||||
gateway's REST client widens its keep-alive pool well past the stdlib default
|
||||
of 2 idle connections per host; otherwise the per-request connection churn
|
||||
exhausts ephemeral ports and burns gateway CPU under load (see
|
||||
@@ -1369,7 +1381,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
|
||||
@@ -1485,7 +1499,8 @@ Two contours, two secret/variable prefixes (`TEST_` / `PROD_`):
|
||||
forwards the domain to `scrabble:80`, so the in-compose caddy serves plain HTTP
|
||||
(`CADDY_SITE_ADDRESS=:80`). The in-compose caddy **trusts X-Forwarded-For from
|
||||
private-range upstreams** (`trusted_proxies private_ranges`), so the real client IP —
|
||||
used for chat-moderation logging and the gateway's per-IP rate limiting — survives the
|
||||
used for the gateway's per-IP rate limiting and, forwarded on to the backend, the account's
|
||||
last-login IP, chat/feedback moderation and the access log — survives the
|
||||
host-caddy hop; in prod (no host caddy) public clients are untrusted and Caddy uses the
|
||||
real peer, so the single config is correct and spoof-safe in both contours. The
|
||||
**bot-link mTLS material** (a private CA + gateway/bot leaves, CN=`gateway`) is
|
||||
|
||||
+21
-3
@@ -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
|
||||
@@ -226,7 +229,10 @@ brief warm-up shows on the first open otherwise — and falls back to the server
|
||||
local dictionary is unavailable. The dictionary check tool is
|
||||
unlimited and offers a complaint on any result; for a word it finds, it also links out to an
|
||||
external reference dictionary (gramota.ru for Russian games, scrabblewordfinder.org for
|
||||
English) to look it up. Hints are governed per game —
|
||||
English) to look it up. While offline in an online game the check runs against the game's own
|
||||
dictionary on the device — the same verdict as the server when that dictionary is available,
|
||||
otherwise it reads *unavailable offline* — and the complaint and the external look-up, which both
|
||||
need the network, are hidden. Hints are governed per game —
|
||||
whether they are allowed and how many each player starts with — and draw on a
|
||||
personal hint wallet once the per-game allowance is spent. Against the robot
|
||||
(**vs_ai**) hints are instead **unlimited and wallet-free**, but idle-gated as an
|
||||
@@ -319,6 +325,17 @@ offline on its own, and restoring the network returns it online by itself. There
|
||||
switch to remember: a brief drop is ridden out quietly and only a sustained loss turns the chrome
|
||||
offline, so you are never interrupted for a hiccup.
|
||||
|
||||
**Losing the connection while inside an online game** is surfaced on **every platform, including the
|
||||
Telegram/VK mini-apps** — unlike the offline-play mode above, which is web/native only — because an
|
||||
online game needs the server for every move. A slim *connection lost* banner appears at the top of the
|
||||
board and the play area **freezes**: the rack and the move controls disable, and the resign, the
|
||||
add-friend/block and the chat/dictionary controls are hidden, while a move you had started stays on the
|
||||
board as a draft. Nothing
|
||||
is sent; when the connection returns the banner clears, the controls come back and you commit the move
|
||||
yourself. If you were already in the chat or the dictionary when the drop happened you stay there — chat
|
||||
becomes read-only (send and nudge disable) and the dictionary keeps checking words against the game's
|
||||
on-device dictionary.
|
||||
|
||||
### Staying up to date
|
||||
When a new client version is required, the app does not lock you out. On the **native** app a
|
||||
too-old build shows a notice with **Update** (opens the store) and **Play offline** (dismiss and keep
|
||||
@@ -539,7 +556,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
|
||||
|
||||
+20
-3
@@ -77,7 +77,10 @@ launch-параметрам VK (их проверяет gateway), и при пе
|
||||
так как VK не кладёт имя в подписанный запуск). Пока тема в режиме «авто», приложение следует
|
||||
светлой/тёмной схеме VK-клиента, а раскладка обходит нижнюю home-bar VK на мобильных. Тот же экран
|
||||
тихого повтора «не удалось
|
||||
загрузить» действует и внутри VK.
|
||||
загрузить» действует и внутри VK. Если открыть ссылку на мини-приложение прямо в обычном браузере —
|
||||
вход `/vk/` без подписанного запуска VK или `/telegram/` без данных входа Telegram — показывается
|
||||
компактный экран диагностики, которым можно поделиться, вместо того чтобы продолжить (гостем-однодневкой
|
||||
на VK или редиректом прочь на Telegram), — чтобы игрок, случайно попавший туда, прислал нам скриншот.
|
||||
**Email**-вход (в вебе) отправляет на адрес брендированный шестизначный код подтверждения —
|
||||
локализованный (ru/en), без картинок; после ввода игрок входит. Новый адрес заводится при первом
|
||||
запросе, но становится постоянным аккаунтом только после подтверждения кода — так брошенная,
|
||||
@@ -233,7 +236,9 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
|
||||
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
|
||||
внешний справочный словарь (gramota.ru для русских игр, scrabblewordfinder.org для английских),
|
||||
чтобы его посмотреть. Подсказки управляются настройками
|
||||
чтобы его посмотреть. В офлайне в онлайн-партии проверка идёт по собственному словарю партии на
|
||||
устройстве — тот же вердикт, что и на сервере, если этот словарь доступен, иначе показывается
|
||||
*недоступно офлайн*, — а жалоба и внешняя ссылка, которым нужна сеть, скрываются. Подсказки управляются настройками
|
||||
партии — разрешены ли они и сколько их у каждого игрока на старте — и расходуют
|
||||
личный кошелёк подсказок после исчерпания внутриигрового лимита. Против робота
|
||||
(**vs_ai**) подсказки, наоборот, **безлимитны и без кошелька**, но с idle-гейтом как
|
||||
@@ -324,6 +329,16 @@ e-mail) либо ввод фразы. Активные игры форфейтя
|
||||
кратковременный сбой пережидается тихо, и только устойчивая потеря переводит интерфейс в офлайн, так что
|
||||
вас не прерывают из-за короткой икоты.
|
||||
|
||||
**Потеря связи внутри онлайн-партии** показывается на **всех платформах, включая мини-приложения
|
||||
Telegram/VK** — в отличие от офлайн-режима игры выше, который только для веба и нативного приложения, —
|
||||
потому что онлайн-партии сервер нужен на каждый ход. Сверху доски появляется тонкий баннер *связь
|
||||
потеряна*, и игровая область **замораживается**: рэк и ходовые кнопки отключаются, а «сдаться»,
|
||||
«в друзья»/«заблокировать» и вход в чат/словарь скрываются; начатый ход остаётся на доске черновиком.
|
||||
Ничего не отправляется;
|
||||
когда связь возвращается, баннер исчезает, кнопки оживают, и ход отправляешь ты сам. Если связь упала,
|
||||
когда ты уже был в чате или словаре, ты там и остаёшься — чат становится «только для чтения» (отправка
|
||||
и nudge отключаются), а словарь продолжает проверять слова по словарю партии на устройстве.
|
||||
|
||||
### Актуальная версия
|
||||
Когда требуется новая версия клиента, приложение не блокирует вас наглухо. В **нативном** приложении
|
||||
слишком старая сборка показывает уведомление с **Обновить** (открывает магазин) и **Играть офлайн**
|
||||
@@ -552,7 +567,9 @@ high-rate флага. С карточки пользователя операт
|
||||
|
||||
Там, где бот ведёт **привязанный к каналу чат-обсуждение**, по умолчанию писать может каждый, а бот
|
||||
**глушит** игрока, который **не зарегистрирован** или **заблокирован**, и снимает мьют, как только тот
|
||||
зарегистрируется или будет разблокирован. То есть незарегистрированного новичка, написавшего в чат,
|
||||
зарегистрируется или будет разблокирован. Ещё бот убирает автоматический пин, который Telegram ставит
|
||||
на каждый пост канала, авто-форварднутый в чат, — закреплёнными остаются только намеренно закреплённые
|
||||
сообщения. То есть незарегистрированного новичка, написавшего в чат,
|
||||
бот глушит (промо-бот направляет его в игру зарегистрироваться, после чего бот возвращает голос), а
|
||||
зарегистрированный незаблокированный игрок просто пишет. Оператор также может **замьютить игрока только
|
||||
в чате** — роль `chat_muted` на карточке пользователя — без полной блокировки аккаунта; блокировка
|
||||
|
||||
@@ -201,7 +201,9 @@ web+PWA / native Android+iOS через Capacitor). Владелец (самоз
|
||||
начисления): Фишки, подсказки, дни-без-рекламы, участие-в-турнире. **Продукт = набор
|
||||
атомов + цена** (по одной ценности или комбо). «Пакет Фишек» — цена **per-метод**
|
||||
(мультивалютная: Голоса/Stars/руб — один продукт, D2). «Ценность за Фишки» — цена в
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге.
|
||||
Фишках (единая). Курсы покупки Фишек и Фишки за rewarded — тоже в каталоге. (Ставка
|
||||
rewarded + дневной/часовой потолки — строка общего конфига `payments.config`, правится в
|
||||
секции «Rewarded ads» на странице каталога админки; это не продаваемый продукт-атом.)
|
||||
- **D33. Стекинг «без рекламы»:** `paid_until[origin] += срок` от `max(now, текущий
|
||||
конец)` (остаток не теряется, «плюсуются» как в документе). «Навсегда» — отдельный
|
||||
вечный флаг (перекрывает сроки). Бонусы «(+50)» — маркетинговая пометка владельца;
|
||||
|
||||
+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
|
||||
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
|
||||
|
||||
@@ -139,6 +139,26 @@ func platformFromContext(ctx context.Context) string {
|
||||
return p
|
||||
}
|
||||
|
||||
// clientIPCtxKey types the request-context slot the originating client IP rides in.
|
||||
type clientIPCtxKey struct{}
|
||||
|
||||
// WithClientIP returns a copy of ctx carrying the originating client IP that do injects as
|
||||
// X-Forwarded-For on every downstream backend request — so the backend records the real caller
|
||||
// (the account's last-login IP, chat/feedback moderation, the access log) rather than the gateway's
|
||||
// own connection. An empty IP leaves ctx unchanged, so no header is sent.
|
||||
func WithClientIP(ctx context.Context, ip string) context.Context {
|
||||
if ip == "" {
|
||||
return ctx
|
||||
}
|
||||
return context.WithValue(ctx, clientIPCtxKey{}, ip)
|
||||
}
|
||||
|
||||
// clientIPFromContext returns the client IP stored by WithClientIP, or an empty string when none.
|
||||
func clientIPFromContext(ctx context.Context) string {
|
||||
ip, _ := ctx.Value(clientIPCtxKey{}).(string)
|
||||
return ip
|
||||
}
|
||||
|
||||
// do performs one REST call. userID, when non-empty, is forwarded as X-User-ID;
|
||||
// clientIP, when non-empty, as X-Forwarded-For (for chat moderation); the trusted
|
||||
// platform carried on ctx (see WithPlatform), when present, as X-Platform. A non-2xx
|
||||
@@ -160,6 +180,13 @@ func (c *Client) do(ctx context.Context, method, path, userID, clientIP string,
|
||||
if userID != "" {
|
||||
req.Header.Set("X-User-ID", userID)
|
||||
}
|
||||
// The client IP rides X-Forwarded-For so the backend records the real caller. It comes from the
|
||||
// explicit param (chat/feedback) or, for every other call, the request context (WithClientIP, set
|
||||
// once per request in the Connect edge) — so the backend never falls back to the gateway's own
|
||||
// connection address.
|
||||
if clientIP == "" {
|
||||
clientIP = clientIPFromContext(ctx)
|
||||
}
|
||||
if clientIP != "" {
|
||||
req.Header.Set("X-Forwarded-For", clientIP)
|
||||
}
|
||||
|
||||
@@ -122,3 +122,44 @@ func TestXPlatformInjection(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestXForwardedForInjection verifies the client IP carried on the context (WithClientIP) rides every
|
||||
// backend call as X-Forwarded-For — not just chat/feedback, which pass it explicitly — so the backend
|
||||
// records the real caller (e.g. the account's last-login IP on the profile fetch) rather than the
|
||||
// gateway's own connection. Absent when no client IP is set.
|
||||
func TestXForwardedForInjection(t *testing.T) {
|
||||
var gotXFF string
|
||||
var hadHeader bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotXFF = r.Header.Get("X-Forwarded-For")
|
||||
_, hadHeader = r.Header["X-Forwarded-For"]
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c, err := backendclient.New(srv.URL, "localhost:9090", 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatalf("backendclient: %v", err)
|
||||
}
|
||||
defer func() { _ = c.Close() }()
|
||||
|
||||
t.Run("client IP on ctx rides a non-chat call", func(t *testing.T) {
|
||||
ctx := backendclient.WithClientIP(context.Background(), "203.0.113.7")
|
||||
if _, err := c.Profile(ctx, "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if gotXFF != "203.0.113.7" {
|
||||
t.Fatalf("X-Forwarded-For = %q, want 203.0.113.7", gotXFF)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no client IP omits the header", func(t *testing.T) {
|
||||
hadHeader = true
|
||||
if _, err := c.Profile(context.Background(), "user-1"); err != nil {
|
||||
t.Fatalf("Profile: %v", err)
|
||||
}
|
||||
if hadHeader {
|
||||
t.Fatal("X-Forwarded-For must be absent when no client IP is set")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -418,6 +418,10 @@ func (s *Server) Execute(ctx context.Context, req *connect.Request[edgev1.Execut
|
||||
return nil, connect.NewError(connect.CodeNotFound, errUnknownMessageType(msgType))
|
||||
}
|
||||
clientIP := peerIP(req.Peer().Addr, req.Header())
|
||||
// Carry the client IP on the context so the backend client injects it as X-Forwarded-For on every
|
||||
// downstream REST call for this request — the account's last-login IP, chat/feedback moderation and
|
||||
// the backend access log, not only the chat/feedback calls that pass it explicitly.
|
||||
ctx = backendclient.WithClientIP(ctx, clientIP)
|
||||
|
||||
tr := transcode.Request{Payload: req.Msg.GetPayload(), ClientIP: clientIP}
|
||||
if op.Auth {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestExecutorCreateInvoice(t *testing.T) {
|
||||
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
|
||||
sender := &fakeSender{invoiceLink: "https://telegram.me/$abc"}
|
||||
exec := NewExecutor(sender, 0, nil)
|
||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
|
||||
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
|
||||
@@ -178,7 +178,7 @@ func TestExecutorCreateInvoice(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("handle: %v", err)
|
||||
}
|
||||
if !delivered || result != "https://t.me/$abc" {
|
||||
if !delivered || result != "https://telegram.me/$abc" {
|
||||
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
|
||||
}
|
||||
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
|
||||
|
||||
@@ -66,7 +66,7 @@ type BotConfig struct {
|
||||
// bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs).
|
||||
BotUsername string
|
||||
// BotLinkURL is the main bot's Mini App direct link — the same value the UI builds
|
||||
// share links from (VITE_TELEGRAM_LINK), e.g. https://t.me/<bot>/<app>. The promo
|
||||
// share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me/<bot>/<app>. The promo
|
||||
// button appends ?startapp=<payload> to it (TELEGRAM_BOT_LINK; required when the
|
||||
// promo bot runs). It is distinct from the BotLink mTLS dial config below.
|
||||
BotLinkURL string
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
t.Setenv("TELEGRAM_CHAT_ID", "-100222")
|
||||
t.Setenv("TELEGRAM_PROMO_BOT_TOKEN", "promo-token")
|
||||
t.Setenv("TELEGRAM_BOT_USERNAME", "@ScrabbleBot")
|
||||
t.Setenv("TELEGRAM_BOT_LINK", "https://t.me/ScrabbleBot/app")
|
||||
t.Setenv("TELEGRAM_BOT_LINK", "https://telegram.me/ScrabbleBot/app")
|
||||
c, err := LoadBot()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadBot: %v", err)
|
||||
@@ -126,7 +126,7 @@ func TestLoadBotChatAndPromo(t *testing.T) {
|
||||
if c.BotUsername != "ScrabbleBot" {
|
||||
t.Errorf("BotUsername = %q, want the leading @ stripped", c.BotUsername)
|
||||
}
|
||||
if c.BotLinkURL != "https://t.me/ScrabbleBot/app" {
|
||||
if c.BotLinkURL != "https://telegram.me/ScrabbleBot/app" {
|
||||
t.Errorf("BotLinkURL = %q", c.BotLinkURL)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func TestPromoTextLocalization(t *testing.T) {
|
||||
const url = "https://t.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||
const url = "https://telegram.me/bot/app?startapp=verudit_ru-scrabble_en"
|
||||
// The @username is rendered as a clickable deep link (the same target as the button),
|
||||
// not a plain mention, so tapping it opens the seeded Mini App.
|
||||
wantLink := `<a href="` + url + `">@ScrabbleBot</a>`
|
||||
@@ -41,17 +41,17 @@ func TestPromoTextLocalization(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLaunchURLAppendsStartapp(t *testing.T) {
|
||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
||||
if got := b.launchURL(""); got != "https://t.me/bot/app" {
|
||||
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||
if got := b.launchURL(""); got != "https://telegram.me/bot/app" {
|
||||
t.Errorf("empty payload = %q, want the base link unchanged", got)
|
||||
}
|
||||
if got := b.launchURL("g123"); got != "https://t.me/bot/app?startapp=g123" {
|
||||
if got := b.launchURL("g123"); got != "https://telegram.me/bot/app?startapp=g123" {
|
||||
t.Errorf("launchURL = %q, want startapp=g123 appended", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchMarkupIsURLButton(t *testing.T) {
|
||||
b := &Bot{linkURL: "https://t.me/bot/app"}
|
||||
b := &Bot{linkURL: "https://telegram.me/bot/app"}
|
||||
btn := b.launchMarkup("Play", "f99").InlineKeyboard[0][0]
|
||||
if btn.WebApp != nil {
|
||||
t.Error("the promo button must not be a web_app button (it would sign initData with the promo token, which the main bot rejects)")
|
||||
@@ -84,7 +84,7 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://t.me/bot/app"}, zap.NewNop())
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "ScrabbleBot", BotLinkURL: "https://telegram.me/bot/app"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestHandleStartReplies(t *testing.T) {
|
||||
if api.chatID != "42" {
|
||||
t.Errorf("chat_id = %q, want 42", api.chatID)
|
||||
}
|
||||
if !strings.Contains(api.text, `<a href="https://t.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||
if !strings.Contains(api.text, `<a href="https://telegram.me/bot/app?startapp=f99">@ScrabbleBot</a>`) {
|
||||
t.Errorf("text = %q, want the @mention linked to the startapp deep link", api.text)
|
||||
}
|
||||
if strings.Contains(api.replyMarkup, "web_app") {
|
||||
@@ -113,7 +113,7 @@ func TestHandleStartIgnoresGroup(t *testing.T) {
|
||||
api := &fakeAPI{}
|
||||
srv := httptest.NewServer(api)
|
||||
t.Cleanup(srv.Close)
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://t.me/b/a"}, zap.NewNop())
|
||||
b, err := New(Config{Token: "1:2", APIBaseURL: srv.URL, BotUsername: "B", BotLinkURL: "https://telegram.me/b/a"}, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("new: %v", err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ pnpm codegen # regenerate src/gen from edge.proto + scrabble.fbs (dev-time)
|
||||
gateway origin for a packaged (non-proxied) build. `VITE_TELEGRAM_BOT_ID`
|
||||
enables the "Link Telegram" web sign-in (the Login Widget) — inert until the site
|
||||
domain is registered with BotFather (`/setdomain`); `VITE_TELEGRAM_LINK` is the
|
||||
friend-invite Mini App link for the single bot (full URL `https://t.me/<bot>/<app>`).
|
||||
friend-invite Mini App link for the single bot (full URL `https://telegram.me/<bot>/<app>`).
|
||||
`VITE_TELEGRAM_GAME_CHANNEL_NAME` is the "Play in Telegram" link shown on the landing page.
|
||||
The **native (Capacitor) build** additionally reads `VITE_DICT_VERSION` (the bundled-dictionary version
|
||||
the offline path requests, matching the packaged DAWGs), `VITE_PAYMENTS_DISABLED=1` (hide in-app purchases
|
||||
|
||||
@@ -39,3 +39,77 @@ test('in-game UX: turn strip, bag badge + table footer, staged-play highlight an
|
||||
await expect(page.locator('.scorebadge')).toBeVisible();
|
||||
await expect(page.locator('.make')).toBeVisible();
|
||||
});
|
||||
|
||||
test('online game offline: banner shows, the tray and comms entry freeze, and it thaws on recovery', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
await page.getByRole('button', { name: 'Random player' }).click();
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
|
||||
// Baseline online: no banner, the tray is interactive, the comms (chat/dictionary) entry is enabled.
|
||||
await expect(page.locator('.offline-banner')).toHaveCount(0);
|
||||
await expect(page.locator('.rack .tile').first()).toBeEnabled();
|
||||
|
||||
// The connection drops (the net hook rides past the hysteresis straight to offline).
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
|
||||
// The explicit "connection lost" banner appears and every rack tile freezes (cannot compose a move).
|
||||
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
|
||||
const tiles = page.locator('.rack .tile');
|
||||
const n = await tiles.count();
|
||||
expect(n).toBeGreaterThan(0);
|
||||
for (let i = 0; i < n; i++) await expect(tiles.nth(i)).toBeDisabled();
|
||||
|
||||
// The comms entry (💬) and the resign control are HIDDEN offline (like the frozen social controls);
|
||||
// both live in the history drawer, so open it and assert no action icons remain in its header.
|
||||
await page.locator('.scoreboard').click();
|
||||
await expect(page.locator('.hhead button.hicon')).toHaveCount(0);
|
||||
|
||||
// Recovery: the network returns, the banner clears, the tray is interactive and the comms entry returns.
|
||||
await page.evaluate(() => (window as unknown as { __net: { online(): void } }).__net.online());
|
||||
await expect(page.locator('.offline-banner')).toHaveCount(0, { timeout: 15000 });
|
||||
await expect(page.locator('.rack .tile').first()).toBeEnabled();
|
||||
await expect(page.locator('button:has(.chat-ico)')).toBeVisible();
|
||||
});
|
||||
|
||||
test('online game offline: the dictionary still accepts input and checks (variant seeded from cache)', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.getByRole('button', { name: /guest/i }).click();
|
||||
// A Russian (erudit_ru — first in the mock's variant preferences) game, so the offline dictionary is
|
||||
// exercised with a non-Latin alphabet that differs from the panel's default 'scrabble_en'. The panel
|
||||
// seeds its variant + pinned version from the cached game; the mock resolves gameState even offline
|
||||
// (no kill switch), so on the real backend — where that fetch fails offline — the seed is what keeps
|
||||
// the input and the dawg fallback working. This guards the offline dictionary flow end-to-end.
|
||||
await page.getByRole('button', { name: /🎲/ }).click();
|
||||
await page.getByRole('button', { name: 'Random player' }).click();
|
||||
await page.locator('.variant').first().click();
|
||||
await page.getByRole('button', { name: /Start game/i }).click();
|
||||
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
|
||||
await expect(page.getByText('Robo')).toBeVisible();
|
||||
|
||||
// Go offline, then open the dictionary directly (its in-game entry is hidden offline) so CheckScreen
|
||||
// mounts with no network and must seed the variant/version from the cached game, not a failed fetch.
|
||||
await page.evaluate(() => (window as unknown as { __net: { offline(): void } }).__net.offline());
|
||||
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 15000 });
|
||||
await page.evaluate(() => {
|
||||
const route = location.hash.slice(1); // /game/<id>
|
||||
(window as unknown as { __router: { navigate(p: string): void } }).__router.navigate(route + '/check');
|
||||
});
|
||||
|
||||
// The check input keeps the Cyrillic word (the variant is known) and the Check button is usable —
|
||||
// the outcome the cache seed guarantees on the real backend where the offline gameState fetch fails.
|
||||
const input = page.locator('.check input');
|
||||
await expect(input).toBeVisible();
|
||||
await input.fill('СЛОВО');
|
||||
await expect(input).toHaveValue('СЛОВО');
|
||||
await expect(page.locator('.check button')).toBeEnabled();
|
||||
|
||||
// Running the check resolves to a verdict (or the neutral offline note), proving the fallback path
|
||||
// executes rather than hanging.
|
||||
await page.locator('.check button').click();
|
||||
await expect(page.locator('.verdict')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ test('the landing footer links to the legal documents and the feedback bot', asy
|
||||
['Пользовательское соглашение', '/eula/'],
|
||||
['Политика конфиденциальности', '/privacy/'],
|
||||
['Публичная оферта', '/offer/'],
|
||||
['Обратная связь', 'https://t.me/Erudit_GameBot'],
|
||||
['Обратная связь', 'https://telegram.me/Erudit_GameBot'],
|
||||
];
|
||||
for (const [name, href] of links) {
|
||||
const link = page.getByRole('link', { name });
|
||||
|
||||
+5
-5
@@ -207,7 +207,7 @@ If the limitation or exclusion of liability is prohibited by applicable law, the
|
||||
|
||||
The Company cares about the protection of personal data. Personal data collected by the Company in the context of this document is subject to automated processing in accordance with applicable law. All information collected as part of providing the service is registered by the Company, which is the data controller. This is very important for the functioning of the services offered by the Company.
|
||||
|
||||
To exercise one or more of their rights, the User must provide an identity document and contact the person responsible for data protection at the Company (via service support on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov).
|
||||
To exercise one or more of their rights, the User must provide an identity document and contact the person responsible for data protection at the Company (via service support on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov).
|
||||
|
||||
In the event of a complaint, the User may contact the personal-data supervisory authority of their country of residence.
|
||||
|
||||
@@ -247,7 +247,7 @@ Although the Company does everything possible to ensure data confidentiality and
|
||||
|
||||
**15.8.** The Company reserves the right to revise the terms of this Licence Agreement, in particular, the Game Rules and/or the Forum Rules, by updating the Licence Agreement at [`erudit-game.ru/eula/`](/eula/) or by notifying the User by email. The revised Licence Agreement enters into force from the day of its publication. The User is advised to check the aforementioned website periodically for notices of such changes. The User's failure to take steps to review does not serve as grounds for the User's failure to perform their obligations and non-compliance with the restrictions established by this Licence Agreement. The User's continued use of the Game is deemed acceptance of any revised terms.
|
||||
|
||||
**15.9.** On matters related to the performance of this Licence Agreement and/or the use of the Game, the User may contact the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) and at ilia.denissov@gmail.com.
|
||||
**15.9.** On matters related to the performance of this Licence Agreement and/or the use of the Game, the User may contact the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) and at ilia.denissov@gmail.com.
|
||||
|
||||
Denisov I.A. | 2026
|
||||
|
||||
@@ -312,7 +312,7 @@ The Company reserves the right to identify and locate all of the User's Accounts
|
||||
|
||||
**5.4.** The User is prohibited from attempting to benefit from deliberate (or repeated) participation in the game process as part of a game group (team) with other Users who have violated paragraphs 5.1, 5.5 and/or 5.6 of the Game Rules.
|
||||
|
||||
**5.5.** The User is prohibited from using and distributing information, calling for the use of and publicly distributing any errors, both within the Game and in any other software. A User who discovers such errors in the Game must stop using it and report them to the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) and at ilia.denissov@gmail.com, setting out in detail and truthfully all the circumstances of such discovery and use. If the User has any doubts as to whether the functioning of any particular game process, In-game items or In-game currency is currently normal or has deviations, malfunctions or errors, the User must stop using such process, In-game items or In-game currency and contact the Company at ilia.denissov@gmail.com for the relevant explanations.
|
||||
**5.5.** The User is prohibited from using and distributing information, calling for the use of and publicly distributing any errors, both within the Game and in any other software. A User who discovers such errors in the Game must stop using it and report them to the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) and at ilia.denissov@gmail.com, setting out in detail and truthfully all the circumstances of such discovery and use. If the User has any doubts as to whether the functioning of any particular game process, In-game items or In-game currency is currently normal or has deviations, malfunctions or errors, the User must stop using such process, In-game items or In-game currency and contact the Company at ilia.denissov@gmail.com for the relevant explanations.
|
||||
|
||||
**5.6.** The User is prohibited from decompiling, decoding and reconstructing data, bypassing data-protection systems, hacking or attempting to hack the software components of the Game or its services, and/or intercepting data coming to or from the server. Prohibited are: (in particular) any modification, alteration, decompilation, decoding, sale or distribution of modified materials of the Game in whole or in part (or the means and materials necessary to perform such actions), the use of programming errors, making changes to the program code and obtaining unauthorised access to the server and database of the Game. In certain special cases, the Company has the right to immediately suspend the User's access to the Game and send a request to the relevant authorities to prevent any violation of the Licence Agreement and/or provisions of applicable law.
|
||||
|
||||
@@ -350,7 +350,7 @@ The Company reserves the right to identify and locate all of the User's Accounts
|
||||
|
||||
**8.2.** The User is prohibited from offering such arguments as "in accordance with the role"/"role-play" in defence of unlawful actions of any kind.
|
||||
|
||||
**8.3.** The User is prohibited from deliberately providing false information when contacting the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) or at ilia.denissov@gmail.com, as well as from falsifying the data provided.
|
||||
**8.3.** The User is prohibited from deliberately providing false information when contacting the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) or at ilia.denissov@gmail.com, as well as from falsifying the data provided.
|
||||
|
||||
---
|
||||
|
||||
@@ -406,7 +406,7 @@ The Company reserves the right to identify and locate all of the User's forum ac
|
||||
|
||||
**3.9.** Trading (discussion of trading) in game characters, In-game items, In-game currency, forum accounts, character level boosting.
|
||||
|
||||
**3.10.** Discussion of vulnerabilities or shortcomings of the Game, as well as any actions that in any way violate the Game Rules, or their discussion. Upon discovering vulnerabilities or shortcomings, the User must report them to the Company on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) or at ilia.denissov@gmail.com.
|
||||
**3.10.** Discussion of vulnerabilities or shortcomings of the Game, as well as any actions that in any way violate the Game Rules, or their discussion. Upon discovering vulnerabilities or shortcomings, the User must report them to the Company on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) or at ilia.denissov@gmail.com.
|
||||
|
||||
**3.11.** Publication of messages causing negative consequences for the game process; provocation of Users to violate the Game Rules.
|
||||
|
||||
|
||||
+5
-5
@@ -207,7 +207,7 @@
|
||||
|
||||
Компания заботится о защите персональных данных. Персональные данные, собранные Компанией в контексте настоящего документа, подлежат автоматизированной обработке в соответствии с действующим законодательством. Вся информация, собранная в рамках предоставления услуги, регистрируется Компанией, которая является контролером данных. Это очень важно для функционирования предлагаемых Компанией услуг.
|
||||
|
||||
Для осуществления одного или нескольких своих прав Пользователь должен предоставить документ, удостоверяющий личность, и связаться с лицом, ответственным за защиту данных в Компании (через сервисную поддержку в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич).
|
||||
Для осуществления одного или нескольких своих прав Пользователь должен предоставить документ, удостоверяющий личность, и связаться с лицом, ответственным за защиту данных в Компании (через сервисную поддержку в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич).
|
||||
|
||||
В случае возникновения жалобы Пользователь может обратиться в надзорный орган по защите персональных данных страны своего проживания.
|
||||
|
||||
@@ -247,7 +247,7 @@
|
||||
|
||||
**15.8.** Компания оставляет за собой право пересматривать условия настоящего Лицензионного соглашения, в частности, Правила Игры и/или Правила форума, обновляя Лицензионное соглашение на странице [`erudit-game.ru/eula/`](/eula/) или уведомляя Пользователя по электронной почте. Пересмотренное Лицензионное соглашение вступает в силу со дня его опубликования. Пользователю рекомендуется периодически проверять вышеуказанный веб-сайт на наличие уведомлений о таких изменениях. Отказ Пользователя от действий по ознакомлению не может служить основанием для невыполнения обязательств Пользователя и несоблюдения Пользователем ограничений, установленных настоящим Лицензионным соглашением. Продолжение использования Игры Пользователем считается принятием любых пересмотренных условий.
|
||||
|
||||
**15.9.** По вопросам, связанным с исполнением настоящего Лицензионного соглашения и/или использованием Игры, Пользователь может связаться с Компанией через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com.
|
||||
**15.9.** По вопросам, связанным с исполнением настоящего Лицензионного соглашения и/или использованием Игры, Пользователь может связаться с Компанией через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com.
|
||||
|
||||
Денисов И.А. | 2026
|
||||
|
||||
@@ -312,7 +312,7 @@
|
||||
|
||||
**5.4.** Пользователю запрещается пытаться извлечь выгоду из преднамеренного (или неоднократного) участия в игровом процессе в составе игровой группы (команды) с другими Пользователями, нарушившими пункты 5.1, 5.5 и/или 5.6 Правил Игры.
|
||||
|
||||
**5.5.** Пользователю запрещается использовать и распространять информацию, призывать к использованию и публично распространять любые ошибки, как внутри Игры, так и в любом другом программном обеспечении. Пользователь, обнаруживший такие ошибки в Игре, должен прекратить её использование и сообщить о них Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com, подробно и достоверно изложив все обстоятельства такого обнаружения и использования. В случае возникновения у Пользователя каких-либо сомнений в том, является ли такое функционирование какого-либо конкретного игрового процесса, Внутриигровых предметов или Внутриигровой валюты в настоящий момент нормальным или имеет отклонения, сбои, ошибки, Пользователь должен прекратить использование таких процесса, Внутриигровых предметов или Внутриигровой валюты и обратиться к Компании через ilia.denissov@gmail.com для получения соответствующих разъяснений.
|
||||
**5.5.** Пользователю запрещается использовать и распространять информацию, призывать к использованию и публично распространять любые ошибки, как внутри Игры, так и в любом другом программном обеспечении. Пользователь, обнаруживший такие ошибки в Игре, должен прекратить её использование и сообщить о них Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) и по адресу ilia.denissov@gmail.com, подробно и достоверно изложив все обстоятельства такого обнаружения и использования. В случае возникновения у Пользователя каких-либо сомнений в том, является ли такое функционирование какого-либо конкретного игрового процесса, Внутриигровых предметов или Внутриигровой валюты в настоящий момент нормальным или имеет отклонения, сбои, ошибки, Пользователь должен прекратить использование таких процесса, Внутриигровых предметов или Внутриигровой валюты и обратиться к Компании через ilia.denissov@gmail.com для получения соответствующих разъяснений.
|
||||
|
||||
**5.6.** Пользователю запрещается декомпилировать, декодировать и реконструировать данные, обходить системы защиты данных, взламывать или пытаться взломать программные компоненты Игры или её сервисов, и/или перехватывать данные, поступающие на сервер или с него. Запрещается: (в частности) любое модифицирование, изменение, декомпиляция, декодирование, продажа или распространение модифицированных материалов Игры в целом или по частям (или средств и материалов, необходимых для выполнения таких действий), использование ошибок программирования, внесение изменений в программный код и получение несанкционированного доступа к серверу и базе данных Игры. В определённых особых случаях Компания имеет право немедленно приостановить доступ Пользователя к Игре и направить запрос в соответствующие органы о предотвращении любого нарушения Лицензионного соглашения и/или положений применимого законодательства.
|
||||
|
||||
@@ -350,7 +350,7 @@
|
||||
|
||||
**8.2.** Пользователю запрещается предлагать такие аргументы, как «в соответствии с ролью»/«отыгрыш роли», в защиту неправомерных действий любого рода.
|
||||
|
||||
**8.3.** Пользователю запрещается преднамеренно предоставлять ложную информацию в случае обращения к Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com, а также фальсифицировать предоставленные данные.
|
||||
**8.3.** Пользователю запрещается преднамеренно предоставлять ложную информацию в случае обращения к Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com, а также фальсифицировать предоставленные данные.
|
||||
|
||||
---
|
||||
|
||||
@@ -406,7 +406,7 @@
|
||||
|
||||
**3.9.** Торговля (обсуждение торговли) игровыми персонажами, Внутриигровыми предметами, Внутриигровой валютой, учётными записями на форумах, повышение уровня персонажа.
|
||||
|
||||
**3.10.** Обсуждение уязвимостей или недоработок Игры, а также любые действия, каким-либо образом нарушающие Правила Игры, или их обсуждение. При обнаружении уязвимостей или недоработок Пользователь должен сообщить об этом Компании через Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com.
|
||||
**3.10.** Обсуждение уязвимостей или недоработок Игры, а также любые действия, каким-либо образом нарушающие Правила Игры, или их обсуждение. При обнаружении уязвимостей или недоработок Пользователь должен сообщить об этом Компании через Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot) или по адресу ilia.denissov@gmail.com.
|
||||
|
||||
**3.11.** Публикация сообщений, вызывающих негативные последствия для игрового процесса; провокация нарушения Пользователями Правил Игры.
|
||||
|
||||
|
||||
@@ -152,4 +152,4 @@ Disputes or disagreements on which the Parties have not reached agreement are su
|
||||
|
||||
Ilya Arkadyevich Denisov, TIN 290210610742.
|
||||
|
||||
Feedback on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot).
|
||||
Feedback on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot).
|
||||
|
||||
@@ -152,4 +152,4 @@
|
||||
|
||||
Денисов Илья Аркадьевич, ИНН 290210610742.
|
||||
|
||||
Обратная связь в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot).
|
||||
Обратная связь в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot).
|
||||
|
||||
@@ -133,7 +133,7 @@ The transfer of personal data to recipients (regardless of their legal status) i
|
||||
|
||||
## 11. Contact us
|
||||
|
||||
**11.1.** If you have any questions, please contact Support on Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov. Please refer to this Privacy Policy so that we can process your request effectively. We will try to respond to you within 30 days of receiving your request.
|
||||
**11.1.** If you have any questions, please contact Support on Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), or send us your request in writing to: 236020, Kaliningrad, Pribrezhny district, Parkovaya St. 1, recipient — Ilya Arkadyevich Denisov. Please refer to this Privacy Policy so that we can process your request effectively. We will try to respond to you within 30 days of receiving your request.
|
||||
|
||||
**11.2.** All correspondence we receive from you (written or electronic requests) is classified as restricted-access information and may not be disclosed without your written consent. Personal data and other information about you may not be used without your consent for any purpose other than responding to the request, except in cases expressly provided for by law.
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@
|
||||
|
||||
## 11. Связаться с нами
|
||||
|
||||
**11.1.** Если у вас есть какие-либо вопросы, пожалуйста, свяжитесь со Службой поддержки в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич. Пожалуйста, ссылайтесь на настоящую Политику конфиденциальности, чтобы мы смогли эффективно обработать ваш запрос. Мы постараемся ответить вам в течение 30 дней с момента получения запроса.
|
||||
**11.1.** Если у вас есть какие-либо вопросы, пожалуйста, свяжитесь со Службой поддержки в Telegram: [@Erudit_GameBot](https://telegram.me/Erudit_GameBot), либо отправьте нам свой запрос в письменном виде по адресу: 236020, г. Калининград, мкр. Прибрежный, ул. Парковая, д. 1, получатель — Денисов Илья Аркадьевич. Пожалуйста, ссылайтесь на настоящую Политику конфиденциальности, чтобы мы смогли эффективно обработать ваш запрос. Мы постараемся ответить вам в течение 30 дней с момента получения запроса.
|
||||
|
||||
**11.2.** Вся полученная нами от вас корреспонденция (письменные или электронные запросы) классифицируется как информация с ограниченным доступом и не может быть разглашена без вашего письменного согласия. Персональные данные и другая информация о вас не могут быть использованы без вашего согласия для каких-либо целей, кроме как для ответа на запрос, за исключением случаев, прямо предусмотренных законом.
|
||||
|
||||
|
||||
+6
-5
@@ -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. -->
|
||||
|
||||
@@ -134,7 +134,7 @@
|
||||
<span class="sep" aria-hidden="true">|</span>
|
||||
<!-- The feedback bot: the Telegram contact the legal documents list as the seller's contact;
|
||||
external, opens in a new tab. -->
|
||||
<a class="doc" href="https://t.me/Erudit_GameBot" target="_blank" rel="noopener noreferrer">{t('landing.feedback')}</a>
|
||||
<a class="doc" href="https://telegram.me/Erudit_GameBot" target="_blank" rel="noopener noreferrer">{t('landing.feedback')}</a>
|
||||
</span>
|
||||
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
|
||||
</footer>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
function openBot() {
|
||||
if (username) {
|
||||
const url = `https://t.me/${username}`;
|
||||
const url = `https://telegram.me/${username}`;
|
||||
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
|
||||
// back to a new tab (routed out of the Android VK WebView by openExternalUrl).
|
||||
if (!telegramOpenLink(url)) openExternalUrl(url);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
function openBot() {
|
||||
if (username) {
|
||||
const url = `https://t.me/${username}`;
|
||||
const url = `https://telegram.me/${username}`;
|
||||
// Inside Telegram, openTelegramLink navigates to the bot chat natively; elsewhere fall
|
||||
// back to a new tab (routed out of the Android VK WebView by openExternalUrl).
|
||||
if (!telegramOpenLink(url)) openExternalUrl(url);
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { gateway } from '../lib/gateway';
|
||||
import { gameSource, isLocalGameId } from '../lib/gamesource';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { handleError, showToast } from '../lib/app.svelte';
|
||||
import { t } from '../lib/i18n/index.svelte';
|
||||
import { alphabetLetters } from '../lib/alphabet';
|
||||
import { canCheckWord, dictionaryLookupUrl, sanitizeCheckWord } from '../lib/checkword';
|
||||
import { onExternalLinkClick } from '../lib/links';
|
||||
import { getCachedGame } from '../lib/gamecache';
|
||||
import type { Variant } from '../lib/model';
|
||||
|
||||
// Word-check on its own screen: unlimited dictionary lookups, each with a
|
||||
@@ -14,25 +16,40 @@
|
||||
let { id }: { id: string } = $props();
|
||||
|
||||
let variant = $state<Variant>('scrabble_en');
|
||||
let dictVersion = $state('');
|
||||
let word = $state('');
|
||||
let result = $state<{ word: string; legal: boolean } | null>(null);
|
||||
// unavailable is set when an offline check cannot run because the game's pinned dictionary is not
|
||||
// on the device (not cached/bundled); the panel then shows a neutral "offline" note.
|
||||
let unavailable = $state(false);
|
||||
let cooling = $state(false);
|
||||
const checked = new Map<string, boolean>();
|
||||
|
||||
onMount(async () => {
|
||||
// Seed the variant + pinned version from the cached game first (present once the board has opened,
|
||||
// and preloaded for ongoing games): an online game's dictionary must keep working while offline,
|
||||
// where the gameState call below cannot run. The variant's alphabet is already cached from opening
|
||||
// the board, so input sanitising and the offline dawg fallback work off the seed alone.
|
||||
const cached = getCachedGame(id)?.view.game;
|
||||
if (cached) {
|
||||
variant = cached.variant;
|
||||
dictVersion = cached.dictVersion;
|
||||
}
|
||||
try {
|
||||
// Include the alphabet so input sanitising + the check accept the variant's letters. Routed
|
||||
// through gameSource so an offline (local) game resolves its state from the device, not the
|
||||
// network.
|
||||
// Refresh over the network (and cache the alphabet on a cold deep-link that skipped the board).
|
||||
const st = await gameSource(id).gameState(id, true);
|
||||
variant = st.game.variant;
|
||||
dictVersion = st.game.dictVersion;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
// Offline or a transient failure: the cache seed already set variant + version, so only surface
|
||||
// an error when there was nothing cached to fall back to.
|
||||
if (!cached) handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
function onInput(e: Event) {
|
||||
word = sanitizeCheckWord((e.target as HTMLInputElement).value, alphabetLetters(variant));
|
||||
unavailable = false;
|
||||
}
|
||||
// Disabled while cooling, for an already-checked word, or an out-of-range length.
|
||||
function canCheck(): boolean {
|
||||
@@ -43,7 +60,25 @@
|
||||
const w = word.trim().toUpperCase();
|
||||
cooling = true;
|
||||
setTimeout(() => (cooling = false), 5000);
|
||||
unavailable = false;
|
||||
try {
|
||||
// Offline in an online game the network check_word would fail, so fall back to the game's own
|
||||
// pinned dictionary read on-device: exact when that dawg is cached/bundled, otherwise the check
|
||||
// is unavailable. A local game already resolves checkWord on-device, so it keeps that path.
|
||||
if (!isLocalGameId(id) && !connection.online) {
|
||||
// Dynamically imported so the dawg reader/loader stays out of the app entry bundle (the dict
|
||||
// subsystem is lazy everywhere else too); it loads only when an offline check actually runs.
|
||||
const { localWordCheck } = await import('../lib/dict/check');
|
||||
const legal = await localWordCheck(variant, dictVersion, w);
|
||||
if (legal === null) {
|
||||
result = null;
|
||||
unavailable = true;
|
||||
return;
|
||||
}
|
||||
checked.set(w, legal);
|
||||
result = { word: w, legal };
|
||||
return;
|
||||
}
|
||||
const r = await gameSource(id).checkWord(id, w, variant);
|
||||
checked.set(w, r.legal);
|
||||
result = { word: w, legal: r.legal };
|
||||
@@ -73,7 +108,9 @@
|
||||
/>
|
||||
<button onclick={runCheck} disabled={!canCheck()}>{t('game.check')}</button>
|
||||
</div>
|
||||
{#if result}
|
||||
{#if unavailable}
|
||||
<p class="verdict">{t('game.checkOffline')}</p>
|
||||
{:else if result}
|
||||
<p class="verdict" class:ok={result.legal} class:bad={!result.legal}>
|
||||
{result.legal
|
||||
? t('game.wordLegal', { word: result.word })
|
||||
@@ -82,10 +119,10 @@
|
||||
<div class="actions">
|
||||
<!-- Complaints go to the admin over the network; a local (offline) game has no backend to
|
||||
receive them, so the control is dropped there. -->
|
||||
{#if !isLocalGameId(id)}
|
||||
{#if !isLocalGameId(id) && connection.online}
|
||||
<button class="complain" onclick={complain}>{t('game.complain')}</button>
|
||||
{/if}
|
||||
{#if result.legal}
|
||||
{#if result.legal && connection.online}
|
||||
<a
|
||||
class="lookup"
|
||||
href={dictionaryLookupUrl(result.word, variant)}
|
||||
|
||||
+27
-2
@@ -15,6 +15,7 @@
|
||||
import { app, handleError, showToast, markChatRead, seedChatUnread } from '../lib/app.svelte';
|
||||
import { connection } from '../lib/connection.svelte';
|
||||
import { offlineMode } from '../lib/offline.svelte';
|
||||
import { netState } from '../lib/netstate.svelte';
|
||||
import { maybeShowInterstitial } from '../lib/ads';
|
||||
import { GatewayError } from '../lib/client';
|
||||
import { t, type MessageKey } from '../lib/i18n/index.svelte';
|
||||
@@ -63,6 +64,10 @@
|
||||
// disabled while disconnected or in offline mode (which also stops the "something went wrong"
|
||||
// toasts a blocked call would otherwise raise).
|
||||
const netReady = $derived(isLocalGameId(id) || (connection.online && !offlineMode.active));
|
||||
// offlineInGame marks an online game that has lost the connection (confirmed offline, on any
|
||||
// platform including Telegram/VK): the play area shows an explicit "connection lost" banner and
|
||||
// the tray/actions freeze. It follows netState (not offlineMode, which is inert in the mini-apps).
|
||||
const offlineInGame = $derived(!isLocalGameId(id) && netState.offline);
|
||||
// Unsubscribes from a local game's robot-reply events (offline only; null for a network game).
|
||||
let localUnsub: (() => void) | null = null;
|
||||
|
||||
@@ -1463,6 +1468,7 @@
|
||||
// a local (offline) game, whose seats are account-less: vs_ai, and hotseat's synthetic seat ids.
|
||||
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||
return (
|
||||
netReady &&
|
||||
!!s.accountId &&
|
||||
!app.profile?.isGuest &&
|
||||
s.accountId !== app.session?.userId &&
|
||||
@@ -1476,7 +1482,7 @@
|
||||
// An already-blocked opponent hides it (both controls go, and the name is struck).
|
||||
function canBlock(s: { accountId: string; seat: number }): boolean {
|
||||
if (view?.game.vsAi || isLocalGameId(id)) return false;
|
||||
return !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||
return netReady && !!s.accountId && !app.profile?.isGuest && s.accountId !== app.session?.userId && !seatBlocked(s);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1490,6 +1496,9 @@
|
||||
selfInset={landscape}
|
||||
>
|
||||
{#if view}
|
||||
{#if offlineInGame}
|
||||
<div class="offline-banner" role="status">{t('game.offlineBanner')}</div>
|
||||
{/if}
|
||||
{#if landscape}
|
||||
<div class="game-land">
|
||||
<div class="leftpane">
|
||||
@@ -1598,6 +1607,10 @@
|
||||
{:else if view.game.hotseat}
|
||||
<!-- Hotseat resign is a host (referee) action, not self-serve; keep the slot empty. -->
|
||||
<span aria-hidden="true"></span>
|
||||
{:else if !netReady}
|
||||
<!-- Offline: resigning needs the server, so hide the control (like the frozen social controls);
|
||||
an empty slot keeps the header's space-between layout. -->
|
||||
<span aria-hidden="true"></span>
|
||||
{:else}
|
||||
<button class="hicon" onclick={onResignClick} disabled={waitingForOpponent} aria-label={t('game.dropGame')}>🏁</button>
|
||||
{/if}
|
||||
@@ -1606,7 +1619,7 @@
|
||||
chat, so its hub is Dictionary-only (CommsHub), and once finished the dictionary closes too
|
||||
— so drop the entry only when a local game is finished; an active local game keeps it for
|
||||
the Dictionary. An online game always keeps it (chat outlives the game). -->
|
||||
{#if !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||
{#if netReady && !(gameOver && (view.game.vsAi || view.game.hotseat))}
|
||||
<button class="hicon" onclick={() => navigate(`/game/${id}/chat`)} aria-label={t('game.chat')}>
|
||||
{#key chatBlink}<span class="chat-ico" class:blink={chatBlink > 0 && !app.reduceMotion}>💬</span>{/key}
|
||||
</button>
|
||||
@@ -1694,6 +1707,7 @@
|
||||
draggingId={reorderDragId}
|
||||
dropIndex={reorderTo}
|
||||
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
|
||||
frozen={!netReady}
|
||||
ondown={onRackDown}
|
||||
/>
|
||||
{/if}
|
||||
@@ -2178,6 +2192,17 @@
|
||||
color: var(--text-muted);
|
||||
padding: 40px;
|
||||
}
|
||||
/* An online game that lost the connection: a slim, non-blocking strip at the top of the play area.
|
||||
A solid token background (not color-mix) keeps it visible on the old-WebView floor. */
|
||||
.offline-banner {
|
||||
padding: 7px var(--pad);
|
||||
text-align: center;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.25;
|
||||
color: var(--text);
|
||||
background: var(--surface-2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.ghost {
|
||||
position: fixed;
|
||||
width: 40px;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
shuffling = false,
|
||||
draggingId = null,
|
||||
dropIndex = null,
|
||||
frozen = false,
|
||||
confirm,
|
||||
ondown,
|
||||
}: {
|
||||
@@ -26,6 +27,10 @@
|
||||
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
|
||||
draggingId?: number | null;
|
||||
dropIndex?: number | null;
|
||||
/** frozen disables tile interaction (offline in an online game): tiles cannot be picked up to
|
||||
* compose or reorder a move until the connection returns. The confirm control is gated
|
||||
* separately by the parent. */
|
||||
frozen?: boolean;
|
||||
/** The confirm-move control, rendered as the fixed 7th slot of the rack while a play is staged
|
||||
* (the parent owns the button and its enablement; the rack only positions it). */
|
||||
confirm?: Snippet;
|
||||
@@ -65,6 +70,7 @@
|
||||
class:selected={selected === slot.index}
|
||||
class:shift={dropIndex != null && i >= dropIndex}
|
||||
data-rack-index={slot.index}
|
||||
disabled={frozen}
|
||||
animate:hop={shuffling}
|
||||
onpointerdown={(e) => ondown(e, slot.index)}
|
||||
>
|
||||
@@ -114,6 +120,10 @@
|
||||
outline: 3px solid var(--accent);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* Frozen (offline in an online game): the tray is inert until the connection returns. */
|
||||
.tile:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
|
||||
tile width plus the rack gap), so the drop position is visible. */
|
||||
.rack.reordering .tile {
|
||||
|
||||
+51
-12
@@ -10,12 +10,12 @@ import { navigate, router } from './router.svelte';
|
||||
import { errorKey, localeFrom, setLocale, t, type Locale } from './i18n/index.svelte';
|
||||
import { languageNeedsServerSync } from './language';
|
||||
import { startLocalEvalMetrics } from './localeval-metrics';
|
||||
import { createStreamWatchdog } from './streamwatchdog';
|
||||
import { applyReduceMotion, applyTelegramTheme, applyTheme, type ThemePref, type TelegramThemeParams } from './theme';
|
||||
import {
|
||||
insideTelegram,
|
||||
telegramClose,
|
||||
collectTelegramDiag,
|
||||
type TelegramDiag,
|
||||
telegramLaunchDiag,
|
||||
onTelegramPath,
|
||||
hasLaunchFragment,
|
||||
loadTelegramSDK,
|
||||
@@ -33,7 +33,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 +83,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;
|
||||
@@ -214,6 +216,7 @@ function bannerSuppressed(): boolean {
|
||||
|
||||
function goBackground(): void {
|
||||
backgrounded = true;
|
||||
streamWatchdog.pause(); // do not count silence against a suspended (throttled) stream
|
||||
}
|
||||
|
||||
function goForeground(): void {
|
||||
@@ -226,6 +229,7 @@ function goForeground(): void {
|
||||
// The stream stayed alive across the suspend, so the reconnect refetch will not fire — but an
|
||||
// event may have been shed from a full hub buffer while away; nudge an open game to refetch.
|
||||
app.resync++;
|
||||
streamWatchdog.resume(); // resume liveness watching paused on the way to the background
|
||||
}
|
||||
void refreshNotifications();
|
||||
}
|
||||
@@ -352,12 +356,35 @@ function viewingGame(gameId: string): boolean {
|
||||
return router.route.name === 'game' && router.route.params.id === gameId;
|
||||
}
|
||||
|
||||
// The live stream carries a heartbeat every GATEWAY_PUSH_HEARTBEAT_INTERVAL (10 s by default), so a
|
||||
// healthy stream delivers an event well inside this window even when the game is idle. A silence
|
||||
// past it means the stream died without erroring (e.g. airplane mode cut the radio): tear the dead
|
||||
// subscription down and drop, so the machine leaves "online" instead of waiting for the next call.
|
||||
// 25 s tolerates a couple of missed heartbeats; it must stay above the server interval + slack.
|
||||
const STREAM_IDLE_TIMEOUT_MS = 25000;
|
||||
const streamWatchdog = createStreamWatchdog({
|
||||
timeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
||||
onDead: () => {
|
||||
// Do not trip while suspended: timers are throttled and a backgrounded stream is legitimately
|
||||
// silent (the reopen on resume covers a genuine drop).
|
||||
if (backgrounded || documentHidden()) return;
|
||||
// Abort the dead subscription explicitly — an aborted stream skips onError (transport gates on
|
||||
// signal.aborted) — then follow the same drop path as an error close.
|
||||
unsubscribeStream?.();
|
||||
unsubscribeStream = null;
|
||||
app.streamAlive = false;
|
||||
if (!bannerSuppressed()) reportOffline();
|
||||
scheduleReconnect();
|
||||
},
|
||||
});
|
||||
|
||||
function openStream(): void {
|
||||
closeStream();
|
||||
app.streamAlive = true;
|
||||
unsubscribeStream = gateway.subscribe(
|
||||
(e) => {
|
||||
reportOnline(); // a delivered event proves the gateway is reachable
|
||||
streamWatchdog.reset(); // any event (incl. the heartbeat) proves the stream is still live
|
||||
app.lastEvent = e;
|
||||
if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) {
|
||||
// While the player is in that game's comms hub (chat or dictionary tab), neither
|
||||
@@ -456,6 +483,7 @@ function openStream(): void {
|
||||
}
|
||||
},
|
||||
() => {
|
||||
streamWatchdog.clear();
|
||||
app.streamAlive = false;
|
||||
// A background suspend drops the single-shot stream. Keep the indicator hidden while
|
||||
// backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the
|
||||
@@ -464,6 +492,7 @@ function openStream(): void {
|
||||
scheduleReconnect();
|
||||
},
|
||||
);
|
||||
streamWatchdog.reset(); // start the liveness watch for this stream (armed by the first heartbeat)
|
||||
}
|
||||
|
||||
/** scheduleReconnect reopens a dropped stream once, after a short delay, while the
|
||||
@@ -528,6 +557,7 @@ export async function refreshFeedbackBadge(): Promise<void> {
|
||||
}
|
||||
|
||||
function closeStream(): void {
|
||||
streamWatchdog.clear();
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
@@ -850,7 +880,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 +905,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 +1111,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;
|
||||
|
||||
@@ -32,8 +32,8 @@ describe('shareLink', () => {
|
||||
});
|
||||
|
||||
it('wraps a payload in a startapp link', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
|
||||
expect(shareLink('f123456')).toBe('https://t.me/bot/app?startapp=f123456');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://telegram.me/bot/app');
|
||||
expect(shareLink('f123456')).toBe('https://telegram.me/bot/app?startapp=f123456');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('botUsername', () => {
|
||||
});
|
||||
|
||||
it('extracts the bot handle from the Mini App link', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://t.me/bot/app');
|
||||
vi.stubEnv('VITE_TELEGRAM_LINK', 'https://telegram.me/bot/app');
|
||||
expect(botUsername()).toBe('bot');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@ function envVar(name: string): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* telegramBase returns the single bot's Mini App link base (e.g. https://t.me/<bot>/<app>)
|
||||
* telegramBase returns the single bot's Mini App link base (e.g. https://telegram.me/<bot>/<app>)
|
||||
* from VITE_TELEGRAM_LINK, or null when it is not configured.
|
||||
*/
|
||||
function telegramBase(): string | null {
|
||||
@@ -52,7 +52,7 @@ function telegramBase(): string | null {
|
||||
|
||||
/**
|
||||
* botUsername extracts the bot's @username (without the @) from the configured Mini App
|
||||
* link: the first path segment of https://t.me/<bot>/<app>. Returns null when no base is
|
||||
* link: the first path segment of https://telegram.me/<bot>/<app>. Returns null when no base is
|
||||
* configured or the link carries no path.
|
||||
*/
|
||||
export function botUsername(): string | null {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { setAlphabet } from '../alphabet';
|
||||
|
||||
// getDawg is the only side-effecting dependency; stub it so the test exercises localWordCheck's
|
||||
// own logic (load -> encode -> membership -> null handling) against a fake reader, not a real DAWG.
|
||||
vi.mock('./loader', () => ({ getDawg: vi.fn() }));
|
||||
import { getDawg } from './loader';
|
||||
import { localWordCheck } from './check';
|
||||
|
||||
const mockedGetDawg = vi.mocked(getDawg);
|
||||
|
||||
// A tiny cached alphabet so indexForLetter can encode the test words.
|
||||
setAlphabet('scrabble_en', [
|
||||
{ index: 0, letter: 'A', value: 1 },
|
||||
{ index: 1, letter: 'B', value: 3 },
|
||||
{ index: 2, letter: 'C', value: 3 },
|
||||
]);
|
||||
|
||||
describe('localWordCheck', () => {
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
it('returns true when the dawg contains the word', async () => {
|
||||
mockedGetDawg.mockResolvedValue({ indexOf: () => 5 } as never);
|
||||
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the dawg does not contain the word', async () => {
|
||||
mockedGetDawg.mockResolvedValue({ indexOf: () => -1 } as never);
|
||||
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when the dawg is unavailable (offline and uncached)', async () => {
|
||||
mockedGetDawg.mockResolvedValue(null);
|
||||
expect(await localWordCheck('scrabble_en', 'v1', 'AB')).toBe(null);
|
||||
});
|
||||
|
||||
it('encodes the word to alphabet indices for the membership test', async () => {
|
||||
const indexOf = vi.fn(() => 0);
|
||||
mockedGetDawg.mockResolvedValue({ indexOf } as never);
|
||||
await localWordCheck('scrabble_en', 'v1', 'CAB');
|
||||
expect(indexOf).toHaveBeenCalledWith([2, 0, 1]);
|
||||
});
|
||||
|
||||
it('loads the dawg for the pinned (variant, version)', async () => {
|
||||
mockedGetDawg.mockResolvedValue({ indexOf: () => 1 } as never);
|
||||
await localWordCheck('scrabble_en', 'v1.3.0', 'AB');
|
||||
expect(mockedGetDawg).toHaveBeenCalledWith('scrabble_en', 'v1.3.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
// Offline word-check fallback for an online game. An online game's dictionary check normally goes to
|
||||
// the gateway (game.check_word); while disconnected that call fails, so the check falls back to the
|
||||
// game's own pinned dictionary read locally. Using the game's pinned (variant, version) means the
|
||||
// answer matches the server's — no divergence — as long as that dawg is available on-device; if it
|
||||
// is not cached/bundled offline, the check is simply unavailable (null). The membership test mirrors
|
||||
// the local engine's dictionaryHas (encode to alphabet indices, then Dawg.indexOf).
|
||||
|
||||
import { getDawg } from './loader';
|
||||
import { indexForLetter } from '../alphabet';
|
||||
import type { Variant } from '../model';
|
||||
|
||||
/**
|
||||
* localWordCheck reports whether word is in the (variant, version) dictionary using the on-device
|
||||
* dawg, or null when that dawg cannot be obtained (offline and neither cached nor bundled). word
|
||||
* must already be sanitised to the variant's alphabet (as the check panel does).
|
||||
*/
|
||||
export async function localWordCheck(
|
||||
variant: Variant,
|
||||
version: string,
|
||||
word: string,
|
||||
): Promise<boolean | null> {
|
||||
const dawg = await getDawg(variant, version);
|
||||
if (!dawg) return null;
|
||||
const idx = Array.from(word, (ch) => indexForLetter(variant, ch));
|
||||
return dawg.indexOf(idx) >= 0;
|
||||
}
|
||||
@@ -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',
|
||||
@@ -132,6 +133,8 @@ export const en = {
|
||||
'game.complaintSent': 'Thanks, sent for review.',
|
||||
'game.check': 'Check',
|
||||
'game.checkWait': 'Please wait a moment.',
|
||||
'game.checkOffline': 'Word check is unavailable offline.',
|
||||
'game.offlineBanner': 'Connection lost — your move will send once you’re back online.',
|
||||
'game.noHintOptions': 'No options with your letters.',
|
||||
'game.thinking': 'thinking…',
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export const ru: Record<MessageKey, string> = {
|
||||
'boot.errorBody': 'Попробуйте ещё раз или зайдите позже.',
|
||||
|
||||
'launch.errorTitle': 'Не открывается в Telegram',
|
||||
'launch.errorTitleVk': 'Не открывается в VK',
|
||||
'launch.errorBody': 'Сделайте скриншот или поделитесь с разработчиком.',
|
||||
'launch.share': 'Поделиться',
|
||||
'launch.copied': 'Скопировано',
|
||||
@@ -132,6 +133,8 @@ export const ru: Record<MessageKey, string> = {
|
||||
'game.complaintSent': 'Спасибо, отправлено на проверку.',
|
||||
'game.check': 'Проверить',
|
||||
'game.checkWait': 'Секунду, пожалуйста.',
|
||||
'game.checkOffline': 'Проверка недоступна офлайн.',
|
||||
'game.offlineBanner': 'Связь потеряна — ход отправится, когда вернётся сеть.',
|
||||
'game.noHintOptions': 'Нет вариантов с вашим набором.',
|
||||
'game.thinking': 'думает…',
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ describe('telegramChannelLink', () => {
|
||||
|
||||
it('builds the t.me link from the channel name', () => {
|
||||
vi.stubEnv('VITE_TELEGRAM_GAME_CHANNEL_NAME', '@Erudit_Game'); // a leading @ is tolerated
|
||||
expect(telegramChannelLink()).toBe('https://t.me/Erudit_Game');
|
||||
expect(telegramChannelLink()).toBe('https://telegram.me/Erudit_Game');
|
||||
});
|
||||
|
||||
it('returns null when the channel name is unset or blank', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
export function telegramChannelLink(): string | null {
|
||||
const raw = import.meta.env.VITE_TELEGRAM_GAME_CHANNEL_NAME;
|
||||
const name = (raw as string | undefined)?.trim().replace(/^@/, '');
|
||||
return name ? `https://t.me/${name}` : null;
|
||||
return name ? `https://telegram.me/${name}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ describe('openExternalUrl', () => {
|
||||
origin: 'https://app.example',
|
||||
});
|
||||
vi.stubGlobal('window', { open });
|
||||
openExternalUrl('https://t.me/some_bot');
|
||||
openExternalUrl('https://telegram.me/some_bot');
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
`https://vk.com/away.php?to=${encodeURIComponent('https://t.me/some_bot')}`,
|
||||
`https://vk.com/away.php?to=${encodeURIComponent('https://telegram.me/some_bot')}`,
|
||||
'_blank',
|
||||
);
|
||||
});
|
||||
@@ -23,7 +23,7 @@ describe('openExternalUrl', () => {
|
||||
it('opens a plain new tab elsewhere', () => {
|
||||
const open = vi.fn();
|
||||
vi.stubGlobal('window', { open });
|
||||
openExternalUrl('https://t.me/some_bot');
|
||||
expect(open).toHaveBeenCalledWith('https://t.me/some_bot', '_blank');
|
||||
openExternalUrl('https://telegram.me/some_bot');
|
||||
expect(open).toHaveBeenCalledWith('https://telegram.me/some_bot', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -829,7 +829,14 @@ export class MockGateway implements GatewayClient {
|
||||
// --- live stream ---
|
||||
subscribe(onEvent: (e: PushEvent) => void): Unsubscribe {
|
||||
this.subs.add(onEvent);
|
||||
return () => this.subs.delete(onEvent);
|
||||
// Mirror the gateway's idle keep-alive (an immediate heartbeat, then one every 10 s) so the
|
||||
// client's stream watchdog sees a live stream through an idle mock session, as in production.
|
||||
onEvent({ kind: 'heartbeat' });
|
||||
const hb = setInterval(() => onEvent({ kind: 'heartbeat' }), 10000);
|
||||
return () => {
|
||||
clearInterval(hb);
|
||||
this.subs.delete(onEvent);
|
||||
};
|
||||
}
|
||||
|
||||
// Fabricate an opponent reply shortly after the human moves, then hand the turn back.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createStreamWatchdog } from './streamwatchdog';
|
||||
|
||||
describe('createStreamWatchdog', () => {
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it('fires onDead after the timeout with no activity', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.reset();
|
||||
vi.advanceTimersByTime(24999);
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(onDead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reset before the timeout re-arms and prevents the fire', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.reset();
|
||||
vi.advanceTimersByTime(20000);
|
||||
w.reset(); // a fresh event arrived; the deadline moves out
|
||||
vi.advanceTimersByTime(20000);
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(5000);
|
||||
expect(onDead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('a steady heartbeat keeps it from ever firing', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.reset();
|
||||
// A 10 s server heartbeat resets it well inside the 25 s window, indefinitely.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
vi.advanceTimersByTime(10000);
|
||||
w.reset();
|
||||
}
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('pause clears the pending timer so it cannot fire', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.reset();
|
||||
w.pause();
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resume re-arms after a pause and then fires on continued silence', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.reset();
|
||||
w.pause();
|
||||
vi.advanceTimersByTime(60000); // stayed silent while paused: no fire
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
w.resume();
|
||||
vi.advanceTimersByTime(24999);
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1);
|
||||
expect(onDead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clear stops a pending fire and does not re-arm', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.reset();
|
||||
w.clear();
|
||||
vi.advanceTimersByTime(60000);
|
||||
expect(onDead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reset after a pause resumes watching (unpauses)', () => {
|
||||
const onDead = vi.fn();
|
||||
const w = createStreamWatchdog({ timeoutMs: 25000, onDead });
|
||||
w.pause();
|
||||
w.reset(); // a reopen after a suspend must start watching again
|
||||
vi.advanceTimersByTime(25000);
|
||||
expect(onDead).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// A liveness watchdog for the live event stream. The gateway pushes a `heartbeat` event on a
|
||||
// fixed cadence (GATEWAY_PUSH_HEARTBEAT_INTERVAL, default 10 s), so a healthy stream delivers at
|
||||
// least one event well inside the timeout even when the game itself is idle. A network that dies
|
||||
// silently — e.g. airplane mode cutting the radio — leaves the streaming fetch neither erroring
|
||||
// nor delivering; without this watchdog the machine would stay "online" until the next active
|
||||
// call fails (often only on a foreground resync). The watchdog resets on every delivered event
|
||||
// and fires onDead once the stream has been silent for the whole timeout, so the app can report
|
||||
// the drop and reconnect.
|
||||
|
||||
/** StreamWatchdog is the control surface returned by {@link createStreamWatchdog}. */
|
||||
export interface StreamWatchdog {
|
||||
/** reset (re)starts the countdown; call on stream open and on every delivered event. It also
|
||||
* clears a prior pause, so a reopen after a suspend resumes watching. */
|
||||
reset(): void;
|
||||
/** pause stops the countdown while the app is backgrounded (timers are throttled while hidden,
|
||||
* and a suspended stream legitimately delivers nothing — neither should trip a false drop). */
|
||||
pause(): void;
|
||||
/** resume restarts the countdown after a pause (on returning to the foreground). */
|
||||
resume(): void;
|
||||
/** clear stops the countdown without re-arming; call when the stream is closed or has errored. */
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* createStreamWatchdog builds a watchdog that calls onDead once no reset has happened for
|
||||
* timeoutMs. timeoutMs must exceed the server heartbeat interval plus proxy/jitter slack, or a
|
||||
* healthy but idle stream would trip it.
|
||||
*/
|
||||
export function createStreamWatchdog(opts: { timeoutMs: number; onDead: () => void }): StreamWatchdog {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let paused = false;
|
||||
|
||||
function stop(): void {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
}
|
||||
function arm(): void {
|
||||
stop();
|
||||
timer = setTimeout(opts.onDead, opts.timeoutMs);
|
||||
}
|
||||
|
||||
return {
|
||||
reset(): void {
|
||||
paused = false;
|
||||
arm();
|
||||
},
|
||||
pause(): void {
|
||||
paused = true;
|
||||
stop();
|
||||
},
|
||||
resume(): void {
|
||||
if (paused) {
|
||||
paused = false;
|
||||
arm();
|
||||
}
|
||||
},
|
||||
clear(): void {
|
||||
stop();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -148,7 +148,7 @@ describe('routeExternalLinkInTelegram', () => {
|
||||
|
||||
it('leaves a t.me link to the native handler (openTelegramLink owns those)', () => {
|
||||
const openLink = stubInside();
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://t.me/some_bot', target: '_blank' })).toBe(false);
|
||||
expect(routeExternalLinkInTelegram({ href: 'https://telegram.me/some_bot', target: '_blank' })).toBe(false);
|
||||
expect(openLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
+27
-49
@@ -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). */
|
||||
@@ -225,19 +226,19 @@ export function isExternalHttpUrl(href: string): boolean {
|
||||
export function routeExternalLinkInTelegram(anchor: { href: string; target: string }): boolean {
|
||||
if (!insideTelegram()) return false;
|
||||
if (anchor.target !== '_blank') return false;
|
||||
if (anchor.href.startsWith('https://t.me/')) return false;
|
||||
if (anchor.href.startsWith('https://telegram.me/')) return false;
|
||||
if (!isExternalHttpUrl(anchor.href)) return false;
|
||||
return telegramOpenExternalLink(anchor.href);
|
||||
}
|
||||
|
||||
/**
|
||||
* shareTelegramLink opens Telegram's native "share to a chat" picker for url with a
|
||||
* caption, through the Mini App SDK (https://t.me/share/url). Returns false outside
|
||||
* caption, through the Mini App SDK (https://telegram.me/share/url). Returns false outside
|
||||
* Telegram or when the SDK lacks the method, so the caller can fall back to the Web
|
||||
* Share API. Works consistently on iOS and Android within Telegram.
|
||||
*/
|
||||
export function shareTelegramLink(url: string, text: string): boolean {
|
||||
return telegramOpenLink(`https://t.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
|
||||
return telegramOpenLink(`https://telegram.me/share/url?url=${encodeURIComponent(url)}&text=${encodeURIComponent(text)}`);
|
||||
}
|
||||
|
||||
/** TelegramLaunch is the data a Mini App launch carries. */
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user