Compare commits

..

21 Commits

Author SHA1 Message Date
developer 516ffbe5f0 Merge pull request 'release: v1.16.0 — offer live pricing, bot health telemetry, edge blocklist' (#247) from development into master 2026-07-11 11:56:09 +00:00
developer 3ce460f72a Merge pull request 'feat(edge): community IP blocklist (Spamhaus DROP) at the gateway' (#246) from feature/edge-blocklist into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 24s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m53s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 25s
CI / ui (pull_request) Successful in 1m11s
CI / deploy (pull_request) Has been skipped
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
2026-07-11 11:39:47 +00:00
Ilia Denisov 4ba9da6721 feat(edge): community IP blocklist (Spamhaus DROP) at the gateway
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m49s
Refuse a client whose IP is in a curated CIDR feed with 403 in the same
abuseGuard, before the fail2ban ban. Prod-only (keys by real client IP), off by
default.

- ratelimit.Blocklist: a sorted-range IPv4 matcher (binary search) with an
  allowlist checked first; ParseDROP reads the feed; ApplyRefresh keeps the
  last-good feed on a transient fetch failure and drops it fail-open once stale
  (better to under-block than block a legitimate client on a frozen feed). A
  separate static CIDR set, not the per-IP fail2ban store.
- gateway: a refresher goroutine re-fetches every few hours (bounded fetch + size
  cap); config GATEWAY_BLOCKLIST_{ENABLED,URL,ALLOW,REFRESH,MAX_STALENESS}.
  IPv6 is not matched (a v6 client is still covered by fail2ban / the honeypot).
- observability: gateway_blocklist_blocked_total + entries/age gauges; a Grafana
  alert warns before the feed is dropped; service-overview panels.
- deploy: compose env + write-prod-env.sh + prod-deploy/rollback wiring (opt-in
  via PROD_ vars).
- docs (ARCHITECTURE, deploy/README); unit tests (match, allowlist, IPv6 skip,
  parser, fault-tolerance) + a 403 abuse-guard integration test.
2026-07-11 13:33:28 +02:00
developer ad1cc361e9 Merge pull request 'feat(bot): Telegram bot Bot API health over the bot-link (metrics + alerts)' (#245) from feature/bot-health-telemetry into development
CI / changes (push) Successful in 1s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 23s
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m55s
2026-07-11 10:58:01 +00:00
Ilia Denisov 2c2316fb5e chore(catalog): order the admin catalog list like the public offer
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Sales (chip packs) first, ascending by rouble price; then the chip-exchange
values, grouped and price-sorted the same way projectOfferPricing lists them, so
the /catalog console mirrors what a buyer sees. Internal cosmetics only — no
product behaviour change. The value-group order moves to a shared helper
(valueGroup) so the offer and the admin list cannot drift.
2026-07-11 12:53:37 +02:00
Ilia Denisov bb71e7b1c7 feat(bot): report Telegram bot Bot API health to the gateway over the bot-link
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m56s
The bot runs on its own host and exports no telemetry (otelcol is unreachable
from there), so it was a monitoring blind spot. Observe its Bot API health
centrally by wrapping the HTTP client — one place, no per-call-site
instrumentation — and relay it up the existing bot-link as a periodic Health
message the gateway turns into its own metrics.

- proto: add Health (delta connect / api / 429 counters + a last-ok stamp) to
  the FromBot oneof (additive, backward-compatible).
- bot: platform/telegram/internal/health wraps the Bot API HTTP client — it
  stamps liveness on any 2xx (so the getUpdates long-poll keeps it fresh even
  when idle), classifies transport/5xx failures (getUpdates vs other) and 429s,
  and honours a 429's Retry-After (bounded) so the bot backs off; a 4xx other
  than 429 is a normal per-request outcome and is not counted.
- bot-link client: flush the reporter as a Health message every 30s over a
  single-sender loop (a gRPC stream forbids concurrent Send).
- gateway: fold each report into bot_tg_errors_total{kind} and the
  bot_tg_last_ok_unix liveness gauge.
- grafana: alerts (bot disconnected, bot not reaching the Bot API, sustained
  429s) routed to the operator email — which does not go through the bot — plus
  a Telegram-bot dashboard.
- docs (ARCHITECTURE, compose comments); unit tests (observer classification,
  Retry-After, snapshot/commit, hub last-ok monotonicity).
2026-07-11 12:48:06 +02:00
developer 5c1f64c7d1 Merge pull request 'feat(offer): live catalog price list in the public offer (render sidecar)' (#244) from feature/offer-pricing-live into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 12s
CI / integration (push) Successful in 19s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 1m48s
2026-07-11 09:51:48 +00:00
Ilia Denisov b3cf024e9e chore(offer): fix typo
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m54s
2026-07-11 11:45:45 +02:00
Ilia Denisov 6e120bdaa7 docs(offer): update phrasing
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m24s
2026-07-11 11:39:43 +02:00
Ilia Denisov 40acbcccdd feat(offer): sort and align the §4.4 price tables, style them for both themes
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m47s
- packs sorted by ascending rouble price;
- values grouped hints-only -> no-ads-only -> no-ads+hints -> tournament
  (the tournament group is empty until such products become sellable),
  ascending chip price within each group;
- price columns right-aligned (GFM "---:" separators);
- tables span the full content width; the name column shrinks to its content
  and never wraps;
- muted-but-visible cell borders on the dark theme, where the section rule
  colour blends into the background.
2026-07-11 11:29:41 +02:00
Ilia Denisov b54371845f harden(offer): escape admin product titles against HTML/markdown injection
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m21s
The offer price list projects the admin-entered product title into a markdown
table cell that is rendered, unsanitised, into the public /offer/ page. Escape
the title at the projection boundary so it renders as literal text: HTML
metacharacters become entities and the markdown table pipe and link brackets are
escaped, so a title can neither inject markup nor form a javascript: link. The
committed offer prose keeps its trusted-content treatment (code-reviewed, not
runtime input).
2026-07-10 20:30:36 +02:00
Ilia Denisov b6c2598710 feat(offer): live catalog price list in the public offer, served by the render sidecar
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with
its price. Move /offer/ off the static landing container to the render sidecar:
it splices the live catalog price list (§4.4) into the owner-edited
ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one
renderer, no drift, always matching the current catalog with no redeploy.

- backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list)
  projects the active catalog into two markdown tables — chip packs priced per
  rail (roubles / VK votes / Telegram Stars) and chip-priced values — through
  payments.Money so no float reaches the page. Cached in memory: warmed at boot,
  marked stale on every catalog mutation, so a served render issues no query.
- renderer: GET /offer/ fetches the tables and substitutes them at the
  <#pricing_template#> marker, then renders; offer_ru.md is baked into the image
  and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed.
- caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/
  handlers and the vite emit-offer plugin.
- offer: fill §4.3 (the chip-payment wording) and drop the in-page back link.
- landing footer: a feedback link (the offer's Telegram contact) beside the
  offer link.
- docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe,
  unit + integration + node tests.
2026-07-10 20:25:41 +02:00
developer 6badc20078 Merge pull request 'Release v1.15.0 — in-game UX + wallet redesign + WAL-alert fix' (#243) from development into master 2026-07-10 16:15:20 +00:00
developer a241e43d79 Merge pull request 'Wallet redesign: split buy/spend, compact balance, exchange confirm + insufficient-chips fix' (#242) from feature/wallet-redesign into development
CI / changes (push) Successful in 2s
CI / deploy (push) Successful in 1m56s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / unit (push) Has been skipped
CI / integration (push) Has been skipped
CI / ui (push) Successful in 1m10s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 19s
CI / ui (pull_request) Successful in 1m12s
CI / deploy (pull_request) Has been skipped
2026-07-10 16:00:54 +00:00
Ilia Denisov 8ce986922a feat(ui): wallet storefront redesign — split buy/spend, compact balance, exchange confirm
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
Reworks the Wallet screen to be more compact and to separate the two flows, and fixes a
reported bug.

Bug: buying a value with too few chips showed the generic "Something went wrong" toast — the
backend's `insufficient_chips` (409) code was propagated correctly but had no i18n mapping, so
`errorKey` fell back to `error.generic`. Add `error.insufficient_chips` / `error.product_not_found`.
Also disable a value's Exchange button up front when the spendable balance cannot cover it (the
server stays authoritative).

Redesign:
- a compact **balance** row leads the screen — the context's own chips (🪙 N) then any linked
  other-platform chips behind that platform's logo (monospaced digits); inside a VK/TG store only
  that store's own segment shows;
- the benefits section is renamed **Active**, moved above the store, shows one inline line
  (hints + ad-free) and hides when nothing is active;
- the **store** splits by a two-way toggle into **Buy chips** (money packs + the watch-an-ad row at
  the top) and **Spend chips** (values, exchanged for chips);
- a value's action reads **Exchange** and opens a single confirmation dialog (chip spends are
  instant, with no provider window to confirm them); the existing cross-platform store-compliance
  warning folds into that same dialog when the spend would draw VK/TG chips.

No payments-model or wire change; the money→chips→values model is unchanged (verified: money buys
only chip packs, values are chips-only — "no-ads for money" is structurally impossible).

Tests: wallet e2e updated for the new layout (chromium + webkit green). Docs: FUNCTIONAL (+_ru)
wallet story rewritten. App bundle budget 126 → 127 KB (always-loaded settings hub).
2026-07-10 17:46:22 +02:00
developer 3469278260 Merge pull request 'fix(monitoring): don't false-fire the WAL-stalled alert on an idle database' (#241) from fix/pg-archive-stalled-idle-falsepositive into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 25s
CI / ui (push) Successful in 1m12s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m38s
2026-07-10 14:31:57 +00:00
Ilia Denisov c66bf1eceb fix(monitoring): pg_archive_stalled must not false-fire on an idle database
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 24s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
The "WAL archiving stalled" alert fired on prod during a quiet period. An idle Postgres
archives nothing — it never force-switches an empty WAL segment on archive_timeout — so
pg_stat_archiver_last_archive_age grows past 30 min even though archiving is perfectly
healthy (failed_count 0, no .ready segments, pg_wal flat, backups current). A false
positive: no data and no disk at risk (nothing was written, so the frozen recovery point
equals the live state).

Gate the age condition on pg_wal actually growing:

  (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16MB)

so it fires only when WAL is being produced but not archived (the real "pg_wal fills the
disk" danger; genuine archive_command failures are already caught by pg_archive_failing).
`and on()` bridges the two metrics' differing label sets (last_archive_age carries a server
label, the pg_wal gauge does not); delta() (not increase) suits the gauge. pg_stat_wal is
not exported by this postgres_exporter, so pg_wal size growth is the available signal.
Validated on prod with promtool: the expression is empty while idle.

Also fix the runbook command in both archive alerts' annotations: `pgbackrest check` needs
`--pg1-user=scrabble`, or it connects as role "root" (which does not exist) and aborts with
"no database found".
2026-07-10 16:25:01 +02:00
developer 7b4d2421e2 Merge pull request 'In-game UX: board highlight + score badge, full-width rack, bag badge, zoom setting' (#240) from feature/ingame-ux-board-highlight into development
CI / changes (push) Successful in 2s
CI / unit (push) Successful in 11s
CI / integration (push) Successful in 21s
CI / ui (push) Successful in 1m11s
CI / conformance (push) Successful in 10s
CI / gate (push) Successful in 0s
CI / deploy (push) Successful in 2m49s
2026-07-10 14:06:20 +00:00
Ilia Denisov bf46b9492d fix(ui): one-word games must not highlight phantom cross words; review polish
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m25s
CI / conformance (pull_request) Successful in 11s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 2m2s
Board-highlight bug (reported on the contour): formedGeometry walked cross words
unconditionally, so in a single-word (one-word-per-turn) game a staged tile sitting
next to a committed tile lit up a green "cross word" the engine ignores — and which
need not even be a real word (the reported "БО lights up green in a one-word ПОПА
play"). Gate the cross-word walk on the game's multipleWordsPerTurn flag. The score
(8) was already correct — a premium square under the main word.

Also, from review:
- the turn strip reads the staged play's "WORD+WORD = N" while composing a legal move,
  reverting to the turn / result text otherwise;
- the Exchange/Pass dialog shows the bag count ("In the bag: N" / "Bag is empty")
  right-aligned in the title row, via a new optional Modal `titleAside`;
- cosmetics: half the turn strip's bottom padding (the plaques below carry their own
  top pad); a top gap above the landscape rack (it sat flush under the docked history);
  more horizontal padding on tab count badges so a 2-3 digit bag count clears the pill
  ends;
- admin console: the game Summary now shows the single-word / multiple-words rule.

Tests: formed single-word case added; full unit (584) + e2e (chromium + webkit, 113
each) green; backend build + adminconsole templates parse. Docs (FUNCTIONAL +_ru,
UI_DESIGN) updated.
2026-07-10 15:58:05 +02:00
Ilia Denisov 1e1117c28e chore(ui): raise the app bundle budget to 126 KB for the in-game board UX
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m59s
2026-07-10 15:03:30 +02:00
Ilia Denisov 77a690fcf6 feat(ui): move in-game status to the board — highlight, score badge, full-width rack
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Has been skipped
CI / integration (pull_request) Has been skipped
CI / ui (pull_request) Failing after 13s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Failing after 0s
CI / deploy (pull_request) Has been skipped
Remove the under-board status strip and relocate its signals:
- bag count -> a badge on the exchange/pass control + the foot of the move table
- whose-turn / win-lose -> a thin strip above the score plaques
- the tentative-move caption -> the board itself: staged tiles tint green (legal) or
  pink (illegal), the board tiles a formed word runs through go a shade darker, and an
  orange score badge sits on the main word (digit sized like a tile value, clamped on-board)

The word geometry (covered cells + badge anchor) is a new pure client-side helper
(ui/src/lib/formed.ts), independent of the move evaluator, so it works on the local or
network preview path alike; the badge's number still comes from the preview score.

Rack: a seven-column grid filling the tray width in both layouts — square, full-width
tiles — with the confirm control in the fixed 7th slot.

Settings: a touch-only "Zoom the board" toggle (default on, device-local) gates the
tile-placement auto-zoom; taking a hint while zoomed in now zooms out so the highlighted
hint word is never left off-screen.

Docs (FUNCTIONAL +_ru, UI_DESIGN, ARCHITECTURE) and e2e/unit tests updated.
2026-07-10 14:58:32 +02:00
83 changed files with 3239 additions and 582 deletions
+12 -7
View File
@@ -510,15 +510,20 @@ jobs:
- name: Probe the /offer/ public offer page is served
run: |
set -u
# /offer/ is a static page baked into the landing image (rendered from
# ui/legal/offer_ru.md). If the landing Caddyfile stops routing it, the request
# silently falls through to the landing shell (also 200) — so assert offer-specific
# content, never just the status.
# /offer/ is rendered by the render sidecar: it splices the live catalog price list
# (fetched from the backend's internal endpoint) into the committed ui/legal/offer_ru.md.
# If the @offer caddy route is missing, the request falls to the landing shell (also 200),
# and if the backend fetch fails the sidecar returns 502 — so assert offer-specific content
# (the seller INN, §11) AND that the pricing marker was substituted (proves the fetch +
# splice ran), never just the status. Data-independent: an empty catalog still substitutes
# the marker with nothing, so "pricing_template" must be absent either way.
out="$(docker run --rm --network edge alpine:3.20 wget -q -O - http://scrabble/offer/ 2>&1 || true)"
if echo "$out" | grep -q "290210610742"; then
echo "ok: /offer/ serves the public offer page"
if echo "$out" | grep -q "290210610742" && ! echo "$out" | grep -q "pricing_template"; then
echo "ok: /offer/ serves the rendered offer with the price list spliced in"
else
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
echo "FAIL: /offer/ did not serve the rendered offer (route fell through, or the price splice failed)"
docker logs --tail 50 scrabble-renderer || true
docker logs --tail 50 scrabble-backend || true
docker logs --tail 50 scrabble-landing || true
exit 1
fi
+5
View File
@@ -115,6 +115,11 @@ jobs:
# Planted honeytoken bearer: presenting it earns a 24h IP ban + a high-severity alarm.
# Per-contour secret; empty = trap off. Rendered by deploy/write-prod-env.sh.
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist (Spamhaus DROP): opt-in. Set _ENABLED=true + _URL (the feed) once
# verified; _ALLOW is a comma-separated never-block set (own infra). Empty ⇒ off.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
# Signs the finished-game export download URLs (backend BACKEND_EXPORT_SIGN_KEY).
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
# Transactional email via the shared Selectel relay (confirm-codes): one account for
+4
View File
@@ -70,6 +70,10 @@ jobs:
VITE_VK_APP_ID: ${{ vars.VITE_VK_APP_ID }}
GATEWAY_VK_ID_CLIENT_SECRET: ${{ secrets.GATEWAY_VK_ID_CLIENT_SECRET }}
GATEWAY_HONEYTOKEN: ${{ secrets.PROD_GATEWAY_HONEYTOKEN }}
# Community IP blocklist — rendered on rollback too so a rollback keeps the same edge policy.
GATEWAY_BLOCKLIST_ENABLED: ${{ vars.PROD_GATEWAY_BLOCKLIST_ENABLED }}
GATEWAY_BLOCKLIST_URL: ${{ vars.PROD_GATEWAY_BLOCKLIST_URL }}
GATEWAY_BLOCKLIST_ALLOW: ${{ vars.PROD_GATEWAY_BLOCKLIST_ALLOW }}
EXPORT_SIGN_KEY: ${{ secrets.PROD_EXPORT_SIGN_KEY }}
SMTP_RELAY_USER: ${{ secrets.SMTP_RELAY_USER }}
SMTP_RELAY_PASS: ${{ secrets.SMTP_RELAY_PASS }}
+7
View File
@@ -283,6 +283,13 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
}
logger.Info("payments domain ready")
// Warm the public-offer price list cache so /offer/ serves the current catalog from the first
// request; it is reprojected lazily thereafter on any catalog edit. Non-fatal — a transient
// failure here only defers the projection to the first read.
if _, err := paymentsSvc.OfferPricing(ctx); err != nil {
logger.Warn("offer pricing warm failed; will project on first request", zap.Error(err))
}
// Wire the payments surface into the domains that consume it: the online-game hint wallet
// and the account-merge wallet fold. Done after the reachability check so a broken payments
// schema fails boot before anything depends on it.
@@ -8,6 +8,7 @@
<li><b>Dictionary</b> {{.DictVersion}}</li>
<li><b>Status</b> {{.Status}}{{if .EndReason}} ({{.EndReason}}){{end}}</li>
<li><b>AI game</b> {{if .VsAI}}🤖 yes{{else}}no{{end}}</li>
<li><b>Word rule</b> {{if .MultipleWordsPerTurn}}multiple words per turn{{else}}single word per turn{{end}}</li>
<li><b>Players</b> {{.Players}}</li>
<li><b>To move</b> seat {{.ToMove}}</li>
<li><b>Moves</b> {{.MoveCount}}</li>
+4
View File
@@ -349,6 +349,10 @@ type GameDetailView struct {
FinishedAt string
// VsAI marks an honest-AI game (shown as a 🤖 flag in the summary).
VsAI bool
// MultipleWordsPerTurn is the game's cross-word rule: true = standard Scrabble (every cross-word
// is validated and scored), false = the single-word rule (only the main word along the play
// direction counts). Shown in the summary so an operator can tell the rule at a glance.
MultipleWordsPerTurn bool
Seats []SeatRow
// HasRobot is true when any seat is a robot, gating the robot-target caption;
// RobotTargetPct is the configured global play-to-win rate, in percent.
@@ -4,6 +4,7 @@ package inttest
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
@@ -111,3 +112,44 @@ func TestPaymentsCatalogExcludesDeactivated(t *testing.T) {
t.Error("deactivated product must not appear in the storefront")
}
}
// TestOfferPricingReflectsCatalogEdits verifies the public-offer price list (§4.4) is projected from
// the live catalog and its cache is invalidated on a catalog mutation: a newly created pack appears
// with its per-rail prices, and archiving it through the service drops it from the next read.
func TestOfferPricingReflectsCatalogEdits(t *testing.T) {
svc := newPaymentsService()
ctx := context.Background()
title := "OfferTest " + uuid.NewString()
id, err := svc.CreateProduct(ctx, payments.ProductInput{
Title: title,
Atoms: []payments.AtomLine{{Atom: "chips", Quantity: 50}},
Prices: []payments.PriceLine{
{Method: "direct", Currency: payments.CurrencyRUB, Amount: 20000},
{Method: "vk", Currency: payments.CurrencyVote, Amount: 30},
{Method: "telegram", Currency: payments.CurrencyStar, Amount: 100},
},
}, true)
if err != nil {
t.Fatalf("create pack: %v", err)
}
md, err := svc.OfferPricing(ctx)
if err != nil {
t.Fatalf("offer pricing: %v", err)
}
if row := "| " + title + " | 200.00 | 30 | 100 |"; !strings.Contains(md, row) {
t.Fatalf("offer pricing missing the new pack row %q\n%s", row, md)
}
// Archiving through the service marks the cache stale; the next read must reproject without it.
if err := svc.SetProductActive(ctx, id, false); err != nil {
t.Fatalf("archive: %v", err)
}
md, err = svc.OfferPricing(ctx)
if err != nil {
t.Fatalf("offer pricing after archive: %v", err)
}
if strings.Contains(md, title) {
t.Errorf("archived pack must drop from the offer pricing:\n%s", md)
}
}
+82 -4
View File
@@ -1,9 +1,12 @@
package payments
import (
"cmp"
"context"
"errors"
"fmt"
"math"
"slices"
"strings"
"github.com/google/uuid"
@@ -147,13 +150,76 @@ func (s *Service) AdminCatalog(ctx context.Context) ([]AdminProduct, error) {
return s.store.adminCatalog(ctx)
}
// SortAdminCatalog orders an admin catalog list in place the same way the public offer lists products
// ([projectOfferPricing]): chip packs first, ascending by rouble price; then chip-priced values,
// grouped (hints only, no-ads only, no-ads + hints, tournament) and ascending by chip price within a
// group. It is stable, so equal-keyed products keep their incoming order, and it keys archived
// 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))
})
}
// adminIsPack reports whether the product is a chip pack (it carries the chips atom).
func adminIsPack(p AdminProduct) bool {
for _, a := range p.Atoms {
if a.Atom == atomChips {
return true
}
}
return false
}
// adminValueGroup ranks a chip-priced value into the shared listing groups (see [valueGroup]).
func adminValueGroup(p AdminProduct) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range p.Atoms {
switch a.Atom {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// adminPriceAmount returns the minor-unit amount of the product's price for the method and currency,
// or math.MaxInt64 when it carries no such price (so a misconfigured product sorts last, not first).
func adminPriceAmount(p AdminProduct, method string, cur Currency) int64 {
for _, pr := range p.Prices {
if pr.Method == method && pr.Currency == cur {
return pr.Amount
}
}
return math.MaxInt64
}
// CreateProduct validates and inserts a new product with its atoms and prices, returning its id. A
// product created active must satisfy the sellable shape.
func (s *Service) CreateProduct(ctx context.Context, in ProductInput, active bool) (uuid.UUID, error) {
if err := validateProduct(in, active); err != nil {
return uuid.Nil, err
}
return s.store.createProduct(ctx, in, active, s.clock())
id, err := s.store.createProduct(ctx, in, active, s.clock())
if err == nil {
s.markOfferStale()
}
return id, err
}
// UpdateProduct validates and replaces a product's title, atoms and prices. An active product must
@@ -166,7 +232,11 @@ func (s *Service) UpdateProduct(ctx context.Context, id uuid.UUID, in ProductInp
if err := validateProduct(in, active); err != nil {
return err
}
return s.store.updateProduct(ctx, id, in, s.clock())
if err := s.store.updateProduct(ctx, id, in, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// SetProductActive archives (active=false) or unarchives a product. Unarchiving revalidates the
@@ -181,11 +251,19 @@ func (s *Service) SetProductActive(ctx context.Context, id uuid.UUID, active boo
return err
}
}
return s.store.setProductActive(ctx, id, active, s.clock())
if err := s.store.setProductActive(ctx, id, active, s.clock()); err != nil {
return err
}
s.markOfferStale()
return nil
}
// DeleteProduct hard-deletes a product only when it has never been transacted (no order or ledger
// row references it); otherwise it returns ErrProductTransacted and the caller archives instead.
func (s *Service) DeleteProduct(ctx context.Context, id uuid.UUID) error {
return s.store.deleteProduct(ctx, id)
if err := s.store.deleteProduct(ctx, id); err != nil {
return err
}
s.markOfferStale()
return nil
}
+44
View File
@@ -134,3 +134,47 @@ func TestProjectCatalog_Empty(t *testing.T) {
t.Errorf("empty catalog projected %d products, want 0", len(got.Products))
}
}
// 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.
func TestSortAdminCatalog(t *testing.T) {
pack := func(title string, rub int64) AdminProduct {
return AdminProduct{
Title: title,
Atoms: []AtomLine{{Atom: atomChips, Quantity: 1}},
Prices: []PriceLine{{Method: string(SourceDirect), Currency: CurrencyRUB, Amount: rub}},
}
}
value := func(title string, chips int64, atoms ...string) AdminProduct {
p := AdminProduct{Title: title, Prices: []PriceLine{{Currency: CurrencyChip, Amount: chips}}}
for _, a := range atoms {
p.Atoms = append(p.Atoms, AtomLine{Atom: a, Quantity: 1})
}
return p
}
products := []AdminProduct{
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"),
}
SortAdminCatalog(products)
want := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
for i, p := range products {
if p.Title != want[i] {
t.Fatalf("order[%d] = %q, want %q (full: %v)", i, p.Title, want[i], titles(products))
}
}
}
// titles extracts the product titles for a failure message.
func titles(products []AdminProduct) []string {
out := make([]string, len(products))
for i, p := range products {
out[i] = p.Title
}
return out
}
+218
View File
@@ -0,0 +1,218 @@
package payments
import (
"cmp"
"context"
"fmt"
"math"
"slices"
"strings"
)
// pricingMarker is the token the owner-edited offer markdown (ui/legal/offer_ru.md, §4.4) carries
// where the price list belongs. The render sidecar replaces it with the markdown [Service.OfferPricing]
// returns before rendering the /offer/ page; the backend only produces the tables, never the marker.
const pricingMarker = "<#pricing_template#>"
// OfferPricing returns the public-offer price list (§4.4) as two markdown tables projected from the
// active catalog: first the chip packs (funding chips with money, priced per rail — roubles / VK
// votes / Telegram Stars), then the chip-priced values (what a player exchanges chips for). The
// result is cached in memory and reprojected only after a catalog mutation (see [Service.markOfferStale]),
// so a steady-state read issues no query — only the first read after an edit reprojects. The render
// sidecar fetches it and splices it into the offer markdown at the pricing marker.
func (s *Service) OfferPricing(ctx context.Context) (string, error) {
s.offerMu.Lock()
defer s.offerMu.Unlock()
if s.offerFresh {
return s.offerMD, nil
}
md, err := s.buildOfferPricing(ctx)
if err != nil {
return "", err
}
s.offerMD = md
s.offerFresh = true
return md, nil
}
// markOfferStale marks the cached offer price list for reprojection on the next [Service.OfferPricing]
// read. Every catalog mutation calls it; it takes no I/O, so it never fails the mutation that triggers it.
func (s *Service) markOfferStale() {
s.offerMu.Lock()
s.offerFresh = false
s.offerMu.Unlock()
}
// buildOfferPricing loads the active catalog and projects it into the offer tables.
func (s *Service) buildOfferPricing(ctx context.Context) (string, error) {
entries, err := s.store.loadCatalog(ctx)
if err != nil {
return "", err
}
return projectOfferPricing(entries), nil
}
// projectOfferPricing renders the active catalog into the two offer tables. A chip pack (it carries
// the chips atom) lists its per-rail money price; a value (no chips atom) lists its uniform chip
// price. Packs are ordered by ascending rouble price; values are grouped by what they grant (hints
// only, then no-ads only, then no-ads + hints, then tournament — see [offerValueGroup]) and, within
// each group, ordered by ascending chip price. Price columns are right-aligned. An empty section is
// 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 {
var packs, values []catalogEntry
for _, e := range entries {
if isPackEntry(e) {
packs = append(packs, e)
} else {
values = append(values, e)
}
}
// 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")
b.WriteString("| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n")
b.WriteString("| --- | ---: | ---: | ---: |\n")
for _, e := range packs {
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
offerCell(e.title),
offerPrice(e, string(SourceDirect), CurrencyRUB),
offerPrice(e, string(SourceVK), CurrencyVote),
offerPrice(e, string(SourceTelegram), CurrencyStar),
)
}
}
if len(values) > 0 {
if len(packs) > 0 {
b.WriteString("\n")
}
b.WriteString("Использование внутриигровой валюты «Фишка»:\n\n")
b.WriteString("| Наименование | «Фишки» |\n")
b.WriteString("| --- | ---: |\n")
for _, e := range values {
fmt.Fprintf(&b, "| %s | %s |\n", offerCell(e.title), offerPrice(e, "", CurrencyChip))
}
}
return strings.TrimRight(b.String(), "\n")
}
// offerValueGroup ranks a chip-priced value into the usage groups (see [valueGroup]).
func offerValueGroup(e catalogEntry) int {
hasHints, hasNoAds, hasTournament := false, false, false
for _, a := range e.atoms {
switch a.atomType {
case "hints":
hasHints = true
case "noads_days":
hasNoAds = true
case "tournament":
hasTournament = true
}
}
return valueGroup(hasHints, hasNoAds, hasTournament)
}
// valueGroup ranks a chip-priced value into the listing groups shared by the public offer and the
// admin catalog, in listing order: hints only (0), no-ads only (1), no-ads + hints (2), then anything
// carrying the tournament atom (3). Tournament products are not sellable yet (validateProduct forbids
// an active one), so group 3 is empty today; the rank reserves their place for when the tournament
// economy lands. A value with no recognised benefit atom sorts after the known groups (defensive —
// the catalog shape forbids it).
func valueGroup(hasHints, hasNoAds, hasTournament bool) int {
switch {
case hasTournament:
return 3
case hasHints && hasNoAds:
return 2
case hasNoAds:
return 1
case hasHints:
return 0
default:
return 4
}
}
// offerSortAmount returns the entry's price in the given method and currency for ordering, or
// math.MaxInt64 when it carries no such price, so a misconfigured row sorts last rather than leading.
func offerSortAmount(e catalogEntry, method string, cur Currency) int64 {
if amt, ok := offerAmount(e, method, cur); ok {
return amt
}
return math.MaxInt64
}
// isPackEntry reports whether the catalog entry is a chip pack — it carries the chips atom (funds
// chips with money) rather than being a chip-priced value.
func isPackEntry(e catalogEntry) bool {
for _, a := range e.atoms {
if a.atomType == atomChips {
return true
}
}
return false
}
// offerPrice formats the entry's price for the given payment method and currency as a major-unit
// string, or an em dash when the entry carries no such price.
func offerPrice(e catalogEntry, method string, cur Currency) string {
amt, ok := offerAmount(e, method, cur)
if !ok {
return "—"
}
m, err := MoneyFromMinor(amt, cur)
if err != nil {
return "—"
}
return m.Major()
}
// offerAmount returns the raw minor-unit amount of the entry's price for the payment method and
// currency, and whether such a price exists.
func offerAmount(e catalogEntry, method string, cur Currency) (int64, bool) {
for _, pr := range e.prices {
if pr.method == method && pr.currency == cur {
return pr.amount, true
}
}
return 0, false
}
// offerCellReplacer neutralises every metacharacter of an admin-entered title so it renders as
// literal text in the public offer. The title is operator input (the /_gm catalog editor) that flows
// into a markdown table cell and then through marked into the /offer/ HTML, which is deliberately not
// sanitised — so escaping here is the trust boundary. It covers HTML (no tag or entity reaches the
// page), the markdown table pipe and the row newline, and the link brackets (a title must never
// become a "javascript:" link). marked passes the entities through unchanged, so the reader sees the
// exact title. NewReplacer scans once and never re-scans its own output, so "&" → "&amp;" does not
// double-escape the entities the other rules emit.
var offerCellReplacer = strings.NewReplacer(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&#39;",
"|", `\|`,
"[", `\[`,
"]", `\]`,
"\n", " ",
)
// offerCell escapes an admin-entered title for safe, literal rendering in a markdown table cell of
// the public offer (see [offerCellReplacer]).
func offerCell(s string) string {
return offerCellReplacer.Replace(s)
}
+137
View File
@@ -0,0 +1,137 @@
package payments
import (
"strings"
"testing"
"github.com/google/uuid"
)
// TestProjectOfferPricing checks the happy path: a chip pack priced on every rail and a chip-priced
// value render into the two tables, pack table first, money formatted through Money.
func TestProjectOfferPricing(t *testing.T) {
entries := []catalogEntry{
{
id: uuid.New(),
title: "50 «Фишек»",
atoms: []atomQty{{atomType: atomChips, quantity: 50}},
prices: []priceRow{
{method: string(SourceDirect), currency: CurrencyRUB, amount: 20000},
{method: string(SourceVK), currency: CurrencyVote, amount: 30},
{method: string(SourceTelegram), currency: CurrencyStar, amount: 100},
},
},
{
id: uuid.New(),
title: "200 подсказок",
atoms: []atomQty{{atomType: "hints", quantity: 200}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 50}},
},
}
md := projectOfferPricing(entries)
for _, want := range []string{
"| Наименование | Рубли | Голоса в VK | Stars в Telegram |",
"| --- | ---: | ---: | ---: |", // price columns right-aligned
"| 50 «Фишек» | 200.00 | 30 | 100 |",
"| Наименование | «Фишки» |",
"| --- | ---: |",
"| 200 подсказок | 50 |",
} {
if !strings.Contains(md, want) {
t.Errorf("projection missing %q\n---\n%s", want, md)
}
}
if strings.Index(md, "Приобретение") > strings.Index(md, "Использование") {
t.Errorf("the pack table must precede the values table:\n%s", md)
}
}
// TestProjectOfferPricingOrdering checks packs sort by ascending rouble price, and values sort by
// group (hints only → no-ads only → no-ads + hints) then ascending chip price within a group.
func TestProjectOfferPricingOrdering(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: string(SourceDirect), 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 out of order on input.
entries := []catalogEntry{
pack("packDear", 30000),
pack("packCheap", 10000),
value("bundle", 500, "hints", "noads_days"),
value("adsOnly", 150, "noads_days"),
value("hintsBig", 200, "hints"),
value("hintsSmall", 50, "hints"),
}
md := projectOfferPricing(entries)
order := []string{"packCheap", "packDear", "hintsSmall", "hintsBig", "adsOnly", "bundle"}
last := -1
for _, title := range order {
i := strings.Index(md, "| "+title+" |")
if i < 0 {
t.Fatalf("row %q missing:\n%s", title, md)
}
if i < last {
t.Errorf("row %q out of order (want sequence %v):\n%s", title, order, md)
}
last = i
}
}
// TestProjectOfferPricingMissingRailAndEscaping checks a pack missing a rail shows an em dash and a
// title carrying a pipe is escaped so the table layout survives.
func TestProjectOfferPricingMissingRailAndEscaping(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: "Bonus | pack",
atoms: []atomQty{{atomType: atomChips, quantity: 10}},
// A roubles price only — no VK, no Telegram.
prices: []priceRow{{method: string(SourceDirect), currency: CurrencyRUB, amount: 9900}},
}}
md := projectOfferPricing(entries)
if want := `| Bonus \| pack | 99.00 | — | — |`; !strings.Contains(md, want) {
t.Errorf("want row %q in:\n%s", want, md)
}
}
// TestProjectOfferPricingEscapesHTMLAndLinks checks an admin title carrying HTML or a markdown link
// is neutralised so it cannot inject markup into the public offer: the tag becomes entities and the
// link brackets are escaped (so no "javascript:" anchor forms). The raw metacharacters must not
// survive into the projected markdown.
func TestProjectOfferPricingEscapesHTMLAndLinks(t *testing.T) {
entries := []catalogEntry{{
id: uuid.New(),
title: `<script>alert(1)</script> [x](javascript:alert(2)) & "q"`,
atoms: []atomQty{{atomType: "hints", quantity: 1}},
prices: []priceRow{{method: "", currency: CurrencyChip, amount: 5}},
}}
md := projectOfferPricing(entries)
for _, bad := range []string{"<script>", "</script>", "[x]", `& "q"`} {
if strings.Contains(md, bad) {
t.Errorf("unescaped %q survived into the projection:\n%s", bad, md)
}
}
for _, want := range []string{"&lt;script&gt;", `\[x\]`, "&amp;", "&quot;q&quot;"} {
if !strings.Contains(md, want) {
t.Errorf("want escaped %q in:\n%s", want, md)
}
}
}
// TestProjectOfferPricingEmpty checks an empty catalog projects to the empty string (no stray table
// headers), so the offer's pricing marker is replaced with nothing.
func TestProjectOfferPricingEmpty(t *testing.T) {
if got := projectOfferPricing(nil); got != "" {
t.Errorf("empty catalog must project to empty string, got %q", got)
}
}
+8
View File
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/google/uuid"
@@ -19,6 +20,13 @@ import (
type Service struct {
store *Store
clock func() time.Time
// offerMu guards the cached public-offer price list (§4.4). offerMD holds the projected
// markdown tables and offerFresh whether they are current; a catalog mutation clears offerFresh
// (markOfferStale) and the next OfferPricing read reprojects, so a served render issues no query.
offerMu sync.Mutex
offerMD string
offerFresh bool
}
// NewService constructs a Service over store with a wall-clock time source.
+3
View File
@@ -74,6 +74,9 @@ func (s *Server) registerRoutes() {
u.POST("/wallet/buy", s.handleWalletBuy)
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
u.POST("/wallet/reward", s.handleWalletReward)
// The public-offer price list (§4.4) as markdown, for the render sidecar that serves the
// /offer/ page. Internal (off the edge allow-list); called by the renderer, not the gateway.
s.internal.GET("/offer/pricing", s.handleOfferPricing)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
@@ -41,6 +41,9 @@ func (s *Server) consoleCatalog(c *gin.Context) {
s.consoleError(c, err)
return
}
// 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
for _, p := range products {
view.Products = append(view.Products, catalogRow(p))
@@ -566,6 +566,7 @@ func (s *Server) consoleGameDetail(c *gin.Context) {
Status: g.Status, Players: g.Players, ToMove: g.ToMove, EndReason: g.EndReason,
MoveCount: g.MoveCount, CreatedAt: fmtTime(g.CreatedAt), UpdatedAt: fmtTime(g.UpdatedAt),
FinishedAt: fmtTimePtr(g.FinishedAt), VsAI: g.VsAI,
MultipleWordsPerTurn: g.MultipleWordsPerTurn,
}
// Resolve seats and detect robot seats; capture the human opponent's timezone, which
// anchors the robot's sleep window for the next-move ETA.
@@ -112,6 +112,22 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
c.JSON(http.StatusOK, catalogDTOFrom(view))
}
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
// projected from the active products. The render sidecar fetches it and splices it into the offer
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
// the steady state). Called by the renderer, not the gateway.
func (s *Server) handleOfferPricing(c *gin.Context) {
md, err := s.payments.OfferPricing(c.Request.Context())
if err != nil {
s.log.Error("offer pricing projection failed", zap.Error(err))
c.String(http.StatusInternalServerError, "offer pricing unavailable")
return
}
c.Header("Content-Type", "text/markdown; charset=utf-8")
c.String(http.StatusOK, md)
}
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
// unconfigured), which gates the client's "watch for chips" button.
+7 -2
View File
@@ -115,6 +115,9 @@ without it Docker's resolver handles `otelcol`, `gateway` and `api.telegram.org`
| `GATEWAY_VK_APP_SECRET` | secret (shared) | _(empty)_ | The VK Mini App's protected key (`client_secret`): the gateway verifies the launch-parameter signature in-process under it (offline HMAC, no VK API call). Empty disables the VK auth path (`auth.vk`). One VK Mini App for all contours. |
| `GATEWAY_VK_ID_CLIENT_SECRET` | secret (shared) | _(empty)_ | The VK ID "Web" app's protected key (`client_secret`) for the gateway's server-side confidential code exchange. A SEPARATE VK app from `GATEWAY_VK_APP_SECRET` (the Mini App). One VK ID "Web" app for all contours. |
| `GATEWAY_HONEYTOKEN` | secret | _(empty)_ | Planted honeytoken bearer value: presenting it earns a 24h IP ban + a high-severity alarm where the IP ban is on (prod), or logs + a `gateway_abuse_banned_total{reason="honeytoken"}` metric (test, ban off). Plant the value somewhere an attacker would find it; empty disables the trap. Per-contour `TEST_`/`PROD_GATEWAY_HONEYTOKEN`. |
| `GATEWAY_BLOCKLIST_ENABLED` | variable | `false` | Enable the community IP blocklist at the edge (prod-only, same real-client-IP reason as the ban). Requires `GATEWAY_BLOCKLIST_URL`. Opt-in via `PROD_GATEWAY_BLOCKLIST_ENABLED` after verifying the feed. |
| `GATEWAY_BLOCKLIST_URL` | variable | _(empty)_ | The curated CIDR feed to fetch (Spamhaus DROP). Required when enabled; refreshed every few hours and dropped fail-open once stale (48h). `PROD_GATEWAY_BLOCKLIST_URL`. |
| `GATEWAY_BLOCKLIST_ALLOW` | variable | _(empty)_ | Comma-separated never-block set (CIDRs / bare IPs — own infra, monitoring) the feed can never block. `PROD_GATEWAY_BLOCKLIST_ALLOW`. |
| `VITE_GATEWAY_URL` | variable | _(empty)_ | UI build-arg: gateway origin; empty = same-origin (the usual single-origin deploy). |
| `SMTP_RELAY_HOST` | variable (shared) | _(empty)_ | Selectel SMTP relay host for confirm-code email. Empty leaves the backend on the log mailer (email disabled) — the contour still boots. One relay for every contour (limit 100 msgs / 5 min). |
| `SMTP_RELAY_PORT` | variable (shared) | `465` | Relay port. No client certificate is needed (the server cert is validated against the system roots). |
@@ -255,8 +258,10 @@ shipping or redeploying this stack does **not** start archiving — the artifact
armed, which is why an un-armed prod deploy can never pile WAL onto the disk. The base-backup
timer is provisioned by the Ansible `main` role behind `pitr_enabled` (also default off). Two
Grafana alerts watch health: `WAL archiving failing` (`pg_stat_archiver_failed_count` rising)
and `WAL archiving stalled` (`pg_stat_archiver_last_archive_age` over 30 min) — both
absent/NaN-safe, so they stay quiet until archiving is armed.
and `WAL archiving stalled` (last archive over 30 min old **while `pg_wal_size_bytes` is growing**
— the pg_wal-growth guard keeps an idle database, which archives nothing because it writes nothing,
from false-triggering during quiet hours) — both absent/NaN-safe, so they stay quiet until
archiving is armed.
**Assessment (owner-reviewed; the gate before the first real money).** Measured on prod
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
+9
View File
@@ -107,6 +107,15 @@
}
}
# The public offer page is rendered by the render sidecar: it splices the live catalog price
# list (backend, internal) into the committed ui/legal/offer_ru.md and returns the HTML. Only
# /offer/ is exposed here — the sidecar's /render stays off this allow-list, internal-only. Kept
# disjoint from the landing/app paths so the catch-all below never shadows it.
@offer path /offer /offer/*
handle @offer {
reverse_proxy renderer:8090
}
# Everything else — the public landing at / and any stray path — is static.
handle {
reverse_proxy landing:80
+6 -3
View File
@@ -3,8 +3,10 @@
# docker compose -f docker-compose.bot.yml up -d
#
# The bot egresses to the Bot API directly (no VPN sidecar) and dials the main host's
# published bot-link :9443 over mTLS. It exports no telemetry — otelcol lives on the
# main host and is unreachable from here — so observe it via `docker logs` on this host.
# published bot-link :9443 over mTLS. It exports no OTLP telemetry — otelcol lives on the
# main host and is unreachable from here — but it reports its Bot API health up the bot-link,
# which the gateway turns into metrics + alerts on the main host (docs/ARCHITECTURE.md); `docker
# logs` on this host is the local detail view.
# Values come from the prod-deploy workflow (PROD_ secrets/variables); BOT_IMAGE is the
# pushed registry tag and BOTLINK_GATEWAY_ADDR is the main host's <ip>:9443.
name: scrabble-bot
@@ -50,7 +52,8 @@ services:
TELEGRAM_BOTLINK_TLS_CA: /certs/ca.crt
TELEGRAM_LOG_LEVEL: ${LOG_LEVEL:-info}
TELEGRAM_SERVICE_NAME: scrabble-telegram-bot
# No telemetry export: otelcol is on the main host, unreachable from here.
# No OTLP export: otelcol is on the main host, unreachable from here. Bot API health rides the
# bot-link instead (the gateway exposes it as metrics); see docs/ARCHITECTURE.md.
TELEGRAM_OTEL_TRACES_EXPORTER: none
TELEGRAM_OTEL_METRICS_EXPORTER: none
GOMAXPROCS: "1"
+9
View File
@@ -84,6 +84,9 @@ services:
logging: *default-logging
environment:
RENDERER_PORT: "8090"
# The offer page (GET /offer/) fetches the live catalog price list from the backend's internal
# endpoint and splices it into the committed offer markdown. Backend down ⇒ /offer/ returns 502.
RENDERER_BACKEND_URL: http://backend:8080
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8090/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 10s
@@ -251,6 +254,12 @@ services:
# secret; empty (unset secret) leaves the trap off.
GATEWAY_ABUSE_BAN_ENABLED: ${GATEWAY_ABUSE_BAN_ENABLED:-false}
GATEWAY_HONEYTOKEN: ${GATEWAY_HONEYTOKEN:-}
# Community IP blocklist (Spamhaus DROP): prod-only, off unless the real client IP is visible
# (the shared-NAT test contour would self-block). Enabled + fed the feed URL + allowlist by the
# prod deploy (write-prod-env.sh); the refresh/staleness windows use the built-in defaults.
GATEWAY_BLOCKLIST_ENABLED: ${GATEWAY_BLOCKLIST_ENABLED:-false}
GATEWAY_BLOCKLIST_URL: ${GATEWAY_BLOCKLIST_URL:-}
GATEWAY_BLOCKLIST_ALLOW: ${GATEWAY_BLOCKLIST_ALLOW:-}
GATEWAY_LOG_LEVEL: ${LOG_LEVEL:-info}
GATEWAY_SERVICE_NAME: scrabble-gateway
GATEWAY_OTEL_TRACES_EXPORTER: otlp
+45
View File
@@ -0,0 +1,45 @@
{
"uid": "scrabble-bot",
"title": "Scrabble — Telegram bot",
"tags": ["scrabble"],
"timezone": "",
"schemaVersion": 39,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"panels": [
{
"type": "stat",
"title": "Bot connected",
"description": "botlink_connected_bots: bots currently holding the gateway bot-link (1 = healthy).",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(botlink_connected_bots)" }]
},
{
"type": "stat",
"title": "Last Bot API OK (age)",
"description": "Seconds since the bot's most recent successful Bot API call. Grows unbounded if the bot wedges.",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "time() - max(bot_tg_last_ok_unix)" }]
},
{
"type": "timeseries",
"title": "Bot API errors/s by kind",
"description": "bot_tg_errors_total by kind: connect (getUpdates), api (other sends), rate_limited (429). 429 should stay ~0.",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum by (kind) (rate(bot_tg_errors_total[5m]))", "legendFormat": "{{kind}}" }]
},
{
"type": "timeseries",
"title": "Bot-link commands/s by result",
"description": "botlink_commands_total by result: delivered / not_delivered / dropped / error. Sustained not_delivered or error means gateway sends are failing to reach Telegram.",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 5 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum by (result) (rate(botlink_commands_total[5m]))", "legendFormat": "{{result}}" }]
}
]
}
@@ -53,6 +53,31 @@
"fieldConfig": { "defaults": { "unit": "bytes" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(go_memory_used) by (service_name)", "legendFormat": "{{service_name}}" }]
},
{
"type": "stat",
"title": "Blocklist entries",
"description": "CIDR ranges in the active community IP blocklist feed (0 when disabled or not yet fetched).",
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 13 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_entries)" }]
},
{
"type": "stat",
"title": "Blocklist feed age",
"description": "Seconds since the community IP blocklist feed was last successfully fetched. Grows if the fetch is failing; the feed is dropped fail-open once stale.",
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 13 },
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "max(gateway_blocklist_age_seconds)" }]
},
{
"type": "timeseries",
"title": "Blocklist blocks/s",
"description": "Requests refused at the edge by the community IP blocklist (gateway_blocklist_blocked_total).",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 13 },
"datasource": { "type": "prometheus", "uid": "prometheus" },
"targets": [{ "refId": "A", "expr": "sum(rate(gateway_blocklist_blocked_total[5m]))" }]
}
]
}
+124 -4
View File
@@ -108,6 +108,32 @@ groups:
labels: { severity: critical }
annotations: { summary: 'Edge TLS cert has under 20 days left — Caddy ACME renewal may have failed.' }
# The community IP blocklist feed has not refreshed in over a day (the gateway re-fetches every
# few hours). Absent/NaN-safe: the age gauge appears only once a feed has loaded, so a disabled
# or never-fetched blocklist stays quiet. The feed itself is dropped (fail-open) at its
# max-staleness window; this warns well before that so the operator can fix the fetch.
- uid: blocklist_stale
title: IP blocklist feed not refreshing
condition: C
for: 15m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: gateway_blocklist_age_seconds, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [86400] }
labels: { severity: warning }
annotations: { summary: 'The community IP blocklist feed has not refreshed in over 24h — the fetch is failing; it will be dropped (fail-open) once stale. Check the gateway blocklist refresher logs and the feed URL.' }
- orgId: 1
name: scrabble-host
folder: Alerts
@@ -245,8 +271,14 @@ groups:
conditions:
- evaluator: { type: gt, params: [0] }
labels: { severity: critical }
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble check`.' }
annotations: { summary: 'pgBackRest archive_command is failing — PITR is degrading and pg_wal can fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
# Fires only when the last archive is >30 min old AND pg_wal is actually growing (WAL is being
# produced but not archived). A genuinely idle database archives nothing — Postgres does not
# force-switch an empty segment on archive_timeout — so `last_archive_age` alone false-triggers
# during quiet hours; the pg_wal-growth guard removes that. `and on()` bridges the differing
# label sets (last_archive_age has a server label, the pg_wal gauge does not); delta() (not
# increase) suits the pg_wal_size_bytes gauge. Real archive failures are caught by pg_archive_failing.
- uid: pg_archive_stalled
title: WAL archiving stalled
condition: C
@@ -255,11 +287,11 @@ groups:
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
relativeTimeRange: { from: 2100, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: pg_stat_archiver_last_archive_age
expr: (pg_stat_archiver_last_archive_age > 1800) and on() (delta(pg_wal_size_bytes[35m]) > 16777216)
instant: true
- refId: C
datasourceUid: __expr__
@@ -270,4 +302,92 @@ groups:
conditions:
- evaluator: { type: gt, params: [1800] }
labels: { severity: critical }
annotations: { summary: 'No WAL segment archived for over 30 minutes (archive_timeout is 5m) — archiving is stalled; the PITR recovery point is frozen and pg_wal may grow.' }
annotations: { summary: 'No WAL archived for over 30 minutes while pg_wal keeps growing — archiving is genuinely stalled; the PITR recovery point is frozen and pg_wal will fill the disk. Run `docker exec scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check`.' }
# Remote Telegram bot health. The bot runs on its own host and exports no telemetry of its own; the
# gateway observes it through the bot-link (botlink_connected_bots) and the health it reports over
# that stream (bot_tg_*). All absent/NaN-safe: before a bot ever connects the metrics are absent, so
# noDataState OK keeps them quiet on a fresh contour. The alert email does NOT go through the bot, so
# a bot-down alert is deliverable.
- orgId: 1
name: scrabble-bot
folder: Alerts
interval: 1m
rules:
- uid: bot_disconnected
title: Telegram bot disconnected
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 300, to: 0 }
datasourceUid: prometheus
model: { refId: A, expr: botlink_connected_bots, instant: true }
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: lt, params: [1] }
labels: { severity: critical }
annotations: { summary: 'No Telegram bot is connected to the gateway bot-link — out-of-app push and admin sends are down. Check the bot host.' }
# Positive liveness: a bot is connected but its last successful Bot API call is over 5 minutes
# old — the getUpdates long-poll returns every ~minute even when idle, so a stale stamp means the
# bot is wedged (no errors, no traffic). Guarded by "and connected" so a mere disconnect (owned by
# bot_disconnected) does not double-fire.
- uid: bot_tg_stale
title: Telegram bot not reaching the Bot API
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 600, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: (time() - bot_tg_last_ok_unix) and on() (botlink_connected_bots >= 1)
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [300] }
labels: { severity: critical }
annotations: { summary: 'A connected Telegram bot has not reached the Bot API in over 5 minutes — the update poll is wedged. Check the bot host and Telegram reachability.' }
# 429s should be ~never once the bot honours Retry-After; any sustained rate-limiting is a symptom
# (a send loop, a misbehaving path) worth investigating.
- uid: bot_tg_rate_limited
title: Telegram bot rate-limited (429)
condition: C
for: 5m
noDataState: OK
execErrState: OK
data:
- refId: A
relativeTimeRange: { from: 900, to: 0 }
datasourceUid: prometheus
model:
refId: A
expr: increase(bot_tg_errors_total{kind="rate_limited"}[15m])
instant: true
- refId: C
datasourceUid: __expr__
model:
refId: C
type: threshold
expression: A
conditions:
- evaluator: { type: gt, params: [0] }
labels: { severity: warning }
annotations: { summary: 'The Telegram bot is being rate-limited (HTTP 429). It should honour Retry-After, so sustained 429s point to a send loop or a hot path.' }
+3 -12
View File
@@ -18,18 +18,9 @@
@shell not path /assets/*
header @shell Cache-Control "no-cache"
# The static public offer page, rendered from ui/legal/offer_ru.md at build
# time into dist/offer/index.html (vite emit-offer plugin). Served with its own
# index so /offer/ resolves to /srv/offer/index.html rather than falling to the
# landing shell below (whose index is landing.html). A bare /offer redirects in.
handle /offer {
redir * /offer/ permanent
}
handle /offer/* {
file_server {
index index.html
}
}
# The public offer page (/offer/) is no longer served here: the contour caddy routes it to the
# render sidecar, which splices the live catalog price list into ui/legal/offer_ru.md. This
# container never sees /offer/, so it carries no offer assets (the vite emit-offer plugin is gone).
# An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below.
+5
View File
@@ -77,6 +77,11 @@ export GF_SMTP_ENABLED='$GF_SMTP_ENABLED'
export PUBLIC_BASE_URL='$PUBLIC_BASE_URL'
export GATEWAY_HONEYTOKEN='$GATEWAY_HONEYTOKEN'
export GATEWAY_ABUSE_BAN_ENABLED='true'
# Community IP blocklist (Spamhaus DROP): opt-in — the operator enables it and sets the feed URL +
# allowlist via PROD_GATEWAY_BLOCKLIST_* vars once the feed is verified. Unset ⇒ off (safe).
export GATEWAY_BLOCKLIST_ENABLED='${GATEWAY_BLOCKLIST_ENABLED:-false}'
export GATEWAY_BLOCKLIST_URL='$GATEWAY_BLOCKLIST_URL'
export GATEWAY_BLOCKLIST_ALLOW='$GATEWAY_BLOCKLIST_ALLOW'
# Continuous WAL archiving (pgBackRest -> S3) for point-in-time recovery. The artifact
# ships disarmed: PGBACKREST_ARCHIVE_MODE defaults off, so archiving stays inert until the
# operator sets the S3 repository values + secrets, creates the stanza and flips the switch
+54 -5
View File
@@ -435,7 +435,11 @@ Key points:
through a **session-gated `GET /dict/{variant}/{version}`** edge route
(immutable; cached in IndexedDB best-effort) and reused across sessions; any
miss, storage eviction or a bad-connection breaker falls back to the network
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`.
`evaluate`. The warm-up overlay is `docs/UI_DESIGN.md`. The **on-board rendering** of a staged
play — the legality tint, the cells a formed word covers, and where the score badge anchors — is a
separate **pure geometry helper** (`ui/src/lib/formed.ts`) derived from the board and the staged
tiles, so it works regardless of which eval path produced the preview (the badge's number still
comes from the preview's score): legality and score stay with the eval, geometry with the board.
## 6. Game rules
@@ -905,6 +909,22 @@ finished journal on each GET. The only degraded platform is a legacy Telegram cl
predating `downloadFile`, where the GCG falls back to the old clipboard copy and the
image option is not offered.
The same sidecar also serves the **public offer page** at `/offer/` — the one edge-exposed
route on it (caddy routes `/offer/` here; its `/render` stays internal). It reuses the shared
`ui/src/lib/offer.ts` renderer (again one renderer, no drift) over the owner-edited
`ui/legal/offer_ru.md`, baked into the image, splicing in the **live price list** (§4.4): it
fetches the two catalog tables as markdown from the backend's internal
`/api/v1/internal/offer/pricing` at the `<#pricing_template#>` marker, then renders the page. The
backend projects the tables from the active catalog through `payments.Money` (no float reaches the
page) and caches them in memory — built at boot, reprojected on any catalog edit — so a served
render issues no query in the steady state and the page always reflects the current catalog without
a redeploy. A backend outage degrades `/offer/` to a 502 rather than a stale price list. Packs are
ordered by ascending rouble price; values are grouped by what they grant — hints only, then no-ads
only, then no-ads + hints, then (reserved, empty today) products carrying the **tournament** atom,
which becomes a fourth group once the tournament economy makes them sellable — and ordered by
ascending chip price within each group. Product titles are admin input, so the projection escapes
them (HTML entities + markdown metacharacters) before they reach the un-sanitised renderer.
The alphabet-on-the-wire transport does **not** touch this invariant: the live edge
exchanges alphabet indices, but the persisted journal (and everything derived from it —
replay, history, GCG) keeps the decoded concrete letters described above, so an archived
@@ -995,6 +1015,20 @@ answering `/start` with a URL button into the **main** bot's Mini App (`?startap
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
Session-revocation events and cursor-based stream resume stay deferred (single-instance MVP).
Because the bot **exports no telemetry of its own** (the OTel collector is on the main host,
unreachable from the bot host), it reports its **Bot API health** up the same stream: a periodic
`Health` message (`platform/telegram/internal/health`) carries delta counts of connect failures (the
getUpdates long-poll), other API errors and 429s, plus the wall-clock second of its last successful
Bot API call. The bot observes these **centrally by wrapping the Bot API HTTP client** — one place,
no per-call-site instrumentation — and there also honours a 429's `Retry-After` (bounded) so it backs
off rather than hammering (a 429 should therefore stay ~0). The gateway folds each report into its
own metrics (`bot_tg_errors_total{kind}`, the `bot_tg_last_ok_unix` liveness gauge). The remote bot
is thus monitored from the main host's Grafana with three layered signals: the **connection gauge**
(`botlink_connected_bots`) catches a link/host/process outage, the **liveness gauge** catches a
silently wedged bot (its stamp stays fresh even when idle, since getUpdates returns every poll), and
**`botlink_commands_total{result}`** catches gateway sends failing to reach Telegram. Alerts route to
the operator email, which does not depend on the bot.
A separate **advertising-banner** channel feeds the client's one-line strip (UI_DESIGN.md),
server-driven by `internal/ads`. An operator manages **campaigns** (each one placement order) in
the admin console (`/_gm/banners`): a campaign has a show **weight** (integer percent 1..100), an
@@ -1053,7 +1087,9 @@ link — misses the event; while an add-email confirmation is pending the client
`OTEL_EXPORTER_OTLP_*` environment) exports to a collector. The Postgres pool is
instrumented with otelsql and `otelgrpc` traces the backend↔gateway push stream
and the gateway↔validator and bot-link calls; the gateway also exports
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link. The OTLP **Collector** (OTLP/gRPC → Prometheus
`botlink_connected_bots` and `botlink_commands_total` (by result) for the bot-link, plus the remote
bot's own Bot API health it relays over the stream — `bot_tg_errors_total` (by kind) and the
`bot_tg_last_ok_unix` liveness gauge (see the bot-link section). The OTLP **Collector** (OTLP/gRPC → Prometheus
metrics + Tempo traces), **Prometheus** (15d), **Tempo** (72h) and **Grafana**
(provisioned datasources + dashboards, behind the caddy `/_gm/grafana` Basic-Auth)
are stood up with the deploy (`deploy/`); the default exporter stays
@@ -1170,6 +1206,18 @@ link — misses the event; while an add-email confirmation is pending the client
(`POST /api/v1/internal/bans/sync`, network-trusted like the rejection report) and applies
the operator unbans the response returns, so a manual unban takes effect within the sync
interval.
- **Community IP blocklist (prod-only):** with `GATEWAY_BLOCKLIST_ENABLED` set, the gateway also
refuses a client whose IP is in a curated CIDR feed (Spamhaus DROP, `GATEWAY_BLOCKLIST_URL`) with
**403** in the same `abuseGuard`, before the fail2ban list. A background refresher re-fetches the
feed every few hours (bounded fetch + size cap) into a sorted-range matcher (`ratelimit.Blocklist`,
binary search, IPv4 only — an IPv6 client is never blocked here). It is **fault-tolerant and
fail-open**: a failed fetch keeps the last-good feed, and once the feed is older than
`GATEWAY_BLOCKLIST_MAX_STALENESS` it is **dropped** rather than block a legitimate client on a
frozen list; a `GATEWAY_BLOCKLIST_ALLOW` allowlist (own infra, monitoring) is never blocked. Off by
default and prod-only for the same real-client-IP reason as the ban. It is a **separate** static
CIDR set, not the per-IP fail2ban store (a bulk CIDR feed cannot expand into per-IP entries).
Observability: `gateway_blocklist_blocked_total`, the `gateway_blocklist_entries` size gauge and the
`gateway_blocklist_age_seconds` staleness gauge (a Grafana alert warns before the feed is dropped).
- Unauthenticated `GET /healthz` (liveness) and `GET /readyz` (readiness — the
database answers a bounded ping and the session cache is warmed).
- The backend serves a **second listener** — a gRPC server
@@ -1345,9 +1393,10 @@ in-process (the distroless image has no `/etc/mime.types`). Hash-named `/assets/
in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth and
routes `/_gm/grafana/*` to **Grafana** (anonymous-admin, so the one shared login gates
it with no per-user Grafana accounts) and the rest of `/_gm/*` to the backend-rendered
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; the
catch-all — notably the landing at `/`, plus the static public offer at `/offer/`
(rendered from `ui/legal/offer_ru.md` at build time) — goes to the landing container. The
**admin console**; `/app/`, `/telegram/`, `/vk/` and the Connect path go to the gateway; `/offer/`
(the public offer, rendered by the `renderer` sidecar with the live catalog price list spliced in)
goes to the render sidecar; and the catch-all — notably the landing at `/` — goes to the landing
container. The
**Telegram validator** runs as a separate container with **no public ingress**,
answering only internal gRPC (HMAC, no Telegram egress). The **Telegram bot** holds
no inbound port either: it dials the gateway's **bot-link** (mTLS) and egresses to
+36 -18
View File
@@ -30,7 +30,11 @@ render with arbitrary languages, so detection would make the indexed content
nondeterministic); a saved 🌐 choice still wins. The page carries a static Russian SEO head
(title/description, the Open Graph card Telegram/VK link previews use, a canonical link
pinned to the production origin, JSON-LD, the favicon set and `robots.txt`), while the SPA
shell is `noindex` — the landing is the only indexable page.
shell is `noindex` — the landing is the only indexable page. The footer links to the **public
offer** (`/offer/`) and, beside it, **feedback** — the Telegram bot the offer names as the seller's
contact. The offer page (the legal document a purchase accepts) is rendered on demand from
`ui/legal/offer_ru.md` with its **price list** (§4.4) generated from the live product catalog, so
the published prices always match what is currently on sale — no redeploy to update them.
On the plain web the client is an **installable PWA**: a logged-out player sees an install
call-to-action under the login form (and at the bottom of Settings) that installs the app to the
@@ -204,13 +208,17 @@ client, and refuses a guest's friend request, friend code or invitation outright
### Playing a game
Place tiles, pass, exchange, or resign. Pass and exchange share one control —
choosing no tiles passes the turn, choosing tiles exchanges them. Tiles are laid
choosing no tiles passes the turn, choosing tiles exchanges them. The tiles left in the
bag are shown as a count on that control and at the foot of the move table. Tiles are laid
without choosing a direction — the game infers the play's orientation, so a single
tile that extends
an existing word (down a column or across a row) is accepted. A play is validated
against the game's dictionary at submit time and scored; an unlimited preview
reports the word(s) a tentative move would form and its score, or that it is not
legal, and the move is offered for submission only once it is confirmed legal. The preview is
against the game's dictionary at submit time and scored; an unlimited preview shows
**on the board itself** whether the tentative move is legal — the staged tiles turn a light
green once they form a word, a shade darker on the board tiles the word runs through (in a
one-word-per-turn game only the main word), or a calm pink when they form none — and an
**orange badge** on the main word carries the move's
score. The confirm control is offered only once the move is legal. The preview is
computed **on-device** for an instant response once the game's dictionary has loaded — a
brief warm-up shows on the first open otherwise — and falls back to the server whenever the
local dictionary is unavailable. The dictionary check tool is
@@ -372,7 +380,9 @@ an email or Telegram and merging accounts are covered under "Accounts, linking &
merge". Inside the Telegram Mini App, Telegram's own ⋮ menu also offers a **Settings**
entry that opens this screen, and your display preferences (theme, board-label style and
reduce-motion — not the interface language, which follows your account) sync across your
Telegram devices.
Telegram devices. On a touch device the settings also offer a **Zoom the board** toggle (on by
default): with it off, dropping a tile no longer auto-magnifies the board toward it. It is a
per-device preference and is hidden on desktop, where the board already fits and never auto-zooms.
**Preferences (which variants you can be matched into).** A profile setting picks the game
variants — Erudite, Russian Scrabble and English Scrabble, shown **Erudite-first** — you allow
@@ -528,21 +538,29 @@ shown defensively (text escaped, attachments downloaded rather than rendered).
### Wallet
Durable players have a **Wallet** — a tab in the Settings hub, between Friends and About; guests have
none. It shows their **chips** (the in-game currency, split by where they were funded — VK, Telegram
or the web), their **active benefits** (ads off until a date or forever, and the available hint
count), and a **store**. What is visible and spendable depends on **where the app runs**, by the
store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that store's chips are
usable; on the open web all attached chips are, drawn web → VK → Telegram; on VK-iOS the balance is
shown but frozen for spending.
none. A compact **balance** leads the screen: the running context's own **chips** (the in-game
currency) first, then any linked other-platform chips behind that platform's logo — VK, Telegram or
the web, split by where they were funded. What is visible and spendable depends on **where the app
runs**, by the store-compliance rules in [`PAYMENTS.md`](PAYMENTS.md): inside a store only that
store's chips are usable; on the open web all attached chips are, drawn web → VK → Telegram; on
VK-iOS the balance is shown but frozen for spending.
Below the balance an **Active** line lists what the player currently holds — the remaining hint count
and, if bought, the ad-free end date (or "forever"); it is hidden when nothing is active. The
**store** then splits, by a two-way toggle, into **Buy chips** and **Spend chips**:
- **Buy chips** — the **chip packs** bought with real money, priced in the running context's currency
(VK Votes, Telegram Stars, or roubles on the web), plus, at the top, a **watch-an-ad-for-chips**
option where it is offered. Paying opens the provider's payment page (accepting the public offer).
- **Spend chips** — the **values** bought with chips (extra hints, days without ads) at a fixed chip
price. Their action reads **Exchange**, and a value the balance cannot cover is disabled. Tapping
Exchange asks the player to **confirm** (the spend is instant, with no provider window to stand in
for a confirmation); that same dialog also carries the store-compliance **warning** when a web
spend would draw VK/Telegram chips — the benefit then works only on the web and in the app.
The **store** lists **values** bought with chips (extra hints, days without ads) at a fixed chip
price shown in every context, and **chip packs** bought with real money, priced in the running
context's currency (VK Votes, Telegram Stars, or roubles on the web). Buying a value applies its
benefit at once. Before a web purchase that would draw VK/Telegram chips, the app **warns** that the
benefit will then work only on the web and in the app — a store rule — and asks the player to confirm.
On the **Google Play** build the money purchases are hidden behind a note pointing to the RuStore
build (Google's in-app-currency rule); spending already-earned chips still works. The wallet keeps
**no purchase history** — only the current balances, benefits and store.
**no purchase history** — only the current balances, active benefits and store.
### Advertising banner
+41 -20
View File
@@ -32,7 +32,12 @@ top-1 подсказку, безлимитную проверку слова с
главнее. Страница несёт статическую русскую SEO-«шапку» (title/description, карточка
Open Graph, которую используют превью ссылок в Telegram/VK, канонический адрес
продакшен-домена, JSON-LD, набор favicon и `robots.txt`), а оболочка SPA помечена
`noindex` — индексируется только посадочная страница.
`noindex` — индексируется только посадочная страница. В подвале — ссылки на **публичную
оферту** (`/offer/`) и рядом на **обратную связь** (тот самый Telegram-бот, который оферта
указывает как контакт Продавца). Страница оферты (юридический документ, который принимается при
покупке) рендерится по запросу из `ui/legal/offer_ru.md`, а её **перечень стоимости** (§4.4)
формируется из живого каталога товаров, поэтому опубликованные цены всегда совпадают с тем, что
сейчас в продаже — без передеплоя.
В обычном вебе клиент — **устанавливаемое PWA**: незалогиненный игрок видит призыв к установке
под формой входа (и внизу «Настроек»), который в один тап ставит приложение на рабочий стол
@@ -212,13 +217,17 @@ e-mail) либо ввод фразы. Активные игры форфейтя
### Игровой процесс
Выкладывание фишек, пас, обмен или сдача. Пас и обмен — один элемент управления:
без выбранных фишек — пас, с выбранными — обмен. Фишки кладутся без выбора
без выбранных фишек — пас, с выбранными — обмен. Число оставшихся в мешке фишек
показано счётчиком на этом элементе и внизу таблицы ходов. Фишки кладутся без выбора
направления — игра сама определяет ориентацию хода, поэтому одна фишка, продолжающая
уже лежащее
слово (по столбцу или по строке), принимается. Ход проверяется по словарю партии при
сдаче и считается; безлимитный предпросмотр показывает слово (или слова), которое
образует предполагаемый ход, и его очки — либо что ход недопустим, — и ход можно
отправить только после подтверждения, что он допустим. Предпросмотр считается **на устройстве**
сдаче и считается; безлимитный предпросмотр показывает **прямо на доске**, допустим ли
предполагаемый ход: выложенные фишки становятся светло-зелёными, когда образуют слово
(и чуть темнее — фишки доски, через которые проходит слово; в партии «одно слово за ход»
подсвечивается только основное слово), или спокойно-розовыми, когда слова нет, — а
**оранжевый бейдж** на основном слове несёт очки хода. Кнопка
подтверждения появляется только когда ход допустим. Предпросмотр считается **на устройстве**
и отвечает мгновенно, как только словарь партии загружен — иначе при первом открытии показывается
короткий прогрев, — и уходит на сервер, если локальный словарь недоступен. Инструмент проверки слова безлимитный и
предлагает пожаловаться на любой результат; для найденного слова он также даёт ссылку на
@@ -381,7 +390,10 @@ UTC; при создании аккаунта она подставляется
Mini App пункт **Settings** в системном меню «⋮» Telegram также открывает этот экран, а
ваши настройки отображения (тема, стиль подписей клеток и reduce-motion — кроме языка
интерфейса, который следует за аккаунтом) синхронизируются между вашими устройствами в
Telegram.
Telegram. На сенсорном устройстве в настройках также есть переключатель **«Приближать
доску»** (по умолчанию включён): если выключить, при кидании фишки доска больше не
приближается к ней автоматически. Это настройка на устройство, и она скрыта на десктопе,
где доска и так помещается целиком и авто-зума нет.
**Предпочтения (в какие варианты тебя можно подбирать).** Настройка профиля задаёт варианты
игры — Эрудит, русский Scrabble и английский Scrabble, показанные **сначала Эрудит**, — в
@@ -541,21 +553,30 @@ high-rate флага. С карточки пользователя операт
### Кошелёк
У постоянных (не гостевых) игроков есть **Кошелёк** — вкладка в разделе настроек, между «Друзьями» и
«О программе»; у гостей его нет. Он показывает **фишки** (внутриигровую валюту, разделённую по тому,
где она была пополнена — VK, Telegram или веб), **активные блага** (реклама выключена до даты или
навсегда, и число доступных подсказок) и **магазин**. Что видно и что можно потратить, зависит от
того, **где запущено приложение**, по правилам соответствия магазинам из [`PAYMENTS.md`](PAYMENTS.md):
внутри магазина доступны только его фишки; в открытом вебе — все привязанные, списываются в порядке
веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
«О программе»; у гостей его нет. Экран открывает компактный **баланс**: сначала собственные **фишки**
(внутриигровая валюта) текущего контекста, затем привязанные фишки других платформ за их логотипом —
VK, Telegram или веб, разделённые по тому, где они были пополнены. Что видно и что можно потратить,
зависит от того, **где запущено приложение**, по правилам соответствия магазинам из
[`PAYMENTS.md`](PAYMENTS.md): внутри магазина доступны только его фишки; в открытом вебе — все
привязанные, списываются в порядке веб → VK → Telegram; на VK-iOS баланс показан, но трата заморожена.
**Магазин** перечисляет **ценности**, покупаемые за фишки (дополнительные подсказки, дни без рекламы),
по фиксированной цене в фишках, одинаковой во всех контекстах, и **наборы фишек**, покупаемые за
настоящие деньги, в валюте текущего контекста (голоса VK, звёзды Telegram или рубли в вебе). Покупка
ценности сразу применяет её благо. Перед покупкой в вебе, которая списала бы фишки VK/Telegram,
приложение **предупреждает**, что благо будет работать только в вебе и в приложении — это правило
магазинов — и просит подтверждения. В сборке для **Google Play** покупки за деньги скрыты за подсказкой
установить сборку из RuStore (правило Google о внутриигровой валюте); трата уже заработанных фишек
по-прежнему работает. Кошелёк **не хранит историю покупок** — только текущие балансы, блага и магазин.
Под балансом строка **«Активные»** перечисляет, что у игрока есть сейчас — остаток подсказок и, если
куплено, дату окончания «без рекламы» (или «навсегда»); она скрыта, когда активного ничего нет. Далее
**магазин** двухпозиционным переключателем делится на **«Купить фишки»** и **«Потратить фишки»**:
- **Купить фишки** — **наборы фишек** за настоящие деньги, в валюте текущего контекста (голоса VK,
звёзды Telegram или рубли в вебе), а сверху — вариант **«ролик за фишки»**, где он предлагается.
Оплата открывает страницу провайдера (с принятием публичной оферты).
- **Потратить фишки** — **ценности** за фишки (дополнительные подсказки, дни без рекламы) по
фиксированной цене в фишках. Их действие называется **«Обмен»**, а ценность, на которую не хватает
баланса, недоступна. Тап по «Обмену» просит **подтверждения** (трата мгновенна, окна провайдера,
которое сошло бы за подтверждение, нет); это же окно несёт и **предупреждение** соответствия
магазинам, когда трата в вебе списала бы фишки VK/Telegram — благо тогда работает только в вебе и в
приложении.
В сборке для **Google Play** покупки за деньги скрыты за подсказкой установить сборку из RuStore
(правило Google о внутриигровой валюте); трата уже заработанных фишек по-прежнему работает. Кошелёк
**не хранит историю покупок** — только текущие балансы, активные блага и магазин.
### Рекламный баннер
+33 -20
View File
@@ -179,16 +179,16 @@ e2e and the screenshots.
back near the edges. It **recentres only on a zoom-in** — placing a 2nd+ tile or
hovering a dragged tile never jumps the board. On touch the first tile placement auto-zooms
in centred on the target, and **holding a dragged tile over a cell ~1 s** auto-zooms there
the first time. A **swipe down on the zoom-out board** opens the history, but only when the
the first time — both gated by the **Zoom the board** setting (on by default; the toggle is
shown only on a touch device). A **swipe down on the zoom-out board** opens the history, but only when the
board is scrolled to its top so it never fights the stage's own vertical scroll (the conflict
that once retired this gesture) — and it is suppressed on the zoomed board, where the
one-finger drag pans (and on desktop / the landscape iframe, where a mouse cannot drag-scroll the
viewport natively, a **drag-to-pan** handler moves the zoomed board instead — active only while
zoomed, off pending tiles, past a small threshold, swallowing the trailing click). History also
opens on a **tap of the score bar** and closes on a tap or
an **upward swipe** of the then-inert board (below). A **hint** auto-zooms centred on the
hint's placement, not
the top-left.
an **upward swipe** of the then-inert board (below). Taking a **hint** while zoomed in **zooms
out** to the whole board, so the hint's word — highlighted (below) — is never left off-screen.
- **Placing & recall** (`Game.svelte`): a rack tile is placed by tap-then-tap or by
dragging it onto a cell; while a dragged tile is carried over the board, the aimed-at empty
cell is **highlighted as a drop target** (an accent ring). A pending tile is taken back by a
@@ -224,7 +224,7 @@ e2e and the screenshots.
are pinned to the card's edges.
Unread chat is also badged on the score bar itself, so it shows with the history closed.
- **Vertical fit & keyboard**: when the game does not fit the viewport, only the
board area scrolls vertically (`Screen` `column` mode; the score bar, status, rack and tab
board area scrolls vertically (`Screen` `column` mode; the turn strip, score bar, rack and tab
bar stay fixed), while zoom keeps its own scroll. The check-word dialog opens in
`Modal` keyboard-overlay mode — the small sheet is top-anchored and the soft keyboard
overlays the empty area below, so the layout doesn't resize/jank; other modals stay
@@ -235,23 +235,32 @@ e2e and the screenshots.
(`min(100cqw, 100cqh)`, height-driven in `Board.svelte`'s `.scaler.land`) inside a
`container-type: size` pane, shrinking by width when the column is narrow — the board has the
**lowest priority**, so the left panel is never squeezed. The **left panel** stacks, top to
bottom: the score plaques, the **always-open** history (docked and scrolling with its header
sticky — no slide-down drawer, no score-bar toggle), the status line (bag · turn · score
preview), the rack (+ the ✅ make control) and, pinned at the bottom, the controls tab bar. Board
bottom: the **turn/result strip**, the score plaques, the **always-open** history (docked and
scrolling with its header sticky and the **bag count** pinned at its foot — no slide-down drawer,
no score-bar toggle), the rack (with the ✅ confirm control in its fixed 7th slot) and, pinned at
the bottom, the controls tab bar. Board
**zoom works as in portrait** (double-tap / pinch / placement auto-zoom), but the viewport is the
full pane: zoom-out shows the height-fitted square centred, and zoom-in magnifies the board past
the pane and pans within it, occupying the full width up to the left panel (the focus-centred
scroll is set directly, without the portrait width-progress tween). Only the history open/close
swipes are dropped (it is always open) and the nav bar does not grow (`growNav` off). The
portrait layout is byte-for-byte unchanged; both layouts render from the same snippets, so the
behaviour and markup stay single-sourced. The rack tiles size to the (fixed-width) panel rather
than the viewport width so seven tiles never overflow the column.
- **Highlights**: pending tiles use a slightly darker tile background (no outline). The
behaviour and markup stay single-sourced. The rack is a **seven-column grid** filling the tray
width in both layouts, so the tiles are square and as large as the row allows (the confirm ✅ takes
the fixed 7th slot while a play is staged).
- **Highlights**: while composing a play the staged tiles are **tinted by legality** — a light
green once they form a word (a shade darker on the committed board tiles the word runs through —
in a **single-word game** only the main word lights up, since the engine ignores cross words),
a calm pink when they form none — and an **orange score badge** sits on a corner of the main
word's tile, its digit sized like a tile's point value (so it scales with the zoom) and clamped
to stay on the board. Off-turn or with no preview the staged tiles keep the plain pending fill.
The colours are theme tokens in `app.css` (`--tile-pending-legal` / `-illegal`, `--tile-formed`,
`--score-badge`); the geometry — which cells a word covers and where the badge anchors — is a
pure client-side helper (`lib/formed.ts`), independent of the move evaluator. The
last completed word keeps the normal tile background; instead its letters — not the point
values — are drawn in the recent-move colour, in both themes. It is static while it is the
opponent's turn (our word), and a 1 s flash (the letter pulses between its normal colour
and the recent-move colour) when it is our turn (their word). While placing, only the
pending tiles are highlighted.
and the recent-move colour) when it is our turn (their word).
- **Bonus-square labels** — a Settings choice (`boardlabels.ts`): `beginner` shows a
split `3×` / `word` (localized слово/буква), `classic` a single `3W` / `3С`, `none`
nothing. Default **beginner**.
@@ -267,18 +276,22 @@ e2e and the screenshots.
otherwise it reverts. Used by the **Hint** tab (the icon morphs to ✅, no label — replacing
the old press-and-hold popover) and the in-game **add-friend 🤝** and **block ✖️** card
controls. The **Drop game** action keeps its `Modal` confirmation (a destructive, less-frequent action).
- **MakeMove / Reset**: when ≥1 tile is pending the rack collapses its used slots
and shifts left, a **borderless ✅ icon button** (styled like a tab, not a filled accent
button) beside the rack commits the move — no popover, and disabled while the pending word
is known illegal; the 🔀 Shuffle tab is replaced by a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Exchange/Pass (opens a dialog — pick tiles to **Exchange N**, or pick
- **Confirm / Reset**: while ≥1 tile is pending a **borderless ✅ icon button** (styled like a
tab, not a filled accent button) takes the rack's **fixed 7th slot** and commits the move — no
popover, and disabled while the pending word is known illegal; the 🔀 Shuffle tab is replaced by
a **↩️ Reset** tab.
- **Game tab bar**: 🔄 Exchange/Pass (a **bag-count badge** on its icon while the bag is non-empty;
opens a dialog — pick tiles to **Exchange N**, or pick
none to **Pass without exchanging**; pass is always available on your turn, exchange only
while the bag still holds a full rack, below which the tiles disable and only the pass
remains), 🛟 Hint (with a remaining-count badge, disabled at zero); 🔀 Shuffle (no label,
no confirm), which
**animates** — tiles hop along a low parabola to their new slots (duration scaled by the
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. The
under-board slot shows the **Scores: N** preview. The screen **title** is the variant's
distance, the longest ≤ 0.3 s; off under reduce-motion) with a short haptic shake. A **thin strip
above the score plaques** reads, while composing a legal play, the formed word(s) and the move
score ("WORD+WORD = N"); otherwise whose turn it is, or the viewer's result once the game ends. The
move score also rides the board badge (above), never an under-board caption.
The screen **title** is the variant's
display name (Scrabble / Скрэббл / Erudite / Эрудит), not a constant "Scrabble".
## Advertising banner (`components/AdBanner.svelte` in `components/Header.svelte`, `lib/banner.ts` + `lib/bannerEngine.ts`)
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"context"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
"go.uber.org/zap"
"scrabble/gateway/internal/config"
"scrabble/gateway/internal/ratelimit"
)
const (
// blocklistFetchTimeout bounds one feed fetch.
blocklistFetchTimeout = 30 * time.Second
// maxBlocklistBytes caps the fetched feed (Spamhaus DROP is well under 1 MiB; the cap stops a
// hostile or misconfigured URL from streaming unbounded data into memory).
maxBlocklistBytes = 8 << 20
)
// parseAllowlist parses the never-block entries (CIDRs or bare IPs, a bare IP read as a /32) into
// networks. A malformed entry is a fatal config error — the operator must fix it, not silently lose
// a protected range.
func parseAllowlist(entries []string) ([]*net.IPNet, error) {
out := make([]*net.IPNet, 0, len(entries))
for _, e := range entries {
s := strings.TrimSpace(e)
if s == "" {
continue
}
if !strings.Contains(s, "/") {
s += "/32"
}
_, n, err := net.ParseCIDR(s)
if err != nil {
return nil, fmt.Errorf("gateway: GATEWAY_BLOCKLIST_ALLOW: %q: %w", e, err)
}
out = append(out, n)
}
return out, nil
}
// runBlocklistRefresher keeps the community IP blocklist feed current: it fetches immediately, then
// every cfg.Refresh, and applies each outcome through ratelimit.ApplyRefresh (keep-last-good on a
// transient failure; drop the feed once it goes stale, fail-open). It returns when ctx is cancelled.
func runBlocklistRefresher(ctx context.Context, bl *ratelimit.Blocklist, cfg config.BlocklistConfig, log *zap.Logger) {
client := &http.Client{Timeout: blocklistFetchTimeout}
for {
cidrs, err := fetchBlocklist(ctx, client, cfg.URL)
switch ratelimit.ApplyRefresh(bl, cidrs, err, time.Now(), cfg.MaxStaleness) {
case ratelimit.RefreshUpdated:
log.Info("blocklist refreshed", zap.String("url", cfg.URL), zap.Int("entries", bl.Len()))
case ratelimit.RefreshKept:
log.Warn("blocklist refresh failed; keeping the last-good feed", zap.Error(err))
case ratelimit.RefreshDropped:
log.Warn("blocklist refresh failed and the feed is stale; dropped it (fail-open)", zap.Error(err))
}
select {
case <-ctx.Done():
return
case <-time.After(cfg.Refresh):
}
}
}
// fetchBlocklist GETs the feed and parses it, bounded by the fetch timeout and the size cap.
func fetchBlocklist(ctx context.Context, client *http.Client, url string) ([]*net.IPNet, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("gateway: blocklist fetch %s: status %d", url, resp.StatusCode)
}
return ratelimit.ParseDROP(io.LimitReader(resp.Body, maxBlocklistBytes))
}
+10
View File
@@ -129,6 +129,11 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Window: cfg.Abuse.BanWindow,
Duration: cfg.Abuse.BanDuration,
})
allow, err := parseAllowlist(cfg.Blocklist.Allow)
if err != nil {
return err
}
blocklist := ratelimit.NewBlocklist(cfg.Blocklist.Enabled, allow)
hub := push.NewHub(0)
var validator transcode.TelegramValidator
@@ -222,6 +227,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Limiter: limiter,
Tracker: tracker,
Banlist: banlist,
Blocklist: blocklist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
@@ -243,6 +249,10 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
if cfg.Abuse.BanEnabled {
go runBanSync(ctx, banlist, backend, logger)
}
// When the edge blocklist is enabled (prod), keep the community feed refreshed.
if cfg.Blocklist.Enabled {
go runBlocklistRefresher(ctx, blocklist, cfg.Blocklist, logger)
}
public := &http.Server{Addr: cfg.HTTPAddr, Handler: edge.HTTPHandler(), ReadHeaderTimeout: readHeaderTimeout}
servers := []*namedServer{{name: "public", srv: public}}
+48
View File
@@ -76,6 +76,11 @@ type Hub struct {
connected metric.Int64UpDownCounter
commands metric.Int64Counter
// tgErrors counts the remote bot's Bot API failures it reports over the stream, by kind
// (connect / api / rate_limited). lastOK holds the wall-clock second of the bot's most recent
// successful Bot API call, read by the bot_tg_last_ok_unix observable gauge for a staleness alert.
tgErrors metric.Int64Counter
lastOK atomic.Int64
}
// link is one connected bot's outbound queue and identity.
@@ -103,6 +108,19 @@ func NewHub(log *zap.Logger, meter metric.Meter, resolve EligibilityResolver) *H
metric.WithDescription("Number of Telegram bots currently connected to the gateway bot-link."))
h.commands, _ = meter.Int64Counter("botlink_commands_total",
metric.WithDescription("Bot-link send commands by result (delivered, not_delivered, dropped, error)."))
h.tgErrors, _ = meter.Int64Counter("bot_tg_errors_total",
metric.WithDescription("Telegram Bot API failures the remote bot reports over the bot-link, by kind (connect, api, rate_limited)."))
// The bot's last successful Bot API second, reported over the stream. An observable gauge
// reads the atomic on each collection; it observes nothing until a bot has reported, so a
// never-connected gateway shows no data (the connected-bots alert covers that case).
_, _ = meter.Int64ObservableGauge("bot_tg_last_ok_unix",
metric.WithDescription("Unix second of the remote bot's most recent successful Bot API call (0/absent until reported)."),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
if v := h.lastOK.Load(); v > 0 {
o.Observe(v)
}
return nil
}))
}
return h
}
@@ -149,6 +167,36 @@ func (h *Hub) Link(stream grpc.BidiStreamingServer[botlinkv1.FromBot, botlinkv1.
if ack := msg.GetAck(); ack != nil {
h.resolve(ack)
}
if hb := msg.GetHealth(); hb != nil {
h.recordHealth(hb)
}
}
}
// recordHealth folds one bot Health report into the gateway's cumulative Bot API metrics: the three
// delta counters are added under their kind label, and the last-ok stamp (an absolute wall-clock
// second) advances the liveness gauge (monotonically — a stale reordered report cannot rewind it).
func (h *Hub) recordHealth(hb *botlinkv1.Health) {
if h.tgErrors != nil {
ctx := context.Background()
if v := hb.GetConnectFailures(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "connect")))
}
if v := hb.GetApiErrors(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "api")))
}
if v := hb.GetRateLimited(); v > 0 {
h.tgErrors.Add(ctx, int64(v), metric.WithAttributes(attribute.String("kind", "rate_limited")))
}
}
for {
cur := h.lastOK.Load()
if hb.GetLastOkUnix() <= cur {
break
}
if h.lastOK.CompareAndSwap(cur, hb.GetLastOkUnix()) {
break
}
}
}
+18
View File
@@ -233,3 +233,21 @@ func TestRelayServerNoBot(t *testing.T) {
t.Fatal("expected an error with no bot connected")
}
}
// TestRecordHealthLastOKMonotonic checks a bot Health report advances the last-ok liveness stamp but
// a stale or reordered report (an earlier second) never rewinds it.
func TestRecordHealthLastOKMonotonic(t *testing.T) {
hub := NewHub(nil, nil, nil)
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 100})
if got := hub.lastOK.Load(); got != 100 {
t.Fatalf("lastOK = %d, want 100", got)
}
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 90}) // reordered/stale — must not rewind
if got := hub.lastOK.Load(); got != 100 {
t.Errorf("lastOK rewound to %d, want 100", got)
}
hub.recordHealth(&botlinkv1.Health{LastOkUnix: 150})
if got := hub.lastOK.Load(); got != 150 {
t.Errorf("lastOK = %d, want 150", got)
}
}
+69
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
pkgtel "scrabble/pkg/telemetry"
@@ -57,6 +58,8 @@ type Config struct {
RateLimit RateLimitConfig
// Abuse configures the temporary IP ban and the honeytoken (prod-only).
Abuse AbuseConfig
// Blocklist configures the community IP blocklist (Spamhaus DROP) enforced at the edge (prod-only).
Blocklist BlocklistConfig
// Telemetry configures the OpenTelemetry providers (shared bootstrap).
Telemetry pkgtel.Config
}
@@ -166,6 +169,42 @@ func DefaultAbuse() AbuseConfig {
}
}
// BlocklistConfig configures the community IP blocklist (a curated CIDR feed such as Spamhaus DROP)
// enforced at the edge alongside the fail2ban ban. Disabled by default and prod-only, for the same
// reason as the ban: it is only safe where the real client IP is visible (not behind the shared-NAT
// test contour). Only IPv4 is matched.
type BlocklistConfig struct {
// Enabled turns edge blocklisting on. Off by default (prod-only).
Enabled bool
// URL is the CIDR feed to fetch (e.g. the Spamhaus DROP list). Required when Enabled; the
// operator sets it explicitly so no feed URL is assumed.
URL string
// Refresh is how often the feed is re-fetched.
Refresh time.Duration
// MaxStaleness is how long a stale feed (no successful refresh) is tolerated before it is dropped
// (fail-open — a legitimate client is never blocked on a frozen feed).
MaxStaleness time.Duration
// Allow is the never-block set: CIDRs or bare IPs (own infrastructure, monitoring, known-good) that
// the feed can never block. Parsed at startup.
Allow []string
}
// Blocklist defaults; the feed URL has no default (the operator sets it).
const (
defaultBlocklistRefresh = 6 * time.Hour
defaultBlocklistMaxStaleness = 48 * time.Hour
)
// DefaultBlocklist returns the built-in blocklist settings: disabled (prod-only), no feed URL, and
// the agreed refresh / staleness windows.
func DefaultBlocklist() BlocklistConfig {
return BlocklistConfig{
Enabled: false,
Refresh: defaultBlocklistRefresh,
MaxStaleness: defaultBlocklistMaxStaleness,
}
}
// VKIDConfig holds the VK ID web-login credentials for the confidential
// authorization-code exchange. AppID is the VK "Web" app's client id; ClientSecret is
// its protected key; RedirectURI must exactly match the trusted redirect URL registered
@@ -203,6 +242,7 @@ func Load() (Config, error) {
SessionCacheMax: defaultSessionCacheMax,
RateLimit: DefaultRateLimit(),
Abuse: DefaultAbuse(),
Blocklist: DefaultBlocklist(),
BotLink: BotLinkConfig{
Addr: os.Getenv("GATEWAY_BOTLINK_ADDR"),
RelayAddr: os.Getenv("GATEWAY_BOTLINK_RELAY_ADDR"),
@@ -244,6 +284,17 @@ func Load() (Config, error) {
if c.Abuse.BanDuration, err = envDuration("GATEWAY_ABUSE_BAN_DURATION", c.Abuse.BanDuration); err != nil {
return Config{}, err
}
if c.Blocklist.Enabled, err = envBool("GATEWAY_BLOCKLIST_ENABLED", c.Blocklist.Enabled); err != nil {
return Config{}, err
}
c.Blocklist.URL = os.Getenv("GATEWAY_BLOCKLIST_URL")
if c.Blocklist.Refresh, err = envDuration("GATEWAY_BLOCKLIST_REFRESH", c.Blocklist.Refresh); err != nil {
return Config{}, err
}
if c.Blocklist.MaxStaleness, err = envDuration("GATEWAY_BLOCKLIST_MAX_STALENESS", c.Blocklist.MaxStaleness); err != nil {
return Config{}, err
}
c.Blocklist.Allow = splitList(os.Getenv("GATEWAY_BLOCKLIST_ALLOW"))
if c.BotLink.SendTimeout, err = envDuration("GATEWAY_BOTLINK_SEND_TIMEOUT", defaultBotLinkSendTimeout); err != nil {
return Config{}, err
}
@@ -284,6 +335,9 @@ func (c Config) validate() error {
if c.Abuse.BanEnabled && (c.Abuse.BanThreshold <= 0 || c.Abuse.BanWindow <= 0 || c.Abuse.BanDuration <= 0) {
return fmt.Errorf("config: GATEWAY_ABUSE_BAN_THRESHOLD/_WINDOW/_DURATION must be positive when GATEWAY_ABUSE_BAN_ENABLED")
}
if c.Blocklist.Enabled && (c.Blocklist.URL == "" || c.Blocklist.Refresh <= 0 || c.Blocklist.MaxStaleness <= 0) {
return fmt.Errorf("config: GATEWAY_BLOCKLIST_URL must be set and _REFRESH/_MAX_STALENESS positive when GATEWAY_BLOCKLIST_ENABLED")
}
if c.BotLink.Addr != "" {
if c.BotLink.CertFile == "" || c.BotLink.KeyFile == "" || c.BotLink.CAFile == "" {
return fmt.Errorf("config: GATEWAY_BOTLINK_ADDR requires GATEWAY_BOTLINK_TLS_CERT, _KEY and _CA")
@@ -304,6 +358,21 @@ func envOr(key, fallback string) string {
return fallback
}
// splitList splits a comma-separated environment value into trimmed, non-empty items.
func splitList(v string) []string {
if v == "" {
return nil
}
parts := strings.Split(v, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
// envBool parses the environment variable named key as a bool, returning fallback
// when it is unset and an error when it is set but malformed.
func envBool(key string, fallback bool) (bool, error) {
+31 -7
View File
@@ -2,6 +2,7 @@ package connectsrv_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
@@ -24,7 +25,7 @@ const honeypotHeader = "X-Scrabble-Honeypot"
// guardedEdge wires an edge with an explicit banlist and honeytoken over a fake
// backend, returning the front URL, a Connect client and a cleanup func.
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
func guardedEdge(t *testing.T, bl *ratelimit.Banlist, block *ratelimit.Blocklist, honeytoken string, limits config.RateLimitConfig, backendHandler http.HandlerFunc) (string, edgev1connect.GatewayClient, func()) {
t.Helper()
backendSrv := httptest.NewServer(backendHandler)
backend, err := backendclient.New(backendSrv.URL, "localhost:9090", 2*time.Second)
@@ -36,6 +37,7 @@ func guardedEdge(t *testing.T, bl *ratelimit.Banlist, honeytoken string, limits
Sessions: session.NewCache(backend, time.Minute, 100),
Limiter: ratelimit.New(),
Banlist: bl,
Blocklist: block,
Honeytoken: honeytoken,
Hub: push.NewHub(0),
RateLimit: limits,
@@ -69,7 +71,7 @@ func enabledBanlist(threshold int) *ratelimit.Banlist {
func TestAbuseGuardBlocksBannedIP(t *testing.T) {
bl := enabledBanlist(100)
bl.BanNow("127.0.0.1", ratelimit.ReasonTripwire)
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
@@ -86,7 +88,7 @@ func TestAbuseGuardBlocksBannedIP(t *testing.T) {
// and bans the client IP, so the next request is blocked.
func TestHoneypotHeaderTrips(t *testing.T) {
bl := enabledBanlist(100)
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for a honeypot hit")
})
defer cleanup()
@@ -118,7 +120,7 @@ func TestHoneypotHeaderTrips(t *testing.T) {
// banlist still 404s the decoy (detection/logging) but bans nothing.
func TestHoneypotDetectsWithoutBanWhenDisabled(t *testing.T) {
bl := ratelimit.NewBanlist(ratelimit.BanConfig{}) // disabled
url, _, cleanup := guardedEdge(t, bl, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
url, _, cleanup := guardedEdge(t, bl, nil, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
req, _ := http.NewRequest(http.MethodGet, url+"/.env", nil)
@@ -150,7 +152,7 @@ func TestPublicRejectionStrikesBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.PublicPerMinute, limits.PublicBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"token":"tok","user_id":"u-1","is_guest":true,"display_name":"Guest"}`))
})
defer cleanup()
@@ -173,7 +175,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
bl := enabledBanlist(1)
limits := config.DefaultRateLimit()
limits.UserPerMinute, limits.UserBurst = 1, 1
_, client, cleanup := guardedEdge(t, bl, "", limits, func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, nil, "", limits, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/internal/sessions/resolve":
_, _ = w.Write([]byte(`{"user_id":"u-1","is_guest":false}`))
@@ -202,7 +204,7 @@ func TestUserRejectionDoesNotBan(t *testing.T) {
// caller and returns the ordinary invalid-session error without a backend call.
func TestHoneytokenBansAndRejects(t *testing.T) {
bl := enabledBanlist(100)
_, client, cleanup := guardedEdge(t, bl, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
_, client, cleanup := guardedEdge(t, bl, nil, "s3cr3t-trap", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {
t.Error("backend must not be called for the honeytoken")
})
defer cleanup()
@@ -217,3 +219,25 @@ func TestHoneytokenBansAndRejects(t *testing.T) {
t.Fatal("the honeytoken must ban the caller")
}
}
// TestAbuseGuardBlocksBlocklistedIP verifies a client IP in the community blocklist feed is refused
// with 403 at the HTTP layer, before any handler runs.
func TestAbuseGuardBlocksBlocklistedIP(t *testing.T) {
block := ratelimit.NewBlocklist(true, nil)
_, feed, err := net.ParseCIDR("127.0.0.0/8")
if err != nil {
t.Fatal(err)
}
block.SetCIDRs([]*net.IPNet{feed}, time.Now())
url, _, cleanup := guardedEdge(t, nil, block, "", config.DefaultRateLimit(), func(w http.ResponseWriter, r *http.Request) {})
defer cleanup()
resp, err := noRedirect().Get(url + "/")
if err != nil {
t.Fatalf("get: %v", err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("blocklisted GET / = %d, want 403", resp.StatusCode)
}
}
+30 -1
View File
@@ -7,6 +7,8 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/metric/noop"
"scrabble/gateway/internal/ratelimit"
)
// meterName scopes the gateway edge's OpenTelemetry instruments.
@@ -27,6 +29,7 @@ type serverMetrics struct {
edge metric.Float64Histogram
rateLimited metric.Int64Counter
banned metric.Int64Counter
blocklisted metric.Int64Counter
active *activeUsers
// Client-reported local move-preview adoption (see localEvalMetricsHandler).
localColdStart metric.Int64Counter
@@ -40,7 +43,7 @@ type serverMetrics struct {
// falling back to a no-op histogram on the (rare) construction error. The
// active_users gauge is registered as an observable callback over the in-memory
// tracker.
func newServerMetrics(meter metric.Meter) *serverMetrics {
func newServerMetrics(meter metric.Meter, bl *ratelimit.Blocklist) *serverMetrics {
if meter == nil {
meter = noop.NewMeterProvider().Meter(meterName)
}
@@ -68,6 +71,8 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
}
m := &serverMetrics{
edge: h, rateLimited: c, banned: b, active: newActiveUsers(),
blocklisted: counterOf(meter, "gateway_blocklist_blocked_total",
"Requests refused at the edge by the community IP blocklist (Spamhaus DROP)."),
localColdStart: counterOf(meter, "local_eval_cold_start_total", "App cold starts reported by clients — the denominator for local-move-preview adoption."),
localDictLoad: counterOf(meter, "local_eval_dict_load_total", "Client dictionary loads for the local move preview, by result (fetched, cache_hit or miss)."),
localPreview: counterOf(meter, "local_eval_preview_total", "Client move previews, by path (local on-device, or network fallback)."),
@@ -90,6 +95,25 @@ func newServerMetrics(meter metric.Meter) *serverMetrics {
return nil
}, gauge)
}
// Community blocklist status: the feed size and how stale it is, for the dashboard and the
// "feed not refreshing" alert. Age is observed only once a feed has loaded, so a disabled or
// never-fetched blocklist reports no age (and entries 0).
if bl != nil {
entries, e1 := meter.Int64ObservableGauge("gateway_blocklist_entries",
metric.WithDescription("CIDR ranges in the active community IP blocklist feed."))
age, e2 := meter.Float64ObservableGauge("gateway_blocklist_age_seconds",
metric.WithDescription("Seconds since the community IP blocklist feed was last successfully fetched (absent until the first fetch)."))
if e1 == nil && e2 == nil {
_, _ = meter.RegisterCallback(func(_ context.Context, o metric.Observer) error {
o.ObserveInt64(entries, int64(bl.Len()))
if last := bl.LastFetch(); !last.IsZero() {
o.ObserveFloat64(age, time.Since(last).Seconds())
}
return nil
}, entries, age)
}
}
return m
}
@@ -118,6 +142,11 @@ func (m *serverMetrics) recordBan(ctx context.Context, reason string) {
m.banned.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
// recordBlocklistBlock counts one request refused by the community IP blocklist.
func (m *serverMetrics) recordBlocklistBlock(ctx context.Context) {
m.blocklisted.Add(ctx, 1)
}
// recordUnsupportedEngine counts one client turned away by the unsupported-engine boot screen,
// labelled by reason and Chromium major. The caller passes both already reduced to bounded label
// sets (see normalizeUnsupported) so a spoofed beacon cannot explode the metric cardinality.
+4 -4
View File
@@ -16,7 +16,7 @@ func TestEdgeMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
m.recordEdge(ctx, "game.submit_play", "ok", time.Now().Add(-time.Millisecond))
@@ -74,7 +74,7 @@ func TestRateLimitedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordRateLimited(ctx, "user")
m.recordRateLimited(ctx, "user")
@@ -112,7 +112,7 @@ func TestBannedMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordBan(ctx, "tripwire")
m.recordBan(ctx, "tripwire")
@@ -150,7 +150,7 @@ func TestUnsupportedEngineMetric(t *testing.T) {
ctx := context.Background()
reader := sdkmetric.NewManualReader()
meter := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)).Meter("test")
m := newServerMetrics(meter)
m := newServerMetrics(meter, nil)
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
m.recordUnsupportedEngine(ctx, "no_bigint", "66")
+17 -1
View File
@@ -77,6 +77,7 @@ type Server struct {
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
blocklist *ratelimit.Blocklist
honeytoken string
vkAppSecret string
hub *push.Hub
@@ -109,6 +110,9 @@ type Deps struct {
// Banlist enforces temporary IP bans on the hot path; nil selects a disabled
// (inert) banlist.
Banlist *ratelimit.Banlist
// Blocklist enforces the community IP blocklist on the hot path; nil selects a
// disabled (inert) blocklist.
Blocklist *ratelimit.Blocklist
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
@@ -148,6 +152,10 @@ func NewServer(d Deps) *Server {
if banlist == nil {
banlist = ratelimit.NewBanlist(ratelimit.BanConfig{})
}
blocklist := d.Blocklist
if blocklist == nil {
blocklist = ratelimit.NewBlocklist(false, nil)
}
rl := d.RateLimit
if rl == (config.RateLimitConfig{}) {
rl = config.DefaultRateLimit()
@@ -160,12 +168,13 @@ func NewServer(d Deps) *Server {
limiter: limiter,
tracker: tracker,
banlist: banlist,
blocklist: blocklist,
honeytoken: d.Honeytoken,
hub: d.Hub,
heartbeat: d.Heartbeat,
log: log,
adminProxy: d.AdminProxy,
metrics: newServerMetrics(d.Meter),
metrics: newServerMetrics(d.Meter, blocklist),
maxBodyBytes: maxBody,
publicPolicy: ratelimit.PerMinute(rl.PublicPerMinute, rl.PublicBurst),
userPolicy: ratelimit.PerMinute(rl.UserPerMinute, rl.UserBurst),
@@ -255,6 +264,13 @@ func (s *Server) abuseGuard(next http.Handler) http.Handler {
http.Error(w, "banned", http.StatusTooManyRequests)
return
}
// The community IP blocklist (Spamhaus DROP): a known-bad source is refused before any work.
// Counted, not logged per request (a blocked scanner hammers). Inert on a disabled blocklist.
if s.blocklist.Blocked(ip) {
s.metrics.recordBlocklistBlock(r.Context())
http.Error(w, "forbidden", http.StatusForbidden)
return
}
if r.Header.Get(honeypotHeader) != "" {
s.log.Warn("honeypot tripwire",
zap.String("path", r.URL.Path),
+188
View File
@@ -0,0 +1,188 @@
package ratelimit
import (
"bufio"
"encoding/binary"
"fmt"
"io"
"net"
"sort"
"strings"
"sync"
"time"
)
// ipRange is an inclusive IPv4 range [lo, hi] as uint32 — the form the blocklist matches against.
type ipRange struct{ lo, hi uint32 }
// Blocklist is a static IPv4 CIDR blocklist (a curated community feed such as Spamhaus DROP) enforced
// on the hot path alongside the fail2ban [Banlist]. It is refreshed periodically ([ApplyRefresh]); an
// allowlist (never blocked) protects known-good infrastructure and is checked first. Only IPv4 is
// matched — an IPv6 client is never blocked here (the fail2ban list and the honeypot still cover it).
// Disabled by default (prod-only, like the ban): while disabled, Blocked is always false.
type Blocklist struct {
enabled bool
allow []ipRange // sorted, non-overlapping; from config, immutable after construction
mu sync.RWMutex
ranges []ipRange // sorted, non-overlapping; the current feed
fetchedAt time.Time // last successful SetCIDRs; zero = never loaded
}
// NewBlocklist builds a Blocklist. enabled gates the whole mechanism; allow is the never-block set
// (CIDRs / bare IPs already parsed) — a client in it is never blocked even if the feed lists it.
func NewBlocklist(enabled bool, allow []*net.IPNet) *Blocklist {
return &Blocklist{enabled: enabled, allow: toRanges(allow)}
}
// Blocked reports whether ip (a textual address) is in the current feed and not allowlisted. It is
// false on a disabled or empty blocklist, and false for any non-IPv4 address.
func (b *Blocklist) Blocked(ip string) bool {
if !b.enabled {
return false
}
v, ok := ipv4ToUint32(ip)
if !ok {
return false
}
b.mu.RLock()
defer b.mu.RUnlock()
if len(b.ranges) == 0 || rangesContain(b.allow, v) {
return false
}
return rangesContain(b.ranges, v)
}
// SetCIDRs swaps in a freshly fetched feed, recording the fetch time. Non-IPv4 CIDRs are ignored.
func (b *Blocklist) SetCIDRs(cidrs []*net.IPNet, at time.Time) {
r := toRanges(cidrs)
b.mu.Lock()
b.ranges = r
b.fetchedAt = at
b.mu.Unlock()
}
// Clear drops the current feed (fail-open) but keeps the last-fetch time, so a staleness gauge keeps
// climbing. Blocked then returns false until a fresh feed loads.
func (b *Blocklist) Clear() {
b.mu.Lock()
b.ranges = nil
b.mu.Unlock()
}
// Len returns the number of ranges currently enforced.
func (b *Blocklist) Len() int {
b.mu.RLock()
defer b.mu.RUnlock()
return len(b.ranges)
}
// LastFetch returns the time of the last successful feed load (zero if never).
func (b *Blocklist) LastFetch() time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.fetchedAt
}
// RefreshOutcome is the result of one refresh attempt, for the caller's logging and metrics.
type RefreshOutcome int
const (
// RefreshUpdated: a new feed was fetched and applied.
RefreshUpdated RefreshOutcome = iota
// RefreshKept: the fetch failed but the last-good feed is still fresh, so it was kept.
RefreshKept
// RefreshDropped: the fetch failed and the feed went stale, so it was dropped (fail-open).
RefreshDropped
)
// ApplyRefresh updates bl from one fetch outcome: on success it applies the new feed; on failure it
// keeps the last-good feed unless it is older than maxStaleness, in which case it drops it (fail-open
// — better to under-block than to block a legitimate client on a frozen feed). now is the wall clock.
func ApplyRefresh(bl *Blocklist, cidrs []*net.IPNet, fetchErr error, now time.Time, maxStaleness time.Duration) RefreshOutcome {
if fetchErr == nil {
bl.SetCIDRs(cidrs, now)
return RefreshUpdated
}
last := bl.LastFetch()
if last.IsZero() || now.Sub(last) > maxStaleness {
bl.Clear()
return RefreshDropped
}
return RefreshKept
}
// ParseDROP parses a Spamhaus DROP-style feed: one CIDR per line with an optional "; comment" tail,
// plus blank and comment lines. It returns the IPv4 networks; a bare IP is read as a /32, and
// non-IPv4 or malformed entries are skipped so one bad line never fails the whole feed.
func ParseDROP(r io.Reader) ([]*net.IPNet, error) {
var out []*net.IPNet
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)
for sc.Scan() {
line := sc.Text()
if i := strings.IndexByte(line, ';'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
if !strings.Contains(line, "/") {
line += "/32"
}
_, ipnet, err := net.ParseCIDR(line)
if err != nil || ipnet.IP.To4() == nil {
continue
}
out = append(out, ipnet)
}
if err := sc.Err(); err != nil {
return nil, fmt.Errorf("ratelimit: read blocklist: %w", err)
}
return out, nil
}
// toRanges converts IPv4 CIDRs to sorted, uint32 inclusive ranges (the match form); non-IPv4 CIDRs
// are dropped. Sorting by lo lets [rangesContain] binary-search.
func toRanges(cidrs []*net.IPNet) []ipRange {
out := make([]ipRange, 0, len(cidrs))
for _, n := range cidrs {
if n == nil {
continue
}
ip4 := n.IP.To4()
if ip4 == nil {
continue
}
ones, bits := n.Mask.Size()
if bits != 32 {
continue
}
lo := binary.BigEndian.Uint32(ip4)
hi := lo | (uint32(0xffffffff) >> uint(ones))
out = append(out, ipRange{lo: lo, hi: hi})
}
sort.Slice(out, func(i, j int) bool { return out[i].lo < out[j].lo })
return out
}
// rangesContain reports whether v falls in any range of the sorted, non-overlapping slice, via a
// binary search for the last range whose lo is at most v.
func rangesContain(ranges []ipRange, v uint32) bool {
i := sort.Search(len(ranges), func(i int) bool { return ranges[i].lo > v })
return i > 0 && ranges[i-1].hi >= v
}
// ipv4ToUint32 parses a textual address to a big-endian uint32, reporting whether it was IPv4.
func ipv4ToUint32(s string) (uint32, bool) {
ip := net.ParseIP(s)
if ip == nil {
return 0, false
}
ip4 := ip.To4()
if ip4 == nil {
return 0, false
}
return binary.BigEndian.Uint32(ip4), true
}
@@ -0,0 +1,106 @@
package ratelimit
import (
"errors"
"net"
"strings"
"testing"
"time"
)
// cidrs parses CIDR strings into networks for a test fixture.
func cidrs(t *testing.T, ss ...string) []*net.IPNet {
t.Helper()
out := make([]*net.IPNet, 0, len(ss))
for _, s := range ss {
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("bad cidr %q: %v", s, err)
}
out = append(out, n)
}
return out
}
func TestBlocklistBlocked(t *testing.T) {
bl := NewBlocklist(true, cidrs(t, "10.20.30.0/24")) // allowlist
bl.SetCIDRs(cidrs(t, "1.2.3.0/24", "203.0.113.5/32", "198.51.100.0/28", "10.20.30.0/24"), time.Now())
cases := map[string]bool{
"1.2.3.0": true, // range start
"1.2.3.255": true, // range end
"1.2.4.0": false, // just past the /24
"203.0.113.5": true, // /32 exact
"203.0.113.6": false,
"198.51.100.15": true, // within /28
"198.51.100.16": false, // past the /28
"9.9.9.9": false, // not listed
"10.20.30.99": false, // listed BUT allowlisted — never blocked
"::1": false, // IPv6 — never matched here
"not-an-ip": false,
}
for ip, want := range cases {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%q) = %v, want %v", ip, got, want)
}
}
}
func TestBlocklistDisabledOrEmpty(t *testing.T) {
off := NewBlocklist(false, nil)
off.SetCIDRs(cidrs(t, "1.2.3.0/24"), time.Now())
if off.Blocked("1.2.3.4") {
t.Error("a disabled blocklist must not block")
}
empty := NewBlocklist(true, nil)
if empty.Blocked("1.2.3.4") {
t.Error("an empty (never-loaded) blocklist must not block")
}
}
func TestParseDROP(t *testing.T) {
in := "; Spamhaus DROP\n" +
"1.2.3.0/24 ; SBL1\n" +
" 198.51.100.0/28 ; SBL2\n" +
"\n" +
"203.0.113.5 ; a bare IP -> /32\n" +
"2001:db8::/32 ; IPv6 skipped\n" +
"garbage line\n"
nets, err := ParseDROP(strings.NewReader(in))
if err != nil {
t.Fatal(err)
}
if len(nets) != 3 {
t.Fatalf("got %d networks, want 3: %v", len(nets), nets)
}
bl := NewBlocklist(true, nil)
bl.SetCIDRs(nets, time.Now())
for ip, want := range map[string]bool{"1.2.3.9": true, "198.51.100.1": true, "203.0.113.5": true, "203.0.113.6": false, "2001:db8::1": false} {
if got := bl.Blocked(ip); got != want {
t.Errorf("Blocked(%s) = %v, want %v", ip, got, want)
}
}
}
func TestApplyRefresh(t *testing.T) {
bl := NewBlocklist(true, nil)
now := time.Now()
if o := ApplyRefresh(bl, cidrs(t, "1.2.3.0/24"), nil, now, time.Hour); o != RefreshUpdated {
t.Fatalf("success: want RefreshUpdated, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Fatal("a successful refresh must apply the feed")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(30*time.Minute), time.Hour); o != RefreshKept {
t.Fatalf("fresh failure: want RefreshKept, got %v", o)
}
if !bl.Blocked("1.2.3.4") {
t.Error("a kept feed must still block")
}
if o := ApplyRefresh(bl, nil, errors.New("net"), now.Add(2*time.Hour), time.Hour); o != RefreshDropped {
t.Fatalf("stale failure: want RefreshDropped, got %v", o)
}
if bl.Blocked("1.2.3.4") {
t.Error("a stale feed must be dropped (fail-open)")
}
}
+182 -79
View File
@@ -30,13 +30,14 @@ const (
)
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command.
// Ack per Command and a periodic Health report.
type FromBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Msg:
//
// *FromBot_Hello
// *FromBot_Ack
// *FromBot_Health
Msg isFromBot_Msg `protobuf_oneof:"msg"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -97,6 +98,15 @@ func (x *FromBot) GetAck() *Ack {
return nil
}
func (x *FromBot) GetHealth() *Health {
if x != nil {
if x, ok := x.Msg.(*FromBot_Health); ok {
return x.Health
}
}
return nil
}
type isFromBot_Msg interface {
isFromBot_Msg()
}
@@ -109,10 +119,92 @@ type FromBot_Ack struct {
Ack *Ack `protobuf:"bytes,2,opt,name=ack,proto3,oneof"`
}
type FromBot_Health struct {
Health *Health `protobuf:"bytes,3,opt,name=health,proto3,oneof"`
}
func (*FromBot_Hello) isFromBot_Msg() {}
func (*FromBot_Ack) isFromBot_Msg() {}
func (*FromBot_Health) isFromBot_Msg() {}
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
type Health struct {
state protoimpl.MessageState `protogen:"open.v1"`
ConnectFailures uint64 `protobuf:"varint,1,opt,name=connect_failures,json=connectFailures,proto3" json:"connect_failures,omitempty"` // getUpdates long-poll failures (transport / 5xx) since the last report
ApiErrors uint64 `protobuf:"varint,2,opt,name=api_errors,json=apiErrors,proto3" json:"api_errors,omitempty"` // other Bot API failures (transport / 5xx) since the last report
RateLimited uint64 `protobuf:"varint,3,opt,name=rate_limited,json=rateLimited,proto3" json:"rate_limited,omitempty"` // Bot API 429 responses since the last report (should stay ~0)
LastOkUnix int64 `protobuf:"varint,4,opt,name=last_ok_unix,json=lastOkUnix,proto3" json:"last_ok_unix,omitempty"` // unix seconds of the last successful Bot API response (0 = none yet)
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *Health) Reset() {
*x = Health{}
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *Health) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*Health) ProtoMessage() {}
func (x *Health) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use Health.ProtoReflect.Descriptor instead.
func (*Health) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
}
func (x *Health) GetConnectFailures() uint64 {
if x != nil {
return x.ConnectFailures
}
return 0
}
func (x *Health) GetApiErrors() uint64 {
if x != nil {
return x.ApiErrors
}
return 0
}
func (x *Health) GetRateLimited() uint64 {
if x != nil {
return x.RateLimited
}
return 0
}
func (x *Health) GetLastOkUnix() int64 {
if x != nil {
return x.LastOkUnix
}
return 0
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
type ToBot struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -123,7 +215,7 @@ type ToBot struct {
func (x *ToBot) Reset() {
*x = ToBot{}
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -135,7 +227,7 @@ func (x *ToBot) String() string {
func (*ToBot) ProtoMessage() {}
func (x *ToBot) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[1]
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -148,7 +240,7 @@ func (x *ToBot) ProtoReflect() protoreflect.Message {
// Deprecated: Use ToBot.ProtoReflect.Descriptor instead.
func (*ToBot) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{1}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
}
func (x *ToBot) GetCommand() *Command {
@@ -171,7 +263,7 @@ type Hello struct {
func (x *Hello) Reset() {
*x = Hello{}
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -183,7 +275,7 @@ func (x *Hello) String() string {
func (*Hello) ProtoMessage() {}
func (x *Hello) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[2]
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -196,7 +288,7 @@ func (x *Hello) ProtoReflect() protoreflect.Message {
// Deprecated: Use Hello.ProtoReflect.Descriptor instead.
func (*Hello) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{2}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
}
func (x *Hello) GetInstanceId() string {
@@ -233,7 +325,7 @@ type Command struct {
func (x *Command) Reset() {
*x = Command{}
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -245,7 +337,7 @@ func (x *Command) String() string {
func (*Command) ProtoMessage() {}
func (x *Command) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[3]
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -258,7 +350,7 @@ func (x *Command) ProtoReflect() protoreflect.Message {
// Deprecated: Use Command.ProtoReflect.Descriptor instead.
func (*Command) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{3}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
}
func (x *Command) GetCommandId() string {
@@ -372,7 +464,7 @@ type Ack struct {
func (x *Ack) Reset() {
*x = Ack{}
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -384,7 +476,7 @@ func (x *Ack) String() string {
func (*Ack) ProtoMessage() {}
func (x *Ack) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[4]
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -397,7 +489,7 @@ func (x *Ack) ProtoReflect() protoreflect.Message {
// Deprecated: Use Ack.ProtoReflect.Descriptor instead.
func (*Ack) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{4}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
}
func (x *Ack) GetCommandId() string {
@@ -445,7 +537,7 @@ type ChatGateCommand struct {
func (x *ChatGateCommand) Reset() {
*x = ChatGateCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -457,7 +549,7 @@ func (x *ChatGateCommand) String() string {
func (*ChatGateCommand) ProtoMessage() {}
func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[5]
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -470,7 +562,7 @@ func (x *ChatGateCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatGateCommand.ProtoReflect.Descriptor instead.
func (*ChatGateCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{5}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
}
func (x *ChatGateCommand) GetExternalId() string {
@@ -498,7 +590,7 @@ type ChatEligibilityRequest struct {
func (x *ChatEligibilityRequest) Reset() {
*x = ChatEligibilityRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -510,7 +602,7 @@ func (x *ChatEligibilityRequest) String() string {
func (*ChatEligibilityRequest) ProtoMessage() {}
func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[6]
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -523,7 +615,7 @@ func (x *ChatEligibilityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatEligibilityRequest.ProtoReflect.Descriptor instead.
func (*ChatEligibilityRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{6}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
}
func (x *ChatEligibilityRequest) GetExternalId() string {
@@ -546,7 +638,7 @@ type ChatEligibilityResponse struct {
func (x *ChatEligibilityResponse) Reset() {
*x = ChatEligibilityResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -558,7 +650,7 @@ func (x *ChatEligibilityResponse) String() string {
func (*ChatEligibilityResponse) ProtoMessage() {}
func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[7]
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -571,7 +663,7 @@ func (x *ChatEligibilityResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ChatEligibilityResponse.ProtoReflect.Descriptor instead.
func (*ChatEligibilityResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{7}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
}
func (x *ChatEligibilityResponse) GetRegistered() bool {
@@ -605,7 +697,7 @@ type CreateInvoiceCommand struct {
func (x *CreateInvoiceCommand) Reset() {
*x = CreateInvoiceCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -617,7 +709,7 @@ func (x *CreateInvoiceCommand) String() string {
func (*CreateInvoiceCommand) ProtoMessage() {}
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -630,7 +722,7 @@ func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
// Deprecated: Use CreateInvoiceCommand.ProtoReflect.Descriptor instead.
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
}
func (x *CreateInvoiceCommand) GetTitle() string {
@@ -674,7 +766,7 @@ type PreCheckoutRequest struct {
func (x *PreCheckoutRequest) Reset() {
*x = PreCheckoutRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -686,7 +778,7 @@ func (x *PreCheckoutRequest) String() string {
func (*PreCheckoutRequest) ProtoMessage() {}
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -699,7 +791,7 @@ func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use PreCheckoutRequest.ProtoReflect.Descriptor instead.
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
}
func (x *PreCheckoutRequest) GetOrderId() string {
@@ -735,7 +827,7 @@ type PreCheckoutResponse struct {
func (x *PreCheckoutResponse) Reset() {
*x = PreCheckoutResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -747,7 +839,7 @@ func (x *PreCheckoutResponse) String() string {
func (*PreCheckoutResponse) ProtoMessage() {}
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -760,7 +852,7 @@ func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use PreCheckoutResponse.ProtoReflect.Descriptor instead.
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
}
func (x *PreCheckoutResponse) GetOk() bool {
@@ -792,7 +884,7 @@ type ForwardPaymentRequest struct {
func (x *ForwardPaymentRequest) Reset() {
*x = ForwardPaymentRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -804,7 +896,7 @@ func (x *ForwardPaymentRequest) String() string {
func (*ForwardPaymentRequest) ProtoMessage() {}
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -817,7 +909,7 @@ func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardPaymentRequest.ProtoReflect.Descriptor instead.
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
}
func (x *ForwardPaymentRequest) GetOrderId() string {
@@ -861,7 +953,7 @@ type ForwardPaymentResponse struct {
func (x *ForwardPaymentResponse) Reset() {
*x = ForwardPaymentResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -873,7 +965,7 @@ func (x *ForwardPaymentResponse) String() string {
func (*ForwardPaymentResponse) ProtoMessage() {}
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
mi := &file_botlink_v1_botlink_proto_msgTypes[13]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -886,7 +978,7 @@ func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use ForwardPaymentResponse.ProtoReflect.Descriptor instead.
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{13}
}
func (x *ForwardPaymentResponse) GetCredited() bool {
@@ -900,11 +992,19 @@ var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"r\n" +
"\x18botlink/v1/botlink.proto\x12\x13scrabble.botlink.v1\x1a\x1atelegram/v1/telegram.proto\"\xa9\x01\n" +
"\aFromBot\x122\n" +
"\x05hello\x18\x01 \x01(\v2\x1a.scrabble.botlink.v1.HelloH\x00R\x05hello\x12,\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ackB\x05\n" +
"\x03msg\"?\n" +
"\x03ack\x18\x02 \x01(\v2\x18.scrabble.botlink.v1.AckH\x00R\x03ack\x125\n" +
"\x06health\x18\x03 \x01(\v2\x1b.scrabble.botlink.v1.HealthH\x00R\x06healthB\x05\n" +
"\x03msg\"\x97\x01\n" +
"\x06Health\x12)\n" +
"\x10connect_failures\x18\x01 \x01(\x04R\x0fconnectFailures\x12\x1d\n" +
"\n" +
"api_errors\x18\x02 \x01(\x04R\tapiErrors\x12!\n" +
"\frate_limited\x18\x03 \x01(\x04R\vrateLimited\x12 \n" +
"\flast_ok_unix\x18\x04 \x01(\x03R\n" +
"lastOkUnix\"?\n" +
"\x05ToBot\x126\n" +
"\acommand\x18\x01 \x01(\v2\x1c.scrabble.botlink.v1.CommandR\acommand\"K\n" +
"\x05Hello\x12\x1f\n" +
@@ -976,47 +1076,49 @@ func file_botlink_v1_botlink_proto_rawDescGZIP() []byte {
return file_botlink_v1_botlink_proto_rawDescData
}
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 2: scrabble.botlink.v1.Hello
(*Command)(nil), // 3: scrabble.botlink.v1.Command
(*Ack)(nil), // 4: scrabble.botlink.v1.Ack
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
(*PreCheckoutResponse)(nil), // 10: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 11: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 12: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 13: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 14: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 15: scrabble.telegram.v1.SendToGameChannelRequest
(*Health)(nil), // 1: scrabble.botlink.v1.Health
(*ToBot)(nil), // 2: scrabble.botlink.v1.ToBot
(*Hello)(nil), // 3: scrabble.botlink.v1.Hello
(*Command)(nil), // 4: scrabble.botlink.v1.Command
(*Ack)(nil), // 5: scrabble.botlink.v1.Ack
(*ChatGateCommand)(nil), // 6: scrabble.botlink.v1.ChatGateCommand
(*ChatEligibilityRequest)(nil), // 7: scrabble.botlink.v1.ChatEligibilityRequest
(*ChatEligibilityResponse)(nil), // 8: scrabble.botlink.v1.ChatEligibilityResponse
(*CreateInvoiceCommand)(nil), // 9: scrabble.botlink.v1.CreateInvoiceCommand
(*PreCheckoutRequest)(nil), // 10: scrabble.botlink.v1.PreCheckoutRequest
(*PreCheckoutResponse)(nil), // 11: scrabble.botlink.v1.PreCheckoutResponse
(*ForwardPaymentRequest)(nil), // 12: scrabble.botlink.v1.ForwardPaymentRequest
(*ForwardPaymentResponse)(nil), // 13: scrabble.botlink.v1.ForwardPaymentResponse
(*v1.NotifyRequest)(nil), // 14: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 15: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 16: scrabble.telegram.v1.SendToGameChannelRequest
}
var file_botlink_v1_botlink_proto_depIdxs = []int32{
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
4, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
3, // 2: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
12, // 15: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
12, // [12:16] is the sub-list for method output_type
8, // [8:12] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
3, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
5, // 1: scrabble.botlink.v1.FromBot.ack:type_name -> scrabble.botlink.v1.Ack
1, // 2: scrabble.botlink.v1.FromBot.health:type_name -> scrabble.botlink.v1.Health
4, // 3: scrabble.botlink.v1.ToBot.command:type_name -> scrabble.botlink.v1.Command
14, // 4: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
15, // 5: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
16, // 6: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest
6, // 7: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand
9, // 8: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
0, // 9: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
10, // 11: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
12, // 12: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
2, // 13: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
8, // 14: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
11, // 15: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
13, // 16: scrabble.botlink.v1.BotLink.ForwardPayment:output_type -> scrabble.botlink.v1.ForwardPaymentResponse
13, // [13:17] is the sub-list for method output_type
9, // [9:13] is the sub-list for method input_type
9, // [9:9] is the sub-list for extension type_name
9, // [9:9] is the sub-list for extension extendee
0, // [0:9] is the sub-list for field type_name
}
func init() { file_botlink_v1_botlink_proto_init() }
@@ -1027,8 +1129,9 @@ func file_botlink_v1_botlink_proto_init() {
file_botlink_v1_botlink_proto_msgTypes[0].OneofWrappers = []any{
(*FromBot_Hello)(nil),
(*FromBot_Ack)(nil),
(*FromBot_Health)(nil),
}
file_botlink_v1_botlink_proto_msgTypes[3].OneofWrappers = []any{
file_botlink_v1_botlink_proto_msgTypes[4].OneofWrappers = []any{
(*Command_Notify)(nil),
(*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil),
@@ -1041,7 +1144,7 @@ func file_botlink_v1_botlink_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
NumEnums: 0,
NumMessages: 13,
NumMessages: 14,
NumExtensions: 0,
NumServices: 1,
},
+17 -1
View File
@@ -48,14 +48,30 @@ service BotLink {
}
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
// Ack per Command.
// Ack per Command and a periodic Health report.
message FromBot {
oneof msg {
Hello hello = 1;
Ack ack = 2;
Health health = 3;
}
}
// Health is a periodic report the bot pushes up the stream so the gateway can expose the remote
// bot's Bot-API health as its own metrics — the bot exports no telemetry of its own (otelcol lives
// on the main host, unreachable from the bot), so the bot-link is its only channel out. The three
// counters are DELTAS since the previous Health: the gateway adds them to cumulative counters, so a
// report lost across a reconnect only undercounts, never corrupts. last_ok_unix is the wall-clock
// second of the bot's most recent successful Bot API response (any call, including the getUpdates
// long-poll that returns every poll cycle even when idle) — an absolute liveness stamp the gateway
// surfaces as a gauge, so a silently stalled bot (no errors, no traffic) is still caught.
message Health {
uint64 connect_failures = 1; // getUpdates long-poll failures (transport / 5xx) since the last report
uint64 api_errors = 2; // other Bot API failures (transport / 5xx) since the last report
uint64 rate_limited = 3; // Bot API 429 responses since the last report (should stay ~0)
int64 last_ok_unix = 4; // unix seconds of the last successful Bot API response (0 = none yet)
}
// ToBot is a message the gateway sends to the bot. Only Command is carried today.
message ToBot {
Command command = 1;
+14
View File
@@ -23,6 +23,7 @@ import (
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/health"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
@@ -31,6 +32,10 @@ import (
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
// healthReportInterval is how often the bot flushes its accumulated Bot API health to the gateway
// over the bot-link. It bounds how stale a metric can be, not how often the bot talks to Telegram.
const healthReportInterval = 30 * time.Second
func main() {
cfg, err := config.LoadBot()
if err != nil {
@@ -81,6 +86,12 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
}
}
// The bot exports no telemetry of its own (otelcol is on the main host, unreachable from here),
// so its Bot API health is observed centrally and reported to the gateway over the bot-link,
// which turns the reports into metrics. Shared between the bot (which feeds it) and the bot-link
// client (which flushes it).
healthReporter := health.NewReporter()
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
@@ -92,6 +103,7 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
Health: healthReporter,
}, logger)
if err != nil {
return err
@@ -120,6 +132,8 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
Health: healthReporter,
HealthInterval: healthReportInterval,
}, exec, logger)
if err != nil {
return err
+16
View File
@@ -7,19 +7,26 @@ package bot
import (
"context"
"net/http"
"net/url"
"strconv"
"strings"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/health"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support"
)
// botPollTimeout is the getUpdates long-poll timeout. It matches the go-telegram/bot default; we set
// it explicitly only because wiring the health observer (WithHTTPClient) also sets the poll timeout.
const botPollTimeout = time.Minute
// Config configures the bot wrapper.
type Config struct {
// Token is the Bot API token.
@@ -54,6 +61,9 @@ type Config struct {
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
// Health, when set, observes every Bot API request for health metrics (reported to the gateway
// over the bot-link) and honours a 429's Retry-After. Nil disables the observation.
Health *health.Reporter
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -159,6 +169,12 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if cfg.APIBaseURL != "" {
opts = append(opts, tgbot.WithServerURL(cfg.APIBaseURL))
}
if cfg.Health != nil {
// Observe every Bot API request centrally for health metrics and the 429 back-off. The
// client timeout leaves slack over the long-poll hold (botPollTimeout - 1s server-side).
base := &http.Client{Timeout: botPollTimeout + 10*time.Second}
opts = append(opts, tgbot.WithHTTPClient(botPollTimeout, cfg.Health.Wrap(base)))
}
api, err := tgbot.New(cfg.Token, opts...)
if err != nil {
return nil, err
+48 -5
View File
@@ -11,6 +11,7 @@ import (
"google.golang.org/grpc/keepalive"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
"scrabble/platform/telegram/internal/health"
)
const (
@@ -34,6 +35,11 @@ type ClientConfig struct {
Creds credentials.TransportCredentials
// ReconnectDelay is the pause before re-dialing after the stream ends.
ReconnectDelay time.Duration
// Health, when set with a positive HealthInterval, is flushed to the gateway as a botlink Health
// message every HealthInterval while the stream is open. Nil disables health reporting.
Health *health.Reporter
// HealthInterval is the cadence of the Health flush; 0 disables it.
HealthInterval time.Duration
}
// Client maintains the long-lived bot-link to the gateway, executing the commands
@@ -140,15 +146,43 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
}
c.log.Info("bot-link connected", zap.String("gateway", c.cfg.GatewayAddr), zap.Bool("owns_updates", c.cfg.OwnsUpdates))
// One goroutine owns stream.Recv, feeding commands to the loop below; the loop owns every
// stream.Send (the Acks and the periodic Health) — a gRPC stream forbids concurrent Send.
rctx, cancel := context.WithCancel(ctx)
defer cancel()
cmds := make(chan *botlinkv1.Command)
recvErr := make(chan error, 1)
go func() {
for {
msg, err := stream.Recv()
if err != nil {
recvErr <- err
return
}
if cmd := msg.GetCommand(); cmd != nil {
select {
case cmds <- cmd:
case <-rctx.Done():
return
}
}
}
}()
var healthTick <-chan time.Time
if c.cfg.Health != nil && c.cfg.HealthInterval > 0 {
t := time.NewTicker(c.cfg.HealthInterval)
defer t.Stop()
healthTick = t.C
}
for {
select {
case <-ctx.Done():
return ctx.Err()
case err := <-recvErr:
return err
}
cmd := msg.GetCommand()
if cmd == nil {
continue
}
case cmd := <-cmds:
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil {
@@ -157,6 +191,15 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Ack{Ack: ack}}); err != nil {
return err
}
case <-healthTick:
// Snapshot without resetting; only commit (clear the deltas) after a successful send, so a
// failed flush loses nothing and the counts ride the next connection.
hb := c.cfg.Health.Snapshot()
if err := stream.Send(&botlinkv1.FromBot{Msg: &botlinkv1.FromBot_Health{Health: hb}}); err != nil {
return err
}
c.cfg.Health.Commit(hb)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
// Package health tracks the Telegram bot's Bot API health and reports it to the gateway over the
// bot-link. The bot exports no telemetry of its own — otelcol lives on the main host, unreachable
// from the bot host — so the gateway turns these reports into its own metrics (see
// gateway/internal/botlink). Health is observed CENTRALLY by wrapping the Bot API HTTP client
// ([Reporter.Wrap]); every request is classified there with no per-call-site instrumentation.
package health
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"sync/atomic"
"time"
botlinkv1 "scrabble/pkg/proto/botlink/v1"
)
// maxBackoff caps how long a single 429 makes the bot wait, so a hostile or buggy Retry-After can
// never wedge the bot.
const maxBackoff = 30 * time.Second
// Reporter accumulates the bot's Bot API health between reports: three delta counters and the last
// successful-response stamp. It is safe for concurrent use. The bot-link client periodically
// [Reporter.Snapshot]s it, sends the snapshot as a botlink Health, and [Reporter.Commit]s it on a
// successful send.
type Reporter struct {
connectFailures atomic.Int64
apiErrors atomic.Int64
rateLimited atomic.Int64
lastOKUnix atomic.Int64
}
// NewReporter returns an empty Reporter.
func NewReporter() *Reporter { return &Reporter{} }
// Snapshot reads the current delta counters and the last-ok stamp into a Health message WITHOUT
// resetting them. The counters are cleared only by [Reporter.Commit] after a successful send, so a
// failed send loses nothing.
func (r *Reporter) Snapshot() *botlinkv1.Health {
return &botlinkv1.Health{
ConnectFailures: uint64(max(r.connectFailures.Load(), 0)),
ApiErrors: uint64(max(r.apiErrors.Load(), 0)),
RateLimited: uint64(max(r.rateLimited.Load(), 0)),
LastOkUnix: r.lastOKUnix.Load(),
}
}
// Commit subtracts a just-sent snapshot's delta counters, so counts accrued between the snapshot and
// the send survive into the next report. last_ok is a stamp, not a delta, so it is left as is.
func (r *Reporter) Commit(sent *botlinkv1.Health) {
r.connectFailures.Add(-int64(sent.GetConnectFailures()))
r.apiErrors.Add(-int64(sent.GetApiErrors()))
r.rateLimited.Add(-int64(sent.GetRateLimited()))
}
// Wrap returns an http.Client-shaped observer over base for tgbot.WithHTTPClient: it stamps liveness
// on every successful response, counts transport and 5xx failures (split getUpdates vs other) and
// 429s, and honours a 429's Retry-After (bounded by maxBackoff) so the bot backs off instead of
// hammering. A 4xx other than 429 (a user blocked the bot, a malformed request) is a normal
// per-request outcome, not a health signal, and is not counted.
func (r *Reporter) Wrap(base Doer) Doer { return &observer{base: base, r: r} }
// Doer is the minimal HTTP surface the Bot API client needs; both *http.Client and [observer]
// satisfy it, and it matches the go-telegram/bot HttpClient interface.
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
// observer is the Bot API HTTP client wrapper (see [Reporter.Wrap]).
type observer struct {
base Doer
r *Reporter
}
// Do performs the request and records its health outcome.
func (o *observer) Do(req *http.Request) (*http.Response, error) {
resp, err := o.base.Do(req)
poll := strings.HasSuffix(req.URL.Path, "/getUpdates")
if err != nil {
// A cancelled context is a shutdown, not a Bot API failure.
if req.Context().Err() == nil {
o.fail(poll)
}
return resp, err
}
switch {
case resp.StatusCode == http.StatusTooManyRequests:
o.r.rateLimited.Add(1)
o.backoff(req.Context(), resp)
case resp.StatusCode >= 200 && resp.StatusCode < 300:
o.r.lastOKUnix.Store(time.Now().Unix())
case resp.StatusCode >= 500:
o.fail(poll)
}
return resp, err
}
// fail records a connect failure on the getUpdates long-poll or an api error otherwise.
func (o *observer) fail(poll bool) {
if poll {
o.r.connectFailures.Add(1)
} else {
o.r.apiErrors.Add(1)
}
}
// backoff waits out a 429's Retry-After (bounded, cancellable). It buffers the response body first so
// the wait holds no connection open and the Bot API library can still parse the 429 afterwards.
func (o *observer) backoff(ctx context.Context, resp *http.Response) {
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
d := retryAfter(resp.Header.Get("Retry-After"), body)
if d <= 0 {
return
}
if d > maxBackoff {
d = maxBackoff
}
select {
case <-ctx.Done():
case <-time.After(d):
}
}
// retryAfter resolves the 429 back-off from the Retry-After header, falling back to the Bot API
// body's parameters.retry_after (both are seconds). It returns 0 when neither gives a positive value.
func retryAfter(header string, body []byte) time.Duration {
if secs, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && secs > 0 {
return time.Duration(secs) * time.Second
}
var payload struct {
Parameters struct {
RetryAfter int `json:"retry_after"`
} `json:"parameters"`
}
if err := json.Unmarshal(body, &payload); err == nil && payload.Parameters.RetryAfter > 0 {
return time.Duration(payload.Parameters.RetryAfter) * time.Second
}
return 0
}
@@ -0,0 +1,121 @@
package health
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// fakeDoer returns a canned response and error for every request.
type fakeDoer struct {
resp *http.Response
err error
}
func (f *fakeDoer) Do(*http.Request) (*http.Response, error) { return f.resp, f.err }
// mkResp builds a response with a status, optional Retry-After header and body.
func mkResp(status int, retryAfter, body string) *http.Response {
h := http.Header{}
if retryAfter != "" {
h.Set("Retry-After", retryAfter)
}
return &http.Response{StatusCode: status, Header: h, Body: io.NopCloser(strings.NewReader(body))}
}
// run sends one request for the Bot API method through a fresh observer and returns the reporter's
// resulting snapshot. The 429 cases carry no Retry-After, so the back-off returns before any wait.
func run(method string, resp *http.Response, err error) *Reporter {
r := NewReporter()
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/"+method, nil)
_, _ = r.Wrap(&fakeDoer{resp: resp, err: err}).Do(req)
return r
}
func TestObserverClassifies(t *testing.T) {
cases := []struct {
name string
method string
resp *http.Response
err error
wantConnect uint64
wantAPI uint64
wantRate uint64
wantLastOKSet bool
}{
{"poll ok stamps liveness", "getUpdates", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
{"send ok stamps liveness", "sendMessage", mkResp(200, "", `{"ok":true}`), nil, 0, 0, 0, true},
{"poll 5xx is a connect failure", "getUpdates", mkResp(502, "", ""), nil, 1, 0, 0, false},
{"send 5xx is an api error", "sendMessage", mkResp(500, "", ""), nil, 0, 1, 0, false},
{"429 is rate limited, not an error", "sendMessage", mkResp(429, "", `{"ok":false}`), nil, 0, 0, 1, false},
{"4xx other than 429 is not counted", "sendMessage", mkResp(403, "", ""), nil, 0, 0, 0, false},
{"poll transport error is a connect failure", "getUpdates", nil, errors.New("dial"), 1, 0, 0, false},
{"send transport error is an api error", "sendMessage", nil, errors.New("dial"), 0, 1, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
s := run(tc.method, tc.resp, tc.err).Snapshot()
if s.GetConnectFailures() != tc.wantConnect || s.GetApiErrors() != tc.wantAPI || s.GetRateLimited() != tc.wantRate {
t.Errorf("counters = connect %d api %d rate %d, want %d/%d/%d",
s.GetConnectFailures(), s.GetApiErrors(), s.GetRateLimited(), tc.wantConnect, tc.wantAPI, tc.wantRate)
}
if (s.GetLastOkUnix() > 0) != tc.wantLastOKSet {
t.Errorf("last_ok set = %v, want %v", s.GetLastOkUnix() > 0, tc.wantLastOKSet)
}
})
}
}
// TestObserverIgnoresShutdownTransportError checks a transport error under a cancelled context (a
// shutdown) is not counted as a Bot API failure.
func TestObserverIgnoresShutdownTransportError(t *testing.T) {
r := NewReporter()
req := httptest.NewRequest(http.MethodPost, "https://api.telegram.org/bot123/getUpdates", nil)
ctx, cancel := context.WithCancel(req.Context())
cancel()
_, _ = r.Wrap(&fakeDoer{err: errors.New("dial")}).Do(req.WithContext(ctx))
if s := r.Snapshot(); s.GetConnectFailures() != 0 {
t.Errorf("shutdown transport error must not count, got connect=%d", s.GetConnectFailures())
}
}
func TestRetryAfter(t *testing.T) {
cases := []struct {
header string
body string
want time.Duration
}{
{"5", "", 5 * time.Second},
{"", `{"parameters":{"retry_after":7}}`, 7 * time.Second},
{"3", `{"parameters":{"retry_after":9}}`, 3 * time.Second}, // header wins
{"", `{"ok":false}`, 0},
{"0", "", 0},
{"", "not json", 0},
}
for _, tc := range cases {
if got := retryAfter(tc.header, []byte(tc.body)); got != tc.want {
t.Errorf("retryAfter(%q, %q) = %v, want %v", tc.header, tc.body, got, tc.want)
}
}
}
// TestSnapshotCommit checks Commit clears only the sent deltas, so counts accrued between the
// snapshot and the send survive into the next report.
func TestSnapshotCommit(t *testing.T) {
r := NewReporter()
r.apiErrors.Add(3)
snap := r.Snapshot()
if snap.GetApiErrors() != 3 {
t.Fatalf("snapshot api_errors = %d, want 3", snap.GetApiErrors())
}
r.apiErrors.Add(2) // accrues after the snapshot
r.Commit(snap)
if got := r.Snapshot().GetApiErrors(); got != 2 {
t.Errorf("after commit api_errors = %d, want 2 (the post-snapshot accrual)", got)
}
}
+4 -1
View File
@@ -23,7 +23,10 @@ ENV APP_VERSION=${VERSION}
WORKDIR /app
COPY --from=build /src/renderer/node_modules ./node_modules
COPY --from=build /src/renderer/dist ./dist
COPY renderer/src/server.mjs renderer/src/render.mjs ./src/
COPY renderer/src/server.mjs renderer/src/render.mjs renderer/src/offer.mjs ./src/
# The public-offer prose (GET /offer/): the owner-edited markdown, read once at boot. The dynamic
# price list is fetched from the backend at request time and spliced in.
COPY ui/legal/offer_ru.md ./legal/offer_ru.md
USER node
EXPOSE 8090
CMD ["node", "src/server.mjs"]
+22 -12
View File
@@ -1,10 +1,10 @@
# renderer — the finished-game image-render sidecar
# renderer — the render sidecar (finished-game image + public offer page)
An internal-only Node service that rasterizes the finished-game export PNG. It runs the
**same** `ui/src/lib/gameimage.ts` the web project unit-tests — bundled verbatim at image
build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) — on
[skia-canvas](https://github.com/samizdatco/skia-canvas), so the server render is
pixel-identical to the design the owner signed off in the browser.
An internal Node service that runs shared `ui/src/lib` renderers server-side — bundled verbatim at
image build time (`src/entry.ts` → esbuild → `dist/gameimage.mjs`) so there is one renderer and no
drift from the browser. It serves two surfaces: the finished-game export **PNG** (on
[skia-canvas](https://github.com/samizdatco/skia-canvas), pixel-identical to the design the owner
signed off) and the public **offer page**.
## Interface
@@ -12,20 +12,30 @@ pixel-identical to the design the owner signed off in the browser.
`image/png`. `game`/`moves` are the ui-model shapes (`GameView` / `MoveRecord[]`);
`alphabet` is the per-variant `(index, letter, value)` table tile values are drawn
from; `labels` localizes the non-play moves (pass/exchange/resign/timeout);
`hostname` + `dateLocale` feed the footer.
`hostname` + `dateLocale` feed the footer. Internal-only.
- `GET /offer/` — the public offer page as `text/html`: the owner-edited `ui/legal/offer_ru.md`
(baked into the image, read at boot) with the live catalog **price list** (§4.4) fetched as
markdown from the backend's internal `/api/v1/internal/offer/pricing` (`RENDERER_BACKEND_URL`)
and spliced in at the `<#pricing_template#>` marker, then rendered by the shared
`ui/src/lib/offer.ts`. `GET /offer` → 301 `/offer/`. **The only edge-exposed route** — caddy
routes `/offer/` here; a backend outage yields a 502, never a stale price list.
- `GET /healthz` — liveness (the compose healthcheck the backend's `depends_on` gates on).
The service draws and nothing else: authentication, the participant check and the signed
public download URL all live in the backend (`backend/internal/server/export.go`); the
network path is client → gateway `/dl/*` → backend → this sidecar.
The service renders and nothing else: for the PNG, authentication, the participant check and the
signed public download URL all live in the backend (`backend/internal/server/export.go`), the path
being client → gateway `/dl/*` → backend → this sidecar; for the offer, the catalog projection and
its cache live in the backend (`backend/internal/payments/offer.go`) and no user input reaches the
page.
## Development
```sh
pnpm install # skia-canvas + esbuild MUST stay approved in pnpm-workspace.yaml
# (allowBuilds) or the native binary is silently never fetched
pnpm test # bundles, then node --test against testdata/request.json
node src/server.mjs # local run on :8090 (RENDERER_PORT overrides)
pnpm test # bundles, then node --test (PNG smoke + offer splice)
# local run on :8090 (RENDERER_PORT overrides). For /offer/, point at the offer source and a
# reachable backend (else the boot read / the price fetch fail):
RENDERER_OFFER_MD=../ui/legal/offer_ru.md RENDERER_BACKEND_URL=http://localhost:8080 node src/server.mjs
```
The runtime image (`renderer/Dockerfile`, `node:22-slim`) bakes in Liberation Sans (the
+4
View File
@@ -9,5 +9,9 @@ await build({
format: 'esm',
platform: 'node',
outfile: 'dist/gameimage.mjs',
// The shared ui/src/lib modules are copied without their node_modules, so a bare npm import they
// make (offer.ts → 'marked', the offer renderer's markdown parser) is not resolvable from the ui
// tree. NODE_PATH-style fallback to the renderer's own node_modules, where marked is a dependency.
nodePaths: ['node_modules'],
logLevel: 'info',
});
+1
View File
@@ -9,6 +9,7 @@
"test": "pnpm run bundle && node --test test/*.test.mjs"
},
"dependencies": {
"marked": "^18.0.5",
"skia-canvas": "^3.0.6"
},
"devDependencies": {
+10
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
marked:
specifier: ^18.0.5
version: 18.0.6
skia-canvas:
specifier: ^3.0.6
version: 3.0.8
@@ -209,6 +212,11 @@ packages:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
marked@18.0.6:
resolution: {integrity: sha512-MrV5puXBfuiy6wl6DLaq3BtIJQAJToAd5zt/ZKhRfGRAuFPALE7/4Y7jnxRQoEgK/pBgurGqLyAuRgZ2xOjr6w==}
engines: {node: '>= 20'}
hasBin: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -347,6 +355,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
marked@18.0.6: {}
ms@2.1.3: {}
parenthesis@3.1.8: {}
+7 -4
View File
@@ -1,6 +1,9 @@
// The esbuild bundle entry: re-exports the SHARED game-image renderer from ui/src/lib —
// the exact module the browser build unit-tests — plus the alphabet cache seeder the
// server fills from the backend-supplied per-variant table. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source).
// The esbuild bundle entry: re-exports the SHARED ui/src/lib modules — the exact modules the
// browser build unit-tests — so the sidecar runs them on the server. Bundled by `pnpm run bundle`
// into dist/gameimage.mjs (type erasure only; the ui project type-checks the source):
// - drawGameImage + setAlphabet: the finished-game PNG export (POST /render).
// - renderOfferHtml: the public-offer page (GET /offer/) — the same renderer the landing build
// used to invoke, now server-side so the live catalog price list can be spliced in.
export { drawGameImage, type RenderOptions } from '../../ui/src/lib/gameimage';
export { setAlphabet } from '../../ui/src/lib/alphabet';
export { renderOfferHtml } from '../../ui/src/lib/offer';
+17
View File
@@ -0,0 +1,17 @@
// The public-offer page renderer: splices the live catalog price list into the owner-edited offer
// markdown and renders it with the SAME renderOfferHtml the landing build used to invoke (bundled
// from ui/src/lib/offer). Kept out of server.mjs so the substitution is unit-testable without HTTP.
import { renderOfferHtml } from '../dist/gameimage.mjs';
// pricingMarker is the token ui/legal/offer_ru.md carries at §4.4 where the price list belongs. It
// mirrors the backend marker (backend/internal/payments/offer.go) and is replaced with the fetched
// tables before rendering.
export const pricingMarker = '<#pricing_template#>';
// renderOffer returns the standalone /offer/ HTML: the offer markdown with the pricing marker
// replaced by pricingTables (the two markdown tables the backend projects from the active catalog),
// rendered to a self-contained document. The replacement is literal (a function replacer) so a "$"
// in a product title is never read as a replacement pattern; a missing marker leaves the text as is.
export function renderOffer(offerMarkdown, pricingTables) {
return renderOfferHtml(offerMarkdown.replace(pricingMarker, () => pricingTables));
}
+43 -8
View File
@@ -1,20 +1,43 @@
// The render sidecar: a minimal internal HTTP service the backend calls to rasterize the
// finished-game export image. It runs the same drawGameImage the browser build ships
// (bundled from ui/src/lib at image build time) on skia-canvas, with system fonts from
// the image (Liberation Sans + Noto Color Emoji via fontconfig).
// The render sidecar: a minimal internal HTTP service on skia-canvas and the shared ui/src/lib
// renderers (bundled from ui/src/lib at image build time). It serves two surfaces:
//
// POST /render {game, moves, alphabet, labels, dateLocale, hostname, scale?} → image/png
// GET /offer/ → text/html (the public offer page: ui/legal/offer_ru.md with the live catalog
// price list spliced in — the only edge-exposed route; caddy routes /offer/ here)
// GET /offer → 301 /offer/
// GET /healthz → 200
//
// The service is internal-only (docker network `internal`); authentication, participant
// checks and the signed public URL all live in the backend — this process only draws.
// /render is internal-only (the backend calls it; authentication, participant checks and the signed
// public URL live in the backend). /offer/ is reachable from the edge but read-only and unprivileged
// — it fetches the price list from the backend's internal endpoint and renders the committed offer
// markdown; no user input reaches it.
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { renderRequest } from './render.mjs';
import { renderOffer } from './offer.mjs';
const PORT = Number(process.env.RENDERER_PORT || 8090);
// A render request is a finished game's journal — generously capped.
const MAX_BODY = 2 * 1024 * 1024;
// The offer prose is baked into the image (renderer/Dockerfile) and read once at boot; only the
// price list is dynamic (fetched per request). The backend endpoint is internal (off the edge
// allow-list) and serves the tables from its in-memory cache. RENDERER_OFFER_MD overrides the baked
// path for a local run (point it at ../ui/legal/offer_ru.md).
const OFFER_MD = readFileSync(process.env.RENDERER_OFFER_MD || new URL('../legal/offer_ru.md', import.meta.url), 'utf8');
const OFFER_PRICING_URL =
(process.env.RENDERER_BACKEND_URL || 'http://backend:8080') + '/api/v1/internal/offer/pricing';
// fetchOfferPricing returns the two catalog price tables as markdown from the backend, or throws a
// 502 (upstream error) / 504 (timeout) so the offer never renders with a broken price list.
async function fetchOfferPricing() {
const resp = await fetch(OFFER_PRICING_URL, { signal: AbortSignal.timeout(5000) });
if (!resp.ok) {
throw Object.assign(new Error(`offer pricing upstream ${resp.status}`), { status: 502 });
}
return resp.text();
}
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
@@ -35,11 +58,23 @@ function readBody(req) {
const server = createServer(async (req, res) => {
try {
if (req.method === 'GET' && req.url === '/healthz') {
const path = (req.url || '').split('?')[0];
if (req.method === 'GET' && path === '/healthz') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
return;
}
if (req.method === 'POST' && req.url === '/render') {
if (req.method === 'GET' && path === '/offer') {
res.writeHead(301, { location: '/offer/' }).end();
return;
}
if (req.method === 'GET' && path === '/offer/') {
const html = renderOffer(OFFER_MD, await fetchOfferPricing());
res
.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache' })
.end(html);
return;
}
if (req.method === 'POST' && path === '/render') {
const png = await renderRequest(JSON.parse(await readBody(req)));
res.writeHead(200, { 'content-type': 'image/png', 'content-length': png.length }).end(png);
return;
+29
View File
@@ -0,0 +1,29 @@
// Unit test for the public-offer page assembly: renderOffer splices the backend's price-list
// markdown into the offer at the pricing marker and renders it with the shared renderOfferHtml
// (bundled from ui/src/lib/offer). The prose rendering itself is unit-tested in ui/ (offer.test.ts);
// here we assert the substitution and that the tables reach the rendered HTML.
import test from 'node:test';
import assert from 'node:assert/strict';
import { renderOffer, pricingMarker } from '../src/offer.mjs';
const tables =
'| Наименование | Рубли | Голоса в VK | Stars в Telegram |\n' +
'| --- | --- | --- | --- |\n' +
'| 50 «Фишек» | 200.00 | 30 | 100 |';
test('splices the price list into the offer and renders the tables to HTML', () => {
const md = `# Публичная оферта\n\n**4.4.** Стоимость Товаров:\n\n${pricingMarker}\n`;
const html = renderOffer(md, tables);
// The marker is gone and the projected table reached the document as a real HTML table.
assert.ok(!html.includes(pricingMarker), 'pricing marker must be substituted');
assert.ok(html.includes('<table>'), 'the price list must render as an HTML table');
assert.ok(html.includes('<td>200.00</td>'), 'a rouble price cell must be present');
assert.ok(html.includes('50 «Фишек»'), 'the product title must be present');
// The offer chrome from the shared renderer is intact.
assert.ok(html.includes('<!doctype html>'));
});
test('a "$" in a title is substituted literally, not as a replacement pattern', () => {
const html = renderOffer(`x ${pricingMarker} y`, '| $5 pack | 1 |');
assert.ok(html.includes('$5 pack'), 'a "$" in the tables must survive substitution');
});
+2 -2
View File
@@ -261,7 +261,7 @@ test('dropping the game ends it and shows the result', async ({ page }) => {
await page.locator('.scoreboard').click(); // open the history
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁 in the history header
await page.locator('button.danger').click(); // confirm in the modal
await expect(page.locator('.status .over')).toBeVisible();
await expect(page.locator('.turnstrip.result')).toBeVisible();
});
test('resigning reveals the full board: closes the history and zooms out', async ({ page }) => {
@@ -283,7 +283,7 @@ test('resigning reveals the full board: closes the history and zooms out', async
await page.getByRole('button', { name: 'Drop game' }).click(); // 🏁
await page.locator('button.danger').click(); // confirm
await expect(page.locator('.status .over')).toBeVisible(); // the game ended
await expect(page.locator('.turnstrip.result')).toBeVisible(); // the game ended
await expect(page.locator('.history')).toHaveCount(0); // history auto-closed (portrait)
await expect(page.locator('.viewport.zoomed')).toHaveCount(0); // board zoomed out
});
+41
View File
@@ -0,0 +1,41 @@
import { expect, test } from './fixtures';
// The in-game composition UX after the under-board status strip was removed:
// - whose turn (or the final result) shows in a thin strip above the score plaques;
// - the tiles left in the bag ride the exchange control as a badge and repeat at the foot of the
// move table;
// - staging a play tints its tiles on the board and shows the orange move-score badge, and the
// confirm control sits in the rack's fixed 7th slot.
// Mock transport only: the mock has no dictionary (Game.svelte skips the local evaluator under the
// mock mode) and its network evaluator accepts any placement, so a staged tile always reads as legal.
test('in-game UX: turn strip, bag badge + table footer, staged-play highlight and score badge', 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();
// Attach the opponent deterministically, then it is the player's turn.
await page.evaluate(() => (window as unknown as { __mock: { joinOpponent(): void } }).__mock.joinOpponent());
await expect(page.getByText('Robo')).toBeVisible();
// The old under-board status line is gone; a thin turn strip sits above the plaques instead.
await expect(page.locator('.turnstrip')).toBeVisible();
await expect(page.locator('.status')).toHaveCount(0);
// The bag count rides the exchange control (the first tab) as a badge, and repeats at the foot of
// the move table, out of the scrolling grid.
await expect(page.locator('.tab').first().locator('.badge')).toBeVisible();
await page.locator('.scoreboard').click(); // open the history drawer
await expect(page.locator('.hbagfoot')).toContainText(/bag/i);
await page.locator('.scoreboard').click(); // close it again
// Stage a play: the pending tile reads as legal (green), the orange move-score badge shows on the
// board, and the confirm control takes the rack's 7th slot.
await page.locator('.rack .tile').first().click();
await page.locator('[data-cell]:not(.filled)').nth(112).click(); // centre (row 7, col 7)
await expect(page.locator('[data-cell].pending.legal')).toHaveCount(1);
await expect(page.locator('.scorebadge')).toBeVisible();
await expect(page.locator('.make')).toBeVisible();
});
+8 -3
View File
@@ -59,13 +59,18 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn
await expect(page.getByText('Веб-версия')).toBeVisible();
});
// The footer carries a public-offer link to the static /offer/ page (rendered from
// ui/legal/offer_ru.md at build; the legal document a purchase accepts).
test('the landing footer links to the public offer at /offer/', async ({ page }) => {
// The footer carries the public-offer link (/offer/ — the legal document a purchase accepts,
// rendered by the render sidecar) and, beside it, the feedback link to the Telegram bot the offer
// lists as the seller's contact.
test('the landing footer links to the public offer and the feedback bot', async ({ page }) => {
await page.goto('/landing.html');
await expect(page.getByText(/Играй в «Эрудита»/)).toBeVisible(); // Russian by default
const offer = page.getByRole('link', { name: 'Публичная оферта' });
await expect(offer).toBeVisible();
expect(await offer.getAttribute('href')).toBe('/offer/');
const feedback = page.getByRole('link', { name: 'Обратная связь' });
await expect(feedback).toBeVisible();
expect(await feedback.getAttribute('href')).toBe('https://t.me/Erudit_GameBot');
});
+1 -1
View File
@@ -75,7 +75,7 @@ test('AI game: after it ends, no GCG export and no comms entry', async ({ page }
await page.locator('.scoreboard').click();
await page.getByRole('button', { name: 'Drop game' }).click();
await page.locator('button.danger').click();
await expect(page.locator('.status .over')).toBeVisible();
await expect(page.locator('.turnstrip.result')).toBeVisible();
// Reopen the history (resigning auto-closed it): the finished AI game offers no GCG export and
// no comms entry at all.
+2 -2
View File
@@ -22,8 +22,8 @@ test('guest reaches a board and previews a placement', async ({ page }) => {
await rackTile.click();
await page.locator('[data-cell]:not(.filled)').nth(30).click();
await expect(page.locator('[data-cell].pending')).toHaveCount(1);
// The score preview appears where the hints count used to be.
await expect(page.locator('.scores')).toContainText(/\d/);
// The move-score badge appears on the board for the staged play.
await expect(page.locator('.scorebadge')).toContainText(/\d/);
// The contextual MakeMove control (✅) appears once a tile is pending.
await expect(page.locator('.make')).toBeVisible();
+41 -24
View File
@@ -1,9 +1,10 @@
import { expect, test, type Page } from './fixtures';
// The Wallet section against the mock transport (no backend). The mock seeds a web/native (direct)
// account holding a direct + vk chip segment and a small catalog (two chip-priced values and one
// rouble-priced chip pack — see lib/mock/client.ts), so the storefront, the store-compliance
// warning and the Google Play stub are all exercisable without a backend.
// account holding a direct (120) + vk (400) chip segment and a small catalog (two chip-priced values
// and one rouble-priced chip pack — see lib/mock/client.ts), so the compact balance, the split
// storefront, the exchange-confirm dialog (with its store-compliance warning) and the Google Play
// stub are all exercisable without a backend.
async function loginLobby(page: Page): Promise<void> {
await page.goto('/');
@@ -22,24 +23,33 @@ async function openWallet(page: Page): Promise<void> {
await expect(page.getByTestId('wallet')).toBeVisible();
}
test('wallet: balances, benefits and the storefront render for a durable account', async ({ page }) => {
test('wallet: balance, active benefits and the split storefront render for a durable account', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// Balances: the seeded direct ("Web") and vk segments.
await expect(page.getByRole('heading', { name: 'Balance' })).toBeVisible();
await expect(page.getByText('Web', { exact: true })).toBeVisible();
await expect(page.getByText('VK', { exact: true })).toBeVisible();
// The compact balance leads with the context's own (direct/"Web") chips, then the linked vk
// segment behind its logo.
const balance = page.getByTestId('balance');
await expect(balance).toContainText('120');
await expect(balance).toContainText('400');
await expect(balance.locator('.plogo')).toHaveCount(1); // the one linked (vk) platform
// Benefits + the storefront: two values priced in chips, one pack priced in roubles.
await expect(page.getByRole('heading', { name: 'Benefits' })).toBeVisible();
// The active benefits (the seeded 5 hints) sit above the store.
await expect(page.getByRole('heading', { name: 'Active' })).toBeVisible();
await expect(page.getByTestId('active')).toContainText('Hints: 5');
await expect(page.getByRole('heading', { name: 'Store' })).toBeVisible();
await expect(page.getByTestId('product')).toHaveCount(3);
const pack = page.locator('[data-kind="pack"]');
await expect(pack).toContainText('₽');
// The pack purchase is wired to money intake: an enabled Buy action, with the public-offer link.
await expect(pack.getByTestId('buy-pack')).toBeEnabled();
// Default tab — Buy chips: one rouble-priced pack + the public offer, and no values.
await expect(page.locator('[data-kind="pack"]')).toHaveCount(1);
await expect(page.locator('[data-kind="pack"]')).toContainText('₽');
await expect(page.locator('[data-kind="pack"]').getByTestId('buy-pack')).toBeEnabled();
await expect(page.getByTestId('offer')).toContainText('Public offer');
await expect(page.locator('[data-kind="value"]')).toHaveCount(0);
// Spend chips tab: the two chip-priced values, exchanged (not bought) with the "Exchange" button.
await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]')).toHaveCount(2);
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toHaveText('Exchange');
});
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
@@ -87,32 +97,39 @@ test('wallet: the Google Play build hides the money purchases behind the RuStore
await expect(page.getByText('Your turn')).toBeVisible();
await openWallet(page);
// The chip pack is gone; the stub points at the RuStore build. Spending earned chips still works.
// Buy chips: the chip pack is gone; the stub points at the RuStore build.
await expect(page.getByTestId('gp-stub')).toBeVisible();
await expect(page.locator('[data-kind="pack"]')).toHaveCount(0);
// Spending earned chips still works — the values live in the Spend tab.
await page.getByTestId('tab-spend').click();
await expect(page.locator('[data-kind="value"]').first().getByTestId('buy')).toBeVisible();
});
test('wallet: a web spend that would draw store chips warns first, then completes', async ({ page }) => {
test('wallet: a web spend that would draw store chips confirms with a warning, then completes', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
await page.getByTestId('tab-spend').click();
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → warn.
// The 300-chip value drains direct (120) then reaches into vk (180) → a store segment → the confirm
// dialog carries the cross-platform warning.
await page.locator('[data-pid="val-noads-30"]').getByTestId('buy').click();
await expect(page.getByTestId('warn')).toBeVisible();
await page.getByTestId('warn-confirm').click();
await expect(page.getByTestId('warn')).toBeHidden();
// The spend applied: the no-ads benefit now shows an end date.
await page.getByTestId('spend-confirm').click();
// The spend applied: the no-ads benefit now shows an end date in the Active section.
await expect(page.getByTestId('wallet')).toContainText('No ads until');
});
test('wallet: a spend the direct segment covers alone does not warn', async ({ page }) => {
test('wallet: a spend the direct segment covers alone confirms without a warning', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
await page.getByTestId('tab-spend').click();
// The 100-chip value is covered by the direct segment (120) alone → no store draw, no warning.
// The 100-chip value is covered by the direct segment (120) alone → the confirm dialog shows, but
// with no cross-platform warning.
await page.locator('[data-pid="val-hints-50"]').getByTestId('buy').click();
await expect(page.getByTestId('spend-body')).toBeVisible();
await expect(page.getByTestId('warn')).toBeHidden();
await page.getByTestId('spend-confirm').click();
// The hints benefit rose from 5 to 55.
await expect(page.getByTestId('wallet')).toContainText('Hints 55');
await expect(page.getByTestId('wallet')).toContainText('Hints: 55');
});
+12 -14
View File
@@ -74,11 +74,11 @@ test.describe('touch placement', () => {
});
});
// A hint taken while the board is ALREADY zoomed in must still scroll to the played word — the
// zoom state does not change, so without an explicit recenter the board stays parked where the
// player was looking (the reported rough edge). The mock hint plays at the centre star (7,7), so
// zooming into the top-left corner first and then hinting must pan the board toward the centre.
test('a hint recentres an already-zoomed board on the played word', async ({ page }) => {
// A hint taken while the board is ALREADY zoomed in now zooms OUT to the whole board, so the played
// word — highlighted while composing — is guaranteed visible rather than parked off-screen under the
// old zoom (the owner changed this from the former recentre-on-the-word behaviour, so the word can
// never be lost off-screen). The mock hint plays at the centre star (7,7).
test('a hint zooms out an already-zoomed board so the played word is visible', async ({ page }) => {
await page.goto('/');
await page.getByRole('button', { name: /guest/i }).click();
await page.getByRole('button', { name: /Ann/ }).click();
@@ -91,19 +91,17 @@ test('a hint recentres an already-zoomed board on the played word', async ({ pag
el.click();
el.click();
});
const viewport = page.locator('.viewport.zoomed');
await expect(viewport).toBeVisible();
await page.waitForTimeout(400); // let the zoom-in settle near the top-left corner
const before = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
await expect(page.locator('.viewport.zoomed')).toBeVisible();
// Take a hint (the control confirms on a second tap), which plays at the centre.
const hint = page.getByRole('button', { name: 'Hint' });
await hint.click();
await hint.click();
await page.waitForTimeout(300); // the board pans to the hint word
const after = await viewport.evaluate((el) => ({ left: el.scrollLeft, top: el.scrollTop }));
// The board panned from the top-left toward the centre word: both offsets grew.
expect(after.left).toBeGreaterThan(before.left + 20);
expect(after.top).toBeGreaterThan(before.top + 20);
// The board zoomed out — no zoomed viewport — with the hint's play staged (the confirm control
// shows). The mock hint plays on the centre star, which the seeded game already occupies, so the
// staged tile hides under the committed one; the confirm control is the reliable "a play is
// staged" signal.
await expect(page.locator('.viewport.zoomed')).toHaveCount(0);
await expect(page.locator('.make')).toBeVisible();
});
+16 -6
View File
@@ -20,6 +20,8 @@
**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`.
**Приложение Продавца** - предоставляемое Продавцом для загрузки из сети «Интернет», добровольно загружаемое Покупателем и исполняемое на электронной вычислительной машине (устройстве) Покупателя программное обеспечение, предоставляющее игровые или иные функции и позволяющее Покупателю взимодействовать с Продавцом путём осуществления сделок купли-продажи внутригровых ценностей. Приложение может распространяться в форматах, но не ограничиваясь, такими как: веб-приложение на Сайте Продавца, мини-приложение в экосистеме VK, мини-приложение в экосистеме Telegram, Android-приложение в экосистеме Google Play, Android-приложение в экосистеме RuStore, iOS-приложение в экосистеме Apple App Store и дугих форматах распространения.
**Стороны Договора (Стороны)** — Продавец и Покупатель.
**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ.
@@ -28,13 +30,13 @@
**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму.
**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`.
**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на Сайте Продавца в сети «Интернет», либо в Приложении Продавца.
**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности:
- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи;
- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» либо в Приложении Продавца при наличии необходимости регистрации учетной записи;
- путем составления и заполнения заявки на оформление заказа Товара;
- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя;
- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на Сайте Продавца в сети «Интернет» либо в Приложении Продавца, в том числе, при обратном звонке Продавца по заявке Покупателя;
- оплаты Товара Покупателем.
Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента.
@@ -76,10 +78,16 @@
## 4. Цена и порядок расчетов
**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`.
**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на Сайте Продавца в сети «Интернет» а так же в Приложении Продавца.
**4.2.** Все расчеты по Договору производятся в безналичном порядке.
**4.3.** Порядок расчётов. Приобретаемым Товаром является внутриигровая валюта «Фишка» — условная учётная единица, используемая исключительно в пределах Сайта Продавца либо в Приложения Продавца. «Фишки» предоставляют Покупателю возможность получения внутриигровых благ и дополнительных функций, включая, но не ограничиваясь: отказ от показа рекламы, приобретение подсказок в игре и иные внутриигровые возможности. «Фишка» не является электронным средством платежа либо денежным средством, не подлежит обмену на денежные средства и не может быть использована за пределами Сайта Продавца либо Приложения Продавца. «Фишки» зачисляются на внутриигровой счёт Покупателя единовременно после поступления оплаты; дальнейшее их использование для получения внутриигровых благ осуществляется Покупателем самостоятельно в рамках используемого Сайта Продавца либо Приложения Продавца.
**4.4.** Стоимость Товаров:
<#pricing_template#>
## 5. Обмен и возврат Товара
**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463.
@@ -120,7 +128,7 @@
**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору.
**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме.
**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на Сайте Продавца в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме при оплате Товаров.
## 10. Дополнительные условия
@@ -138,8 +146,10 @@
**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем.
**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
**10.6.** Если на Сайте Продавца в сети «Интернет» либо в Приложении Продавца есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
## 11. Реквизиты Продавца
Денисов Илья Аркадьевич, ИНН 290210610742.
Обратная связь в Telegram: [@Erudit_GameBot](https://t.me/Erudit_GameBot).
+5 -1
View File
@@ -32,9 +32,13 @@ const DIST = 'dist';
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
const BUDGET = { app: 125, shared: 31, landing: 5 };
const BUDGET = { app: 127, shared: 31, landing: 5 };
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
+15
View File
@@ -55,6 +55,7 @@
locale: lc,
reduceMotion: prefs.reduceMotion ?? false,
boardLabels: prefs.boardLabels ?? 'beginner',
zoomBoard: prefs.zoomBoard ?? true,
});
prefs = { ...prefs, locale: lc };
}
@@ -124,7 +125,13 @@
</section>
<footer class="ft">
<span class="legal">
<a class="offer" href="/offer/">{t('landing.offer')}</a>
<span class="sep" aria-hidden="true">|</span>
<!-- The feedback bot: the same Telegram contact the offer lists (section 11 «Реквизиты»);
external, opens in a new tab. -->
<a class="offer" href="https://t.me/Erudit_GameBot" target="_blank" rel="noopener noreferrer">{t('landing.feedback')}</a>
</span>
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer>
</main>
@@ -286,6 +293,14 @@
color: var(--text-muted);
font-size: 0.8rem;
}
.ft .legal {
display: flex;
align-items: center;
gap: 8px;
}
.ft .sep {
color: var(--text-muted);
}
.ft .offer {
color: inherit;
}
+16
View File
@@ -29,6 +29,14 @@
--tile-edge: #d8c190;
--tile-text: #2a2113;
--tile-pending: #f2cf73;
/* In-composition highlight of the staged play (see game/Board.svelte): a calm reddish-pink
when the tiles form no word, a light green for the player's own tiles once they do, a
deeper green for the board tiles a formed word runs through, and the orange move-score
badge. Tuned per theme; refined on the contour. */
--tile-pending-illegal: #f2c7bf;
--tile-pending-legal: #c9e7bd;
--tile-formed: #a5cf93;
--score-badge: #e5811d;
/* Last-word highlight letter — a lighter burgundy than the dark theme on purpose: against
the lighter tile the perceived contrast needs it, so the two are tuned per theme. */
--tile-recent: #9c5849;
@@ -84,6 +92,10 @@
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #d8b75e;
--tile-pending-illegal: #d8a99f;
--tile-pending-legal: #a9cf94;
--tile-formed: #83b571;
--score-badge: #e88f31;
--tile-recent: #8c4a3c;
--prem-tw: #9c3f34; /* 3x word: a touch darker red */
--prem-dw: #a8636b; /* 2x word: softer, pinker */
@@ -115,6 +127,10 @@
--tile-edge: #b6a473;
--tile-text: #20190d;
--tile-pending: #f0d98f;
--tile-pending-illegal: #d8a99f;
--tile-pending-legal: #a9cf94;
--tile-formed: #83b571;
--score-badge: #e88f31;
/* Last-word highlight letter — a warm burgundy whose red hue stays distinct from both the
near-black glyph and the warm tile. The light theme uses a lighter burgundy (tuned per
theme; perceived contrast depends on the surrounding board). */
+23 -1
View File
@@ -3,12 +3,16 @@
let {
title = '',
titleAside = '',
onclose,
overlayKeyboard = false,
bottomSheet = false,
children,
}: {
title?: string;
/** Optional secondary text pinned to the right of the title row, in a lighter (non-bold)
* muted style — e.g. the bag count beside the "Exchange or pass" title. */
titleAside?: string;
onclose?: () => void;
overlayKeyboard?: boolean;
bottomSheet?: boolean;
@@ -65,7 +69,12 @@
style:--kb={bottomSheet ? `${kb}px` : null}
onclick={(e) => e.stopPropagation()}
>
{#if title}<h2>{title}</h2>{/if}
{#if title}
<div class="titlerow">
<h2>{title}</h2>
{#if titleAside}<span class="aside">{titleAside}</span>{/if}
</div>
{/if}
{@render children?.()}
</div>
</div>
@@ -121,8 +130,21 @@
max-height: 86dvh;
overflow: auto;
}
.titlerow {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 8px;
}
h2 {
margin: 0 0 10px;
font-size: 1.05rem;
}
/* Secondary title-row text (e.g. the bag count): lighter and muted so the title still leads. */
.aside {
font-weight: 400;
font-size: 0.9rem;
color: var(--text-muted);
white-space: nowrap;
}
</style>
+3 -1
View File
@@ -85,7 +85,9 @@
color: var(--accent-text);
border-radius: 999px;
min-width: 15px;
padding: 0 3px;
/* Enough horizontal room that a two/three-digit count (e.g. the bag count) does not touch the
pill's rounded ends. */
padding: 0 5px;
line-height: 1.4;
text-align: center;
}
+68
View File
@@ -23,6 +23,9 @@
focus,
recenter,
dropTarget,
previewLegal,
formed,
scoreBadge,
oncell,
ontogglezoom,
onrecall,
@@ -48,6 +51,15 @@
recenter: number;
/** The cell a dragged tile is currently aimed at, highlighted as a drop target. */
dropTarget: { row: number; col: number } | null;
/** While composing: true when the staged tiles form a legal play, false when not, null when
* there is no preview (off-turn, nothing staged, or a recall drag) — tints the pending tiles
* green vs a calm pink. */
previewLegal: boolean | null;
/** "row,col" keys of every committed board cell a formed word runs through, greened a shade
* darker than the player's own staged tiles. */
formed: Set<string>;
/** The orange move-score badge for the legal play being composed, or null. */
scoreBadge: { row: number; col: number; corner: 'tr' | 'bl'; score: number } | null;
oncell: (row: number, col: number) => void;
ontogglezoom: (row: number, col: number) => void;
/** Recall the pending tile at (row, col) — fired on a double-tap of a pending cell. */
@@ -60,6 +72,20 @@
const z = $derived(zoomed ? Z : 1);
const premClass: Record<Premium, string> = { '': '', TW: 'tw', DW: 'dw', TL: 'tl', DL: 'dl' };
// The score badge sits on a corner of its anchor cell, clamped so the whole pill stays on the
// board. Its position is a percentage of the (15-cell) board; CSS centres the pill on the point.
const BADGE_MARGIN = 2.4;
const badgePos = $derived.by(() => {
const b = scoreBadge;
if (!b) return null;
const cell = 100 / 15;
let x = b.corner === 'tr' ? (b.col + 1) * cell : b.col * cell;
let y = b.corner === 'tr' ? b.row * cell : (b.row + 1) * cell;
x = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, x));
y = Math.max(BADGE_MARGIN, Math.min(100 - BADGE_MARGIN, y));
return { x, y };
});
let viewport = $state<HTMLElement>();
// Genuine layout zoom (the board grows; cqw labels stay constant), so native scroll
@@ -315,6 +341,9 @@
class="cell {premClass[premium[r][c]]}"
class:filled={!!cell}
class:pending={!!p && !cell}
class:legal={!!p && !cell && previewLegal === true}
class:illegal={!!p && !cell && previewLegal === false}
class:formed={!!cell && formed.has(key(r, c))}
class:hl={!!cell && highlight.has(key(r, c)) && !flash}
class:flash={!!cell && flash && highlight.has(key(r, c))}
class:dark={premium[r][c] === '' && !cell && !p && (r + c) % 2 === 1}
@@ -342,6 +371,9 @@
</button>
{/each}
{/each}
{#if scoreBadge && badgePos}
<span class="scorebadge" style="left:{badgePos.x}%; top:{badgePos.y}%">{scoreBadge.score}</span>
{/if}
</div>
</div>
</div>
@@ -397,6 +429,8 @@
gap: 0;
background: var(--board-bg);
padding: 0;
/* Anchor for the absolutely-positioned score badge (its percentages resolve against the board). */
position: relative;
}
.cell {
position: relative;
@@ -445,6 +479,19 @@
board) instead of the touch starting a board pan. */
touch-action: none;
}
/* While composing, the staged play recolours its tiles: a calm reddish-pink when they form no
word, a light green for the player's own tiles once they do; committed board tiles a formed
word runs through go a shade darker (see lib/formed + Game.svelte). .formed sits after .filled
so it wins on committed cells (equal specificity). */
.cell.pending.illegal {
background: var(--tile-pending-illegal);
}
.cell.pending.legal {
background: var(--tile-pending-legal);
}
.cell.formed {
background: var(--tile-formed);
}
.cell.droptarget {
/* The cell a carried tile is aimed at: an accent ring plus a light accent wash, so the
target reads clearly while dragging without obscuring the bonus label underneath. */
@@ -545,4 +592,25 @@
font-weight: 700;
white-space: nowrap;
}
/* The move-score badge for the legal play being composed: an orange pill centred on a corner of
the main word's tile (see lib/formed.badgePlacement + Game.svelte), its digit sized like a
tile's point value so it scales with the zoom. Decorative — no pointer events — and drawn above
the tiles. */
.scorebadge {
position: absolute;
transform: translate(-50%, -50%);
z-index: 5;
pointer-events: none;
border-radius: 999px;
background: var(--score-badge);
color: #fff;
font-weight: 700;
line-height: 1;
white-space: nowrap;
padding: 2px 5px; /* px fallback for Chrome < 105 (no cqw) */
padding: 0.4cqw 0.9cqw;
font-size: calc(2.4vmin * var(--z, 1)); /* cqw fallback for Chrome < 105 — see .letter */
font-size: 2.4cqw;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.25);
}
</style>
+118 -100
View File
@@ -49,6 +49,7 @@
type Placement,
} from '../lib/placement';
import { liveDraftTiles, parseDraft, serializeDraft, validRackOrder } from '../lib/draft';
import { badgePlacement, formedGeometry } from '../lib/formed';
let { id }: { id: string } = $props();
@@ -483,6 +484,23 @@
// move's ✅ confirm and its preview caption are hidden so they don't sit over the opening gap;
// they return the moment the tile is dragged back out over the board.
const recallOverRack = $derived(draggingPend != null && reorderTo != null);
// The composed play's word geometry (client-side, independent of the move evaluator): the cells a
// formed word runs through — greened on the board — and the main word that anchors the score
// badge. Derived only for a legal preview, and never while dragging a staged tile back over the
// rack (recallOverRack), so the highlight and badge clear the instant a recall begins.
const EMPTY_FORMED = new Set<string>();
const formedGeom = $derived(
preview?.legal && !recallOverRack
? formedGeometry(board, placement.pending, !!view?.game.multipleWordsPerTurn)
: null,
);
const formedCells = $derived(formedGeom?.cells ?? EMPTY_FORMED);
const boardBadge = $derived(
formedGeom && preview?.legal ? { ...badgePlacement(formedGeom.main), score: preview.score } : null,
);
// The pending-tile tint: null (default colour) with no preview or during a recall drag, otherwise
// the play's legality — green when legal, a calm pink when not.
const previewLegal: boolean | null = $derived(recallOverRack ? null : preview ? preview.legal : null);
let dragPointerId = -1;
function beginDrag(src: DragSrc, e: PointerEvent) {
@@ -610,7 +628,7 @@
? setTimeout(() => {
// Still holding the tile over this cell: magnify into it. Only the first
// (zoom-in) hold centres; once zoomed we never move the board on hover.
if (drag && isCoarse() && !landscape && !zoomed) {
if (drag && isCoarse() && !landscape && !zoomed && app.zoomBoard) {
focus = c;
zoomed = true;
haptic('light');
@@ -729,8 +747,8 @@
if (pendingMap.has(`${row},${col}`)) return;
focus = { row, col };
// Auto-zoom is portrait-only: landscape fits the whole board, magnifying it there
// only hides the rest of the position.
if (isCoarse() && !landscape && !zoomed) zoomed = true;
// only hides the rest of the position. The "Zoom the board" setting can turn it off.
if (isCoarse() && !landscape && !zoomed && app.zoomBoard) zoomed = true;
if (placement.rack[index] === BLANK) {
blankPrompt = { rackIndex: index, row, col };
return;
@@ -1030,23 +1048,11 @@
// Mark the turn as hinted: the interstitial fires when the player CONFIRMS the move (commit),
// not now — showing it here would interrupt placing the preview and revert the board on close.
hintUsedThisTurn = true;
// Scroll the (zoomed) board to the hint's placement rather than the top-left:
// focus the centre of the laid tiles' bounding box.
const p = placement.pending;
if (p.length) {
const rows = p.map((tt) => tt.row);
const cols = p.map((tt) => tt.col);
focus = {
row: Math.round((Math.min(...rows) + Math.max(...rows)) / 2),
col: Math.round((Math.min(...cols) + Math.max(...cols)) / 2),
};
// Ask the board to scroll to the hint word. A zoomed-out board zooms in (and centres)
// on the next line (portrait only); this nonce also recentres a board that is already
// zoomed in, where the unchanged zoom state would otherwise leave it parked where the
// player was looking.
recenter++;
}
if (isCoarse() && !landscape) zoomed = true;
// A hint just landed: if the board was zoomed in, zoom OUT to the whole board so the hint's
// word — highlighted green while composing — is guaranteed visible rather than possibly
// parked off-screen under the old zoom. A full board needs no scroll-to-word, so the former
// focus/recenter step is gone with the zoom-in.
if (zoomed) zoomed = false;
view = { ...view, hintsRemaining: h.hintsRemaining };
syncWallet(h.walletBalance);
// The hint is the engine's own top-ranked, fully scored legal move: reuse it as the
@@ -1486,15 +1492,16 @@
{#if landscape}
<div class="game-land">
<div class="leftpane">
{@render turnStrip()}
{@render scoreboardBlock()}
<div class="history land">{@render historyBody()}</div>
{@render statusBlock()}
{@render rackRow()}
<TabBar>{@render controlButtons()}</TabBar>
</div>
<div class="rightpane">{@render boardBlock()}</div>
</div>
{:else}
{@render turnStrip()}
{@render scoreboardBlock()}
<div class="stage" class:histopen={historyOpen} bind:this={stageEl}>
{#if historyOpen}
@@ -1502,7 +1509,6 @@
{/if}
{@render boardBlock()}
</div>
{@render statusBlock()}
{@render rackRow()}
{/if}
{:else}
@@ -1517,6 +1523,24 @@
<!-- Reusable game-screen pieces, arranged differently by the portrait and landscape branches
above so the markup and logic stay single-sourced (see the {#if landscape} split). -->
{#snippet turnStrip()}
<!-- A thin line above the score plaques: while composing a legal play it reads the formed
word(s) and the move score ("WORD+WORD = N"); otherwise whose turn it is during play, or the
viewer's result once the game ends. A hotseat game shows its result as per-seat medals on the
plaques (2-4 local players have no single "you"), so the strip is hidden when it is over. -->
{#if view && !(gameOver && view.game.hotseat)}
<div class="turnstrip" class:result={gameOver}>
{#if gameOver}
{resultText()}
{:else if preview?.legal && !recallOverRack}
{preview.words.map((w) => w.toUpperCase()).join('+')} = {preview.score}
{:else}
{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}
{/if}
</div>
{/if}
{/snippet}
{#snippet scoreboardBlock()}
{#if view}
{@const badge = badgeKind(app.chatUnread[id] ?? false, app.messageUnread[id] ?? false)}
@@ -1603,10 +1627,11 @@
{/each}
{/each}
</div>
</div>
{#if view.game.endReason === 'aborted'}
<p class="horganizer">{t('game.abortedNote')}</p>
{/if}
</div>
<div class="hbagfoot">{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</div>
{/if}
{/snippet}
@@ -1639,6 +1664,9 @@
{focus}
{recenter}
{dropTarget}
{previewLegal}
formed={formedCells}
scoreBadge={boardBadge}
oncell={onCell}
ontogglezoom={(r, c) => { focus = { row: r, col: c }; if (!gameOver) zoomed = !zoomed; }}
onrecall={onRecall}
@@ -1647,25 +1675,6 @@
</div>
{/snippet}
{#snippet statusBlock()}
{#if view}
<div class="status">
<span>{view.bagLen === 0 ? t('game.bagEmpty') : t('game.bag', { n: view.bagLen })}</span>
{#if gameOver}
<!-- A hotseat game shows its result as per-seat medals on the plaques (below), not a
viewer-centric "you won/lost" — with 2-4 local players there is no single "you". A vs_ai
game keeps the "you won/lost" text (one human). -->
{#if !view.game.hotseat}<strong class="over">{resultText()}</strong>{/if}
{:else if placement.pending.length === 0}
<span class="turn-ind">{view.game.hotseat ? t('hotseat.turnOf', { name: hotseatName(view.game.toMove) }) : isMyTurn ? t('game.yourTurn') : turnLabel()}</span>
{/if}
<span class="scores">
{#if recallOverRack}{:else if preview}{preview.legal ? t('game.previewWords', { words: preview.words.join(', '), n: preview.score }) : t('game.previewIllegal')}{/if}
</span>
</div>
{/if}
{/snippet}
{#snippet rackRow()}
<!-- The footer is drawn even when the game is over (rack + controls), but inert:
a finished game shows the final rack greyed out and the controls disabled. -->
@@ -1680,23 +1689,26 @@
slots={rackSlots}
{variant}
{selected}
{landscape}
shuffling={shuffling && !app.reduceMotion}
draggingId={reorderDragId}
dropIndex={reorderTo}
confirm={!gameOver && placement.pending.length > 0 && !recallOverRack ? confirmBtn : undefined}
ondown={onRackDown}
/>
{/if}
</div>
{#if !gameOver && placement.pending.length > 0 && !recallOverRack}
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/if}
</div>
{/snippet}
{#snippet confirmBtn()}
<!-- The confirm-move control lives in the rack's fixed 7th slot (see Rack). Shown only while a
play is staged; disabled until the preview says it is legal. -->
<button class="make" onclick={commit} disabled={busy || !canMove || !netReady || !preview?.legal} aria-label={t('game.makeMove')}>✅</button>
{/snippet}
{#snippet controlButtons()}
<button class="tab" disabled={busy || !canMove || !netReady} onclick={openExchange}>
<span class="sq" data-coach="game-turn">🔄</span><span class="lbl">{t('game.draw')}</span>
<span class="sq" data-coach="game-turn">🔄{#if view && view.bagLen > 0}<span class="badge">{view.bagLen}</span>{/if}</span><span class="lbl">{t('game.draw')}</span>
</button>
{#if view?.game.hotseat}
<!-- Hotseat: no hints. The freed slot becomes the host (referee) button — always available
@@ -1757,7 +1769,11 @@
{/if}
{#if exchangeOpen && view}
<Modal title={t('game.exchangeTitle')} onclose={() => (exchangeOpen = false)}>
<Modal
title={t('game.exchangeTitle')}
titleAside={view.bagLen === 0 ? t('game.bagEmpty') : t('game.bagCount', { n: view.bagLen })}
onclose={() => (exchangeOpen = false)}
>
<div class="exch">
{#each view.rack as letter, i (i)}
<button class="etile" class:sel={exchangeSel.includes(i)} disabled={!canExchange} onclick={() => toggleExch(i)}>
@@ -1906,6 +1922,25 @@
font-weight: 700;
font-variant-numeric: tabular-nums;
}
/* The thin turn/result line above the score plaques (see turnStrip): whose turn it is during
play, accented for the viewer's win/lose result once the game ends. */
.turnstrip {
flex: none;
/* A smaller bottom pad than top: the score plaques below carry their own top padding, so an
even strip read as too tall with the text pushed up. */
padding: 4px var(--pad) 2px;
text-align: center;
font-size: 0.85rem;
font-weight: 600;
color: var(--text);
background: var(--bg-elev);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.turnstrip.result {
color: var(--accent);
}
.stage {
position: relative;
/* The board is the only part that scrolls vertically when the game does not fit;
@@ -1924,16 +1959,12 @@
position: absolute;
inset: 0 0 auto 0;
z-index: 2;
/* A fixed-height drawer matching the board's slid offset, so the bottom border
and its shadow pin to the board immediately instead of tracking the table as
moves accumulate. scrollbar-gutter reserves the scrollbar so the centred word
column does not jump left/right when the list overflows. */
/* A fixed-height drawer matching the board's slid offset, so the bottom border and its shadow
pin to the board immediately instead of tracking the table as moves accumulate. It is a flex
column: the header and the pinned bag footer stay put while the grid (.hgridwrap) scrolls. */
height: 62%;
overflow: auto;
/* No iOS rubber-band inside the drawer: the moves list does not elastically bounce past its
ends (the document is already pinned; this stops the inner scroller's own bounce). */
overscroll-behavior: none;
scrollbar-gutter: stable;
display: flex;
flex-direction: column;
background: var(--surface-2);
box-shadow: inset 0 -6px 10px -8px rgba(0, 0, 0, 0.5);
border-bottom: 1px solid var(--border);
@@ -1945,8 +1976,26 @@
The wrapper's horizontal padding matches the scoreboard so the columns line up under the
plaques. */
.hgridwrap {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
/* No iOS rubber-band inside the scroller: the moves list does not elastically bounce past its
ends (the document is already pinned; this stops the inner scroller's own bounce). */
overscroll-behavior: none;
/* Reserve the scrollbar so the centred word column does not jump left/right on overflow. */
scrollbar-gutter: stable;
padding: 8px var(--pad);
}
/* The bag count pinned bottom-left of the move table, out of the scrolling grid, so it stays put
as moves accumulate. The same count also rides the exchange control as a badge (see
controlButtons), which is the always-visible indicator when the table is closed. */
.hbagfoot {
flex: none;
padding: 6px var(--pad);
color: var(--text-muted);
font-size: 0.85rem;
border-top: 1px solid var(--border);
}
.hgrid {
display: grid;
gap: 1px;
@@ -1991,28 +2040,6 @@
.boardwrap.slid :global(.viewport) {
pointer-events: none;
}
.status {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
padding: 2px var(--pad) 6px;
color: var(--text-muted);
font-size: 0.85rem;
}
.turn-ind {
font-weight: 600;
color: var(--text);
}
.over {
color: var(--accent);
}
.scores {
font-weight: 600;
color: var(--ok);
min-width: 64px;
text-align: right;
}
/* The single-word-rule label centred in the history header between its two icons. */
.oneword-label {
flex: 1;
@@ -2033,40 +2060,31 @@
pointer-events: none;
opacity: 0.55;
}
/* Landscape: the rack sits directly under the docked history (no board between them), so give it a
small gap above; portrait gets its spacing from the board's own padding. */
.game-land .rack-row {
padding-top: 6px;
}
.rack-wrap {
flex: 1;
min-width: 0;
}
/* A borderless icon button (like the tab bar), not a filled accent button — and disabled
while the pending word is known to be illegal. It is an overlay, NOT a flex sibling of the
rack: the rack keeps the full row width with a fixed tile size (no reflow or tile resize
when a tile is staged), and the button is absolutely pinned over the slots a staged tile
frees on the right. Its right edge sits at var(--pad) — directly under the right edge of the
preview caption above (.status shares that padding). */
/* The confirm-move control occupies the rack's fixed 7th column while a play is staged (a
borderless icon button, like the tab bar). It is rendered inside the rack grid (see Rack), so it
lines up with the tiles and stays the rightmost slot no matter how many tiles remain. */
.make {
position: absolute;
right: var(--pad);
top: 0;
bottom: 6px;
width: 56px;
grid-column: 7;
align-self: stretch;
display: grid;
place-items: center;
background: none;
color: var(--text);
border: none;
display: grid;
place-items: center end;
font-size: 1.8rem;
}
.make:disabled {
opacity: 0.4;
}
/* Landscape: the rack fills a narrow fixed-width panel with exactly seven tile slots, so the 56px
button (sized for the roomy portrait rack) is wider than the single slot a staged tile frees and
overlaps the now-rightmost tile. Match the button to one landscape tile slot (its width formula),
so it sits inside the freed slot with its right edge level with the rack's right edge — the
mirror of the first tile's left edge. */
.game-land .make {
width: calc((100% - 2 * var(--pad) - 6 * 5px) / 7);
}
/* The move-history header: leave (active) / export (finished) on the left, comms on the
right, icon-only. Sticky so it stays atop the scrolling move list. */
.hhead {
+23 -43
View File
@@ -4,15 +4,16 @@
import { valueForLetter } from '../lib/alphabet';
import type { Variant } from '../lib/model';
import { usesStarBlank, BLANK_STAR } from '../lib/variants';
import type { Snippet } from 'svelte';
let {
slots,
variant,
selected,
landscape = false,
shuffling = false,
draggingId = null,
dropIndex = null,
confirm,
ondown,
}: {
// Each slot carries a stable id that travels with its tile through a shuffle, so the
@@ -20,14 +21,14 @@
slots: (RackSlot & { id: number })[];
variant: Variant;
selected: number | null;
/** Landscape layout: size the tiles to share the (narrow) left-panel width instead of the
* viewport-width measure, so seven tiles never overflow the panel. */
landscape?: boolean;
shuffling?: boolean;
// While a rack tile is being dragged to reorder it, draggingId is its id (hidden here —
// the drag ghost stands in) and dropIndex is the slot where a gap opens.
draggingId?: number | null;
dropIndex?: number | null;
/** 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;
ondown: (e: PointerEvent, index: number) => void;
} = $props();
@@ -57,7 +58,7 @@
}
</script>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} class:landscape data-rack>
<div class="rack" class:reordering={draggingId != null || dropIndex != null} data-rack>
{#each shown as slot, i (slot.id)}
<button
class="tile"
@@ -75,21 +76,26 @@
{/if}
</button>
{/each}
{@render confirm?.()}
</div>
<style>
/* Seven equal columns filling the whole tray width, so the tiles are square and as large as the
row allows — the same in portrait and landscape. The tile glyphs use cqw against the rack width
(the query container) so they track the tile size, the way the board sizes its labels on its
.scaler. */
.rack {
display: flex;
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 5px;
align-items: center;
/* Reserve one tile's height so an empty rack (e.g. a finished game) keeps the
footer the same size as during play — no layout jump between states. */
align-items: start;
container-type: inline-size;
/* Reserve about one tile's height so an empty rack (e.g. a finished game) keeps the footer the
same size as during play — no layout jump between states. */
min-height: min(12.5vw, 46px);
}
.tile {
position: relative;
flex: 0 0 auto;
width: min(12.5vw, 46px);
aspect-ratio: 1;
background: var(--tile-bg);
color: var(--tile-text);
@@ -97,7 +103,6 @@
border-radius: 5px;
box-shadow: inset 0 -3px 0 var(--tile-edge);
font-weight: 700;
font-size: 1.4rem;
touch-action: none;
user-select: none;
-webkit-user-select: none;
@@ -109,35 +114,6 @@
outline: 3px solid var(--accent);
outline-offset: -3px;
}
/* Landscape: the rack sits in a fixed-width left panel, so the tiles share the panel width
(capped at the portrait size) and shrink below it when the panel is narrow, instead of the
viewport-width measure that would overflow seven tiles in a column. */
.rack.landscape {
/* Size the tile glyphs relative to the rack (the board sizes its labels the same way, on its
.scaler container). The tile is a flex + aspect-ratio item whose OWN container-query size
resolves unreliably, so the rack — which has a definite width — is the query container. A
fixed-rem letter overflowed the tile once it shrank below 46px in a narrow left panel and
dropped into the bottom-left corner. The cqw values track the rack≈7×tile relation. */
container-type: inline-size;
}
.rack.landscape .tile {
/* Fixed tile size (1/7 of the fixed-width rack, accounting for the six 5px gaps), NOT flex-grow:
so placing a tile leaves the remaining ones put instead of growing them to refill the row —
matching the portrait rack. The constant rack width also keeps the cqw glyphs above steady as
tiles leave. */
flex: 0 0 auto;
width: calc((100% - 6 * 5px) / 7);
max-width: 46px;
}
.rack.landscape .letter {
font-size: 6cqw;
}
.rack.landscape .val {
font-size: 3.1cqw;
}
.rack.landscape .star {
font-size: 7.6cqw;
}
/* While reordering, tiles at/after the drop slot slide right to open a gap there (one
tile width plus the rack gap), so the drop position is visible. */
.rack.reordering .tile {
@@ -150,12 +126,15 @@
position: absolute;
top: 8%;
left: 14%;
font-size: 6vmin; /* cqw fallback for Chrome < 105 approximates the portrait rack viewport width */
font-size: 6cqw;
}
.val {
position: absolute;
right: 4px;
bottom: 1px;
font-size: 0.7rem;
font-size: 3.1vmin; /* cqw fallback for Chrome < 105 */
font-size: 3.1cqw;
font-weight: 600;
}
/* Erudit's blank ("звёздочка") shows its star horizontally centred on the otherwise empty
@@ -167,6 +146,7 @@
left: 0;
right: 0;
text-align: center;
font-size: 1.7rem;
font-size: 7.6vmin; /* cqw fallback for Chrome < 105 */
font-size: 7.6cqw;
}
</style>
+11
View File
@@ -118,6 +118,9 @@ export const app = $state<{
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
/** Auto-zoom the board toward a tile when it is placed (touch/mobile only; the wide desktop and
* landscape layouts already fit the whole board and never auto-zoom). On by default. */
zoomBoard: boolean;
/** Completion flags for the two first-run coachmark series (components/Coachmark.svelte). */
onboarding: OnboardingState;
/** Whether a first-run coachmark overlay is currently on screen. While set, the scrolling promo
@@ -172,6 +175,7 @@ export const app = $state<{
locale: 'en',
reduceMotion: false,
boardLabels: 'beginner',
zoomBoard: true,
onboarding: { lobbyDone: false, gameDone: false },
coachActive: false,
notifications: 0,
@@ -813,6 +817,7 @@ export async function bootstrap(): Promise<void> {
app.theme = prefs.theme ?? 'auto';
app.reduceMotion = prefs.reduceMotion ?? false;
app.boardLabels = prefs.boardLabels ?? 'beginner';
app.zoomBoard = prefs.zoomBoard ?? true;
app.onboarding = await loadOnboarding();
applyTheme(app.theme);
applyReduceMotion(app.reduceMotion);
@@ -1232,6 +1237,7 @@ function persistPrefs(): void {
locale: app.locale,
reduceMotion: app.reduceMotion,
boardLabels: app.boardLabels,
zoomBoard: app.zoomBoard,
});
// Mirror the device-independent display prefs to Telegram CloudStorage so they follow the user
// across devices (no-op outside Telegram / on a client predating it). Locale is excluded — it
@@ -1321,6 +1327,11 @@ export function setBoardLabels(mode: BoardLabelMode): void {
persistPrefs();
}
export function setZoomBoard(on: boolean): void {
app.zoomBoard = on;
persistPrefs();
}
// Background/foreground lifecycle: silence the reconnect banner during a suspend and
// reconnect quietly on return (and refresh the lobby badge for any push missed while
// hidden, §10). Several signals cover the platforms: the page Visibility API, the
+129
View File
@@ -0,0 +1,129 @@
import { describe, expect, it } from 'vitest';
import { formedGeometry, badgePlacement } from './formed';
import type { Board } from './board';
const SIZE = 15;
// board builds a 15×15 empty board with the given committed tiles placed.
function board(tiles: Array<[number, number, string]> = []): Board {
const b: Board = [];
for (let r = 0; r < SIZE; r++) {
const row: (Board[number][number])[] = [];
for (let c = 0; c < SIZE; c++) row.push(null);
b.push(row);
}
for (const [r, c, letter] of tiles) b[r][c] = { letter, blank: false };
return b;
}
const cells = (g: ReturnType<typeof formedGeometry>) => [...(g?.cells ?? new Set())].sort();
describe('formedGeometry', () => {
it('returns null with nothing staged', () => {
expect(formedGeometry(board(), [], true)).toBeNull();
});
it('finds a fresh horizontal word with no cross words', () => {
const g = formedGeometry(board(), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('finds a fresh vertical word', () => {
const g = formedGeometry(board(), [
{ row: 7, col: 7 },
{ row: 8, col: 7 },
], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'V', len: 2 });
expect(cells(g)).toEqual(['7,7', '8,7']);
});
it('extends a committed tile into the main word (committed cell in the set)', () => {
// A committed A at (7,7); a lone tile to its right forms the two-letter main word.
const g = formedGeometry(board([[7, 7, 'A']]), [{ row: 7, col: 8 }], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('collects a cross word through a staged tile, committed cells included', () => {
// Committed X above and Y below (7,8) leave a one-cell gap; a horizontal play through it also
// completes the vertical X-_-Y cross word.
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
], true);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['6,8', '7,7', '7,8', '8,8']);
});
it('omits cross words under the single-word rule (multipleWords = false)', () => {
// The same board as the cross-word case, but a single-word game: the engine ignores the vertical
// X-_-Y run (and it need not be a real word), so the geometry must highlight only the main
// (horizontal) word — the reported "БО lights up green in a one-word game" bug.
const g = formedGeometry(board([[6, 8, 'X'], [8, 8, 'Y']]), [
{ row: 7, col: 7 },
{ row: 7, col: 8 },
], false);
expect(g?.main).toEqual({ row: 7, col: 7, dir: 'H', len: 2 });
expect(cells(g)).toEqual(['7,7', '7,8']);
});
it('takes the longer axis as the main word for a lone connecting tile', () => {
// (7,8) joins a length-3 horizontal (cols 6-8) and a length-4 vertical (rows 5-8): the vertical
// is longer, so it is the main word and the horizontal becomes a cross word.
const g = formedGeometry(
board([
[7, 6, 'A'],
[7, 7, 'B'],
[5, 8, 'C'],
[6, 8, 'D'],
[8, 8, 'E'],
]),
[{ row: 7, col: 8 }],
true,
);
expect(g?.main).toEqual({ row: 5, col: 8, dir: 'V', len: 4 });
expect(cells(g)).toEqual(['5,8', '6,8', '7,6', '7,7', '7,8', '8,8']);
});
});
describe('badgePlacement', () => {
it('anchors a mid-board horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 7, col: 5, dir: 'H', len: 3 })).toEqual({ row: 7, col: 7, corner: 'tr' });
});
it('anchors a right-edge horizontal word at the first letter, bottom-left', () => {
expect(badgePlacement({ row: 7, col: 12, dir: 'H', len: 3 })).toEqual({ row: 7, col: 12, corner: 'bl' });
});
it('anchors a top-edge horizontal word at the first letter, bottom-left', () => {
expect(badgePlacement({ row: 0, col: 5, dir: 'H', len: 3 })).toEqual({ row: 0, col: 5, corner: 'bl' });
});
it('anchors a left-edge horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 3 })).toEqual({ row: 7, col: 2, corner: 'tr' });
});
it('anchors a bottom-edge horizontal word at the last letter, top-right', () => {
expect(badgePlacement({ row: 14, col: 5, dir: 'H', len: 3 })).toEqual({ row: 14, col: 7, corner: 'tr' });
});
it('anchors a mid-board vertical word at the first letter, top-right', () => {
expect(badgePlacement({ row: 5, col: 7, dir: 'V', len: 3 })).toEqual({ row: 5, col: 7, corner: 'tr' });
});
it('anchors a top-edge vertical word at the last letter, bottom-left', () => {
expect(badgePlacement({ row: 0, col: 7, dir: 'V', len: 3 })).toEqual({ row: 2, col: 7, corner: 'bl' });
});
it('anchors a right-edge vertical word at the last letter, bottom-left', () => {
expect(badgePlacement({ row: 5, col: 14, dir: 'V', len: 3 })).toEqual({ row: 7, col: 14, corner: 'bl' });
});
it('anchors a full-width horizontal word at the last letter, top-right (clamped by the board)', () => {
expect(badgePlacement({ row: 7, col: 0, dir: 'H', len: 15 })).toEqual({ row: 7, col: 14, corner: 'tr' });
});
});
+174
View File
@@ -0,0 +1,174 @@
// Client-side geometry of the play being composed: which cells the formed word(s) cover, and
// where to anchor the move-score badge. This is deliberately independent of the move evaluator
// (lib/dict/eval.ts) — the network `evaluate` returns legality/score/word strings but no cell
// coordinates, and the board highlight plus the score badge must work even while the local
// dictionary is still warming up. Legality and the score come from the preview; the geometry is
// derived here purely from the board and the staged tiles. Both functions are pure (testable in
// lib/formed.test.ts); the board stays the source of truth for legality.
import type { Board } from './board';
import { BOARD_SIZE } from './premiums';
/** Play axis: horizontal or vertical. */
export type Dir = 'H' | 'V';
/** MainWord locates the play's main word: its start cell, axis and length in cells. */
export interface MainWord {
row: number;
col: number;
dir: Dir;
len: number;
}
/** FormedGeometry is the composed play's shape: the main word plus the set of every cell (as a
* "row,col" key) that the main word and any cross words cover committed tiles included. */
export interface FormedGeometry {
main: MainWord;
cells: Set<string>;
}
/** A minimal cell reference; PendingTile satisfies it structurally. */
interface Cell {
row: number;
col: number;
}
const key = (r: number, c: number): string => `${r},${c}`;
/**
* filledPredicate returns a test for whether a cell holds a tile once the staged play is on the
* board: either a committed tile sits there or a pending tile is being placed there.
*/
function filledPredicate(board: Board, pending: readonly Cell[]): (r: number, c: number) => boolean {
const pend = new Set(pending.map((p) => key(p.row, p.col)));
return (r, c) =>
r >= 0 &&
r < BOARD_SIZE &&
c >= 0 &&
c < BOARD_SIZE &&
(board[r]?.[c] != null || pend.has(key(r, c)));
}
/**
* span walks the maximal contiguous filled run through cell (row, col) along dir and returns the
* run's start index and length on the moving axis (columns for H, rows for V).
*/
function span(
filled: (r: number, c: number) => boolean,
dir: Dir,
row: number,
col: number,
): { start: number; len: number } {
if (dir === 'H') {
let s = col;
while (filled(row, s - 1)) s--;
let e = col;
while (filled(row, e + 1)) e++;
return { start: s, len: e - s + 1 };
}
let s = row;
while (filled(s - 1, col)) s--;
let e = row;
while (filled(e + 1, col)) e++;
return { start: s, len: e - s + 1 };
}
/**
* formedGeometry derives the composed play's word geometry from the board and the staged tiles.
* It returns the main word (the maximal run along the play axis through the staged tiles) and the
* set of every cell the main word and each cross word (a perpendicular run of two or more cells
* through a staged tile) cover. Under the **single-word rule** (multipleWords = false) cross words
* are omitted, matching the engine, which ignores them entirely a perpendicular run there is
* neither validated nor scored (and need not even be a real word), so highlighting it would be
* misleading. It returns null when nothing is staged, or when the staged tiles are not on a single
* line a shape only an illegal play produces, which the caller never asks about (it is invoked
* only for a legal preview). The play axis is fixed by the staged tiles when two or more are
* colinear; a lone tile takes whichever axis forms the longer word.
*/
export function formedGeometry(
board: Board,
pending: readonly Cell[],
multipleWords: boolean,
): FormedGeometry | null {
if (pending.length === 0) return null;
const filled = filledPredicate(board, pending);
const first = pending[0];
let dir: Dir;
if (pending.length === 1) {
const h = span(filled, 'H', first.row, first.col);
const v = span(filled, 'V', first.row, first.col);
dir = h.len >= v.len ? 'H' : 'V';
} else if (pending.every((p) => p.row === first.row)) {
dir = 'H';
} else if (pending.every((p) => p.col === first.col)) {
dir = 'V';
} else {
return null;
}
const cells = new Set<string>();
// Main word: the maximal run along the play axis through the staged tiles (contiguous via any
// committed tiles between them), collected from any one staged tile.
const ms = span(filled, dir, first.row, first.col);
let main: MainWord;
if (dir === 'H') {
main = { row: first.row, col: ms.start, dir, len: ms.len };
for (let c = ms.start; c < ms.start + ms.len; c++) cells.add(key(first.row, c));
} else {
main = { row: ms.start, col: first.col, dir, len: ms.len };
for (let r = ms.start; r < ms.start + ms.len; r++) cells.add(key(r, first.col));
}
// Cross words: the perpendicular run through each staged tile, kept only when it is a real word
// (two or more cells). Skipped under the single-word rule, where the engine ignores cross words,
// so a perpendicular run must not be highlighted as if it were a formed word.
if (multipleWords) {
const cross: Dir = dir === 'H' ? 'V' : 'H';
for (const p of pending) {
const cs = span(filled, cross, p.row, p.col);
if (cs.len < 2) continue;
if (cross === 'H') {
for (let c = cs.start; c < cs.start + cs.len; c++) cells.add(key(p.row, c));
} else {
for (let r = cs.start; r < cs.start + cs.len; r++) cells.add(key(r, p.col));
}
}
}
return { main, cells };
}
/** Where the move-score badge is anchored: a main-word cell and the tile corner it sits on. */
export interface BadgeAnchor {
row: number;
col: number;
corner: 'tr' | 'bl';
}
/**
* badgePlacement chooses the main-word tile and corner for the move-score badge, so the badge
* points into the board and away from the nearest edge (the board clamps the final pill fully
* on-board). The rules: a word touching the right or top edge anchors at a tile's bottom-left
* corner (horizontal its first letter, vertical its last letter); a word touching the left or
* bottom edge and any mid-board word anchors at the top-right corner (horizontal its last
* letter, vertical its first letter). A full-width horizontal word (it touches both side edges)
* anchors at the last letter's top-right, which the board then clamps leftward.
*/
export function badgePlacement(main: MainWord): BadgeAnchor {
const last = BOARD_SIZE - 1;
const h = main.dir === 'H';
const minRow = main.row;
const maxRow = h ? main.row : main.row + main.len - 1;
const minCol = main.col;
const maxCol = h ? main.col + main.len - 1 : main.col;
if (h && main.len === BOARD_SIZE) {
return { row: main.row, col: maxCol, corner: 'tr' };
}
if (maxCol === last || minRow === 0) {
return h ? { row: main.row, col: minCol, corner: 'bl' } : { row: maxRow, col: main.col, corner: 'bl' };
}
return h ? { row: main.row, col: maxCol, corner: 'tr' } : { row: minRow, col: main.col, corner: 'tr' };
}
+13 -10
View File
@@ -87,6 +87,7 @@ export const en = {
'onboarding.gameRack': 'Pick a tile and place it on the board', // rack
'game.bag': '{n} in the bag',
'game.bagCount': 'In the bag: {n}',
'game.bagEmpty': 'Bag is empty',
'game.hints': 'Hints {n}',
'game.yourTurn': 'Your turn',
@@ -104,8 +105,6 @@ export const en = {
'game.checkWord': 'Check word',
'game.dictionary': 'Dictionary',
'game.dropGame': 'Drop game',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Not a legal move',
'game.oneWordRule': 'One word per turn',
'game.chooseBlank': 'Choose a letter for the blank',
'game.exchangeTitle': 'Exchange or pass',
@@ -215,6 +214,7 @@ export const en = {
'settings.labelsClassic': 'Classic',
'settings.labelsNone': 'None',
'settings.reduceMotion': 'Reduce motion',
'settings.zoomBoard': 'Zoom the board',
'settings.offlineMode': 'Play mode',
'settings.online': 'Online',
'settings.offline': 'Offline',
@@ -228,20 +228,19 @@ export const en = {
// --- wallet ---
'wallet.title': 'Wallet',
'wallet.tab': 'Wallet',
'wallet.balance': 'Balance',
'wallet.noChips': 'No chips yet',
'wallet.source.vk': 'VK',
'wallet.source.telegram': 'Telegram',
'wallet.source.direct': 'Web',
'wallet.viewOnly': 'view only',
'wallet.benefits': 'Benefits',
'wallet.active': 'Active',
'wallet.activeHints': 'Hints: {n}',
'wallet.noAdsUntil': 'No ads until {date}',
'wallet.noAdsForever': 'No ads forever',
'wallet.adsOn': 'Ads are on',
'wallet.hints': 'Hints {n}',
'wallet.store': 'Store',
'wallet.tabBuy': 'Buy chips',
'wallet.tabSpend': 'Spend chips',
'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy',
'wallet.spend': 'Exchange',
'wallet.rewardWatch': 'Watch an ad for {n} 🪙',
'wallet.rewardCta': 'Watch',
'wallet.rewardCredited': '+{n} 🪙 for watching',
@@ -256,10 +255,11 @@ export const en = {
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
'wallet.cur.XTR': '⭐',
'wallet.warnTitle': 'Web-only purchase',
'wallet.spendTitle': 'Confirm exchange',
'wallet.spendBody': 'Exchange {n} 🪙 for “{title}”?',
'wallet.spendConfirm': 'Exchange',
'wallet.warnBody':
'This spends your VK/Telegram chips, and the benefit will work on the web and in the app only — because of store rules.',
'wallet.warnConfirm': 'Continue',
'wallet.warnCancel': 'Cancel',
'about.title': 'About',
'about.tab': 'Info',
@@ -285,6 +285,7 @@ export const en = {
'landing.captionVK': 'VK',
'landing.captionWeb': 'Web',
'landing.offer': 'Public offer',
'landing.feedback': 'Feedback',
'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
@@ -313,6 +314,8 @@ export const en = {
'error.invalid_email': 'Enter a valid email address.',
'error.invalid_config': 'Invalid game settings.',
'error.not_found': 'Not found.',
'error.insufficient_chips': 'Not enough chips.',
'error.product_not_found': 'This item is no longer available.',
'error.session_invalid': 'Your session expired. Please sign in again.',
'error.unauthenticated': 'Please sign in.',
'error.rate_limited': 'Too many requests, slow down.',
+13 -10
View File
@@ -87,6 +87,7 @@ export const ru: Record<MessageKey, string> = {
'onboarding.gameRack': 'Выбирайте фишку и ставьте на доску', // rack
'game.bag': '{n} в мешке',
'game.bagCount': 'В мешке: {n}',
'game.bagEmpty': 'Мешок пуст',
'game.hints': 'Подсказки {n}',
'game.yourTurn': 'Ваш ход',
@@ -104,8 +105,6 @@ export const ru: Record<MessageKey, string> = {
'game.checkWord': 'Проверить слово',
'game.dictionary': 'Словарь',
'game.dropGame': 'Покинуть игру',
'game.previewWords': '{words}: {n}',
'game.previewIllegal': 'Недопустимый ход',
'game.oneWordRule': 'Одно слово за ход',
'game.chooseBlank': 'Выберите букву для бланка',
'game.exchangeTitle': 'Обмен или пас',
@@ -215,6 +214,7 @@ export const ru: Record<MessageKey, string> = {
'settings.labelsClassic': 'Классика',
'settings.labelsNone': 'Без текста',
'settings.reduceMotion': 'Меньше анимаций',
'settings.zoomBoard': 'Приближать доску',
'settings.offlineMode': 'Режим игры',
'settings.online': 'Онлайн',
'settings.offline': 'Оффлайн',
@@ -228,20 +228,19 @@ export const ru: Record<MessageKey, string> = {
// --- wallet ---
'wallet.title': 'Кошелёк',
'wallet.tab': 'Кошелёк',
'wallet.balance': 'Баланс',
'wallet.noChips': 'Пока нет фишек',
'wallet.source.vk': 'VK',
'wallet.source.telegram': 'Telegram',
'wallet.source.direct': 'Веб',
'wallet.viewOnly': 'просмотр',
'wallet.benefits': 'Блага',
'wallet.active': 'Активные',
'wallet.activeHints': 'Подсказки: {n}',
'wallet.noAdsUntil': 'Без рекламы до {date}',
'wallet.noAdsForever': 'Без рекламы навсегда',
'wallet.adsOn': 'Реклама включена',
'wallet.hints': 'Подсказки {n}',
'wallet.store': 'Магазин',
'wallet.tabBuy': 'Купить фишки',
'wallet.tabSpend': 'Потратить фишки',
'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить',
'wallet.spend': 'Обмен',
'wallet.rewardWatch': 'Ролик за {n} 🪙',
'wallet.rewardCta': 'Смотреть',
'wallet.rewardCredited': '+{n} 🪙 за просмотр',
@@ -256,10 +255,11 @@ export const ru: Record<MessageKey, string> = {
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
'wallet.cur.XTR': '⭐',
'wallet.warnTitle': 'Покупка только для веба',
'wallet.spendTitle': 'Подтверждение обмена',
'wallet.spendBody': 'Обменять {n} 🪙 на «{title}»?',
'wallet.spendConfirm': 'Обменять',
'wallet.warnBody':
'Оплата спишет фишки VK/Telegram, и благо будет работать только в вебе и приложении — из-за правил магазинов.',
'wallet.warnConfirm': 'Продолжить',
'wallet.warnCancel': 'Отмена',
'about.title': 'О программе',
'about.tab': 'Инфо',
@@ -285,6 +285,7 @@ export const ru: Record<MessageKey, string> = {
'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия',
'landing.offer': 'Публичная оферта',
'landing.feedback': 'Обратная связь',
'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
@@ -313,6 +314,8 @@ export const ru: Record<MessageKey, string> = {
'error.invalid_email': 'Введите корректный адрес почты.',
'error.invalid_config': 'Неверные настройки игры.',
'error.not_found': 'Не найдено.',
'error.insufficient_chips': 'Не хватает фишек.',
'error.product_not_found': 'Этого товара больше нет.',
'error.session_invalid': 'Сессия истекла. Войдите снова.',
'error.unauthenticated': 'Пожалуйста, войдите.',
'error.rate_limited': 'Слишком много запросов, помедленнее.',
+2 -2
View File
@@ -10,8 +10,8 @@ describe('renderOfferHtml', () => {
// The markdown heading is rendered, not left as literal source.
expect(html).toContain('<h1>Публичная оферта</h1>');
expect(html).not.toContain('# Публичная оферта');
// A back link to the landing root is always present.
expect(html).toContain('href="/"');
// No in-page navigation: the standalone offer carries no "back" link.
expect(html).not.toContain('class="back"');
});
it('renders headings, bold clause numbers and lists', () => {
+37 -12
View File
@@ -2,13 +2,15 @@ import { marked } from 'marked';
/**
* renderOfferHtml renders the public-offer markdown source into a standalone,
* self-contained HTML document served statically at `/offer/`. The build emits
* the result as `dist/offer/index.html` (see the `emit-offer` plugin in
* `vite.config.ts`), which the landing container serves.
* self-contained HTML document served at `/offer/`. It runs server-side in the
* render sidecar (`renderer/src/offer.mjs`), which reads the owner-edited
* `ui/legal/offer_ru.md`, splices the live catalog price list into it and calls
* this function one renderer shared with the browser build, kept unit-tested
* here (`offer.test.ts`).
*
* The input `markdown` is trusted repository content (the owner-edited
* `ui/legal/offer_ru.md`), not user input, so the rendered HTML is deliberately
* not sanitised. The page carries its own minimal light/dark styling so it needs
* The input `markdown` is trusted repository content plus the backend's own
* catalog projection, not user input, so the rendered HTML is deliberately not
* sanitised. The page carries its own minimal light/dark styling so it needs
* neither the app bundle nor `app.css`.
*/
export function renderOfferHtml(markdown: string): string {
@@ -29,6 +31,7 @@ export function renderOfferHtml(markdown: string): string {
--text: #1a1c20;
--accent: #2563eb;
--rule: #e5e7eb;
--cell-border: #d1d5db;
}
@media (prefers-color-scheme: dark) {
:root {
@@ -36,6 +39,9 @@ export function renderOfferHtml(markdown: string): string {
--text: #e7e9ee;
--accent: #6ea8fe;
--rule: #242832;
/* A muted grey the section rules (--rule) vanish on the dark background, so table cells
get a slightly firmer border to stay legible without being loud. */
--cell-border: #3a4250;
}
}
* {
@@ -55,11 +61,6 @@ export function renderOfferHtml(markdown: string): string {
a {
color: var(--accent);
}
.back {
display: inline-block;
margin-bottom: 20px;
text-decoration: none;
}
h1 {
font-size: 1.6rem;
text-align: center;
@@ -80,11 +81,35 @@ export function renderOfferHtml(markdown: string): string {
li {
margin: 0.25em 0;
}
/* The price tables (§4.4) span the full content width. */
table {
width: 100%;
border-collapse: collapse;
margin: 0.75em 0 1.25em;
font-size: 0.95rem;
}
th,
td {
border: 1px solid var(--cell-border);
padding: 6px 10px;
}
/* The product-name column shrinks to its content and never wraps; the price columns share the
rest of the width (width:1% is the shrink-to-fit idiom paired with the table's width:100%). */
th:first-child,
td:first-child {
width: 1%;
white-space: nowrap;
}
/* Right-aligned price columns. marked emits the alignment from the "---:" separator; this rule
keeps it applied whether that surfaces as an align attribute or an inline style. */
th[align='right'],
td[align='right'] {
text-align: right;
}
</style>
</head>
<body>
<main>
<a class="back" href="/"> На главную</a>
${body}
</main>
</body>
+1
View File
@@ -143,6 +143,7 @@ export interface Prefs {
locale: Locale;
reduceMotion: boolean;
boardLabels: BoardLabelMode;
zoomBoard: boolean;
}
export async function loadPrefs(): Promise<Partial<Prefs>> {
+15
View File
@@ -5,6 +5,7 @@
setLocalePref,
setReduceMotion,
setTheme,
setZoomBoard,
} from '../lib/app.svelte';
import { t, type Locale, type MessageKey } from '../lib/i18n/index.svelte';
import type { ThemePref } from '../lib/theme';
@@ -15,6 +16,10 @@
import { isStandalone } from '../lib/pwa';
import InstallApp from '../components/InstallApp.svelte';
// The board-zoom toggle only makes sense on a touch device (the desktop / landscape layouts fit
// the whole board and never auto-zoom), so it is shown only for a coarse pointer.
const coarsePointer = typeof matchMedia !== 'undefined' && matchMedia('(pointer: coarse)').matches;
// The offline toggle is for the installed web PWA with a confirmed email only: the service worker
// that lets the app launch with no network runs only in a standalone web install (not a mini-app),
// and a durable account (email) anchors the device-local games. Elsewhere the control is hidden.
@@ -102,6 +107,16 @@
onchange={(e) => setReduceMotion(e.currentTarget.checked)}
/>
</label>
{#if coarsePointer}
<label class="row">
<span>{t('settings.zoomBoard')}</span>
<input
type="checkbox"
checked={app.zoomBoard}
onchange={(e) => setZoomBoard(e.currentTarget.checked)}
/>
</label>
{/if}
</section>
{#if offlineEligible}
+133 -68
View File
@@ -5,7 +5,7 @@
import { gateway } from '../lib/gateway';
import { normalizeSubtype } from '../lib/platform';
import { t, i18n, type MessageKey } from '../lib/i18n/index.svelte';
import { executionContext, formatAmount, needsWebSpendWarning, type SpendContext } from '../lib/wallet';
import { executionContext, formatAmount, needsWebSpendWarning, spendableChips, type SpendContext } from '../lib/wallet';
import { isGooglePlayBuild } from '../lib/distribution';
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
@@ -14,11 +14,10 @@
import { GatewayError } from '../lib/client';
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
// Values are bought with chips (they work once the player has any); chip packs are bought with
// money (the purchase itself arrives with payment intake — here they are shown priced only). On
// the Google Play build the money purchases are hidden behind a RuStore stub (store policy), while
// spending already-earned chips still works.
// The Wallet section: a compact chip balance, the active benefits, and the storefront. The store
// splits into "buy chips" (chip packs, funded with money — a provider redirect) and "spend chips"
// (values bought with chips — an instant exchange). On the Google Play build the money purchases
// are hidden behind a RuStore stub (store policy), while spending already-earned chips still works.
let wallet = $state<Wallet | null>(null);
let catalog = $state<Catalog | null>(null);
@@ -27,10 +26,17 @@
// (busy) and scopes the pressed/dimmed visual to the tapped button — not every buy button.
let busyId = $state<string | null>(null);
const busy = $derived(busyId !== null);
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
let warnProduct = $state<CatalogProduct | null>(null);
const context: SpendContext = executionContext();
// The storefront half in view: buying chips for money, or spending chips on values.
let tab = $state<'buy' | 'spend'>('buy');
// The value awaiting an exchange confirmation, or null. Every chip-spend confirms (it is instant —
// no provider redirect to stand in as a confirmation), and the dialog folds in the cross-platform
// warning when the spend would draw vk/tg chips.
let spendProduct = $state<CatalogProduct | null>(null);
const spendWarn = $derived(
!!spendProduct && needsWebSpendWarning(context, wallet?.segments ?? [], spendProduct.chips),
);
const gpBuild = isGooglePlayBuild();
// Money purchases are not permitted inside the VK iOS app (Apple ToS); the pack CTA is shown
// muted and taps explain why, rather than hiding the storefront. VK's device family is not on the
@@ -42,6 +48,29 @@
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
// The chips the player can actually spend in this context — a value costing more is out of reach,
// so its exchange button is disabled (the server stays authoritative and also rejects it).
const spendable = $derived(wallet ? spendableChips(wallet) : 0);
// The balance reads as the context's own segment first (🪙 N), with any linked other-platform
// segments appended behind their logo — on the web all linked segments show and spend together;
// inside a VK/Telegram store only that store's own segment is present, so it stands alone.
const mainSeg = $derived(wallet?.segments.find((s) => s.source === context));
const linkedSegs = $derived((wallet?.segments ?? []).filter((s) => s.source !== context));
// The active benefits, one short phrase each, only for what the player actually holds — nothing
// to show (ad-free lapsed and no hints) hides the whole section.
const activeItems = $derived.by(() => {
const items: string[] = [];
if (!wallet) return items;
if (wallet.hints > 0) items.push(t('wallet.activeHints', { n: wallet.hints }));
if (wallet.adsForever) items.push(t('wallet.noAdsForever'));
else if (wallet.adsPaidUntilMs > Date.now()) {
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
items.push(t('wallet.noAdsUntil', { date }));
}
return items;
});
// Rewarded video (VK ads): the "watch for chips" CTA shows only when the server offers a payout in
// this context (wallet.rewardChips > 0 — VK, configured) and an ad is ready to show.
@@ -121,26 +150,17 @@
return t(`wallet.cur.${currency}` as MessageKey);
}
function adsLine(): string {
if (!wallet) return '';
if (wallet.adsForever) return t('wallet.noAdsForever');
if (wallet.adsPaidUntilMs > Date.now()) {
const date = new Date(wallet.adsPaidUntilMs).toLocaleDateString(i18n.locale === 'ru' ? 'ru-RU' : 'en-US');
return t('wallet.noAdsUntil', { date });
}
return t('wallet.adsOn');
function platformLogo(source: string): string {
return source === 'vk' ? 'vk-logo.svg' : 'telegram-logo.svg';
}
function onBuy(p: CatalogProduct) {
if (needsWebSpendWarning(context, wallet?.segments ?? [], p.chips)) {
warnProduct = p;
return;
}
void doBuy(p);
// onSpend opens the exchange confirmation for a chip-priced value; doBuy runs the actual spend.
function onSpend(p: CatalogProduct) {
spendProduct = p;
}
async function doBuy(p: CatalogProduct) {
warnProduct = null;
spendProduct = null;
if (busy) return;
busyId = p.productId;
try {
@@ -183,30 +203,28 @@
</script>
<div class="wallet" data-testid="wallet">
<section>
<h3>{t('wallet.balance')}</h3>
{#if wallet && wallet.segments.length > 0}
{#each wallet.segments as s (s.source)}
<div class="row">
<span class="name">{sourceLabel(s.source)}</span>
<span class="value"
>🪙 {s.chips}{#if !s.spendable}&nbsp;<span class="muted">({t('wallet.viewOnly')})</span>{/if}</span
>
</div>
<div class="balance" data-testid="balance">
<span class="coin" aria-hidden="true">🪙</span><span class="num" class:dim={mainSeg && !mainSeg.spendable}>{mainSeg?.chips ?? 0}</span>
{#each linkedSegs as s (s.source)}
<span class="plus" aria-hidden="true">+</span><img class="plogo" src={platformLogo(s.source)} width="16" height="16" alt={sourceLabel(s.source)} /><span class="num" class:dim={!s.spendable}>{s.chips}</span>
{/each}
{:else if !loading}
<p class="empty">{t('wallet.noChips')}</p>
{/if}
</section>
</div>
<section>
<h3>{t('wallet.benefits')}</h3>
<div class="row"><span class="name">{adsLine()}</span></div>
<div class="row"><span class="name">{t('wallet.hints', { n: wallet?.hints ?? 0 })}</span></div>
{#if activeItems.length > 0}
<section data-testid="active">
<h3>{t('wallet.active')}</h3>
<div class="row"><span class="name">{activeItems.join('. ')}</span></div>
</section>
{/if}
<section>
<h3>{t('wallet.store')}</h3>
<div class="seg">
<button class="opt" class:active={tab === 'buy'} data-testid="tab-buy" onclick={() => (tab = 'buy')}>{t('wallet.tabBuy')}</button>
<button class="opt" class:active={tab === 'spend'} data-testid="tab-spend" onclick={() => (tab = 'spend')}>{t('wallet.tabSpend')}</button>
</div>
{#if tab === 'buy'}
{#if purchaseBlocked}
<!-- prettier-ignore -->
<p class="ios-note" data-testid="ios-note">{t('wallet.iosBlockedPre')}<a href={siteRoot} target="_blank" rel="noopener noreferrer" onclick={onExternalLinkClick}>{t('wallet.iosBlockedLink')}</a>{t('wallet.iosBlockedPost')}</p>
@@ -219,16 +237,6 @@
>
</div>
{/if}
{#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span>
<span class="price">🪙 {p.chips}</span>
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
>{t('wallet.buy')}</button
>
</div>
{/each}
{#if gpBuild}
<p class="stub" data-testid="gp-stub">{t('wallet.gpStub')}</p>
{:else}
@@ -253,22 +261,35 @@
<p class="offer" data-testid="offer">
<a href="/offer/" target="_blank" rel="noopener noreferrer">{t('wallet.offer')}</a>
</p>
{/if}
{/if}
{#if !loading && values.length === 0 && (gpBuild || packs.length === 0)}
{:else if !loading}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/if}
{:else}
{#each values as p (p.productId)}
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
<span class="name">{p.title}</span>
<span class="price">🪙 {p.chips}</span>
<button class="buy" class:working={busyId === p.productId} class:cantafford={spendable < p.chips} data-testid="buy" disabled={busy || spendable < p.chips} onclick={() => onSpend(p)}
>{t('wallet.spend')}</button
>
</div>
{/each}
{#if !loading && values.length === 0}
<p class="empty">{t('wallet.empty')}</p>
{/if}
{/if}
</section>
</div>
{#if warnProduct}
<Modal title={t('wallet.warnTitle')} onclose={() => (warnProduct = null)}>
<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>
{#if spendProduct}
<Modal title={t('wallet.spendTitle')} onclose={() => (spendProduct = null)}>
<p class="spend-body" data-testid="spend-body">{t('wallet.spendBody', { n: spendProduct.chips, title: spendProduct.title })}</p>
{#if spendWarn}<p class="warn-body" data-testid="warn">{t('wallet.warnBody')}</p>{/if}
<div class="warn-actions">
<button class="cancel" onclick={() => (warnProduct = null)}>{t('wallet.warnCancel')}</button>
<button class="buy" data-testid="warn-confirm" onclick={() => warnProduct && doBuy(warnProduct)}
>{t('wallet.warnConfirm')}</button
<button class="cancel" onclick={() => (spendProduct = null)}>{t('wallet.warnCancel')}</button>
<button class="buy" data-testid="spend-confirm" onclick={() => spendProduct && doBuy(spendProduct)}
>{t('wallet.spendConfirm')}</button
>
</div>
</Modal>
@@ -281,6 +302,29 @@
gap: 18px;
padding: var(--pad);
}
/* The compact balance: the context's own chips (🪙 N) then any linked other-platform segments
behind their logo, digits monospaced so they align; a non-spendable (view-only) segment dims. */
.balance {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
font-weight: 600;
font-size: 1.05rem;
}
.balance .num {
font-family: monospace;
}
.balance .num.dim {
opacity: 0.5;
}
.balance .plus {
margin: 0 3px;
color: var(--text-muted);
}
.balance .plogo {
display: block;
}
section {
display: flex;
flex-direction: column;
@@ -291,6 +335,26 @@
font-size: 0.95rem;
color: var(--text-muted);
}
/* The buy/spend segmented control, matching the New Game type selector. */
.seg {
display: flex;
gap: 8px;
}
.opt {
flex: 1;
min-width: 64px;
padding: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
}
.opt.active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
.row {
display: flex;
align-items: center;
@@ -304,16 +368,11 @@
flex: 1 1 auto;
min-width: 0;
}
.value,
.price {
flex: 0 0 auto;
font-weight: 600;
white-space: nowrap;
}
.muted {
color: var(--text-muted);
font-weight: 400;
}
.buy {
flex: 0 0 auto;
padding: 8px 14px;
@@ -330,7 +389,8 @@
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
.buy.blocked,
.buy.working {
.buy.working,
.buy.cantafford {
opacity: 0.5;
}
.ios-note {
@@ -362,8 +422,13 @@
.offer a {
color: var(--text-muted);
}
.spend-body {
margin: 0 0 10px;
}
.warn-body {
margin: 0 0 14px;
color: var(--text-muted);
font-size: 0.9rem;
}
.warn-actions {
display: flex;
-18
View File
@@ -3,7 +3,6 @@ import { resolve } from 'node:path';
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { VitePWA } from 'vite-plugin-pwa';
import { renderOfferHtml } from './src/lib/offer';
/**
* injectBootVersion stamps the app version into index.html's boot-capability guard, replacing its
@@ -39,22 +38,6 @@ function emitPolyfills(): Plugin {
};
}
/**
* emitOffer renders the public offer markdown (legal/offer_ru.md) to a standalone
* static page and emits it as dist/offer/index.html, which the landing container
* serves at /offer/ (deploy/landing/Caddyfile). The markdown is the owner-editable
* source of truth, so the page is regenerated from it on every build.
*/
function emitOffer(): Plugin {
return {
name: 'emit-offer',
generateBundle() {
const md = readFileSync(resolve(import.meta.dirname, 'legal/offer_ru.md'), 'utf8');
this.emitFile({ type: 'asset', fileName: 'offer/index.html', source: renderOfferHtml(md) });
},
};
}
// The edge Connect service is scrabble.edge.v1.Gateway; the gateway serves it over
// h2c on :8081 by default. In dev we proxy the RPC path so the browser (which can
// not speak h2c directly) talks to the dev server on the same origin. In `mock`
@@ -77,7 +60,6 @@ export default defineConfig(({ mode }) => ({
plugins: [
svelte(),
emitPolyfills(),
emitOffer(),
injectBootVersion(),
// App-shell precache for the offline mode: a custom (injectManifest) service worker precaches
// index.html + the hashed assets so the installed web PWA cold-launches with no network. It