Compare commits

..

21 Commits

Author SHA1 Message Date
developer 18785efc8c Merge pull request 'release v1.13.0: payments wallet mechanics + database point-in-time recovery' (#220) from development into master 2026-07-08 23:42:15 +00:00
developer 780ff68ec2 Merge pull request 'release: offline mode + local pass-and-play (hotseat) — proposed v1.12.0' (#212) from development into master 2026-07-07 14:40:43 +00:00
developer 57ff2d03f8 Merge pull request 'Release v1.11.0: PWA install + code-only PWA login + email/metrics fixes' (#187) from development into master 2026-07-05 21:02:08 +00:00
developer a9d0986e74 Merge pull request 'Release v1.10.0: banner colours + urgent, and 3 UI fixes' (#183) from development into master 2026-07-05 14:10:25 +00:00
developer 45957bdcd6 Merge pull request 'Release: promote development → master (old Android WebView support + unsupported-engine telemetry)' (#178) from development into master 2026-07-04 21:23:29 +00:00
developer 829e29a726 Merge pull request 'Release v1.8.0: prod build fix (re-promote)' (#175) from development into master 2026-07-03 21:48:13 +00:00
developer 399508f2f0 Merge pull request 'Release v1.8.0: promote development → master' (#173) from development into master 2026-07-03 21:31:25 +00:00
developer 3a18e683ca Merge pull request 'release: VK Mini App + landscape UI + dict v1.3.1 seed (development→master)' (#144) from development into master 2026-06-30 05:37:43 +00:00
developer 93d086a8a3 Merge pull request 'release: v1.7.0 — Telegram Mini App embedding enhancements' (#138) from development into master 2026-06-24 11:49:44 +00:00
developer 8fe1bdba6b Merge pull request 'release: v1.6.0 — promo deep-link seeds EN variant (+ UI nits)' (#135) from development into master 2026-06-23 21:02:19 +00:00
developer 7923b3cc09 Merge pull request 'release v1.5.1: support-relay card + topic-reopen fixes' (#133) from development into master 2026-06-23 16:54:01 +00:00
developer 4891216749 Merge pull request 'release v1.5.0: Telegram bot support relay' (#131) from development into master 2026-06-23 16:16:04 +00:00
developer f1b8769c89 Merge pull request 'release: v1.4.1 — Telegram nav (windowed, own back button, debug panel)' (#129) from development into master 2026-06-23 13:27:31 +00:00
developer b6f28a2423 Merge pull request 'release: v1.4.0 — Telegram launch diagnostic + dynamic SDK load' (#127) from development into master 2026-06-23 08:40:09 +00:00
developer e32ee9ce68 Merge pull request 'Release: development → master' (#125) from development into master 2026-06-22 22:36:42 +00:00
developer dc946a1faf Merge pull request 'release v1.2.2: edge HTTP/3 stall fix + db-size dashboard threshold' (#121) from development into master 2026-06-22 19:50:58 +00:00
developer 384bd143d0 Merge pull request 'Promote development → master: banner tip set + banner/push language fix' (#114) from development into master 2026-06-22 18:28:00 +00:00
developer c5d22fceca Merge pull request 'Promote development → master: Erudit blank star + dictionary v1.3.0 pin' (#111) from development into master 2026-06-22 13:12:01 +00:00
developer deaa7a29c5 Merge pull request 'Promote development → master (docs finalize + UI tweaks + Telegram name fallback)' (#108) from development into master 2026-06-22 07:27:40 +00:00
developer 24017bcb7f Merge pull request 'Promote development → master (deploy v2: versioning + visible jobs + rollback)' (#106) from development into master 2026-06-22 06:01:03 +00:00
developer 2c4f4b10dc Merge pull request 'Promote development → master (initial production release: pre-release line + Stage 18)' (#104) from development into master 2026-06-22 05:05:48 +00:00
79 changed files with 158 additions and 4379 deletions
-39
View File
@@ -366,13 +366,6 @@ jobs:
SMTP_RELAY_HOST: ${{ vars.SMTP_RELAY_HOST }}
SMTP_RELAY_PORT: ${{ vars.SMTP_RELAY_PORT }}
SMTP_RELAY_TLS: ${{ vars.SMTP_RELAY_TLS }}
# Direct-rail (Robokassa) sandbox intake on the contour: the test shop's merchant login +
# Password1/Password2. IsTest is forced to 1 below so the contour can never take real money
# (independent of the shop's own mode). Empty login leaves the direct rail off.
ROBOKASSA_MERCHANT_LOGIN: ${{ secrets.TEST_BACKEND_ROBOKASSA_MERCHANT_LOGIN }}
ROBOKASSA_PASSWORD1: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD1 }}
ROBOKASSA_PASSWORD2: ${{ secrets.TEST_BACKEND_ROBOKASSA_PASSWORD2 }}
ROBOKASSA_TEST: "1"
SMTP_RELAY_FROM: ${{ vars.TEST_SMTP_RELAY_FROM }}
# Operator alerts: backend admin emails (new feedback / complaints) + Grafana
# infra alerts. Distinct senders + recipients; Grafana uses the relay's STARTTLS
@@ -504,38 +497,6 @@ jobs:
docker logs --tail 50 scrabble-backend || true
exit 1
- 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.
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"
else
echo "FAIL: /offer/ did not serve the offer page (fell through to the landing shell?)"
docker logs --tail 50 scrabble-landing || true
exit 1
fi
- name: Probe the /pay/ callback route reaches the gateway
run: |
set -u
# /pay/robokassa/result must reach the gateway, not fall to the landing catch-all. An
# unsigned probe is rejected downstream, so the gateway answers a 4xx/5xx (never a 200 or
# a 404 landing.html), which proves the edge route is wired.
out="$(docker run --rm --network edge alpine:3.20 wget -S -q -O /dev/null http://scrabble/pay/robokassa/result 2>&1 || true)"
echo "$out" | grep -E "HTTP/" || true
if echo "$out" | grep -qE "HTTP/1\.1 (4|5)[0-9][0-9]"; then
echo "ok: /pay/ reaches the gateway (non-landing response)"
else
echo "FAIL: /pay/robokassa/result did not reach the gateway (landing catch-all?)"
docker logs --tail 50 scrabble-gateway || true
exit 1
fi
- name: Probe the /dict edge route reaches the gateway
run: |
set -u
+22 -57
View File
@@ -33,8 +33,8 @@ status — without re-deriving decisions.
| E1 | Trusted platform signal | 1 | DONE |
| E2 | Currency + benefit core | 1 | DONE |
| E3 | Wallet UI | 1 | DONE |
| E4 | Durability (PITR) | 2 | DONE |
| E5 | Payment intake | 2 | WIP |
| E4 | Durability (PITR) | 2 | WIP |
| E5 | Payment intake | 2 | TODO |
| E6 | Ads | 2 | TODO |
| E7 | Admin & reports | 2 | TODO |
| E8 | Guest limits | — | TODO |
@@ -446,8 +446,8 @@ noun agreement). Svelte whitespace/`$state` naming gotchas apply.
## E4 — Durability (PITR)
**Status:** DONE — armed + restore-drilled on prod (v1.13.0, 2026-07-09). · **Release 2** ·
depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Status:** WIP — repo artifacts landed; **prod arming pending** (owner-coordinated, before
E5). · **Release 2** · depends on: E0 (schema exists) · mechanics: PAYMENTS §14 (D4).
**Goal.** Continuous WAL archiving with point-in-time recovery, armed **before the first
real money** is accepted (E5 prod). Protects both money and game data.
@@ -479,63 +479,30 @@ real money** is accepted (E5 prod). Protects both money and game data.
`-n backend`, silently excluding `payments`); manual-restore runbook updated.
- Full PITR runbook + arming sequence + recorded assessment in `deploy/README.md`.
**Prod arming (completed 2026-07-09 with the v1.13.0 release).** Owner created the Selectel S3
bucket (`erudite`, ru-6) + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
promoted `development → master` → `prod-deploy` (archive_mode on behind the maintenance window),
then `stanza-create` + first base backup (31.9 MB cluster → 3.7 MB in the repo) + `check`, the
Ansible `-e pitr_enabled=true` timer, and a restore drill on an isolated one-shot target (data
intact, then wiped). Exact steps: `deploy/README.md` (point-in-time recovery — arming).
**Prod arming (SEPARATE — owner-coordinated, before E5; NOT the artifacts PR).** Owner creates
the Selectel S3 bucket + the `PROD_PGBACKREST_*` secrets/variables (incl. `ARCHIVE_MODE=on`);
then promote `development → master` → `prod-deploy` (archive_mode on behind the maintenance
window), `pgbackrest stanza-create` + first base backup + `check`, re-run Ansible with
`-e pitr_enabled=true`, and run the restore drill on an isolated one-shot target. Exact steps:
`deploy/README.md` (point-in-time recovery — arming).
**Tests.** Restore drill on an isolated one-shot instance (base + WAL → target timestamp),
recorded in `deploy/README.md`. No app-level tests. Local verification: `docker compose config`
valid + the custom PG image builds (archiving inert on the contour).
**Done-criteria (met).** WAL archiving live on prod PG (v1.13.0); a restore verified on an
isolated one-shot target; cost + perf assessment reviewed; runbook current in
`deploy/README.md`.
**Done-criteria.** Artifacts merged with archiving inert. **Fully done** at arming: WAL
archiving live on prod PG; a timed restore verified; cost + perf assessment reviewed; runbook
current in `deploy/README.md`.
**Notes/risks.** The repository **cipher passphrase is unrecoverable if lost** — stored apart
from the S3 keys. Manual/timer pgBackRest runs use `docker exec -u postgres … --pg1-user=scrabble`
(docker exec is root; the DB superuser role is `scrabble`, not `postgres`) — the systemd timer
+ the runbook carry this. Enabling `archive_mode` restarts postgres (rode the prod-deploy
maintenance window). Migrations stay expand-contract so image rollback remains DB-safe with PITR.
from the S3 keys. Enabling `archive_mode` restarts postgres (rides the prod-deploy maintenance
window). Migrations stay expand-contract so image rollback remains DB-safe alongside PITR.
---
## E5 — Payment intake
**Status:** WIP · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Delivery & baked decisions.** Shipped as a linear PR stack (owner's choice), Robokassa first.
Resolved: match the order by a Robokassa **`Shp_order`** custom parameter, not the numeric `InvId`
(an order id is a uuid); idempotency key = the order id (`provider_payment_id = order_id`); the НПД
receipt is formed **shop-side in the Robokassa cabinet**, so no `Receipt` parameter is sent; a
chargeback **never drives the balance negative** (D27 stands, `balances_chips_chk` kept), so E5 is
**schema-free** (no migration, no contour wipe). Delivered on `feature/payment-intake-robokassa`:
the offer page (`/offer/`), the order/`fund` engine (idempotent, honours an expired order), the
`internal/robokassa` adapter, the `POST /wallet/order` + internal Result-callback handlers (a D36
confirmed-email gate on `direct`), the pending reaper, the `wallet.order` edge wire + the public
`/pay/*` routes, the Wallet purchase CTA, the contour deploy env (IsTest forced), and the
`payment_events` dispatcher — an in-app wallet-refresh push (KindNotification `"payment"`) with a
self-closing provider-return page (the payment opens in a separate window) and a return-focus
refetch fallback. The **VK Votes rail** is delivered too: the client opens
`VKWebAppShowOrderBox({item: order_id})`; a two-phase signed server callback (`get_item` → the pack
title + vote price; a chargeable `order_status_change` → the same `Fund` with source=`vk`, idempotent
on VK's own order id) is verified at the gateway with the app protected key (`GATEWAY_VK_APP_SECRET`,
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
bot** — on the `wallet.order` path the gateway sends a new `CreateInvoice` command over the reverse
bot-link and the bot returns the `createInvoiceLink` (XTR) in its Ack, handed to the client's
`WebApp.openInvoice`. The bot answers `pre_checkout_query` via a new bot→gateway **`ValidatePreCheckout`**
unary (backed by the backend: the order must exist, be still creditable and **not already paid** — the
reusable-invoice double-pay guard — with a matching amount); the decline reason is localised to the
order account's language. A completed `successful_payment` is persisted to a pure-Go **SQLite outbox**
(`modernc.org/sqlite`) then forwarded by a new bot→gateway **`ForwardPayment`** unary into the same
`Fund` (source=`telegram`, idempotent on `telegram_payment_charge_id`, honours an expired order),
re-driven at startup and every 30 s. The rail is wired by `TELEGRAM_STARS_OUTBOX_DIR` (defaults to the
bot `/data` volume) but stays **inert until a chip pack carries an XTR price**, so seeding a Stars price
in the admin is the go-live. Remaining: refunds; and hiding the ad banner on a no-ads purchase (a
spend-path `NotifyBanner`, deferred with the owner's agreement).
**Status:** TODO · **Release 2** · depends on: E0, E1, E2, E4 · mechanics: PAYMENTS §9, §12.
**Goal.** Accept real money on all three rails into the payments domain: order-flow,
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
@@ -558,14 +525,12 @@ receipts, and refunds.
**TG bot outbox (`platform/telegram/`).**
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
`telegram_payment_charge_id`.
- Invoice creation (Stars) is minted by the bot (`createInvoiceLink`, XTR) on the gateway's
`CreateInvoice` bot-link command, returned to the Mini App as the `openInvoice` link.
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
**Events & notifications.**
-54
View File
@@ -308,7 +308,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Notifier: hub,
ExportSignKey: cfg.ExportSignKey,
Renderer: renderer,
Robokassa: cfg.Robokassa,
})
pushSrv := pushgrpc.NewServer(cfg.GRPCAddr, hub, logger)
@@ -317,13 +316,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
logger.Info("servers starting",
zap.String("http_addr", cfg.HTTPAddr),
zap.String("grpc_addr", cfg.GRPCAddr))
// Sweep expired pending payment orders on a cadence (cosmetic hygiene; a late valid callback
// still credits). Runs until ctx is cancelled.
go runOrderReaper(ctx, paymentsSvc, logger)
// Deliver pending payment_events to connected clients as an in-app wallet-refresh push (the
// credit already landed in the ledger; a return-focus poll is the client-side fallback).
go runPaymentDispatcher(ctx, paymentsSvc, hub, logger)
errc := make(chan error, 2)
go func() { errc <- pushSrv.Run(ctx) }()
go func() { errc <- srv.Run(ctx) }()
@@ -333,52 +325,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
return err
}
// runOrderReaper periodically expires pending payment orders past their configured lifetime, until
// ctx is cancelled. Expiry is cosmetic: a later valid provider callback still credits an expired
// order.
func runOrderReaper(ctx context.Context, p *payments.Service, log *zap.Logger) {
t := time.NewTicker(5 * time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
if n, err := p.ExpireOrders(ctx); err != nil {
log.Warn("order reaper: sweep failed", zap.Error(err))
} else if n > 0 {
log.Info("order reaper: expired pending orders", zap.Int("count", n))
}
}
}
}
// runPaymentDispatcher delivers pending payment_events to connected clients as a wallet-refresh
// signal (KindNotification / "payment"), marking each delivered, until ctx is cancelled. The credit
// already committed to the ledger; this is only the in-app push so an open wallet updates in place.
func runPaymentDispatcher(ctx context.Context, p *payments.Service, pub notify.Publisher, log *zap.Logger) {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
evs, err := p.UndispatchedEvents(ctx, 50)
if err != nil {
log.Warn("payment dispatcher: read failed", zap.Error(err))
continue
}
for _, e := range evs {
pub.Publish(notify.Notification(e.AccountID, notify.NotifyPayment))
if err := p.MarkEventDispatched(ctx, e.EventID); err != nil {
log.Warn("payment dispatcher: mark failed", zap.String("event", e.EventID.String()), zap.Error(err))
}
}
}
}
}
// newMailer builds the confirm-code mailer: an SMTP relay when a host is
// configured, otherwise the development log mailer (the code is logged, not sent).
func newMailer(cfg account.SMTPConfig, logger *zap.Logger) account.Mailer {
-15
View File
@@ -393,21 +393,6 @@ func (s *Store) Identities(ctx context.Context, accountID uuid.UUID) ([]Identity
return out, nil
}
// HasConfirmedEmail reports whether the account owns a confirmed email identity — the direct-rail
// recovery anchor a first purchase requires (D36).
func (s *Store) HasConfirmedEmail(ctx context.Context, accountID uuid.UUID) (bool, error) {
ids, err := s.Identities(ctx, accountID)
if err != nil {
return false, err
}
for _, id := range ids {
if id.Kind == "email" && id.Confirmed {
return true, nil
}
}
return false, nil
}
// ListAccounts returns accounts for the admin user list, newest first, paginated
// by limit and offset.
func (s *Store) ListAccounts(ctx context.Context, limit, offset int) ([]Account, error) {
-15
View File
@@ -14,7 +14,6 @@ import (
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/postgres"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/robot"
"scrabble/backend/internal/telemetry"
)
@@ -65,9 +64,6 @@ type Config struct {
// RendererURL is the base URL of the internal image-render sidecar (e.g.
// http://renderer:8090). Empty disables the PNG export artifact.
RendererURL string
// Robokassa configures the direct-rail (RUB) payment provider. An empty MerchantLogin
// leaves the direct order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
}
// Defaults applied when the corresponding environment variable is unset.
@@ -157,13 +153,6 @@ func Load() (Config, error) {
AdminTo: os.Getenv("BACKEND_ADMIN_EMAIL"),
}
robo := robokassa.Config{
MerchantLogin: os.Getenv("BACKEND_ROBOKASSA_MERCHANT_LOGIN"),
Password1: os.Getenv("BACKEND_ROBOKASSA_PASSWORD1"),
Password2: os.Getenv("BACKEND_ROBOKASSA_PASSWORD2"),
IsTest: os.Getenv("BACKEND_ROBOKASSA_TEST") == "1",
}
c := Config{
HTTPAddr: envOr("BACKEND_HTTP_ADDR", defaultHTTPAddr),
GRPCAddr: envOr("BACKEND_GRPC_ADDR", defaultGRPCAddr),
@@ -181,7 +170,6 @@ func Load() (Config, error) {
GuestRetention: guestRetention,
ExportSignKey: os.Getenv("BACKEND_EXPORT_SIGN_KEY"),
RendererURL: os.Getenv("BACKEND_RENDERER_URL"),
Robokassa: robo,
}
if err := c.validate(); err != nil {
return Config{}, err
@@ -234,9 +222,6 @@ func (c Config) validate() error {
return fmt.Errorf("config: BACKEND_PUBLIC_BASE_URL %q must be an absolute URL (scheme://host)", c.PublicBaseURL)
}
}
if c.Robokassa.MerchantLogin != "" && (c.Robokassa.Password1 == "" || c.Robokassa.Password2 == "") {
return fmt.Errorf("config: BACKEND_ROBOKASSA_PASSWORD1 and BACKEND_ROBOKASSA_PASSWORD2 must be set when BACKEND_ROBOKASSA_MERCHANT_LOGIN is")
}
return nil
}
@@ -1,328 +0,0 @@
//go:build integration
package inttest
import (
"context"
"errors"
"testing"
"github.com/google/uuid"
"scrabble/backend/internal/payments"
)
// orderStatus reads an order's status.
func orderStatus(t *testing.T, orderID uuid.UUID) string {
t.Helper()
var status string
if err := testDB.QueryRowContext(context.Background(),
`SELECT status FROM payments.orders WHERE order_id=$1`, orderID).Scan(&status); err != nil {
t.Fatalf("read order status: %v", err)
}
return status
}
// TestPaymentsOrderFundCreditsOnce verifies the intake path over Postgres: creating an order then
// funding it credits the funded segment exactly once, and a replayed callback (the same order)
// credits nothing more — the ledger idempotency index holds.
func TestPaymentsOrderFundCreditsOnce(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900}) // 149.00 RUB funds 100 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
if res.Amount.Minor() != 14900 || res.Amount.Currency() != payments.CurrencyRUB {
t.Fatalf("order amount = %s, want 149.00 RUB", res.Amount)
}
if orderStatus(t, res.OrderID) != "pending" {
t.Errorf("new order status = %s, want pending", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 || out.Source != payments.SourceDirect {
t.Fatalf("fund outcome = %+v, want 100 chips to direct, not already-credited", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after fund = %d, want 100", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Errorf("fund ledger rows = %d, want 1", ledgerRows(t, acc, "fund"))
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status after fund = %s, want paid", orderStatus(t, res.OrderID))
}
// A replayed callback for the same order is rejected by the unique index: no second credit.
out2, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("duplicate fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance after duplicate = %d, want 100 (credited once)", got)
}
if ledgerRows(t, acc, "fund") != 1 {
t.Error("duplicate callback wrote a second fund ledger row")
}
}
// TestPaymentsVKOrderFundCredits exercises the VK Votes rail over Postgres: a VK order prices the
// pack in votes; the get_item lookup returns its title and vote price; a chargeable
// order_status_change credits the vk segment exactly once (idempotent on VK's own order id).
func TestPaymentsVKOrderFundCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 200, methodPrice{method: "vk", currency: "VOTE", amount: 30})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("vk", "web"), []payments.Source{payments.SourceVK}, prod, "vk")
if err != nil {
t.Fatalf("create vk order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyVote || res.Amount.Minor() != 30 {
t.Fatalf("vk order amount = %s, want 30 VOTE", res.Amount)
}
// get_item phase: title + vote price.
title, amount, err := svc.OrderItem(ctx, res.OrderID)
if err != nil {
t.Fatalf("order item: %v", err)
}
if title == "" || amount.Minor() != 30 || amount.Currency() != payments.CurrencyVote {
t.Errorf("order item = %q / %s, want a title + 30 VOTE", title, amount)
}
// order_status_change phase: VK's own order id is the idempotency key.
paid, _ := payments.MoneyFromMinor(30, payments.CurrencyVote)
out, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("vk fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 200 || out.Source != payments.SourceVK {
t.Fatalf("vk fund outcome = %+v, want 200 chips credited to vk", out)
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after fund = %d, want 200", got)
}
// A duplicate VK callback (same VK order id) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "vk", "vk-order-777", paid)
if err != nil {
t.Fatalf("duplicate vk fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("duplicate VK callback not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "vk"); got != 200 {
t.Errorf("vk balance after duplicate = %d, want 200 (credited once)", got)
}
}
// TestPaymentsTelegramStarsRail exercises the Telegram Stars rail over Postgres: an order prices the
// pack in whole stars (XTR); a pre_checkout on the pending order is approved; the forwarded payment
// credits the telegram segment exactly once (idempotent on the Telegram charge id); and a
// pre_checkout after the order is paid is declined (a reusable invoice link paid twice).
func TestPaymentsTelegramStarsRail(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 50, methodPrice{method: "telegram", currency: "XTR", amount: 40}) // 40 stars fund 50 chips
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
if res.Amount.Currency() != payments.CurrencyStar || res.Amount.Minor() != 40 {
t.Fatalf("telegram order amount = %s, want 40 XTR", res.Amount)
}
// pre_checkout on the pending order: approved, and it reports the account for reason localisation.
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout: %v", err)
}
if !pc.OK || pc.AccountID != acc {
t.Fatalf("pre_checkout = %+v, want approved for account %s", pc, acc)
}
// The forwarded successful_payment credits once, idempotent on the Telegram charge id.
out, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("telegram fund: %v", err)
}
if out.AlreadyCredited || out.Chips != 50 || out.Source != payments.SourceTelegram {
t.Fatalf("telegram fund outcome = %+v, want 50 chips credited to telegram", out)
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after fund = %d, want 50", got)
}
// A retried forward (same charge id, e.g. a lost ack) credits nothing more.
out2, err := svc.Fund(ctx, res.OrderID, "telegram", "tg-charge-1", starAmt)
if err != nil {
t.Fatalf("duplicate telegram fund: %v", err)
}
if !out2.AlreadyCredited {
t.Error("retried forward not flagged AlreadyCredited")
}
if got := readBalance(t, acc, "telegram"); got != 50 {
t.Errorf("telegram balance after retry = %d, want 50 (credited once)", got)
}
// pre_checkout after the order is paid: declined (the reusable-link double-pay guard).
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, starAmt)
if err != nil {
t.Fatalf("pre_checkout after paid: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutAlreadyPaid {
t.Errorf("pre_checkout after paid = %+v, want a decline with already_paid", pc2)
}
}
// TestPaymentsTelegramPreCheckoutDeclines covers the pre_checkout decline reasons: an unknown order
// and an amount that no longer matches.
func TestPaymentsTelegramPreCheckoutDeclines(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
starAmt, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
// An unknown order id declines as gone, with no account to localise against.
pc, err := svc.ValidatePreCheckout(ctx, uuid.New(), starAmt)
if err != nil {
t.Fatalf("pre_checkout unknown: %v", err)
}
if pc.OK || pc.Reason != payments.PreCheckoutGone || pc.AccountID != (uuid.UUID{}) {
t.Errorf("pre_checkout unknown = %+v, want a gone decline with no account", pc)
}
// A pending order validated at the wrong amount declines as price-changed.
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "telegram", currency: "XTR", amount: 80})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("telegram", "android"), []payments.Source{payments.SourceTelegram}, prod, "telegram")
if err != nil {
t.Fatalf("create telegram order: %v", err)
}
wrong, _ := payments.MoneyFromMinor(40, payments.CurrencyStar)
pc2, err := svc.ValidatePreCheckout(ctx, res.OrderID, wrong)
if err != nil {
t.Fatalf("pre_checkout mismatch: %v", err)
}
if pc2.OK || pc2.Reason != payments.PreCheckoutPriceChanged {
t.Errorf("pre_checkout mismatch = %+v, want a price_changed decline", pc2)
}
}
// TestPaymentsFundAmountMismatch verifies a callback whose paid amount does not match the order is
// refused and credits nothing (§9: verify the amount after matching by order id).
func TestPaymentsFundAmountMismatch(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
underpaid, _ := payments.MoneyFromMinor(100, payments.CurrencyRUB)
if _, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), underpaid); !errors.Is(err, payments.ErrAmountMismatch) {
t.Fatalf("fund = %v, want ErrAmountMismatch", err)
}
if got := readBalance(t, acc, "direct"); got != 0 {
t.Errorf("balance = %d, want 0 (nothing credited on mismatch)", got)
}
if ledgerRows(t, acc, "fund") != 0 {
t.Error("fund ledger row written on an amount mismatch")
}
}
// TestPaymentsEventDispatchDrain verifies the payment_events dispatcher queue: a recorded event is
// returned as undispatched until marked, then drops out (so the dispatcher delivers it once).
func TestPaymentsEventDispatchDrain(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
if err := svc.RecordPaymentEvent(ctx, acc, nil, "succeeded", []byte(`{"chips":10,"source":"direct"}`)); err != nil {
t.Fatalf("record event: %v", err)
}
// testDB is shared, so filter the queue to our account.
find := func() *payments.PaymentEvent {
evs, err := svc.UndispatchedEvents(ctx, 100)
if err != nil {
t.Fatalf("undispatched: %v", err)
}
for i := range evs {
if evs[i].AccountID == acc {
return &evs[i]
}
}
return nil
}
mine := find()
if mine == nil {
t.Fatal("recorded event not in the undispatched queue")
}
if mine.Type != "succeeded" {
t.Errorf("event type = %s, want succeeded", mine.Type)
}
if err := svc.MarkEventDispatched(ctx, mine.EventID); err != nil {
t.Fatalf("mark dispatched: %v", err)
}
if find() != nil {
t.Error("event still undispatched after MarkEventDispatched")
}
}
// TestPaymentsExpiredOrderStillCredits verifies an expired pending order is still honoured by a
// later valid callback (§9/D23: expiry is cosmetic, the money is real).
func TestPaymentsExpiredOrderStillCredits(t *testing.T) {
ctx := context.Background()
svc := newPaymentsService()
acc := uuid.New()
prod := seedPackProduct(t, 100, methodPrice{method: "direct", currency: "RUB", amount: 14900})
res, err := svc.CreateOrder(ctx, acc, payments.NewContext("direct", "web"), []payments.Source{payments.SourceDirect}, prod, "robokassa")
if err != nil {
t.Fatalf("create order: %v", err)
}
// Age the order well past the configured TTL, then sweep it to expired.
if _, err := testDB.ExecContext(ctx,
`UPDATE payments.orders SET created_at = now() - interval '1 day' WHERE order_id=$1`, res.OrderID); err != nil {
t.Fatalf("age order: %v", err)
}
if n, err := svc.ExpireOrders(ctx); err != nil || n < 1 {
t.Fatalf("expire orders = %d (err %v), want at least 1", n, err)
}
if orderStatus(t, res.OrderID) != "expired" {
t.Fatalf("order status = %s, want expired before the late callback", orderStatus(t, res.OrderID))
}
paid, _ := payments.MoneyFromMinor(14900, payments.CurrencyRUB)
out, err := svc.Fund(ctx, res.OrderID, "robokassa", res.OrderID.String(), paid)
if err != nil {
t.Fatalf("fund after expiry: %v", err)
}
if out.AlreadyCredited || out.Chips != 100 {
t.Fatalf("fund outcome = %+v, want a fresh 100-chip credit", out)
}
if got := readBalance(t, acc, "direct"); got != 100 {
t.Errorf("balance = %d, want 100 (expired order honoured)", got)
}
if orderStatus(t, res.OrderID) != "paid" {
t.Errorf("order status = %s, want paid after the honoured callback", orderStatus(t, res.OrderID))
}
}
-4
View File
@@ -73,10 +73,6 @@ const (
// (e.g. an email was confirmed via the one-tap deeplink opened in another browser),
// so it re-fetches profile.get. It carries no payload. In-app only.
NotifyProfile = "profile"
// NotifyPayment tells the client that the viewer's wallet changed from a payment-intake event
// (a credit or a refund), so it re-fetches the wallet in place. It carries no payload. In-app
// only; the payment_events dispatcher emits it after the crediting transaction commits.
NotifyPayment = "payment"
// NotifyUserBlocked confirms to the blocker that a per-user block took effect,
// carrying the blocked account, so every one of the blocker's sessions updates the
// in-game block/add-friend controls and the struck name in place. It is delivered
+5 -11
View File
@@ -147,13 +147,12 @@ func (m Money) Cmp(o Money) (int, error) {
}
}
// Major renders the amount as a decimal string without the currency, with the currency's
// fractional digits and no floating point (e.g. "149.50", "250") — the form a provider's amount
// field (Robokassa OutSum) takes.
func (m Money) Major() string {
// String renders the amount as "<value> <currency>", with the currency's
// fractional digits and no floating point (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
scale := m.currency.minorPerUnit()
if scale == 1 {
return fmt.Sprintf("%d", m.minor)
return fmt.Sprintf("%d %s", m.minor, m.currency)
}
neg := m.minor < 0
abs := m.minor
@@ -168,10 +167,5 @@ func (m Money) Major() string {
if neg {
sign = "-"
}
return fmt.Sprintf("%s%d.%0*d", sign, abs/scale, width, abs%scale)
}
// String renders the amount as "<value> <currency>" (e.g. "149.50 RUB", "250 XTR").
func (m Money) String() string {
return m.Major() + " " + string(m.currency)
return fmt.Sprintf("%s%d.%0*d %s", sign, abs/scale, width, abs%scale, m.currency)
}
-153
View File
@@ -1,153 +0,0 @@
package payments
import (
"context"
"errors"
"fmt"
"github.com/google/uuid"
)
// OrderResult is what CreateOrder returns to the transport: the created order id and the details a
// provider launch payload needs — the amount to charge and a human title for the payment.
type OrderResult struct {
OrderID uuid.UUID
Amount Money
Title string
}
// CreateOrder opens a pending order to fund a chip pack in the execution context's payment method,
// tagged with the provider that will settle it. It gate-checks the context (trusted, not the
// VK-iOS spend freeze) and that the method's funding segment is attached, prices the pack in the
// method's currency, then writes the order. The caller enforces any account-level precondition
// (e.g. the direct email anchor, D36) before calling — payments holds no cross-schema identity
// knowledge.
func (s *Service) CreateOrder(ctx context.Context, accountID uuid.UUID, cxt Context, present []Source, productID uuid.UUID, provider string) (OrderResult, error) {
if !cxt.Trusted() || cxt.vkFrozen() {
return OrderResult{}, ErrUntrusted
}
method := cxt.Kind
if !has(present, method) {
return OrderResult{}, ErrUntrusted // the funding segment is not attached to the account
}
pack, err := s.store.loadPackForOrder(ctx, productID, method)
if err != nil {
return OrderResult{}, err
}
orderID, err := uuid.NewV7()
if err != nil {
return OrderResult{}, fmt.Errorf("payments: order id: %w", err)
}
o := newOrder{
orderID: orderID,
accountID: accountID,
platform: string(method),
productID: productID,
amount: pack.price,
origin: method,
provider: provider,
}
if err := s.store.createOrder(ctx, o, s.clock()); err != nil {
return OrderResult{}, err
}
return OrderResult{OrderID: orderID, Amount: pack.price, Title: pack.title}, nil
}
// OrderItem returns a pending order's human title and the amount it charges, in the order's own
// currency — the details a provider's item-lookup phase needs (VK's get_item). It reads the order
// and the pack title, honouring the pack even if it was later deactivated (mirrors Fund).
func (s *Service) OrderItem(ctx context.Context, orderID uuid.UUID) (title string, amount Money, err error) {
ord, err := s.store.orderByID(ctx, orderID)
if err != nil {
return "", Money{}, err
}
_, title, err = s.store.packForCredit(ctx, ord.productID)
if err != nil {
return "", Money{}, err
}
amount, err = MoneyFromMinor(ord.expectedAmount, Currency(ord.currency))
if err != nil {
return "", Money{}, err
}
return title, amount, nil
}
// Fund credits a paid order into its funded segment exactly once, from a verified provider callback
// — the single writer for every rail. It matches the order, verifies the paid amount, appends the
// fund ledger row (idempotent on (provider, provider_payment_id)), credits the balance and marks
// the order paid. A duplicate callback returns AlreadyCredited without a second credit; a valid
// callback is honoured even on an expired order (§9/D23).
func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money) (FundOutcome, error) {
return s.store.fund(ctx, orderID, provider, providerPaymentID, paid, s.clock())
}
// Pre-checkout decline reason codes. They are language-neutral: the transport layer localises them
// to the order account's preferred language before showing the payer (the reason is displayed in the
// Telegram payment sheet).
const (
// PreCheckoutGone means no order matches — an unknown or stale invoice payload.
PreCheckoutGone = "order_gone"
// PreCheckoutAlreadyPaid means the order is already paid (a reusable invoice link paid twice).
PreCheckoutAlreadyPaid = "already_paid"
// PreCheckoutPriceChanged means the amount or currency no longer matches the order.
PreCheckoutPriceChanged = "price_changed"
)
// PreCheckoutOutcome is the pre-charge validation of a Telegram Stars order. OK approves the charge;
// otherwise Reason is a decline reason code the transport localises. AccountID is the order's account
// (for localising the reason to its preferred language); it is the zero UUID when the order is unknown.
type PreCheckoutOutcome struct {
OK bool
Reason string
AccountID uuid.UUID
}
// ValidatePreCheckout answers whether a Stars pre_checkout_query for orderID paying amount may be
// approved, before any star is charged. It approves an order that exists, is not already paid (a
// reusable invoice link paid a second time is refused here) and whose expected amount and currency
// match the invoice. A pending or honoured-expired order is approved — a late credit is honoured
// (§9/D23). A missing order or a mismatch is a clean decline with a reason code, not an error.
func (s *Service) ValidatePreCheckout(ctx context.Context, orderID uuid.UUID, amount Money) (PreCheckoutOutcome, error) {
ord, err := s.store.orderByID(ctx, orderID)
if errors.Is(err, ErrOrderNotFound) {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutGone}, nil
}
if err != nil {
return PreCheckoutOutcome{}, err
}
if ord.status == "paid" {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutAlreadyPaid, AccountID: ord.accountID}, nil
}
if amount.Currency() != Currency(ord.currency) || amount.Minor() != ord.expectedAmount {
return PreCheckoutOutcome{OK: false, Reason: PreCheckoutPriceChanged, AccountID: ord.accountID}, nil
}
return PreCheckoutOutcome{OK: true, AccountID: ord.accountID}, nil
}
// ExpireOrders marks pending orders older than the configured lifetime as expired, returning how
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
// still credits — see Fund).
func (s *Service) ExpireOrders(ctx context.Context) (int, error) {
ttl, err := s.store.orderTTL(ctx)
if err != nil {
return 0, err
}
return s.store.expirePending(ctx, ttl, s.clock())
}
// RecordPaymentEvent appends a payment lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver to the user (live stream, botlink or email).
func (s *Service) RecordPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte) error {
return s.store.insertPaymentEvent(ctx, accountID, orderID, eventType, payload, s.clock())
}
// UndispatchedEvents returns up to limit payment events awaiting delivery. The dispatcher drains
// them and marks each delivered via MarkEventDispatched.
func (s *Service) UndispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
return s.store.undispatchedEvents(ctx, limit)
}
// MarkEventDispatched stamps a payment event as delivered so it is not re-sent.
func (s *Service) MarkEventDispatched(ctx context.Context, eventID uuid.UUID) error {
return s.store.markEventDispatched(ctx, eventID, s.clock())
}
@@ -1,42 +0,0 @@
package payments
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
)
// gateOnlyService builds a Service with a fixed clock and no store, usable only for the CreateOrder
// gate rejections that return before any store access.
func gateOnlyService() *Service {
return &Service{clock: func() time.Time { return time.Unix(0, 0).UTC() }}
}
// TestCreateOrderGateRejections checks that CreateOrder fails closed — before touching the store —
// on an untrusted platform, the VK-iOS spend freeze, and a method whose funding segment the account
// does not hold.
func TestCreateOrderGateRejections(t *testing.T) {
ctx := context.Background()
acc, prod := uuid.New(), uuid.New()
present := []Source{SourceDirect, SourceVK}
cases := []struct {
name string
cxt Context
}{
{"untrusted context", Context{}},
{"vk-ios spend freeze", Context{Kind: SourceVK, Subtype: SubtypeIOS}},
{"method segment not attached", Context{Kind: SourceTelegram}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := gateOnlyService().CreateOrder(ctx, acc, tc.cxt, present, prod, "robokassa")
if !errors.Is(err, ErrUntrusted) {
t.Fatalf("CreateOrder(%s) = %v, want ErrUntrusted", tc.name, err)
}
})
}
}
-381
View File
@@ -1,381 +0,0 @@
package payments
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"scrabble/backend/internal/postgres/jet/payments/model"
"scrabble/backend/internal/postgres/jet/payments/table"
)
// Intake errors surfaced by the order-flow and external-credit (fund) path.
var (
// ErrNotAPack means the product is not a fundable chip pack in the requested method: it
// carries no chips atom, or no price for that payment method.
ErrNotAPack = errors.New("payments: product is not a chip pack for this method")
// ErrOrderNotFound means no order matches the id (an unknown or forged callback reference).
ErrOrderNotFound = errors.New("payments: order not found")
// ErrAmountMismatch means the callback's paid amount or currency does not match the order's
// expected amount — the credit is refused (§9: verify amount after matching by order id).
ErrAmountMismatch = errors.New("payments: paid amount does not match the order")
)
// errAlreadyCredited is the internal sentinel that unwinds the fund transaction when the ledger's
// (provider, provider_payment_id) unique index rejects a duplicate callback. It is not surfaced:
// a replayed callback is a success that credits nothing.
var errAlreadyCredited = errors.New("payments: already credited")
// packInfo is a chip pack resolved for an order: the product, the chips it funds and its price in
// the requested payment method's currency.
type packInfo struct {
productID uuid.UUID
title string
chips int
price Money
}
// packChips returns the quantity of the chips atom a product carries, or 0 if it has none (which
// marks it as a value, not a fundable pack).
func (s *Store) packChips(ctx context.Context, productID uuid.UUID) (int, error) {
var item model.ProductItem
err := postgres.SELECT(table.ProductItem.AllColumns).
FROM(table.ProductItem).
WHERE(table.ProductItem.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductItem.AtomType.EQ(postgres.String(atomChips)))).
LIMIT(1).
QueryContext(ctx, s.db, &item)
if errors.Is(err, qrm.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, fmt.Errorf("payments: load pack chips %s: %w", productID, err)
}
return int(item.Quantity), nil
}
// loadPackForOrder resolves an active chip pack for a new order in the given payment method: an
// active product carrying a chips atom and a price row for the method (its currency and amount are
// the order's expected amount). It rejects a missing/deactivated product (ErrProductNotFound) and
// a product that is not a pack for the method (ErrNotAPack).
func (s *Store) loadPackForOrder(ctx context.Context, productID uuid.UUID, method Source) (packInfo, error) {
var p model.Product
err := postgres.SELECT(table.Product.AllColumns).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(err, qrm.ErrNoRows) || (err == nil && !p.Active) {
return packInfo{}, ErrProductNotFound
}
if err != nil {
return packInfo{}, fmt.Errorf("payments: load product %s: %w", productID, err)
}
chips, err := s.packChips(ctx, productID)
if err != nil {
return packInfo{}, err
}
if chips <= 0 {
return packInfo{}, ErrNotAPack
}
var price model.ProductPrice
err = postgres.SELECT(table.ProductPrice.AllColumns).
FROM(table.ProductPrice).
WHERE(table.ProductPrice.ProductID.EQ(postgres.UUID(productID)).
AND(table.ProductPrice.Method.EQ(postgres.String(string(method))))).
LIMIT(1).
QueryContext(ctx, s.db, &price)
if errors.Is(err, qrm.ErrNoRows) {
return packInfo{}, ErrNotAPack
}
if err != nil {
return packInfo{}, fmt.Errorf("payments: load pack price %s: %w", productID, err)
}
money, err := MoneyFromMinor(price.Amount, Currency(price.Currency))
if err != nil {
return packInfo{}, err
}
return packInfo{productID: productID, title: p.Title, chips: chips, price: money}, nil
}
// packForCredit resolves the chips and title of an ordered pack at credit time, ignoring the
// product's active flag: the money is real, so an order is honoured even if the pack was
// deactivated after it was placed (§9/D23).
func (s *Store) packForCredit(ctx context.Context, productID uuid.UUID) (chips int, title string, err error) {
var p model.Product
e := postgres.SELECT(table.Product.Title).
FROM(table.Product).
WHERE(table.Product.ProductID.EQ(postgres.UUID(productID))).
LIMIT(1).
QueryContext(ctx, s.db, &p)
if errors.Is(e, qrm.ErrNoRows) {
return 0, "", ErrProductNotFound
}
if e != nil {
return 0, "", fmt.Errorf("payments: load product %s: %w", productID, e)
}
chips, err = s.packChips(ctx, productID)
if err != nil {
return 0, "", err
}
if chips <= 0 {
return 0, "", ErrNotAPack
}
return chips, p.Title, nil
}
// newOrder is the intent a CreateOrder writes: a pending order for a pack, priced in the method's
// currency, tagged with the provider that will settle it.
type newOrder struct {
orderID uuid.UUID
accountID uuid.UUID
platform string
productID uuid.UUID
amount Money
origin Source
provider string
}
// createOrder inserts a pending order.
func (s *Store) createOrder(ctx context.Context, o newOrder, now time.Time) error {
stmt := table.Orders.INSERT(
table.Orders.OrderID, table.Orders.AccountID, table.Orders.Platform,
table.Orders.ProductID, table.Orders.ExpectedAmount, table.Orders.Currency,
table.Orders.Origin, table.Orders.Status, table.Orders.Provider,
table.Orders.CreatedAt, table.Orders.UpdatedAt,
).VALUES(
o.orderID, o.accountID, o.platform,
o.productID, o.amount.Minor(), string(o.amount.Currency()),
string(o.origin), "pending", o.provider,
now, now,
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: create order: %w", err)
}
return nil
}
// orderRow is a stored order read back for the intake path.
type orderRow struct {
orderID uuid.UUID
accountID uuid.UUID
productID uuid.UUID
expectedAmount int64
currency string
origin string
status string
}
// orderByID reads an order, or ErrOrderNotFound.
func (s *Store) orderByID(ctx context.Context, orderID uuid.UUID) (orderRow, error) {
var o model.Orders
err := postgres.SELECT(table.Orders.AllColumns).
FROM(table.Orders).
WHERE(table.Orders.OrderID.EQ(postgres.UUID(orderID))).
LIMIT(1).
QueryContext(ctx, s.db, &o)
if errors.Is(err, qrm.ErrNoRows) {
return orderRow{}, ErrOrderNotFound
}
if err != nil {
return orderRow{}, fmt.Errorf("payments: load order %s: %w", orderID, err)
}
return orderRow{
orderID: o.OrderID,
accountID: o.AccountID,
productID: o.ProductID,
expectedAmount: o.ExpectedAmount,
currency: o.Currency,
origin: o.Origin,
status: o.Status,
}, nil
}
// FundOutcome reports the result of an intake credit: whose balance, which segment and how many
// chips were credited, and whether the callback was a duplicate that credited nothing.
type FundOutcome struct {
AccountID uuid.UUID
Source Source
Chips int
AlreadyCredited bool
}
// fund credits a paid order exactly once: it matches the order, verifies the amount, then in one
// transaction appends a fund ledger row (idempotent on the (provider, provider_payment_id) unique
// index), credits the funded segment's balance and marks the order paid. A duplicate callback is
// rejected by the unique index and returns AlreadyCredited with no error and no second credit. A
// valid callback is honoured even on an expired order (§9/D23). The read cache is invalidated after
// the commit, since the credit runs outside any request the payments package owns.
func (s *Store) fund(ctx context.Context, orderID uuid.UUID, provider, providerPaymentID string, paid Money, now time.Time) (FundOutcome, error) {
ord, err := s.orderByID(ctx, orderID)
if err != nil {
return FundOutcome{}, err
}
if paid.Currency() != Currency(ord.currency) || paid.Minor() != ord.expectedAmount {
return FundOutcome{}, ErrAmountMismatch
}
chips, title, err := s.packForCredit(ctx, ord.productID)
if err != nil {
return FundOutcome{}, err
}
snapshot, err := marshalFundSnapshot(ord.productID, title, chips, paid)
if err != nil {
return FundOutcome{}, err
}
src := Source(ord.origin)
outcome := FundOutcome{AccountID: ord.accountID, Source: src, Chips: chips}
pv, pp := provider, providerPaymentID
productID := ord.productID
err = withTx(ctx, s.db, func(tx *sql.Tx) error {
if e := insertLedgerTx(ctx, tx, ord.accountID, "fund", &src, &src, chips, &productID, &orderID, &pv, &pp, snapshot, now); e != nil {
if isUniqueViolation(e) {
outcome.AlreadyCredited = true
return errAlreadyCredited
}
return e
}
if _, e := tx.ExecContext(ctx,
`INSERT INTO payments.balances (account_id, source, chips, updated_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (account_id, source) DO UPDATE
SET chips = payments.balances.chips + EXCLUDED.chips, updated_at = now()`,
ord.accountID, string(src), chips); e != nil {
return fmt.Errorf("payments: credit balance %s: %w", src, e)
}
if _, e := tx.ExecContext(ctx,
`UPDATE payments.orders SET status = 'paid', provider = $2, provider_payment_id = $3, updated_at = now()
WHERE order_id = $1`,
orderID, provider, providerPaymentID); e != nil {
return fmt.Errorf("payments: mark order paid: %w", e)
}
return nil
})
if err != nil {
if errors.Is(err, errAlreadyCredited) {
return outcome, nil
}
return FundOutcome{}, err
}
s.cache.invalidate(ord.accountID)
return outcome, nil
}
// insertPaymentEvent appends an undispatched lifecycle event (succeeded/failed/refunded) for the
// dispatcher to deliver. orderID and payload (a jsonb detail blob) are optional.
func (s *Store) insertPaymentEvent(ctx context.Context, accountID uuid.UUID, orderID *uuid.UUID, eventType string, payload []byte, now time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("payments: event id: %w", err)
}
var pl any = postgres.NULL
if payload != nil {
pl = string(payload)
}
stmt := table.PaymentEvents.INSERT(
table.PaymentEvents.EventID, table.PaymentEvents.AccountID, table.PaymentEvents.OrderID,
table.PaymentEvents.Type, table.PaymentEvents.Payload, table.PaymentEvents.CreatedAt,
).VALUES(id, accountID, uuidOrNull(orderID), eventType, pl, now)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("payments: insert %s event: %w", eventType, err)
}
return nil
}
// PaymentEvent is an undispatched lifecycle event the dispatcher delivers to an account.
type PaymentEvent struct {
EventID uuid.UUID
AccountID uuid.UUID
Type string
}
// undispatchedEvents reads up to limit payment events not yet delivered, oldest first.
func (s *Store) undispatchedEvents(ctx context.Context, limit int) ([]PaymentEvent, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT event_id, account_id, type FROM payments.payment_events
WHERE dispatched_at IS NULL ORDER BY created_at LIMIT $1`, limit)
if err != nil {
return nil, fmt.Errorf("payments: read undispatched events: %w", err)
}
defer rows.Close()
var out []PaymentEvent
for rows.Next() {
var e PaymentEvent
if err := rows.Scan(&e.EventID, &e.AccountID, &e.Type); err != nil {
return nil, fmt.Errorf("payments: scan event: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// markEventDispatched stamps an event as delivered so it is not re-sent.
func (s *Store) markEventDispatched(ctx context.Context, eventID uuid.UUID, now time.Time) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE payments.payment_events SET dispatched_at = $2 WHERE event_id = $1`, eventID, now); err != nil {
return fmt.Errorf("payments: mark event dispatched: %w", err)
}
return nil
}
// orderTTL reads the configured pending-order lifetime in whole seconds.
func (s *Store) orderTTL(ctx context.Context) (int, error) {
var cfg model.Config
if err := postgres.SELECT(table.Config.OrderTTLSeconds).
FROM(table.Config).
LIMIT(1).
QueryContext(ctx, s.db, &cfg); err != nil {
return 0, fmt.Errorf("payments: read order ttl: %w", err)
}
return int(cfg.OrderTTLSeconds), nil
}
// expirePending marks every pending order older than ttlSeconds as expired, returning how many.
// Expiry is cosmetic DB hygiene: a later valid callback still credits an expired order (§9/D23).
func (s *Store) expirePending(ctx context.Context, ttlSeconds int, now time.Time) (int, error) {
cutoff := now.Add(-time.Duration(ttlSeconds) * time.Second)
res, err := table.Orders.
UPDATE(table.Orders.Status, table.Orders.UpdatedAt).
SET(postgres.String("expired"), postgres.TimestampzT(now)).
WHERE(table.Orders.Status.EQ(postgres.String("pending")).
AND(table.Orders.CreatedAt.LT(postgres.TimestampzT(cutoff)))).
ExecContext(ctx, s.db)
if err != nil {
return 0, fmt.Errorf("payments: expire pending orders: %w", err)
}
n, _ := res.RowsAffected()
return int(n), nil
}
// marshalFundSnapshot records what a fund credited (the pack, chips and paid amount) on the ledger
// row, so history stays independent of later catalog edits (§7/D34).
func marshalFundSnapshot(productID uuid.UUID, title string, chips int, paid Money) ([]byte, error) {
b, err := json.Marshal(struct {
ProductID string `json:"product_id"`
Title string `json:"title,omitempty"`
Chips int `json:"chips"`
Amount int64 `json:"amount_minor"`
Currency string `json:"currency"`
}{productID.String(), title, chips, paid.Minor(), string(paid.Currency())})
if err != nil {
return nil, fmt.Errorf("payments: marshal fund snapshot: %w", err)
}
return b, nil
}
// isUniqueViolation reports whether err is a PostgreSQL unique-constraint violation (SQLSTATE
// 23505) — here, a duplicate provider callback hitting the ledger idempotency index.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+6 -19
View File
@@ -230,11 +230,8 @@ func applyBenefitTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, origin
return nil
}
// insertLedgerTx appends one append-only ledger row inside tx. orderID, provider and
// providerPaymentID are set only on an intake credit (fund/refund) and are nil for a
// spend/admin_grant; a non-nil (provider, providerPaymentID) pair is guarded by the partial
// unique index, so a duplicate provider callback fails here (the idempotency key).
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID, orderID *uuid.UUID, provider, providerPaymentID *string, snapshot []byte, now time.Time) error {
// insertLedgerTx appends one append-only ledger row inside tx.
func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind string, source, origin *Source, chipsDelta int, productID *uuid.UUID, snapshot []byte, now time.Time) error {
id, err := uuid.NewV7()
if err != nil {
return fmt.Errorf("payments: ledger id: %w", err)
@@ -249,13 +246,11 @@ func insertLedgerTx(ctx context.Context, tx *sql.Tx, accountID uuid.UUID, kind s
stmt := table.Ledger.INSERT(
table.Ledger.LedgerID, table.Ledger.AccountID, table.Ledger.Kind,
table.Ledger.Source, table.Ledger.Origin, table.Ledger.ChipsDelta,
table.Ledger.ProductID, table.Ledger.OrderID, table.Ledger.Provider,
table.Ledger.ProviderPaymentID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
table.Ledger.ProductID, table.Ledger.Snapshot, table.Ledger.CreatedAt,
).VALUES(
id, accountID, kind,
sourceOrNull(source), sourceOrNull(origin), int32(chipsDelta),
uuidOrNull(productID), uuidOrNull(orderID), stringOrNull(provider),
stringOrNull(providerPaymentID), snap, now,
uuidOrNull(productID), snap, now,
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("payments: insert %s ledger: %w", kind, err)
@@ -283,7 +278,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
return ErrInsufficientChips
}
src := dr.source
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, nil, nil, nil, snapshot, now); err != nil {
if err := insertLedgerTx(ctx, tx, accountID, "spend", &src, &origin, -dr.amount, &productID, snapshot, now); err != nil {
return err
}
}
@@ -300,7 +295,7 @@ func (s *Store) spend(ctx context.Context, accountID uuid.UUID, draws []sourceAm
// the chosen origin, in one transaction — a zero-price sale of a value.
func (s *Store) grant(ctx context.Context, accountID uuid.UUID, origin Source, d benefitDelta, snapshot []byte, now time.Time) error {
err := withTx(ctx, s.db, func(tx *sql.Tx) error {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, nil, nil, nil, snapshot, now); err != nil {
if err := insertLedgerTx(ctx, tx, accountID, "admin_grant", nil, &origin, 0, nil, snapshot, now); err != nil {
return err
}
return applyBenefitTx(ctx, tx, accountID, origin, d, now)
@@ -424,11 +419,3 @@ func uuidOrNull(id *uuid.UUID) postgres.Expression {
}
return postgres.UUID(*id)
}
// stringOrNull renders an optional string as a SQL string or NULL.
func stringOrNull(s *string) postgres.Expression {
if s == nil {
return postgres.NULL
}
return postgres.String(*s)
}
-104
View File
@@ -1,104 +0,0 @@
// Package robokassa builds and verifies Robokassa direct-rail (RUB) payments: it forms the signed
// hosted-payment URL a client is sent to, and verifies the Result-URL server callback that credits
// an order. It is pure provider glue — no database, no payments-domain coupling — so the payments
// domain stays provider-agnostic and this layer is unit-testable in isolation.
//
// The order is threaded through Robokassa's custom-parameter channel as Shp_order=<order id> (echoed
// back in the callback and bound into the signature), not the numeric InvId, because an order id is
// a uuid; InvId is sent as 0. Idempotency is therefore keyed on the order id at the credit site.
// Signatures use SHA-256 (configured to match the shop's technical settings); the shop's test mode
// is carried by IsTest.
package robokassa
import (
"crypto/sha256"
"encoding/hex"
"net/url"
"sort"
"strings"
"github.com/google/uuid"
)
// payEndpoint is Robokassa's hosted payment page; the signed query sends the client there.
const payEndpoint = "https://auth.robokassa.ru/Merchant/Index.aspx"
// Config is a Robokassa shop's credentials. Password1 signs the outgoing payment request;
// Password2 signs (and so verifies) the incoming Result callback. IsTest adds IsTest=1 so the shop's
// test mode simulates payments without money movement.
type Config struct {
MerchantLogin string
Password1 string
Password2 string
IsTest bool
}
// PaymentURL builds the signed hosted-payment URL for an order: amount is the OutSum decimal string
// (roubles, e.g. "149.00"), description is the human payment purpose. The order id rides as
// Shp_order and is bound into the SHA-256 signature; InvId is 0 (unused).
func (c Config) PaymentURL(orderID uuid.UUID, amount, description string) string {
q := url.Values{}
q.Set("Shp_order", orderID.String())
// Signature base: MerchantLogin:OutSum:InvId:Password1[:Shp_key=value...sorted].
base := c.MerchantLogin + ":" + amount + ":0:" + c.Password1 + shpSuffix(q)
q.Set("MerchantLogin", c.MerchantLogin)
q.Set("OutSum", amount)
q.Set("InvId", "0")
q.Set("Description", description)
q.Set("SignatureValue", sign(base))
if c.IsTest {
q.Set("IsTest", "1")
}
return payEndpoint + "?" + q.Encode()
}
// VerifyResult verifies a Result-URL callback and extracts the order it credits. It recomputes the
// SHA-256 signature OutSum:InvId:Password2[:Shp_...sorted] over the callback's own fields and
// compares it (case-insensitively) with SignatureValue. On success it returns the order id (from
// Shp_order) and the raw OutSum string the caller re-checks against the order amount; on any
// missing field, a signature mismatch or an unparseable order id it returns ok=false.
func (c Config) VerifyResult(v url.Values) (orderID uuid.UUID, outSum string, ok bool) {
outSum = v.Get("OutSum")
sig := v.Get("SignatureValue")
order := v.Get("Shp_order")
if outSum == "" || sig == "" || order == "" {
return uuid.Nil, "", false
}
// Robokassa signs with the InvId it returns in the callback; recompute with that same value.
base := outSum + ":" + v.Get("InvId") + ":" + c.Password2 + shpSuffix(v)
if !strings.EqualFold(sign(base), sig) {
return uuid.Nil, "", false
}
id, err := uuid.Parse(order)
if err != nil {
return uuid.Nil, "", false
}
return id, outSum, true
}
// shpSuffix renders the Shp_ custom parameters of v as Robokassa binds them into a signature:
// every Shp_-prefixed key, sorted alphabetically, appended as ":key=value".
func shpSuffix(v url.Values) string {
var keys []string
for k := range v {
if strings.HasPrefix(k, "Shp_") {
keys = append(keys, k)
}
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(":")
b.WriteString(k)
b.WriteString("=")
b.WriteString(v.Get(k))
}
return b.String()
}
// sign returns the lowercase hex SHA-256 of s, the hash Robokassa compares against SignatureValue.
func sign(s string) string {
sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:])
}
@@ -1,85 +0,0 @@
package robokassa
import (
"net/url"
"strings"
"testing"
"github.com/google/uuid"
)
func testCfg() Config {
return Config{MerchantLogin: "shop", Password1: "p1", Password2: "p2", IsTest: true}
}
// resultSig computes the Pass2 Result signature Robokassa would send for a callback carrying only
// Shp_order — the fixture the verifier must accept.
func resultSig(pass2, outSum, invID string, orderID uuid.UUID) string {
return sign(outSum + ":" + invID + ":" + pass2 + ":Shp_order=" + orderID.String())
}
func TestPaymentURL(t *testing.T) {
cfg := testCfg()
id := uuid.New()
u, err := url.Parse(cfg.PaymentURL(id, "149.00", "10 chips"))
if err != nil {
t.Fatalf("parse payment url: %v", err)
}
q := u.Query()
if got := q.Get("OutSum"); got != "149.00" {
t.Errorf("OutSum = %q, want 149.00", got)
}
if got := q.Get("InvId"); got != "0" {
t.Errorf("InvId = %q, want 0 (unused)", got)
}
if got := q.Get("Shp_order"); got != id.String() {
t.Errorf("Shp_order = %q, want the order id", got)
}
if got := q.Get("IsTest"); got != "1" {
t.Errorf("IsTest = %q, want 1 (test shop)", got)
}
want := sign("shop:149.00:0:p1:Shp_order=" + id.String())
if got := q.Get("SignatureValue"); got != want {
t.Errorf("SignatureValue = %q, want %q", got, want)
}
}
func TestVerifyResult(t *testing.T) {
cfg := testCfg()
id := uuid.New()
valid := func() url.Values {
v := url.Values{}
v.Set("OutSum", "149.00")
v.Set("InvId", "0")
v.Set("Shp_order", id.String())
// Robokassa sends the hash uppercase; the verifier must be case-insensitive.
v.Set("SignatureValue", strings.ToUpper(resultSig("p2", "149.00", "0", id)))
return v
}
gotID, gotSum, ok := cfg.VerifyResult(valid())
if !ok || gotID != id || gotSum != "149.00" {
t.Fatalf("VerifyResult(valid) = %v/%q/%v, want %v/149.00/true", gotID, gotSum, ok, id)
}
// A tampered amount breaks the signature.
tampered := valid()
tampered.Set("OutSum", "1.00")
if _, _, ok := cfg.VerifyResult(tampered); ok {
t.Error("VerifyResult accepted a tampered amount")
}
// The wrong Password2 (a forged callback) does not verify.
wrongPass := Config{MerchantLogin: "shop", Password1: "p1", Password2: "other"}
if _, _, ok := wrongPass.VerifyResult(valid()); ok {
t.Error("VerifyResult accepted a signature under the wrong password")
}
// A missing Shp_order is rejected (no order to credit).
noOrder := valid()
noOrder.Del("Shp_order")
if _, _, ok := cfg.VerifyResult(noOrder); ok {
t.Error("VerifyResult accepted a callback with no order")
}
}
-17
View File
@@ -73,21 +73,6 @@ func (s *Server) registerRoutes() {
u.GET("/wallet/catalog", s.handleWalletCatalog)
u.POST("/wallet/buy", s.handleWalletBuy)
}
if s.payments != nil {
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
// unsupported or unconfigured rail returns 501 from the handler. The provider callbacks are
// gateway-only (the single writer): the VK payment callback (both phases handled here), and
// the Robokassa Result callback when a merchant is configured.
u.POST("/wallet/order", s.handleWalletOrder)
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
// The Telegram Stars rail: the bot forwards a pre_checkout validation and a completed
// payment over the reverse bot-link; the gateway proxies both onto these gateway-only routes.
s.internal.POST("/payments/telegram/precheckout", s.handleTelegramPreCheckout)
s.internal.POST("/payments/telegram/payment", s.handleTelegramPayment)
if s.robokassa.MerchantLogin != "" {
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
}
}
if s.links != nil {
// Account linking & merge. The request step always mails a code;
// a required merge is revealed only after the code is verified, and the
@@ -281,8 +266,6 @@ func statusForError(err error) (int, string) {
return http.StatusNotFound, "product_not_found"
case errors.Is(err, payments.ErrNotAValue):
return http.StatusBadRequest, "not_a_value"
case errors.Is(err, payments.ErrNotAPack):
return http.StatusBadRequest, "not_a_pack"
case errors.Is(err, account.ErrInvalidEmail):
return http.StatusBadRequest, "invalid_email"
case errors.Is(err, account.ErrCodeMismatch), errors.Is(err, account.ErrCodeExpired),
-399
View File
@@ -1,399 +0,0 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/payments"
)
// Ledger/order provider tags per rail.
const (
providerRobokassa = "robokassa" // the direct (RUB) rail
providerVK = "vk" // the VK Votes rail
providerTelegram = "telegram" // the Telegram Stars (XTR) rail
)
// walletOrderRequest is the POST body of a chip-pack purchase: the pack to fund.
type walletOrderRequest struct {
ProductID string `json:"product_id"`
}
// walletOrderResponse returns the created order id and the rail's launch details. RedirectURL is
// the provider's hosted-payment URL for the direct rail (empty for VK/Telegram, which settle
// in-app). Rail names the settling rail so the gateway knows how to launch it; for the Telegram
// Stars rail the gateway mints the invoice link from InvoiceTitle and InvoiceAmount (whole stars)
// via the bot and returns it in RedirectURL.
type walletOrderResponse struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title,omitempty"`
InvoiceAmount int64 `json:"invoice_amount,omitempty"`
}
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
// invoice link (Telegram). It enforces the wallet gate and, on the direct rail, D36 (a purchase
// requires a confirmed email anchor). No chips are credited here — only later, by the verified
// provider callback.
func (s *Server) handleWalletOrder(c *gin.Context) {
uid, ok := userID(c)
if !ok {
return
}
var req walletOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
return
}
productID, err := uuid.Parse(req.ProductID)
if err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
return
}
ctx := c.Request.Context()
cxt, present, err := s.walletGate(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
switch cxt.Kind {
case payments.SourceDirect:
if s.robokassa.MerchantLogin == "" {
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available"}})
return
}
// D36: a direct purchase requires a confirmed email anchor.
hasEmail, err := s.accounts.HasConfirmedEmail(ctx, uid)
if err != nil {
s.abortErr(c, err)
return
}
if !hasEmail {
c.AbortWithStatusJSON(http.StatusForbidden, errorResponse{Error: errorBody{Code: "email_required", Message: "confirm your email before making a purchase"}})
return
}
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerRobokassa)
if err != nil {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
Rail: providerRobokassa,
})
case payments.SourceVK:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
if err != nil {
s.abortErr(c, err)
return
}
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
c.JSON(http.StatusOK, walletOrderResponse{OrderID: res.OrderID.String(), Rail: providerVK})
case payments.SourceTelegram:
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerTelegram)
if err != nil {
s.abortErr(c, err)
return
}
// The gateway mints the Stars invoice link from the title and amount via the bot (only
// the bot reaches Telegram) and returns it to the client as RedirectURL; the amount is in
// whole stars (the XTR minor unit is the star). No chips are credited until the verified
// successful_payment is forwarded back through the bot.
c.JSON(http.StatusOK, walletOrderResponse{
OrderID: res.OrderID.String(),
Rail: providerTelegram,
InvoiceTitle: res.Title,
InvoiceAmount: res.Amount.Minor(),
})
default:
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
}
}
// handleRobokassaResult is the verified Robokassa Result callback, reached only through the gateway
// (which forwards the provider's form parameters as a JSON object on the internal, gateway-only
// route). It verifies the Password2 signature, credits the matched order exactly once (idempotent,
// and honoured even if the order expired), records a succeeded event, and answers Robokassa's
// expected "OK<InvId>". A duplicate callback credits nothing but still answers OK.
func (s *Server) handleRobokassaResult(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.String(http.StatusBadRequest, "bad request")
return
}
v := url.Values{}
for k, val := range params {
v.Set(k, val)
}
orderID, outSum, ok := s.robokassa.VerifyResult(v)
if !ok {
s.log.Warn("robokassa result: bad signature")
c.String(http.StatusBadRequest, "bad sign")
return
}
paid, err := payments.ParseMoney(outSum, payments.CurrencyRUB)
if err != nil {
c.String(http.StatusBadRequest, "bad amount")
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerRobokassa, orderID.String(), paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("robokassa result rejected", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusBadRequest, "rejected")
return
}
s.log.Error("robokassa fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.String(http.StatusInternalServerError, "error")
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// The gateway echoes response verbatim to Robokassa, which requires the body "OK<InvId>".
c.JSON(http.StatusOK, gin.H{"response": "OK" + v.Get("InvId")})
}
// vkErrorResponse builds VK's error envelope for a payment callback.
func vkErrorResponse(code int, msg string, critical bool) gin.H {
return gin.H{"error": gin.H{"error_code": code, "error_msg": msg, "critical": critical}}
}
// handleVKCallback is the VK Mini Apps payment callback, reached only through the gateway (which
// verifies the VK signature and forwards the provider's parameters as a JSON object on the internal
// route). It answers VK's two phases: get_item returns the ordered pack's title and vote price; a
// chargeable order_status_change credits the matched order exactly once (idempotent on VK's order
// id) and records a succeeded event. Both phases use VK's response envelope; the _test variants are
// the sandbox notifications and are handled identically.
func (s *Server) handleVKCallback(c *gin.Context) {
var params map[string]string
if err := c.ShouldBindJSON(&params); err != nil {
c.JSON(http.StatusOK, vkErrorResponse(1, "bad request", true))
return
}
ctx := c.Request.Context()
switch params["notification_type"] {
case "get_item", "get_item_test":
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
title, amount, err := s.payments.OrderItem(ctx, orderID)
if err != nil {
s.log.Warn("vk get_item lookup failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
c.JSON(http.StatusOK, gin.H{"response": gin.H{
"item_id": orderID.String(),
"title": title,
"price": amount.Minor(),
}})
case "order_status_change", "order_status_change_test":
if params["status"] != "chargeable" {
c.JSON(http.StatusOK, vkErrorResponse(100, "unsupported status", false))
return
}
orderID, err := uuid.Parse(params["item"])
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(20, "item not available", true))
return
}
price, err := strconv.ParseInt(params["item_price"], 10, 64)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
paid, err := payments.MoneyFromMinor(price, payments.CurrencyVote)
if err != nil {
c.JSON(http.StatusOK, vkErrorResponse(100, "bad price", false))
return
}
vkOrderID := params["order_id"]
outcome, err := s.payments.Fund(ctx, orderID, providerVK, vkOrderID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
s.log.Warn("vk order rejected", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "cannot process the order", false))
return
}
s.log.Error("vk fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.JSON(http.StatusOK, vkErrorResponse(100, "internal error", false))
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record vk payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
// Echo VK's own order id; app_order_id is optional and our order id is a uuid, so omit it.
appOrderID, _ := strconv.ParseInt(vkOrderID, 10, 64)
c.JSON(http.StatusOK, gin.H{"response": gin.H{"order_id": appOrderID}})
default:
c.JSON(http.StatusOK, vkErrorResponse(100, "unknown notification", false))
}
}
// telegramPreCheckoutRequest is the bot's pre_checkout validation, forwarded through the gateway:
// the order in the invoice payload and the amount and currency Telegram is about to charge.
type telegramPreCheckoutRequest struct {
OrderID string `json:"order_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
// telegramPreCheckoutResponse tells the bot whether to approve the pre_checkout_query; Reason is a
// short message the bot surfaces to the payer on a decline.
type telegramPreCheckoutResponse struct {
OK bool `json:"ok"`
Reason string `json:"reason,omitempty"`
}
// handleTelegramPreCheckout validates a Telegram Stars pre_checkout_query before the charge,
// reached only through the gateway (the bot's ValidatePreCheckout, forwarded on the internal
// route). It approves an order that exists, is not already paid (a reusable invoice link paid twice
// is refused here, before any star moves) and whose amount and currency match. A malformed
// reference is a decline, not an error.
func (s *Server) handleTelegramPreCheckout(c *gin.Context) {
var req telegramPreCheckoutRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
ctx := c.Request.Context()
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
amount, err := payments.MoneyFromMinor(req.Amount, payments.Currency(req.Currency))
if err != nil {
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: false, Reason: telegramDeclineText(payments.PreCheckoutGone, "")})
return
}
out, err := s.payments.ValidatePreCheckout(ctx, orderID, amount)
if err != nil {
s.log.Error("telegram pre_checkout validate failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
reason := ""
if !out.OK {
// Localise the decline to the order account's preferred language (the reason shows in the
// Telegram payment sheet). An unknown order has no account, so it falls back to English.
lang := ""
if s.accounts != nil && out.AccountID != (uuid.UUID{}) {
if acc, aerr := s.accounts.GetByID(ctx, out.AccountID); aerr == nil {
lang = acc.PreferredLanguage
}
}
reason = telegramDeclineText(out.Reason, lang)
}
c.JSON(http.StatusOK, telegramPreCheckoutResponse{OK: out.OK, Reason: reason})
}
// telegramDeclineText renders a pre-checkout decline reason code in the payer's language (ru or
// anything else falls back to English), for display in the Telegram payment sheet.
func telegramDeclineText(code, lang string) string {
ru := lang == "ru"
switch code {
case payments.PreCheckoutAlreadyPaid:
if ru {
return "Этот заказ уже оплачен."
}
return "This order has already been paid."
case payments.PreCheckoutPriceChanged:
if ru {
return "Цена изменилась — начните покупку заново."
}
return "The price has changed; please start the purchase again."
default:
if ru {
return "Этот заказ больше недоступен."
}
return "This order is no longer available."
}
}
// telegramPaymentRequest is a completed Stars payment forwarded from the bot's outbox through the
// gateway: the order, the Telegram charge id (the idempotency key), the stars paid, and the payer.
type telegramPaymentRequest struct {
OrderID string `json:"order_id"`
TelegramPaymentChargeID string `json:"telegram_payment_charge_id"`
Amount int64 `json:"amount"`
TelegramUserID int64 `json:"telegram_user_id"`
}
// telegramPaymentResponse reports the durable outcome to the bot: Credited is true once the order
// is credited (or already was). A false with a 200 means the payment was recorded but not creditable
// (the bot drops it); a 5xx means a transient failure the bot retries.
type telegramPaymentResponse struct {
Credited bool `json:"credited"`
}
// handleTelegramPayment credits a completed Telegram Stars payment, reached only through the gateway
// (the bot's ForwardPayment, forwarded on the internal route). It credits the matched order exactly
// once (idempotent on the Telegram charge id, honoured even if the order expired) and records a
// succeeded event. A permanent rejection (unknown order, amount mismatch) is answered 200 with
// Credited=false so the bot stops retrying a payment it cannot place; a transient failure is a 5xx
// the bot retries.
func (s *Server) handleTelegramPayment(c *gin.Context) {
var req telegramPaymentRequest
if err := c.ShouldBindJSON(&req); err != nil {
abortBadRequest(c, "invalid request body")
return
}
orderID, err := uuid.Parse(req.OrderID)
if err != nil {
s.log.Warn("telegram payment: bad order id", zap.String("charge", req.TelegramPaymentChargeID))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
paid, err := payments.MoneyFromMinor(req.Amount, payments.CurrencyStar)
if err != nil {
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
ctx := c.Request.Context()
outcome, err := s.payments.Fund(ctx, orderID, providerTelegram, req.TelegramPaymentChargeID, paid)
if err != nil {
if errors.Is(err, payments.ErrOrderNotFound) || errors.Is(err, payments.ErrAmountMismatch) ||
errors.Is(err, payments.ErrNotAPack) || errors.Is(err, payments.ErrProductNotFound) {
// The star charge already happened but the order cannot be placed; record it loudly for
// an operator and tell the bot to stop retrying (an operator refunds or credits by hand).
s.log.Error("telegram payment rejected (charge taken, not credited)",
zap.String("order", orderID.String()), zap.String("charge", req.TelegramPaymentChargeID), zap.Error(err))
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: false})
return
}
s.log.Error("telegram fund failed", zap.String("order", orderID.String()), zap.Error(err))
c.AbortWithStatusJSON(http.StatusInternalServerError, errorResponse{Error: errorBody{Code: "internal", Message: "internal error"}})
return
}
if !outcome.AlreadyCredited {
payload, _ := json.Marshal(map[string]any{"chips": outcome.Chips, "source": string(outcome.Source)})
if err := s.payments.RecordPaymentEvent(ctx, outcome.AccountID, &orderID, "succeeded", payload); err != nil {
s.log.Error("record telegram payment event failed", zap.String("order", orderID.String()), zap.Error(err))
}
}
c.JSON(http.StatusOK, telegramPaymentResponse{Credited: true})
}
-6
View File
@@ -31,7 +31,6 @@ import (
"scrabble/backend/internal/payments"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/render"
"scrabble/backend/internal/robokassa"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
"scrabble/backend/internal/telemetry"
@@ -110,9 +109,6 @@ type Deps struct {
// Renderer is the image-render sidecar client for the PNG export artifact. A
// nil Renderer makes the PNG download answer 404 (the GCG artifact still works).
Renderer *render.Client
// Robokassa configures the direct-rail (RUB) provider; an empty MerchantLogin leaves the
// order and Result-callback endpoints unregistered.
Robokassa robokassa.Config
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -140,7 +136,6 @@ type Server struct {
banview *banview.View
ads *ads.Service
payments *payments.Service
robokassa robokassa.Config
notifier notify.Publisher
console *adminconsole.Renderer
exportKey []byte
@@ -194,7 +189,6 @@ func New(addr string, deps Deps) *Server {
banview: deps.BanView,
ads: deps.Ads,
payments: deps.Payments,
robokassa: deps.Robokassa,
notifier: notifier,
renderer: deps.Renderer,
http: &http.Server{Addr: addr, Handler: engine},
+12 -31
View File
@@ -236,7 +236,6 @@ The main host archives Postgres continuously with **pgBackRest** to **Selectel S
(encrypted at rest, path-style addressing) so the database can be restored to any moment —
protecting the money ledger and the game state against corruption or host loss. It is the
primary recovery path; the migration-window `pg_dump` above is the secondary net.
**Live on the prod main host since v1.13.0** (armed + restore-drilled 2026-07-09).
**Shape.** A daily full **base backup** (a systemd timer on the main host, `04:00`) plus
**continuous WAL** archived by Postgres `archive_command` (a segment is forced at least every
@@ -259,9 +258,8 @@ absent/NaN-safe, so they stay quiet until archiving is armed.
`pg_stat_wal`: WAL is generated at **~0.77 MB/day** and the database is **~9.6 MB**. At
30-day retention on Selectel S3 (~2 ₽/GB·month) the archive is **under ~0.3 GB → well under
1 ₽/month** (compression halves it again); request volume is trivial. Performance impact is
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup is
small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and checkpoint-bound
(~1.5 min wall, minimal CPU/I-O) on the 2 vCPU host. Revisit if traffic grows ~100× (watch
**negligible**: archive-push moves tiny compressed segments, and the daily full base backup
is a ~10 MB, sub-second job on the 2 vCPU host. Revisit both if traffic grows ~100× (watch
`node_exporter` during a base backup).
**Arming (owner-coordinated, once, before real payments).** Ships disarmed; to turn it on:
@@ -278,22 +276,14 @@ small (the whole cluster is ~32 MB → ~3.7 MB compressed in the repo) and check
2. Promote `development → master`, tag, and run **prod-deploy**. The roll recreates postgres
with `archive_mode=on` behind the maintenance page. (Archive pushes fail harmlessly for the
minute until step 3 creates the repository — the WAL is retained, not lost.)
3. On the main host, create the repository, verify archiving, take the first base backup. Run
pgBackRest **as the `postgres` OS user** (`-u postgres` — lock-dir/PGDATA consistency with
`archive-push`) and connect to the DB **as the superuser role** (`--pg1-user=scrabble`, the
`POSTGRES_USER`, not `postgres`); it inherits the container's `PGBACKREST_*` env:
3. On the main host, create the repository, take the first base backup, and verify:
```sh
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble stanza-create
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble check
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble info # (info takes no --pg1-user)
docker exec scrabble-postgres pgbackrest --stanza=scrabble stanza-create
docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
docker exec scrabble-postgres pgbackrest --stanza=scrabble check
```
The few `archive-push` failures logged between `archive_mode=on` and `stanza-create` are the
expected transient (WAL retained, not lost); clear the counter afterwards with
`docker exec -u postgres scrabble-postgres psql -U scrabble -d scrabble -c "SELECT pg_stat_reset_shared('archiver');"`
so it does not trip the failing-archive alert.
4. Enable the daily timer: `ansible-playbook site.yml --limit main -e pitr_enabled=true`
(installs + starts `pgbackrest-backup.timer` on the main host).
4. Enable the daily timer: `ansible-playbook site.yml -e pitr_enabled=true` (installs + starts
`pgbackrest-backup.timer` on the main host).
5. Confirm in Grafana that the two archiving alerts are green (`last_archive_age` now tracks a
real number, `failed_count` flat).
@@ -302,26 +292,17 @@ an **isolated, one-shot** target (a throwaway VM or a container with no ingress)
the matching Postgres major, an empty `PGDATA`, and the same `PGBACKREST_*` environment:
```sh
# Throwaway container from the DB image (carries pgBackRest), the live PGBACKREST_* env, an
# empty PGDATA and no app network; restore, recover, verify, then wipe (-v drops the data).
IMG=$(docker inspect -f '{{.Config.Image}}' scrabble-postgres)
docker exec scrabble-postgres env | grep '^PGBACKREST_' > /tmp/drill.env; echo POSTGRES_PASSWORD=drill >> /tmp/drill.env
docker run -d --name pitr-drill --env-file /tmp/drill.env --entrypoint sleep "$IMG" infinity
docker exec pitr-drill sh -c 'rm -rf /var/lib/postgresql/data/*; chown -R postgres:postgres /var/lib/postgresql/data; chmod 700 /var/lib/postgresql/data'
# --type=immediate = to the base backup's consistency point; --type=time "--target=<ts>" for PITR
docker exec -u postgres pitr-drill pgbackrest --stanza=scrabble --pg1-path=/var/lib/postgresql/data --type=immediate restore
docker exec -u postgres pitr-drill pg_ctl -D /var/lib/postgresql/data -w start
docker exec -u postgres pitr-drill psql -U scrabble -d scrabble -c "SELECT count(*) FROM backend.accounts;" # verify data intact
docker rm -fv pitr-drill; rm -f /tmp/drill.env # wipe the restored real data
pgbackrest --stanza=scrabble --type=time "--target=YYYY-MM-DD HH:MM:SS+00" --delta restore
# start Postgres; it replays WAL to the target; then verify a known row / the ledger tail
```
The target holds **real money + personal data** while it exists — keep it network-isolated and
**destroy it (wipe `PGDATA` + the instance) afterwards**. Record each drill (date, target
timestamp, outcome) here:
| Date | Target | Result |
| Date | Target timestamp | Result |
| --- | --- | --- |
| 2026-07-09 | latest (`--type=immediate`) | PASS — v1.13.0 arming: 31.9 MB cluster restored from the encrypted S3 repo (3.7 MB compressed), recovered to consistency, `backend.accounts` intact; drill instance wiped. |
| _pending first arming_ | — | — |
**bot-link cert rotation:** regenerate (`deploy/gen-certs.sh /tmp/c --force`), reset the
five `PROD_BOTLINK_*` secrets from `/tmp/c`, and re-run the workflow — both hosts redeploy
@@ -10,8 +10,6 @@ Requires=docker.service
[Service]
Type=oneshot
# docker exec defaults to root, so run as the postgres OS user (-u postgres) for lock-dir and
# PGDATA consistency with archive-push, and connect to the database as the superuser role via
# --pg1-user: that role is the POSTGRES_USER (scrabble), not "postgres". It inherits the
# container's PGBACKREST_* environment (the S3 repository + cipher), so no host-side config.
ExecStart=/usr/bin/docker exec -u postgres scrabble-postgres pgbackrest --stanza=scrabble --pg1-user=scrabble --type=full backup
# docker exec runs as the image's postgres user and inherits the container's PGBACKREST_*
# environment (the S3 repository + cipher), so no repository config is needed on the host.
ExecStart=/usr/bin/docker exec scrabble-postgres pgbackrest --stanza=scrabble --type=full backup
+1 -1
View File
@@ -86,7 +86,7 @@
# The game SPA and the Connect edge are served by the gateway. Strip any
# client-supplied X-Scrabble-Honeypot here so the gateway only ever honours the
# tag the honeypot block sets below (a client cannot self-tag a real request).
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /pay/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
@gateway path /app /app/* /telegram /telegram/* /vk /vk/* /dict/* /dl/* /metrics/* /telemetry/* /scrabble.edge.v1.Gateway/*
handle @gateway {
reverse_proxy gateway:8081 {
header_up -X-Scrabble-Honeypot
-4
View File
@@ -28,10 +28,6 @@ services:
# the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail; empty disables it. The rail stays inert until a chip pack carries an XTR (Stars)
# price, so it is safe to leave on — seeding a Stars price in the admin is the real go-live.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
TELEGRAM_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
-11
View File
@@ -161,13 +161,6 @@ services:
# recipient(s) (comma-separated allowed). Both empty disables the alert worker.
BACKEND_SMTP_ADMIN_FROM: ${SMTP_RELAY_ADMIN_FROM:-}
BACKEND_ADMIN_EMAIL: ${ADMIN_EMAIL:-}
# Direct-rail (Robokassa) payment intake: the merchant login + Password1/Password2 and the
# test-mode flag (deploy env: TEST_/PROD_BACKEND_ROBOKASSA_*). An empty login leaves the
# direct order and Result-callback endpoints unregistered (the rail is off).
BACKEND_ROBOKASSA_MERCHANT_LOGIN: ${ROBOKASSA_MERCHANT_LOGIN:-}
BACKEND_ROBOKASSA_PASSWORD1: ${ROBOKASSA_PASSWORD1:-}
BACKEND_ROBOKASSA_PASSWORD2: ${ROBOKASSA_PASSWORD2:-}
BACKEND_ROBOKASSA_TEST: ${ROBOKASSA_TEST:-}
# The dictionary lives on a named volume seeded from the image on first boot
# (the image's /opt/dawg is owned by the nonroot UID, which the fresh volume
# inherits). The admin console writes new version subdirectories here, and the
@@ -385,10 +378,6 @@ services:
# set the bot must be an admin there with the manage-topics and delete-messages rights.
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
TELEGRAM_SUPPORT_STATE_DIR: /data
# The Telegram Stars payment outbox (shares the bot-state volume). A writable dir enables the
# Stars rail (pre_checkout + successful_payment + the durable outbox); empty disables it. The
# rail stays inert until a chip pack carries an XTR (Stars) price, so it is safe to leave on.
TELEGRAM_STARS_OUTBOX_DIR: ${TELEGRAM_STARS_OUTBOX_DIR:-/data}
# The optional standalone promo bot (its own token) answering /start with a button
# into the main bot's app. Empty disables it; when set it needs the main bot's
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
+3 -18
View File
@@ -18,25 +18,10 @@
@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
}
}
# An unknown path falls back to the landing shell (the gateway's old "/"
# behaviour); "/" itself resolves through the index below.
handle {
try_files {path} /landing.html
file_server {
index landing.html
}
try_files {path} /landing.html
file_server {
index landing.html
}
}
+2 -9
View File
@@ -971,13 +971,7 @@ the console; the backend calls them on the **gateway's bot-link relay**, which f
to the bot and **awaits its delivery ack** (so the console still reports delivered/not). Beyond
messages the same bot-link carries a **chat-gate control path** — a `ChatGate` command sets a user's
write access in the moderated discussion chat and the bot's unary `ResolveChatEligibility` resolves a
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). It also
carries the **Telegram Stars payment path** (§payments): a `CreateInvoice` command has the bot mint a
`createInvoiceLink` (XTR) and return it in the Ack; the bot's unary `ValidatePreCheckout` gates a
`pre_checkout_query` against the intake (declining an already-paid reusable invoice before the
charge); and its unary `ForwardPayment` delivers a completed `successful_payment` — durably queued in
a bot-side **SQLite outbox** (`platform/telegram/internal/outbox`, re-driven on restart) — which the
gateway proxies to the backend intake, credited once (idempotent on `telegram_payment_charge_id`). An optional
joiner's eligibility (neither renders a message; see *Moderated discussion chat* below). An optional
**standalone promo bot** runs in the bot container (`TELEGRAM_PROMO_BOT_TOKEN`): a second bot
answering `/start` with a URL button into the **main** bot's Mini App (`?startapp`, since a `web_app`
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
@@ -1334,8 +1328,7 @@ in-compose **caddy** is the contour's edge: it owns a single `/_gm` Basic-Auth a
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
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
+6 -15
View File
@@ -219,21 +219,12 @@ the amount, credits, marks `paid`. **Idempotency:** dedup by `(provider, provide
valid callback is **always** honoured, even on an expired order (`expired` ≠ cancellation —
the money is real, the chips are owed). The user sees only successful purchases.
**TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
(backed by the intake): approve only if the order exists, is still creditable and is **not already
paid** — a Stars invoice link is reusable, so this gate is the one place a repeat payment is stopped
before the charge; the decline reason is localised to the order account's language.
`successful_payment` reaches the bot only (Bot API, not the Mini App), and the bot host is weak and
can lose connectivity, so the bot is a durable link. Store-and-forward on **SQLite** on the bot's
disk (`internal/outbox`): persist on receipt (idempotent on `telegram_payment_charge_id`) → forward
over the bot-link (a `ForwardPayment` unary; the gateway proxies to the intake) → on a durable
response, mark `forwarded`. Re-drives undelivered on restart and on a periodic tick. At-least-once
delivery + idempotent intake (dedup by `telegram_payment_charge_id`) = credited exactly once.
**TG bot outbox.** `successful_payment` reaches the bot only (Bot API, not the Mini App), and
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
intake = credited exactly once.
**Events.** The payments domain writes `payment_events` (succeeded / failed / refunded); a
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
+6 -16
View File
@@ -220,22 +220,12 @@ provider_payment_id)`.
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
**TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный
mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack,
который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на
`pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить,
только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка Stars-инвойса
переиспользуема, так что этот гейт — единственное место, где повторная оплата отсекается **до**
списания; текст отказа локализован в язык аккаунта заказа.
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а хост бота
слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на **SQLite** на диске
бота (`internal/outbox`): сохранил при получении (идемпотентно по `telegram_payment_charge_id`) →
форвардит по bot-link (унарный `ForwardPayment`; gateway проксирует в приём) → при durable-ответе
пометил `forwarded`. Дореталивает недоставленное при рестарте и по периодическому тику. Доставка
at-least-once + идемпотентный приём (дедуп по `telegram_payment_charge_id`) = начисление ровно один
раз.
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
at-least-once + идемпотентный приём = начисление ровно один раз.
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
+1 -17
View File
@@ -153,16 +153,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
r, rerr := backend.ChatEligibility(ctx, externalID)
return r.Registered, r.Eligible, rerr
})
// The Telegram Stars payment bridge rides the same bot-link: the bot validates each
// pre_checkout and forwards each completed payment through these, backed by the backend intake.
botHub.SetPaymentBridge(
func(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
return backend.ValidatePreCheckout(ctx, orderID, amount, currency)
},
func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
return backend.TelegramPayment(ctx, orderID, chargeID, amount, telegramUserID)
},
)
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
if terr != nil {
return terr
@@ -209,12 +199,7 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
logger.Info("vk id web login enabled")
}
regOpts := []transcode.Option{transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)}
if botHub != nil {
// A Telegram-context wallet order mints its Stars invoice link through the connected bot.
regOpts = append(regOpts, transcode.WithTelegramStars(botHub))
}
registry := transcode.NewRegistry(backend, validator, regOpts...)
registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger))
edge := connectsrv.NewServer(connectsrv.Deps{
Registry: registry,
Sessions: sessions,
@@ -223,7 +208,6 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
Tracker: tracker,
Banlist: banlist,
Honeytoken: cfg.Abuse.Honeytoken,
VKAppSecret: cfg.VKAppSecret,
Hub: hub,
RateLimit: cfg.RateLimit,
Heartbeat: cfg.PushHeartbeatInterval,
-83
View File
@@ -396,89 +396,6 @@ func (c *Client) WalletBuy(ctx context.Context, userID, productID string) (Walle
return out, err
}
// walletOrderBody is the POST body of an order: the chip pack to fund.
type walletOrderBody struct {
ProductID string `json:"product_id"`
}
// WalletOrderResp is a created order: its id and the rail's launch details. RedirectURL is the
// provider's hosted-payment URL (direct); Rail names the settling rail; for the Telegram Stars rail
// InvoiceTitle and InvoiceAmount (whole stars) are the invoice the gateway mints via the bot.
type WalletOrderResp struct {
OrderID string `json:"order_id"`
RedirectURL string `json:"redirect_url"`
Rail string `json:"rail"`
InvoiceTitle string `json:"invoice_title"`
InvoiceAmount int64 `json:"invoice_amount"`
}
// WalletOrder opens a pending order to fund a chip pack and returns the rail's launch details.
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
var out WalletOrderResp
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
return out, err
}
// preCheckoutResp is the backend intake's pre_checkout answer.
type preCheckoutResp struct {
OK bool `json:"ok"`
Reason string `json:"reason"`
}
// ValidatePreCheckout asks the backend intake whether a Telegram Stars pre_checkout_query for
// orderID paying amount in currency may be approved, before the charge. It returns the approval and
// a short decline reason for the payer. It backs the bot's pre_checkout gate through the bot-link.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (bool, string, error) {
var out preCheckoutResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/precheckout", "", "",
map[string]any{"order_id": orderID, "amount": amount, "currency": currency}, &out)
return out.OK, out.Reason, err
}
// telegramPaymentResp is the backend intake's credit outcome.
type telegramPaymentResp struct {
Credited bool `json:"credited"`
}
// TelegramPayment forwards a completed Telegram Stars payment from the bot's outbox to the backend
// intake, which credits the order idempotently on chargeID. It reports whether the order was
// credited (or already had been); a transport error is a transient failure the bot retries.
func (c *Client) TelegramPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (bool, error) {
var out telegramPaymentResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/telegram/payment", "", "",
map[string]any{
"order_id": orderID,
"telegram_payment_charge_id": chargeID,
"amount": amount,
"telegram_user_id": telegramUserID,
}, &out)
return out.Credited, err
}
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
type robokassaResultResp struct {
Response string `json:"response"`
}
// RobokassaResult forwards a Robokassa Result callback's parameters to the backend intake (the
// single writer, which verifies the signature and credits) and returns the body to echo to
// Robokassa ("OK<InvId>"). params carries the provider's raw form fields.
func (c *Client) RobokassaResult(ctx context.Context, params map[string]string) (string, error) {
var out robokassaResultResp
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/robokassa/result", "", "", params, &out)
return out.Response, err
}
// VKCallback forwards a gateway-verified VK payment callback's parameters to the backend intake and
// returns the backend's raw VK response envelope (`{"response":…}` or `{"error":…}`) for the gateway
// to relay to VK verbatim. The backend answers 200 in every case (VK's protocol), so a transport
// error here is a genuine proxy failure.
func (c *Client) VKCallback(ctx context.Context, params map[string]string) (json.RawMessage, error) {
var out json.RawMessage
err := c.do(ctx, http.MethodPost, "/api/v1/internal/payments/vk/callback", "", "", params, &out)
return out, err
}
// CatalogAtomResp is one atom line of a storefront product: the value type it grants and quantity.
type CatalogAtomResp struct {
AtomType string `json:"atom_type"`
-15
View File
@@ -48,18 +48,3 @@ func ChatGateCommand(externalID string, allow bool) *botlinkv1.Command {
}},
}
}
// CreateInvoiceCommand builds a Telegram Stars invoice-mint command: the bot calls
// createInvoiceLink (in XTR) with the given title and description, the order id as the
// payload (echoed by Telegram in pre_checkout and successful_payment) and amountStars whole
// stars, and returns the link in its Ack result.
func CreateInvoiceCommand(title, description, orderID string, amountStars int64) *botlinkv1.Command {
return &botlinkv1.Command{
Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: title,
Description: description,
Payload: orderID,
Amount: amountStars,
}},
}
}
-120
View File
@@ -13,7 +13,6 @@ import (
"strconv"
"sync"
"sync/atomic"
"time"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
@@ -32,11 +31,6 @@ var ErrNoBot = errors.New("botlink: no bot connected")
// (at-most-once under backpressure).
const outboundBuffer = 64
// invoiceMintTimeout bounds one synchronous invoice-mint round-trip (the gateway commands the bot
// and awaits the Ack carrying the createInvoiceLink result), so a hung or slow bot cannot stall the
// caller's wallet-order request indefinitely.
const invoiceMintTimeout = 15 * time.Second
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
// for the bot's join-time ResolveChatEligibility query: registered reports whether the
// identity maps to an account, eligible is the final gate the bot acts on (registered
@@ -44,17 +38,6 @@ const invoiceMintTimeout = 15 * time.Second
// chat-access endpoint.
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
// PreCheckoutResolver validates a Telegram Stars pre_checkout_query for the bot's
// ValidatePreCheckout query: it answers whether the order may still be charged (ok) and, when not,
// a short reason to show the payer. The gateway backs it with the backend intake.
type PreCheckoutResolver func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
// PaymentForwarder delivers a completed Telegram Stars payment for the bot's ForwardPayment call:
// it credits the order through the backend intake and reports whether it was credited (or already
// had been). A non-nil error is a transient failure the bot retries. The gateway backs it with the
// backend intake.
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
// Hub registers connected bots and routes send commands to them. A single bot is
// expected today; the registry already holds a set so adding more later needs no
// rewrite.
@@ -63,10 +46,6 @@ type Hub struct {
log *zap.Logger
eligibility EligibilityResolver
// precheck and forward back the Telegram Stars payment RPCs; nil until SetPaymentBridge wires
// them, in which case the RPCs report Unavailable. Set once before the gRPC server serves.
precheck PreCheckoutResolver
forward PaymentForwarder
mu sync.Mutex
links map[*link]struct{}
@@ -168,105 +147,6 @@ func (h *Hub) ResolveChatEligibility(ctx context.Context, req *botlinkv1.ChatEli
return &botlinkv1.ChatEligibilityResponse{Registered: registered, Eligible: eligible}, nil
}
// SetPaymentBridge wires the Telegram Stars payment resolvers, backing ValidatePreCheckout and
// ForwardPayment. It must be called before the gRPC server starts serving (the fields are read
// without a lock, relying on that happens-before). A nil resolver leaves its RPC reporting
// Unavailable.
func (h *Hub) SetPaymentBridge(precheck PreCheckoutResolver, forward PaymentForwarder) {
h.precheck = precheck
h.forward = forward
}
// ValidatePreCheckout serves the bot's pre_checkout validation over the same mTLS channel: it
// delegates to the configured resolver (the backend intake) and returns whether the order may be
// charged. A resolver failure maps to Internal, which the bot treats as a decline (fail-closed).
func (h *Hub) ValidatePreCheckout(ctx context.Context, req *botlinkv1.PreCheckoutRequest) (*botlinkv1.PreCheckoutResponse, error) {
if h.precheck == nil {
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
}
ok, reason, err := h.precheck(ctx, req.GetOrderId(), req.GetAmount(), req.GetCurrency())
if err != nil {
h.log.Warn("validate pre_checkout failed", zap.String("order", req.GetOrderId()), zap.Error(err))
return nil, status.Error(codes.Internal, "validate pre_checkout")
}
return &botlinkv1.PreCheckoutResponse{Ok: ok, Reason: reason}, nil
}
// ForwardPayment serves the bot's completed-payment delivery: it credits the order through the
// configured forwarder (the backend intake) and reports the durable outcome. A forwarder failure
// maps to Internal, which the bot treats as transient and retries; a clean response (credited true
// or false) lets the bot forget the outbox row.
func (h *Hub) ForwardPayment(ctx context.Context, req *botlinkv1.ForwardPaymentRequest) (*botlinkv1.ForwardPaymentResponse, error) {
if h.forward == nil {
return nil, status.Error(codes.Unavailable, "payment bridge not configured")
}
credited, err := h.forward(ctx, req.GetOrderId(), req.GetTelegramPaymentChargeId(), req.GetAmount(), req.GetTelegramUserId())
if err != nil {
h.log.Warn("forward telegram payment failed", zap.String("order", req.GetOrderId()), zap.Error(err))
return nil, status.Error(codes.Internal, "forward payment")
}
return &botlinkv1.ForwardPaymentResponse{Credited: credited}, nil
}
// MintInvoice commands the connected bot to mint a Telegram Stars invoice link for the order and
// returns the link. It is a bounded, synchronous round-trip (invoiceMintTimeout): no bot connected
// returns ErrNoBot, and a bot error or an empty link is an error the caller surfaces as a failed
// order launch.
func (h *Hub) MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error) {
cctx, cancel := context.WithTimeout(ctx, invoiceMintTimeout)
defer cancel()
ack, err := h.sendAwaitAck(cctx, CreateInvoiceCommand(title, description, orderID, amountStars))
if err != nil {
return "", err
}
if ack.GetResult() == "" {
return "", errors.New("botlink: bot returned an empty invoice link")
}
return ack.GetResult(), nil
}
// sendAwaitAck enqueues a command and waits for the bot's full Ack (or ctx). It mirrors SendAwait
// but returns the Ack — the invoice-mint path needs its result field — and reports ctx expiry as an
// error rather than a not-delivered, since a timed-out mint has no link to return.
func (h *Hub) sendAwaitAck(ctx context.Context, cmd *botlinkv1.Command) (*botlinkv1.Ack, error) {
l, ok := h.pick()
if !ok {
h.count("dropped")
return nil, ErrNoBot
}
id := h.nextID()
cmd.CommandId = id
ackc := make(chan *botlinkv1.Ack, 1)
h.mu.Lock()
h.pending[id] = ackc
h.mu.Unlock()
defer func() {
h.mu.Lock()
delete(h.pending, id)
h.mu.Unlock()
}()
select {
case l.out <- &botlinkv1.ToBot{Command: cmd}:
case <-ctx.Done():
h.count("dropped")
return nil, ctx.Err()
}
select {
case ack := <-ackc:
if e := ack.GetError(); e != "" {
h.count("error")
return nil, errors.New(e)
}
h.count(deliveredLabel(ack.GetDelivered()))
return ack, nil
case <-ctx.Done():
h.count("error")
return nil, ctx.Err()
}
}
// register adds a connected bot.
func (h *Hub) register(l *link) {
h.mu.Lock()
+18 -126
View File
@@ -31,7 +31,6 @@ import (
"scrabble/gateway/internal/ratelimit"
"scrabble/gateway/internal/session"
"scrabble/gateway/internal/transcode"
"scrabble/gateway/internal/vkpay"
"scrabble/gateway/internal/webui"
edgev1 "scrabble/gateway/proto/edge/v1"
"scrabble/gateway/proto/edge/v1/edgev1connect"
@@ -71,19 +70,18 @@ const (
// Server implements edgev1connect.GatewayHandler.
type Server struct {
registry *transcode.Registry
sessions *session.Cache
backend *backendclient.Client
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
honeytoken string
vkAppSecret string
hub *push.Hub
heartbeat time.Duration
log *zap.Logger
adminProxy http.Handler
metrics *serverMetrics
registry *transcode.Registry
sessions *session.Cache
backend *backendclient.Client
limiter *ratelimit.Limiter
tracker *ratelimit.Tracker
banlist *ratelimit.Banlist
honeytoken string
hub *push.Hub
heartbeat time.Duration
log *zap.Logger
adminProxy http.Handler
metrics *serverMetrics
maxBodyBytes int
@@ -112,15 +110,12 @@ type Deps struct {
// Honeytoken, when non-empty, is the planted bearer value whose presentation
// bans the caller and raises a high-severity alarm.
Honeytoken string
// VKAppSecret is the VK Mini App protected key; it verifies the VK payment callback signature
// (the same key the launch-signature auth uses). Empty leaves the VK callback rejecting.
VKAppSecret string
Hub *push.Hub
RateLimit config.RateLimitConfig
Heartbeat time.Duration
Logger *zap.Logger
AdminProxy http.Handler
Meter metric.Meter
Hub *push.Hub
RateLimit config.RateLimitConfig
Heartbeat time.Duration
Logger *zap.Logger
AdminProxy http.Handler
Meter metric.Meter
// MaxBodyBytes caps one inbound request body and one Connect message read;
// zero or negative selects config.DefaultMaxBodyBytes.
MaxBodyBytes int
@@ -156,7 +151,6 @@ func NewServer(d Deps) *Server {
registry: d.Registry,
sessions: d.Sessions,
backend: d.Backend,
vkAppSecret: d.VKAppSecret,
limiter: limiter,
tracker: tracker,
banlist: banlist,
@@ -202,12 +196,6 @@ func (s *Server) HTTPHandler() http.Handler {
// through this session-gated route (not public); see dictBytesHandler.
mux.Handle("/dict/", s.dictBytesHandler())
mux.Handle("/dl/", s.exportDownloadHandler())
// Direct-rail (Robokassa) return + callback routes: the server Result callback (the single
// crediting signal, proxied to the backend intake) and the browser Success/Fail redirects.
mux.Handle("/pay/robokassa/result", s.robokassaResultHandler())
mux.Handle("/pay/vk/callback", s.vkCallbackHandler())
mux.Handle("/pay/robokassa/success", s.robokassaReturnHandler("Оплата принята."))
mux.Handle("/pay/robokassa/fail", s.robokassaReturnHandler("Оплата не завершена."))
// The client posts its local-move-preview adoption telemetry here (session-gated).
mux.Handle("/metrics/local-eval", s.localEvalMetricsHandler())
// The index.html boot guard beacons here when it turns a client away on the unsupported-engine
@@ -494,102 +482,6 @@ func (s *Server) exportDownloadHandler() http.Handler {
})
}
// robokassaResultHandler proxies the Robokassa Result callback to the backend intake (the single
// writer). It rate-limits per IP, forwards the provider's form parameters, and echoes the backend's
// "OK<InvId>" to Robokassa on success; any error tells Robokassa the notification was not accepted,
// so it retries. The gateway does not verify the signature — the backend holds the secret.
func (s *Server) robokassaResultHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "robokassa-result")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
resp, err := s.backend.RobokassaResult(r.Context(), params)
if err != nil {
s.log.Warn("robokassa result proxy failed", zap.Error(err))
http.Error(w, "not accepted", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte(resp))
})
}
// vkCallbackHandler proxies a VK Mini Apps payment callback to the backend intake. It rate-limits
// per IP, verifies the VK signature with the app's protected key (the backend holds no VK secret),
// forwards the provider's parameters, and relays the backend's VK response envelope. A missing or
// bad signature is rejected before reaching the backend.
func (s *Server) vkCallbackHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
http.NotFound(w, r)
return
}
ip := peerIP(r.RemoteAddr, r.Header)
if !s.limiter.Allow("public:"+ip, s.publicPolicy) {
s.noteRateLimited(r.Context(), classPublic, ip, "vk-callback")
http.Error(w, "rate limited", http.StatusTooManyRequests)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
params := make(map[string]string, len(r.Form))
for k := range r.Form {
params[k] = r.Form.Get(k)
}
if !vkpay.Verify(params, s.vkAppSecret) {
s.log.Warn("vk callback: bad signature")
http.Error(w, "bad signature", http.StatusForbidden)
return
}
resp, err := s.backend.VKCallback(r.Context(), params)
if err != nil {
s.log.Warn("vk callback proxy failed", zap.Error(err))
http.Error(w, "bad gateway", http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_, _ = w.Write(resp)
})
}
// robokassaReturnHandler serves a minimal self-closing page for Robokassa's Success/Fail browser
// return. The payment opens in a separate window, so on return this closes that window and drops the
// customer back into the live app (never a cold app start); a link is the fallback if the window
// cannot self-close. The credit rides the server Result callback, never this redirect — the wallet
// updates in place from the payment push, with a return-focus refetch as the fallback.
func (s *Server) robokassaReturnHandler(message string) http.Handler {
page := []byte(`<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Оплата</title></head>
<body style="font-family: system-ui, sans-serif; text-align: center; padding: 48px 20px; color: #1a1c20">
<p style="font-size: 1.1rem">` + message + `</p>
<p style="color: #5b6472">Можно закрыть это окно и вернуться в приложение.</p>
<p><a href="/app/">Открыть приложение</a></p>
<script>setTimeout(function () { try { window.close(); } catch (e) {} }, 1200);</script>
</body></html>`)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(page)
})
}
func (s *Server) dictBytesHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.backend == nil {
-13
View File
@@ -37,19 +37,6 @@ func encodeAck(ok bool) []byte {
return b.FinishedBytes()
}
// encodeWalletOrder builds a WalletOrderResponse payload: the created order id and the provider
// launch URL the client opens.
func encodeWalletOrder(o backendclient.WalletOrderResp) []byte {
b := flatbuffers.NewBuilder(128)
oid := b.CreateString(o.OrderID)
url := b.CreateString(o.RedirectURL)
fb.WalletOrderResponseStart(b)
fb.WalletOrderResponseAddOrderId(b, oid)
fb.WalletOrderResponseAddRedirectUrl(b, url)
b.Finish(fb.WalletOrderResponseEnd(b))
return b.FinishedBytes()
}
// encodeDeleteRequestResult builds an AccountDeleteRequestResult payload reporting which
// deletion step-up the account uses ("email" | "phrase").
func encodeDeleteRequestResult(method string) []byte {
-48
View File
@@ -55,7 +55,6 @@ const (
MsgWalletGet = "wallet.get"
MsgWalletCatalog = "wallet.catalog"
MsgWalletBuy = "wallet.buy"
MsgWalletOrder = "wallet.order"
)
// Request is one decoded Execute call.
@@ -112,7 +111,6 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(backend), Auth: true}
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, nil), Auth: true}
r.ops[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
r.ops[MsgGameState] = Op{Handler: gameStateHandler(backend), Auth: true}
@@ -170,17 +168,6 @@ func WithVKLink(ex VKIDExchanger) Option {
}
}
// WithTelegramStars re-registers the wallet.order op with a Telegram Stars invoice minter (the
// bot-link), so a Telegram-context order mints its Stars invoice link through the bot. A nil minter
// is a no-op, leaving the default (Stars-unavailable) handler in place.
func WithTelegramStars(minter InvoiceMinter) Option {
return func(r *Registry, backend *backendclient.Client) {
if minter != nil {
r.ops[MsgWalletOrder] = Op{Handler: walletOrderHandler(backend, minter), Auth: true}
}
}
}
// Lookup returns the operation for messageType, and whether it is registered.
func (r *Registry) Lookup(messageType string) (Op, bool) {
op, ok := r.ops[messageType]
@@ -344,41 +331,6 @@ func walletBuyHandler(backend *backendclient.Client) Handler {
}
}
// InvoiceMinter mints a Telegram Stars invoice link for a created order via the bot (only the bot
// reaches Telegram). The gateway bot-link Hub implements it; it is nil where the bot-link is off,
// which leaves the Telegram rail unavailable.
type InvoiceMinter interface {
MintInvoice(ctx context.Context, title, description, orderID string, amountStars int64) (string, error)
}
// errTelegramStarsUnavailable is returned when a Telegram Stars order is requested but no invoice
// minter (the bot-link) is wired — the rail is not available on this gateway.
var errTelegramStarsUnavailable = errors.New("telegram stars rail unavailable")
func walletOrderHandler(backend *backendclient.Client, minter InvoiceMinter) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
if err != nil {
return nil, err
}
// Telegram Stars: mint the invoice link here via the bot-link and return it to the client
// as the redirect URL it opens with WebApp.openInvoice. The title doubles as the invoice
// description (both are required and the pack title is the whole product).
if o.Rail == "telegram" {
if minter == nil {
return nil, errTelegramStarsUnavailable
}
link, merr := minter.MintInvoice(ctx, o.InvoiceTitle, o.InvoiceTitle, o.OrderID, o.InvoiceAmount)
if merr != nil {
return nil, merr
}
o.RedirectURL = link
}
return encodeWalletOrder(o), nil
}
}
func blockStatusHandler(backend *backendclient.Client) Handler {
return func(ctx context.Context, req Request) ([]byte, error) {
bs, err := backend.BlockStatus(ctx, req.UserID)
-40
View File
@@ -1,40 +0,0 @@
// Package vkpay verifies VK Mini Apps payment ("голоса") callback signatures. VK signs each
// payment notification (get_item / order_status_change) and expects the receiving server to verify
// it with the app's secret before acting. The signature is MD5 — mandated by VK's payment protocol,
// not a security choice on our part — so this package uses crypto/md5 deliberately.
package vkpay
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
)
// Verify reports whether params carry a valid VK payment signature under secret. VK computes the
// signature as the MD5 of the sig-excluded parameters, sorted alphabetically by name and
// concatenated as key=value with no separators, with the app secret appended. The comparison is
// case-insensitive over the hex digest.
func Verify(params map[string]string, secret string) bool {
got := params["sig"]
if got == "" || secret == "" {
return false
}
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k)
b.WriteString("=")
b.WriteString(params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return strings.EqualFold(hex.EncodeToString(sum[:]), got)
}
-74
View File
@@ -1,74 +0,0 @@
package vkpay
import (
"crypto/md5"
"encoding/hex"
"maps"
"sort"
"strings"
"testing"
)
// vkSig computes a valid VK signature the way VK does, for the fixtures (sig excluded, sorted
// key=value concatenation, secret appended, MD5 hex).
func vkSig(params map[string]string, secret string) string {
keys := make([]string, 0, len(params))
for k := range params {
if k == "sig" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
for _, k := range keys {
b.WriteString(k + "=" + params[k])
}
b.WriteString(secret)
sum := md5.Sum([]byte(b.String()))
return hex.EncodeToString(sum[:])
}
func clone(m map[string]string) map[string]string {
out := make(map[string]string, len(m))
maps.Copy(out, m)
return out
}
func TestVerify(t *testing.T) {
const secret = "app_secret"
base := map[string]string{
"notification_type": "order_status_change",
"app_id": "123",
"user_id": "456",
"receiver_id": "456",
"order_id": "789",
"date": "1700000000",
"status": "chargeable",
"item": "019f47a0-6f9e-7000-8000-000000000000",
"item_price": "10",
}
valid := clone(base)
// VK sends the digest uppercase; the verifier must accept it case-insensitively.
valid["sig"] = strings.ToUpper(vkSig(base, secret))
if !Verify(valid, secret) {
t.Fatal("valid VK signature rejected")
}
tampered := clone(valid)
tampered["item_price"] = "1"
if Verify(tampered, secret) {
t.Error("accepted a tampered amount")
}
if Verify(valid, "wrong-secret") {
t.Error("accepted under the wrong secret")
}
noSig := clone(valid)
delete(noSig, "sig")
if Verify(noSig, secret) {
t.Error("accepted a callback with no signature")
}
}
+3 -20
View File
@@ -73,12 +73,10 @@ github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EO
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0 h1:A8PeW59pxE9IoFRqBp37U+mSNaQoZ46F1f0f863XSXw=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/iliadenisov/alphabet v1.1.0 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4=
@@ -97,7 +95,6 @@ github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6K
github.com/konsorten/go-windows-terminal-sequences v1.0.2 h1:DB17ag19krx9CFsz4o3enTrPXyIXCl+2iCXH/aMAp9s=
github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
@@ -149,6 +146,8 @@ github.com/volatiletech/randomize v0.0.1 h1:eE5yajattWqTB2/eN8df4dw+8jwAzBtbdo5s
github.com/volatiletech/randomize v0.0.1/go.mod h1:GN3U0QYqfZ9FOJ67bzax1cqZ5q2xuj2mXrXBjWaRTlY=
github.com/volatiletech/strmangle v0.0.1 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0=
github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk=
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
@@ -177,7 +176,6 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJk
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
@@ -185,14 +183,10 @@ golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
@@ -206,17 +200,6 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
honnef.co/go/tools v0.0.1-2019.2.3 h1:3JgtbtFHMiCmsznwGVTUWbgGov+pVqnlf1dEJTNAXeM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.28.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.33.0/go.mod h1:+RhXBoRYzRwaH21mV/aj6XvQRDtfjcZfAlPMsQo8CR0=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/xurls/v2 v2.6.0 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
-12
View File
@@ -873,18 +873,6 @@ table WalletBuyRequest {
product_id:string;
}
// WalletOrderRequest opens a money order to fund a chip pack: the pack to buy.
table WalletOrderRequest {
product_id:string;
}
// WalletOrderResponse returns the created order id and the provider launch URL the client opens
// (the Robokassa hosted-payment page); chips are credited later, by the verified server callback.
table WalletOrderResponse {
order_id:string;
redirect_url:string;
}
// CatalogAtom is one atom line of a storefront product: the base value type it grants
// ("chips"/"hints"/"noads_days"/"tournament") and how many of it the product carries.
table CatalogAtom {
-60
View File
@@ -1,60 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletOrderRequest struct {
_tab flatbuffers.Table
}
func GetRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletOrderRequest{}
x.Init(buf, n+offset)
return x
}
func FinishWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletOrderRequest(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderRequest {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletOrderRequest{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletOrderRequestBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletOrderRequest) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletOrderRequest) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletOrderRequest) ProductId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletOrderRequestStart(builder *flatbuffers.Builder) {
builder.StartObject(1)
}
func WalletOrderRequestAddProductId(builder *flatbuffers.Builder, productId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(productId), 0)
}
func WalletOrderRequestEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
-71
View File
@@ -1,71 +0,0 @@
// Code generated by the FlatBuffers compiler. DO NOT EDIT.
package scrabblefb
import (
flatbuffers "github.com/google/flatbuffers/go"
)
type WalletOrderResponse struct {
_tab flatbuffers.Table
}
func GetRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse {
n := flatbuffers.GetUOffsetT(buf[offset:])
x := &WalletOrderResponse{}
x.Init(buf, n+offset)
return x
}
func FinishWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.Finish(offset)
}
func GetSizePrefixedRootAsWalletOrderResponse(buf []byte, offset flatbuffers.UOffsetT) *WalletOrderResponse {
n := flatbuffers.GetUOffsetT(buf[offset+flatbuffers.SizeUint32:])
x := &WalletOrderResponse{}
x.Init(buf, n+offset+flatbuffers.SizeUint32)
return x
}
func FinishSizePrefixedWalletOrderResponseBuffer(builder *flatbuffers.Builder, offset flatbuffers.UOffsetT) {
builder.FinishSizePrefixed(offset)
}
func (rcv *WalletOrderResponse) Init(buf []byte, i flatbuffers.UOffsetT) {
rcv._tab.Bytes = buf
rcv._tab.Pos = i
}
func (rcv *WalletOrderResponse) Table() flatbuffers.Table {
return rcv._tab
}
func (rcv *WalletOrderResponse) OrderId() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(4))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func (rcv *WalletOrderResponse) RedirectUrl() []byte {
o := flatbuffers.UOffsetT(rcv._tab.Offset(6))
if o != 0 {
return rcv._tab.ByteVector(o + rcv._tab.Pos)
}
return nil
}
func WalletOrderResponseStart(builder *flatbuffers.Builder) {
builder.StartObject(2)
}
func WalletOrderResponseAddOrderId(builder *flatbuffers.Builder, orderId flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(0, flatbuffers.UOffsetT(orderId), 0)
}
func WalletOrderResponseAddRedirectUrl(builder *flatbuffers.Builder, redirectUrl flatbuffers.UOffsetT) {
builder.PrependUOffsetTSlot(1, flatbuffers.UOffsetT(redirectUrl), 0)
}
func WalletOrderResponseEnd(builder *flatbuffers.Builder) flatbuffers.UOffsetT {
return builder.EndObject()
}
+24 -392
View File
@@ -225,7 +225,6 @@ type Command struct {
// *Command_SendToUser
// *Command_SendToChannel
// *Command_ChatGate
// *Command_CreateInvoice
Payload isCommand_Payload `protobuf_oneof:"payload"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
@@ -311,15 +310,6 @@ func (x *Command) GetChatGate() *ChatGateCommand {
return nil
}
func (x *Command) GetCreateInvoice() *CreateInvoiceCommand {
if x != nil {
if x, ok := x.Payload.(*Command_CreateInvoice); ok {
return x.CreateInvoice
}
}
return nil
}
type isCommand_Payload interface {
isCommand_Payload()
}
@@ -340,10 +330,6 @@ type Command_ChatGate struct {
ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"`
}
type Command_CreateInvoice struct {
CreateInvoice *CreateInvoiceCommand `protobuf:"bytes,6,opt,name=create_invoice,json=createInvoice,proto3,oneof"`
}
func (*Command_Notify) isCommand_Payload() {}
func (*Command_SendToUser) isCommand_Payload() {}
@@ -352,20 +338,15 @@ func (*Command_SendToChannel) isCommand_Payload() {}
func (*Command_ChatGate) isCommand_Payload() {}
func (*Command_CreateInvoice) isCommand_Payload() {}
// Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered. result
// carries a command's return value when it has one (the created invoice link for a
// create_invoice command); it is empty otherwise.
// unexpected transport/render failure, distinct from a clean not-delivered.
type Ack struct {
state protoimpl.MessageState `protogen:"open.v1"`
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"`
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,omitempty"`
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -421,13 +402,6 @@ func (x *Ack) GetError() string {
return ""
}
func (x *Ack) GetResult() string {
if x != nil {
return x.Result
}
return ""
}
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
// chat. external_id is the user's Telegram identity (as in the backend identities
// table); allow grants the right to write when true and revokes it when false. The
@@ -588,314 +562,6 @@ func (x *ChatEligibilityResponse) GetEligible() bool {
return false
}
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
// echoes back in the pre_checkout_query and the successful_payment; amount is the
// price in whole stars; title and description are shown on the invoice. The bot
// returns the link in its Ack result.
type CreateInvoiceCommand struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
Payload string `protobuf:"bytes,3,opt,name=payload,proto3" json:"payload,omitempty"`
Amount int64 `protobuf:"varint,4,opt,name=amount,proto3" json:"amount,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *CreateInvoiceCommand) Reset() {
*x = CreateInvoiceCommand{}
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *CreateInvoiceCommand) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CreateInvoiceCommand) ProtoMessage() {}
func (x *CreateInvoiceCommand) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[8]
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 CreateInvoiceCommand.ProtoReflect.Descriptor instead.
func (*CreateInvoiceCommand) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{8}
}
func (x *CreateInvoiceCommand) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *CreateInvoiceCommand) GetDescription() string {
if x != nil {
return x.Description
}
return ""
}
func (x *CreateInvoiceCommand) GetPayload() string {
if x != nil {
return x.Payload
}
return ""
}
func (x *CreateInvoiceCommand) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
// (whole stars) in currency may be approved before the charge.
type PreCheckoutRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
Amount int64 `protobuf:"varint,2,opt,name=amount,proto3" json:"amount,omitempty"`
Currency string `protobuf:"bytes,3,opt,name=currency,proto3" json:"currency,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreCheckoutRequest) Reset() {
*x = PreCheckoutRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreCheckoutRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreCheckoutRequest) ProtoMessage() {}
func (x *PreCheckoutRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[9]
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 PreCheckoutRequest.ProtoReflect.Descriptor instead.
func (*PreCheckoutRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{9}
}
func (x *PreCheckoutRequest) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *PreCheckoutRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *PreCheckoutRequest) GetCurrency() string {
if x != nil {
return x.Currency
}
return ""
}
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
// short user-facing decline message when ok is false.
type PreCheckoutResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Ok bool `protobuf:"varint,1,opt,name=ok,proto3" json:"ok,omitempty"`
Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *PreCheckoutResponse) Reset() {
*x = PreCheckoutResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *PreCheckoutResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*PreCheckoutResponse) ProtoMessage() {}
func (x *PreCheckoutResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[10]
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 PreCheckoutResponse.ProtoReflect.Descriptor instead.
func (*PreCheckoutResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{10}
}
func (x *PreCheckoutResponse) GetOk() bool {
if x != nil {
return x.Ok
}
return false
}
func (x *PreCheckoutResponse) GetReason() string {
if x != nil {
return x.Reason
}
return ""
}
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
// and the payer's Telegram user id.
type ForwardPaymentRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
OrderId string `protobuf:"bytes,1,opt,name=order_id,json=orderId,proto3" json:"order_id,omitempty"`
TelegramPaymentChargeId string `protobuf:"bytes,2,opt,name=telegram_payment_charge_id,json=telegramPaymentChargeId,proto3" json:"telegram_payment_charge_id,omitempty"`
Amount int64 `protobuf:"varint,3,opt,name=amount,proto3" json:"amount,omitempty"`
TelegramUserId int64 `protobuf:"varint,4,opt,name=telegram_user_id,json=telegramUserId,proto3" json:"telegram_user_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ForwardPaymentRequest) Reset() {
*x = ForwardPaymentRequest{}
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ForwardPaymentRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ForwardPaymentRequest) ProtoMessage() {}
func (x *ForwardPaymentRequest) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[11]
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 ForwardPaymentRequest.ProtoReflect.Descriptor instead.
func (*ForwardPaymentRequest) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{11}
}
func (x *ForwardPaymentRequest) GetOrderId() string {
if x != nil {
return x.OrderId
}
return ""
}
func (x *ForwardPaymentRequest) GetTelegramPaymentChargeId() string {
if x != nil {
return x.TelegramPaymentChargeId
}
return ""
}
func (x *ForwardPaymentRequest) GetAmount() int64 {
if x != nil {
return x.Amount
}
return 0
}
func (x *ForwardPaymentRequest) GetTelegramUserId() int64 {
if x != nil {
return x.TelegramUserId
}
return 0
}
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
// was credited (or already had been); false means the payment was recorded but could
// not be matched to a creditable order (an operator follows up). Either way the bot
// may forget the outbox row — only a transport error triggers a retry.
type ForwardPaymentResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Credited bool `protobuf:"varint,1,opt,name=credited,proto3" json:"credited,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ForwardPaymentResponse) Reset() {
*x = ForwardPaymentResponse{}
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ForwardPaymentResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ForwardPaymentResponse) ProtoMessage() {}
func (x *ForwardPaymentResponse) ProtoReflect() protoreflect.Message {
mi := &file_botlink_v1_botlink_proto_msgTypes[12]
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 ForwardPaymentResponse.ProtoReflect.Descriptor instead.
func (*ForwardPaymentResponse) Descriptor() ([]byte, []int) {
return file_botlink_v1_botlink_proto_rawDescGZIP(), []int{12}
}
func (x *ForwardPaymentResponse) GetCredited() bool {
if x != nil {
return x.Credited
}
return false
}
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
const file_botlink_v1_botlink_proto_rawDesc = "" +
@@ -910,7 +576,7 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\x05Hello\x12\x1f\n" +
"\vinstance_id\x18\x01 \x01(\tR\n" +
"instanceId\x12!\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xb2\x03\n" +
"\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xde\x02\n" +
"\aCommand\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" +
@@ -918,15 +584,13 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" +
"sendToUser\x12X\n" +
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\n" +
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGate\x12R\n" +
"\x0ecreate_invoice\x18\x06 \x01(\v2).scrabble.botlink.v1.CreateInvoiceCommandH\x00R\rcreateInvoiceB\t\n" +
"\apayload\"p\n" +
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGateB\t\n" +
"\apayload\"X\n" +
"\x03Ack\x12\x1d\n" +
"\n" +
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\n" +
"\x05error\x18\x03 \x01(\tR\x05error\x12\x16\n" +
"\x06result\x18\x04 \x01(\tR\x06result\"H\n" +
"\x05error\x18\x03 \x01(\tR\x05error\"H\n" +
"\x0fChatGateCommand\x12\x1f\n" +
"\vexternal_id\x18\x01 \x01(\tR\n" +
"externalId\x12\x14\n" +
@@ -938,31 +602,10 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
"\n" +
"registered\x18\x01 \x01(\bR\n" +
"registered\x12\x1a\n" +
"\beligible\x18\x02 \x01(\bR\beligible\"\x80\x01\n" +
"\x14CreateInvoiceCommand\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12 \n" +
"\vdescription\x18\x02 \x01(\tR\vdescription\x12\x18\n" +
"\apayload\x18\x03 \x01(\tR\apayload\x12\x16\n" +
"\x06amount\x18\x04 \x01(\x03R\x06amount\"c\n" +
"\x12PreCheckoutRequest\x12\x19\n" +
"\border_id\x18\x01 \x01(\tR\aorderId\x12\x16\n" +
"\x06amount\x18\x02 \x01(\x03R\x06amount\x12\x1a\n" +
"\bcurrency\x18\x03 \x01(\tR\bcurrency\"=\n" +
"\x13PreCheckoutResponse\x12\x0e\n" +
"\x02ok\x18\x01 \x01(\bR\x02ok\x12\x16\n" +
"\x06reason\x18\x02 \x01(\tR\x06reason\"\xb1\x01\n" +
"\x15ForwardPaymentRequest\x12\x19\n" +
"\border_id\x18\x01 \x01(\tR\aorderId\x12;\n" +
"\x1atelegram_payment_charge_id\x18\x02 \x01(\tR\x17telegramPaymentChargeId\x12\x16\n" +
"\x06amount\x18\x03 \x01(\x03R\x06amount\x12(\n" +
"\x10telegram_user_id\x18\x04 \x01(\x03R\x0etelegramUserId\"4\n" +
"\x16ForwardPaymentResponse\x12\x1a\n" +
"\bcredited\x18\x01 \x01(\bR\bcredited2\x99\x03\n" +
"\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\n" +
"\aBotLink\x12D\n" +
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\n" +
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponse\x12h\n" +
"\x13ValidatePreCheckout\x12'.scrabble.botlink.v1.PreCheckoutRequest\x1a(.scrabble.botlink.v1.PreCheckoutResponse\x12i\n" +
"\x0eForwardPayment\x12*.scrabble.botlink.v1.ForwardPaymentRequest\x1a+.scrabble.botlink.v1.ForwardPaymentResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
"\x16ResolveChatEligibility\x12+.scrabble.botlink.v1.ChatEligibilityRequest\x1a,.scrabble.botlink.v1.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3"
var (
file_botlink_v1_botlink_proto_rawDescOnce sync.Once
@@ -976,7 +619,7 @@ 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, 8)
var file_botlink_v1_botlink_proto_goTypes = []any{
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
@@ -986,37 +629,27 @@ var file_botlink_v1_botlink_proto_goTypes = []any{
(*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
(*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest
(*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest
(*v1.SendToGameChannelRequest)(nil), // 10: 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
8, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest
9, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest
10, // 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
0, // 7: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
9, // [9:11] is the sub-list for method output_type
7, // [7:9] is the sub-list for method input_type
7, // [7:7] is the sub-list for extension type_name
7, // [7:7] is the sub-list for extension extendee
0, // [0:7] is the sub-list for field type_name
}
func init() { file_botlink_v1_botlink_proto_init() }
@@ -1033,7 +666,6 @@ func file_botlink_v1_botlink_proto_init() {
(*Command_SendToUser)(nil),
(*Command_SendToChannel)(nil),
(*Command_ChatGate)(nil),
(*Command_CreateInvoice)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
@@ -1041,7 +673,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: 8,
NumExtensions: 0,
NumServices: 1,
},
+1 -68
View File
@@ -27,24 +27,6 @@ service BotLink {
// same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort).
rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse);
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
rpc ValidatePreCheckout(PreCheckoutRequest) returns (PreCheckoutResponse);
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
rpc ForwardPayment(ForwardPaymentRequest) returns (ForwardPaymentResponse);
}
// FromBot is a message the bot sends to the gateway: the opening Hello, then one
@@ -79,21 +61,17 @@ message Command {
scrabble.telegram.v1.SendToUserRequest send_to_user = 3;
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
ChatGateCommand chat_gate = 5;
CreateInvoiceCommand create_invoice = 6;
}
}
// Ack reports the outcome of the Command with command_id. delivered mirrors the
// connector delivery semantics (false when the kind is not rendered out-of-app, the
// user never started the bot, or no channel is configured); error carries an
// unexpected transport/render failure, distinct from a clean not-delivered. result
// carries a command's return value when it has one (the created invoice link for a
// create_invoice command); it is empty otherwise.
// unexpected transport/render failure, distinct from a clean not-delivered.
message Ack {
string command_id = 1;
bool delivered = 2;
string error = 3;
string result = 4;
}
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
@@ -121,48 +99,3 @@ message ChatEligibilityResponse {
bool registered = 1;
bool eligible = 2;
}
// CreateInvoiceCommand asks the bot to mint a Telegram Stars invoice link for a
// pending order (createInvoiceLink in XTR). payload is the order id, which Telegram
// echoes back in the pre_checkout_query and the successful_payment; amount is the
// price in whole stars; title and description are shown on the invoice. The bot
// returns the link in its Ack result.
message CreateInvoiceCommand {
string title = 1;
string description = 2;
string payload = 3;
int64 amount = 4;
}
// PreCheckoutRequest asks whether a Stars pre_checkout_query for order_id at amount
// (whole stars) in currency may be approved before the charge.
message PreCheckoutRequest {
string order_id = 1;
int64 amount = 2;
string currency = 3;
}
// PreCheckoutResponse is the approval answer. ok approves the charge; reason carries a
// short user-facing decline message when ok is false.
message PreCheckoutResponse {
bool ok = 1;
string reason = 2;
}
// ForwardPaymentRequest carries a completed Stars payment: order_id from the invoice
// payload, the Telegram charge id (the idempotency key), the amount in whole stars,
// and the payer's Telegram user id.
message ForwardPaymentRequest {
string order_id = 1;
string telegram_payment_charge_id = 2;
int64 amount = 3;
int64 telegram_user_id = 4;
}
// ForwardPaymentResponse reports the durable outcome. credited is true when the order
// was credited (or already had been); false means the payment was recorded but could
// not be matched to a creditable order (an operator follows up). Either way the bot
// may forget the outbox row — only a transport error triggers a retry.
message ForwardPaymentResponse {
bool credited = 1;
}
-104
View File
@@ -28,8 +28,6 @@ const _ = grpc.SupportPackageIsVersion9
const (
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility"
BotLink_ValidatePreCheckout_FullMethodName = "/scrabble.botlink.v1.BotLink/ValidatePreCheckout"
BotLink_ForwardPayment_FullMethodName = "/scrabble.botlink.v1.BotLink/ForwardPayment"
)
// BotLinkClient is the client API for BotLink service.
@@ -50,22 +48,6 @@ type BotLinkClient interface {
// same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error)
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error)
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error)
}
type botLinkClient struct {
@@ -99,26 +81,6 @@ func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatElig
return out, nil
}
func (c *botLinkClient) ValidatePreCheckout(ctx context.Context, in *PreCheckoutRequest, opts ...grpc.CallOption) (*PreCheckoutResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(PreCheckoutResponse)
err := c.cc.Invoke(ctx, BotLink_ValidatePreCheckout_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *botLinkClient) ForwardPayment(ctx context.Context, in *ForwardPaymentRequest, opts ...grpc.CallOption) (*ForwardPaymentResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ForwardPaymentResponse)
err := c.cc.Invoke(ctx, BotLink_ForwardPayment_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// BotLinkServer is the server API for BotLink service.
// All implementations must embed UnimplementedBotLinkServer
// for forward compatibility.
@@ -137,22 +99,6 @@ type BotLinkServer interface {
// same mTLS channel when a user joins the chat, to decide whether to grant the
// write permission. Delivery of the answer is request/response (not best-effort).
ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error)
// ValidatePreCheckout answers whether a Telegram Stars pre_checkout_query may be
// approved before any star is charged: the order in the invoice payload exists, is
// still creditable (pending or an honoured-expired order, never one already paid),
// and its amount and currency match the invoice. The bot calls it on every
// pre_checkout_query and approves only on ok; a not-ok answer or a channel failure
// declines the charge (fail-closed). Reusable Stars invoice links make this gate the
// one place a repeat payment is stopped before money moves. Request/response.
ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error)
// ForwardPayment delivers a completed Telegram Stars payment from the bot's durable
// outbox to the gateway for crediting. The bot calls it (retrying until it gets a
// response) after Telegram confirms the payment; the gateway forwards it to the
// backend intake, which credits the order once, idempotent on
// telegram_payment_charge_id. A response means the payment was durably handled
// (credited, or recorded as unmatched) and the bot may forget the outbox row; a
// transport error leaves the row for a later retry. Request/response.
ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error)
mustEmbedUnimplementedBotLinkServer()
}
@@ -169,12 +115,6 @@ func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot])
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented")
}
func (UnimplementedBotLinkServer) ValidatePreCheckout(context.Context, *PreCheckoutRequest) (*PreCheckoutResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ValidatePreCheckout not implemented")
}
func (UnimplementedBotLinkServer) ForwardPayment(context.Context, *ForwardPaymentRequest) (*ForwardPaymentResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method ForwardPayment not implemented")
}
func (UnimplementedBotLinkServer) mustEmbedUnimplementedBotLinkServer() {}
func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
@@ -221,42 +161,6 @@ func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _BotLink_ValidatePreCheckout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(PreCheckoutRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ValidatePreCheckout(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ValidatePreCheckout_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ValidatePreCheckout(ctx, req.(*PreCheckoutRequest))
}
return interceptor(ctx, in, info, handler)
}
func _BotLink_ForwardPayment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ForwardPaymentRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(BotLinkServer).ForwardPayment(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: BotLink_ForwardPayment_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(BotLinkServer).ForwardPayment(ctx, req.(*ForwardPaymentRequest))
}
return interceptor(ctx, in, info, handler)
}
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -268,14 +172,6 @@ var BotLink_ServiceDesc = grpc.ServiceDesc{
MethodName: "ResolveChatEligibility",
Handler: _BotLink_ResolveChatEligibility_Handler,
},
{
MethodName: "ValidatePreCheckout",
Handler: _BotLink_ValidatePreCheckout_Handler,
},
{
MethodName: "ForwardPayment",
Handler: _BotLink_ForwardPayment_Handler,
},
},
Streams: []grpc.StreamDesc{
{
+3 -16
View File
@@ -91,16 +91,6 @@ Telegram identity to an account from a browser. Both map a rejection to gRPC
the seeded Mini App rather than the bot profile.
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
default 25) to respect the Bot API flood limits.
- **Payments (Telegram Stars).** When `TELEGRAM_STARS_OUTBOX_DIR` is set (default `/data`), the
bot handles the Stars rail. Only the bot reaches Telegram, so it mints the invoice on a
`CreateInvoice` bot-link command (`createInvoiceLink`, XTR — the link goes back to the Mini App
for `WebApp.openInvoice`); it gates each `pre_checkout_query` through the bot-link
(`ValidatePreCheckout`, backed by the backend intake — declining an already-paid reusable
invoice before the charge); and it records each `successful_payment` in a durable **SQLite
outbox** (`internal/outbox`, `stars.db` on the writable volume) before forwarding it over the
bot-link (`ForwardPayment`). The outbox is re-driven on startup and every 30 s, so a gateway or
backend outage never loses a paid order; crediting is idempotent on `telegram_payment_charge_id`.
The rail stays inert until a chip pack carries an XTR price (seeded in the admin).
The send commands address a recipient by the identity `external_id` (as in the backend
`identities` table), so a future VK / MAX bot reuses them; only the validator's initData
@@ -114,11 +104,9 @@ parsing is Telegram-specific.
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
relay.
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
Ack result), plus unary `ResolveChatEligibility` (the bot's join-time query),
`ValidatePreCheckout` and `ForwardPayment` (the Stars rail) over the same mTLS channel.
Generated Go is committed under `pkg`.
on the gateway (`Hello` / `Command` / `Ack`), now also carrying a `ChatGateCommand` (set
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
query) over the same mTLS channel. Generated Go is committed under `pkg`.
## Deep-link scheme
@@ -165,7 +153,6 @@ Bot (`cmd/bot`):
| `TELEGRAM_CHAT_ID` | — | the moderated discussion chat id (a channel's linked group); empty disables chat gating |
| `TELEGRAM_SUPPORT_CHAT_ID` | — | the support relay's forum supergroup id (topic per user); empty disables the relay |
| `TELEGRAM_SUPPORT_STATE_DIR` | `/data` | directory for the support relay's JSON state (a writable volume) |
| `TELEGRAM_STARS_OUTBOX_DIR` | `/data` | directory for the Telegram Stars payment outbox (`stars.db`, a writable volume); empty disables the Stars rail |
| `TELEGRAM_PROMO_BOT_TOKEN` | — | the optional standalone promo bot's token; empty disables it |
| `TELEGRAM_BOT_USERNAME` | — | the main bot's @username without the @ (promo message); required when the promo bot runs |
| `TELEGRAM_BOT_LINK` | — | the main bot's Mini App link for the promo button (the UI's `VITE_TELEGRAM_LINK`); required when the promo bot runs |
-25
View File
@@ -23,7 +23,6 @@ import (
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
)
@@ -91,24 +90,11 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
}, logger)
if err != nil {
return err
}
// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
// durable outbox would risk losing a paid-for order.
var paymentOutbox *outbox.Store
if cfg.StarsOutboxDir != "" {
paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
if err != nil {
return err
}
defer func() { _ = paymentOutbox.Close() }()
}
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
if err != nil {
return err
@@ -129,12 +115,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
// the bot after the client is built — the late binding that breaks the bot <->
// client construction cycle.
b.SetEligibilityResolver(client.ResolveChatEligibility)
// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
// completed-payment forwarding, backed by the durable outbox — wired here for the same
// late-binding reason.
if paymentOutbox != nil {
b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
}
// The optional standalone promo bot: a second bot (its own token) that only answers
// /start with a button opening the main bot's Mini App. It is self-contained — no
@@ -180,11 +160,6 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
logger.Error("bot-link client stopped", zap.Error(err))
}
})
// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
// outage) and periodically thereafter.
if paymentOutbox != nil {
wg.Go(func() { b.RunPaymentDrainer(ctx) })
}
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
// the main bot's lease).
if promo != nil {
-13
View File
@@ -12,16 +12,3 @@ require (
google.golang.org/protobuf v1.36.11
scrabble/pkg v0.0.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.43.0 // indirect
modernc.org/libc v1.72.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.49.1 // indirect
)
+3 -29
View File
@@ -16,7 +16,6 @@ import (
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support"
)
@@ -50,10 +49,6 @@ type Config struct {
// SupportStore persists the support relay's state (topic mapping, block list,
// relayed message ids); required when SupportChatID is set, ignored otherwise.
SupportStore *support.Store
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -96,12 +91,6 @@ type Bot struct {
supportLocks *keyedMutex
// admins caches the support chat's administrator ids (who may reply and act).
admins *adminCache
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
precheck PreCheckoutValidator
forward PaymentForwarder
outbox *outbox.Store
}
// New builds the bot wrapper, registering the /start handler and a default handler
@@ -134,10 +123,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// callback handler by their "sup:" data prefix.
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
}
// Allowed updates default to "all except chat_member" (which already includes
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
// pre_checkout_query, which the explicit set would otherwise drop.
// Allowed updates default to "all except chat_member". Specify an explicit set only
// when we need chat_member (moderated chat) — and then re-add callback_query (which
// the explicit set would otherwise drop) when the support relay needs it.
if cfg.ChatID != 0 {
allowed := tgbot.AllowedUpdates{
models.AllowedUpdateMessage,
@@ -147,9 +135,6 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
}
if cfg.AcceptPayments {
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
}
if cfg.TestEnv {
@@ -361,17 +346,6 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleChatMember(ctx, update.ChatMember)
return
}
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
// message is never mistaken for a support DM or given a launch reply.
if update.PreCheckoutQuery != nil {
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
return
}
if update.Message != nil && update.Message.SuccessfulPayment != nil {
t.handleSuccessfulPayment(ctx, update.Message)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply.
-166
View File
@@ -1,166 +0,0 @@
package bot
import (
"context"
"time"
tgbot "github.com/go-telegram/bot"
"github.com/go-telegram/bot/models"
"go.uber.org/zap"
"scrabble/platform/telegram/internal/outbox"
)
// starsCurrency is the Telegram Stars currency code for createInvoiceLink and the invoice line.
const starsCurrency = "XTR"
// paymentDrainInterval is how often the bot re-drives undelivered outbox payments (the backstop for a
// gateway or backend outage, and the restart re-drive); the happy path forwards immediately on receipt.
const paymentDrainInterval = 30 * time.Second
// PreCheckoutValidator validates a Stars pre_checkout order over the bot-link and returns whether it
// may be charged plus a short, already-localised decline reason for the payer. The bot-link client
// backs it.
type PreCheckoutValidator func(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error)
// PaymentForwarder delivers a completed Stars payment over the bot-link for crediting and reports
// whether it was credited (or already had been). A non-nil error is transient and retried. The
// bot-link client backs it.
type PaymentForwarder func(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error)
// SetPaymentHandlers wires the Telegram Stars runtime dependencies after construction: the
// pre_checkout validator and payment forwarder (both over the bot-link, built after the bot) and the
// durable outbox. It is the late binding that breaks the bot <-> bot-link construction cycle.
func (t *Bot) SetPaymentHandlers(precheck PreCheckoutValidator, forward PaymentForwarder, store *outbox.Store) {
t.precheck = precheck
t.forward = forward
t.outbox = store
}
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged with payload
// (the order id, echoed back in pre_checkout and successful_payment), and returns the link. The
// provider token is empty for Stars. Only the bot reaches Telegram, so the gateway calls this over
// the bot-link on the wallet-order path.
func (t *Bot) CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error) {
return t.api.CreateInvoiceLink(ctx, &tgbot.CreateInvoiceLinkParams{
Title: title,
Description: description,
Payload: payload,
Currency: starsCurrency,
Prices: []models.LabeledPrice{{Label: title, Amount: int(amountStars)}},
})
}
// handlePreCheckout answers a Stars pre_checkout_query. It validates the order against the backend
// (over the bot-link) and approves only on a positive answer; a not-ok answer or a validation
// failure declines the charge (fail-closed), before any star moves. The decline reason from the
// backend is already localised to the payer's account language.
func (t *Bot) handlePreCheckout(ctx context.Context, q *models.PreCheckoutQuery) {
ok, reason := false, ""
if t.precheck == nil {
t.log.Warn("pre_checkout received but the validator is not wired; declining", zap.String("order", q.InvoicePayload))
reason = fallbackDeclineText(q.From)
} else if v, r, err := t.precheck(ctx, q.InvoicePayload, int64(q.TotalAmount), q.Currency); err != nil {
t.log.Warn("pre_checkout validation failed; declining", zap.String("order", q.InvoicePayload), zap.Error(err))
reason = fallbackDeclineText(q.From)
} else {
ok, reason = v, r
}
params := &tgbot.AnswerPreCheckoutQueryParams{PreCheckoutQueryID: q.ID, OK: ok}
if !ok {
params.ErrorMessage = reason
}
if _, err := t.api.AnswerPreCheckoutQuery(ctx, params); err != nil {
t.log.Warn("answer pre_checkout failed", zap.String("order", q.InvoicePayload), zap.Error(err))
}
}
// handleSuccessfulPayment records a completed Stars payment in the durable outbox and forwards it to
// the gateway. Persisting first is the durability point: a crash before forwarding still re-drives
// the payment on restart. The immediate forward is best-effort; the periodic drainer covers a
// gateway or backend outage.
func (t *Bot) handleSuccessfulPayment(ctx context.Context, msg *models.Message) {
sp := msg.SuccessfulPayment
if t.outbox == nil {
t.log.Error("successful_payment received but the outbox is not wired; the payment is at risk",
zap.String("charge", sp.TelegramPaymentChargeID))
return
}
var tgUserID int64
if msg.From != nil {
tgUserID = msg.From.ID
}
rec := outbox.Record{
ChargeID: sp.TelegramPaymentChargeID,
OrderID: sp.InvoicePayload,
Amount: int64(sp.TotalAmount),
UserID: tgUserID,
}
if err := t.outbox.Add(ctx, rec); err != nil {
t.log.Error("outbox persist failed; the drainer cannot recover this payment",
zap.String("charge", rec.ChargeID), zap.Error(err))
return
}
t.log.Info("stars payment received", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID))
t.forwardOne(ctx, rec)
}
// RunPaymentDrainer re-drives undelivered outbox payments: once at startup (recovering any left by a
// restart or a past outage) and then on paymentDrainInterval, until ctx is cancelled. It is a no-op
// when the Stars rail is not wired.
func (t *Bot) RunPaymentDrainer(ctx context.Context) {
if t.outbox == nil || t.forward == nil {
return
}
t.drainOutbox(ctx)
ticker := time.NewTicker(paymentDrainInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.drainOutbox(ctx)
}
}
}
// drainOutbox forwards a batch of pending payments to the gateway.
func (t *Bot) drainOutbox(ctx context.Context) {
recs, err := t.outbox.Pending(ctx, 50)
if err != nil {
t.log.Warn("outbox drain read failed", zap.Error(err))
return
}
for _, rec := range recs {
t.forwardOne(ctx, rec)
}
}
// forwardOne delivers one payment to the gateway and, on a durable response (credited or not), marks
// it forwarded so it is not re-sent. A transport error leaves the row for the next drain. Crediting is
// idempotent at the backend, so a re-forward after a lost ack never double-credits.
func (t *Bot) forwardOne(ctx context.Context, rec outbox.Record) {
if t.forward == nil {
return
}
credited, err := t.forward(ctx, rec.OrderID, rec.ChargeID, rec.Amount, rec.UserID)
if err != nil {
t.log.Warn("forward payment failed; will retry", zap.String("charge", rec.ChargeID), zap.Error(err))
return
}
if err := t.outbox.MarkForwarded(ctx, rec.ChargeID); err != nil {
t.log.Error("mark forwarded failed", zap.String("charge", rec.ChargeID), zap.Error(err))
return
}
t.log.Info("stars payment forwarded", zap.String("charge", rec.ChargeID), zap.String("order", rec.OrderID), zap.Bool("credited", credited))
}
// fallbackDeclineText is the pre_checkout decline message used only when the backend cannot be
// reached to form a localised one; it falls back to the payer's Telegram client language.
func fallbackDeclineText(from *models.User) string {
if from != nil && from.LanguageCode == "ru" {
return "Оплата временно недоступна. Попробуйте позже."
}
return "Payment is temporarily unavailable. Please try again later."
}
+2 -30
View File
@@ -83,34 +83,6 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
return resp.GetEligible(), nil
}
// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
// paying amount in currency may be approved, before the charge. The bot calls it on every
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
// decline message (already localised by the backend) to show the payer.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
if err != nil {
return false, "", err
}
return resp.GetOk(), resp.GetReason(), nil
}
// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
// error is a transient failure the bot retries.
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
OrderId: orderID,
TelegramPaymentChargeId: chargeID,
Amount: amount,
TelegramUserId: telegramUserID,
})
if err != nil {
return false, err
}
return resp.GetCredited(), nil
}
// Run keeps the bot-link command stream open, re-opening it after each break, until
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
func (c *Client) Run(ctx context.Context) error {
@@ -149,8 +121,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
if cmd == nil {
continue
}
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
delivered, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
if herr != nil {
ack.Error = herr.Error()
}
+7 -31
View File
@@ -27,10 +27,6 @@ type Sender interface {
// chat, but only when they are currently in it; it reports whether a restriction
// was applied.
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged
// with payload (the order id, echoed back in pre_checkout and successful_payment), and
// returns the link.
CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error)
}
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
@@ -52,42 +48,22 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
return &Executor{sender: sender, channelID: channelID, log: log}
}
// Handle dispatches one command to the matching Bot API call. It returns whether the command was
// delivered, an optional result string (the created invoice link for a create_invoice command; empty
// otherwise), and an error for an unexpected or malformed failure.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, string, error) {
// Handle dispatches one command to the matching Bot API send.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
switch p := cmd.GetPayload().(type) {
case *botlinkv1.Command_Notify:
d, err := e.notify(ctx, p.Notify)
return d, "", err
return e.notify(ctx, p.Notify)
case *botlinkv1.Command_SendToUser:
d, err := e.sendToUser(ctx, p.SendToUser)
return d, "", err
return e.sendToUser(ctx, p.SendToUser)
case *botlinkv1.Command_SendToChannel:
d, err := e.sendToChannel(ctx, p.SendToChannel)
return d, "", err
return e.sendToChannel(ctx, p.SendToChannel)
case *botlinkv1.Command_ChatGate:
d, err := e.chatGate(ctx, p.ChatGate)
return d, "", err
case *botlinkv1.Command_CreateInvoice:
return e.createInvoice(ctx, p.CreateInvoice)
return e.chatGate(ctx, p.ChatGate)
default:
return false, "", fmt.Errorf("botlink: empty command")
return false, fmt.Errorf("botlink: empty command")
}
}
// createInvoice mints a Telegram Stars invoice link for the order and returns it in the Ack result.
// A Bot API failure is a hard error carried back in the Ack, so the gateway's synchronous mint fails
// (rather than returning an empty link).
func (e *Executor) createInvoice(ctx context.Context, req *botlinkv1.CreateInvoiceCommand) (bool, string, error) {
link, err := e.sender.CreateInvoiceLink(ctx, req.GetTitle(), req.GetDescription(), req.GetPayload(), req.GetAmount())
if err != nil {
e.log.Warn("create invoice link failed", zap.String("order", req.GetPayload()), zap.Error(err))
return false, "", err
}
return true, link, nil
}
// chatGate applies a chat-gate command: it parses the target Telegram user id and
// sets their write access in the moderated chat (a no-op when they are not in it). A
// Bot API failure is logged and reported as not-delivered, not a hard error.
@@ -13,13 +13,11 @@ import (
// fakeSender records the delivery calls the executor makes.
type fakeSender struct {
notify []notifyCall
text []textCall
gate []gateCall
invoice []invoiceCall
invoiceLink string // CreateInvoiceLink's returned link
applied bool // ApplyChatGate's reported result
err error
notify []notifyCall
text []textCall
gate []gateCall
applied bool // ApplyChatGate's reported result
err error
}
type notifyCall struct {
@@ -34,10 +32,6 @@ type gateCall struct {
userID int64
allow bool
}
type invoiceCall struct {
title, description, payload string
amount int64
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
@@ -54,11 +48,6 @@ func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool)
return f.applied, f.err
}
func (f *fakeSender) CreateInvoiceLink(_ context.Context, title, description, payload string, amountStars int64) (string, error) {
f.invoice = append(f.invoice, invoiceCall{title, description, payload, amountStars})
return f.invoiceLink, f.err
}
func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID)
@@ -78,7 +67,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -96,7 +85,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -110,7 +99,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
@@ -119,7 +108,7 @@ func TestExecutorSendToUser(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
delivered, _, err := exec.Handle(context.Background(), cmd)
delivered, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -137,7 +126,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
func TestExecutorChatGateApplied(t *testing.T) {
sender := &fakeSender{applied: true}
exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("777", true))
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -149,7 +138,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
func TestExecutorChatGateNotInChat(t *testing.T) {
sender := &fakeSender{applied: false} // user not in the chat
exec := NewExecutor(sender, 0, nil)
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("888", false))
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -163,53 +152,24 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
func TestExecutorCreateInvoice(t *testing.T) {
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
}}}
delivered, result, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || result != "https://t.me/$abc" {
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
}
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
t.Errorf("invoice calls = %+v", sender.invoice)
}
}
func TestExecutorCreateInvoiceError(t *testing.T) {
sender := &fakeSender{err: context.DeadlineExceeded}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "x", Description: "x", Payload: "order-2", Amount: 10,
}}}
if _, _, err := exec.Handle(context.Background(), cmd); err == nil {
t.Error("expected an error when minting the invoice fails")
}
}
func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
t.Run("unconfigured", func(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, _, err := exec.Handle(context.Background(), channelCmd); err == nil {
if _, err := exec.Handle(context.Background(), channelCmd); err == nil {
t.Error("expected an error when no channel is configured")
}
})
t.Run("configured", func(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 555, nil)
delivered, _, err := exec.Handle(context.Background(), channelCmd)
delivered, err := exec.Handle(context.Background(), channelCmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -52,11 +52,6 @@ type BotConfig struct {
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
// container user (UID 65532) and backed by a persistent volume.
SupportStateDir string
// StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file
// (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by
// the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any
// payment not yet forwarded to the gateway.
StarsOutboxDir string
// PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
@@ -162,7 +157,6 @@ func LoadBot() (BotConfig, error) {
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
-104
View File
@@ -1,104 +0,0 @@
// Package outbox is the Telegram Stars payment outbox: a small SQLite store on the bot host's
// writable volume that durably records each completed Stars payment the moment Telegram delivers it,
// so a gateway or backend outage cannot lose it. The bot forwards pending rows to the gateway and
// marks them delivered; rows still undelivered are re-driven on restart. It is pure-Go SQLite
// (modernc.org/sqlite, no CGO) so it runs on the distroless image.
package outbox
import (
"context"
"database/sql"
"fmt"
"time"
_ "modernc.org/sqlite"
)
// Record is one completed Stars payment awaiting delivery to the gateway.
type Record struct {
// ChargeID is the telegram_payment_charge_id — the primary key here and the credit idempotency
// key at the backend, so a re-delivered or retried payment is never credited twice.
ChargeID string
// OrderID is the invoice payload (our order id) the payment settles.
OrderID string
// Amount is the stars paid (the XTR minor unit is the whole star).
Amount int64
// UserID is the payer's Telegram user id.
UserID int64
}
// Store is the SQLite-backed payment outbox.
type Store struct {
db *sql.DB
}
// Open opens (creating if absent) the outbox database at path and ensures its schema. The parent
// directory must exist and be writable by the container user.
func Open(path string) (*Store, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("outbox: open %s: %w", path, err)
}
// A single connection serialises writes and sidesteps SQLite's "database is locked" under the
// bot's low, bursty payment volume.
db.SetMaxOpenConns(1)
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS stars_payments (
charge_id TEXT PRIMARY KEY,
order_id TEXT NOT NULL,
amount INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
forwarded INTEGER NOT NULL DEFAULT 0
)`); err != nil {
_ = db.Close()
return nil, fmt.Errorf("outbox: init schema: %w", err)
}
return &Store{db: db}, nil
}
// Close closes the database.
func (s *Store) Close() error { return s.db.Close() }
// Add records a completed payment. It is idempotent on the charge id: a payment Telegram re-delivers
// (or one already recorded and forwarded) is ignored, so it is never forwarded — and credited —
// twice.
func (s *Store) Add(ctx context.Context, r Record) error {
_, err := s.db.ExecContext(ctx,
`INSERT INTO stars_payments (charge_id, order_id, amount, user_id, created_at, forwarded)
VALUES (?, ?, ?, ?, ?, 0)
ON CONFLICT(charge_id) DO NOTHING`,
r.ChargeID, r.OrderID, r.Amount, r.UserID, time.Now().Unix())
if err != nil {
return fmt.Errorf("outbox: add %s: %w", r.ChargeID, err)
}
return nil
}
// Pending returns up to limit payments not yet forwarded, oldest first.
func (s *Store) Pending(ctx context.Context, limit int) ([]Record, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT charge_id, order_id, amount, user_id FROM stars_payments
WHERE forwarded = 0 ORDER BY created_at LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("outbox: read pending: %w", err)
}
defer rows.Close()
var out []Record
for rows.Next() {
var r Record
if err := rows.Scan(&r.ChargeID, &r.OrderID, &r.Amount, &r.UserID); err != nil {
return nil, fmt.Errorf("outbox: scan: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// MarkForwarded flags a payment as delivered so it is not forwarded again.
func (s *Store) MarkForwarded(ctx context.Context, chargeID string) error {
if _, err := s.db.ExecContext(ctx,
`UPDATE stars_payments SET forwarded = 1 WHERE charge_id = ?`, chargeID); err != nil {
return fmt.Errorf("outbox: mark forwarded %s: %w", chargeID, err)
}
return nil
}
@@ -1,124 +0,0 @@
package outbox
import (
"context"
"path/filepath"
"testing"
)
// openTemp opens a fresh outbox in a temp dir.
func openTemp(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "stars.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func TestOutboxAddPendingMark(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "ch1", OrderID: "ord1", Amount: 40, UserID: 777}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending = %+v, want [%+v]", pending, rec)
}
if err := s.MarkForwarded(ctx, "ch1"); err != nil {
t.Fatalf("mark: %v", err)
}
pending, err = s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending after mark: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending after mark = %+v, want empty", pending)
}
}
func TestOutboxAddIdempotent(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "dup", OrderID: "ord1", Amount: 80, UserID: 1}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add 1: %v", err)
}
// A re-delivered payment (same charge id) must not add a second row, even with different fields.
if err := s.Add(ctx, Record{ChargeID: "dup", OrderID: "other", Amount: 999, UserID: 2}); err != nil {
t.Fatalf("add 2: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending = %+v, want the single original row", pending)
}
}
func TestOutboxAddIgnoresForwarded(t *testing.T) {
ctx := context.Background()
s := openTemp(t)
rec := Record{ChargeID: "ch", OrderID: "o", Amount: 40, UserID: 5}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.MarkForwarded(ctx, "ch"); err != nil {
t.Fatalf("mark: %v", err)
}
// A re-delivery after forwarding must not resurrect the row as pending (no double credit).
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("re-add: %v", err)
}
pending, err := s.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 0 {
t.Fatalf("pending = %+v, want empty (already forwarded)", pending)
}
}
// TestOutboxReopenReDrives proves a restart re-drives undelivered payments: a payment persisted but
// not marked forwarded is still pending after the store is reopened from the same file.
func TestOutboxReopenReDrives(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, "stars.db")
s, err := Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
rec := Record{ChargeID: "persist", OrderID: "o1", Amount: 40, UserID: 9}
if err := s.Add(ctx, rec); err != nil {
t.Fatalf("add: %v", err)
}
if err := s.Close(); err != nil {
t.Fatalf("close: %v", err)
}
reopened, err := Open(path)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer func() { _ = reopened.Close() }()
pending, err := reopened.Pending(ctx, 10)
if err != nil {
t.Fatalf("pending: %v", err)
}
if len(pending) != 1 || pending[0] != rec {
t.Fatalf("pending after reopen = %+v, want the undelivered payment", pending)
}
}
-11
View File
@@ -58,14 +58,3 @@ test('the landing shows a web-version entry linking /app/, with a caption', asyn
expect(await web.getAttribute('href')).toContain('/app/');
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 }) => {
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/');
});
+2 -21
View File
@@ -37,27 +37,8 @@ test('wallet: balances, benefits and the storefront render for a durable account
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();
await expect(page.getByTestId('offer')).toContainText('Public offer');
});
test('wallet: buying a chip pack opens the provider payment page', async ({ page }) => {
await loginLobby(page);
await openWallet(page);
// The purchase opens the provider's hosted-payment page in a new tab (window.open); capture it.
await page.evaluate(() => {
(window as { __opened?: string }).__opened = '';
window.open = ((u: string) => {
(window as { __opened?: string }).__opened = u;
return null;
}) as typeof window.open;
});
await page.locator('[data-kind="pack"]').getByTestId('buy-pack').click();
await expect
.poll(() => page.evaluate(() => (window as { __opened?: string }).__opened))
.toContain('robokassa');
// The pack purchase (money intake) is not wired yet, so its action is the disabled 'Soon'.
await expect(pack.getByRole('button', { name: 'Soon' })).toBeDisabled();
});
test('wallet: the tab sits between Friends and About', async ({ page }) => {
-145
View File
@@ -1,145 +0,0 @@
# Публичная оферта
Публичная оферта о заключении договора купли-продажи.
## 1. Общие положения
В настоящей Публичной оферте содержатся условия заключения Договора купли-продажи (далее по тексту — «Договор купли-продажи» и/или «Договор»). Настоящей офертой признается предложение, адресованное одному или нескольким конкретным лицам, которое достаточно определенно и выражает намерение лица, сделавшего предложение, считать себя заключившим Договор с адресатом, которым будет принято предложение.
Совершение указанных в настоящей Оферте действий является подтверждением согласия обеих Сторон заключить Договор купли-продажи на условиях, в порядке и объеме, изложенных в настоящей Оферте.
Нижеизложенный текст Публичной оферты является официальным публичным предложением Продавца, адресованный заинтересованному кругу лиц заключить Договор купли-продажи в соответствии с положениями пункта 2 статьи 437 Гражданского кодекса РФ.
Договор купли-продажи считается заключенным и приобретает силу с момента совершения Сторонами действий, предусмотренных в настоящей Оферте, и, означающих безоговорочное, а также полное принятие всех условий настоящей Оферты без каких-либо изъятий или ограничений на условиях присоединения.
### Термины и определения
**Договор** — текст настоящей Оферты с Приложениями, являющимися неотъемлемой частью настоящей Оферты, акцептованный Покупателем путем совершения конклюдентных действий, предусмотренных настоящей Офертой.
**Конклюдентные действия** — это поведение, которое выражает согласие с предложением контрагента заключить, изменить или расторгнуть договор. Действия состоят в полном или частичном выполнении условий, которые предложил контрагент.
**Сайт Продавца в сети «Интернет»** — совокупность программ для электронных вычислительных машин и иной информации, содержащейся в информационной системе, доступ к которой обеспечивается посредством сети «Интернет» по доменному имени и сетевому адресу: `erudit-game.ru`.
**Стороны Договора (Стороны)** — Продавец и Покупатель.
**Товар** — товаром по договору купли-продажи могут быть любые вещи с соблюдением правил, предусмотренных статьей 129 Гражданского кодекса РФ.
## 2. Предмет Договора
**2.1.** По настоящему Договору Продавец обязуется передать вещь (Товар) в собственность Покупателя, а Покупатель обязуется принять Товар и уплатить за него определенную денежную сумму.
**2.2.** Наименование, количество, а также ассортимент Товара, его стоимость, порядок доставки и иные условия определяются на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет» `erudit-game.ru`.
**2.3.** Акцепт настоящей Оферты выражается в совершении конклюдентных действий, в частности:
- действиях, связанных с регистрацией учетной записи на Сайте Продавца в сети «Интернет» при наличии необходимости регистрации учетной записи;
- путем составления и заполнения заявки на оформление заказа Товара;
- путем сообщения требуемых для заключения Договора сведений по телефону, электронной почте, указанными на сайте Продавца в сети «Интернет», в том числе, при обратном звонке Продавца по заявке Покупателя;
- оплаты Товара Покупателем.
Данный перечень не является исчерпывающим, могут быть и другие действия, которые ясно выражают намерение лица принять предложение контрагента.
## 3. Права и обязанности Сторон
### 3.1. Права и обязанности Продавца
**3.1.1.** Продавец вправе требовать оплаты Товаров и их доставки в порядке и на условиях, предусмотренных Договором;
**3.1.2.** Отказать в заключении Договора на основании настоящей Оферты Покупателю в случае его недобросовестного поведения, в частности, в случае:
- более 2 (Двух) отказов от Товаров надлежащего качества в течение года;
- предоставления заведомо недостоверной персональной информации;
- возврата испорченного Покупателем Товара или Товара, бывшего в употреблении;
- иных случаях недобросовестного поведения, свидетельствующих о заключении Покупателем Договора с целью злоупотребления правами, и отсутствия обычной экономической цели Договора — приобретения Товара.
**3.1.3.** Продавец обязуется передать Покупателю Товар надлежащего качества и в надлежащей упаковке;
**3.1.4.** Передать Товар свободным от прав третьих лиц;
**3.1.5.** Организовать доставку Товаров Покупателю;
**3.1.6.** Предоставить Покупателю всю необходимую информацию в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
### 3.2. Права и обязанности Покупателя
**3.2.1.** Покупатель вправе требовать передачи Товара в порядке и на условиях, предусмотренных Договором.
**3.2.2.** Требовать предоставления всей необходимой информации в соответствии с требованиями действующего законодательства РФ и настоящей Оферты;
**3.2.3.** Отказаться от Товара по основаниям, предусмотренным Договором и действующим законодательством Российской Федерации.
**3.2.4.** Покупатель обязуется предоставить Продавцу достоверную информацию, необходимую для надлежащего исполнения Договора;
**3.2.5.** Принять и оплатить Товар в соответствии с условиями Договора;
**3.2.6.** Покупатель гарантирует, что все условия Договора ему понятны; Покупатель принимает условия без оговорок, а также в полном объеме.
## 4. Цена и порядок расчетов
**4.1.** Стоимость, а также порядок оплаты Товара определяется на основании сведений Продавца при оформлении заявки Покупателем, либо устанавливаются на сайте Продавца в сети «Интернет»: `erudit-game.ru`.
**4.2.** Все расчеты по Договору производятся в безналичном порядке.
## 5. Обмен и возврат Товара
**5.1.** Покупатель вправе осуществить возврат (обмен) Продавцу Товара, приобретенный дистанционным способом, за исключением перечня товаров, не подлежащих обмену и возврату согласно действующему законодательству Российской Федерации. Условия, сроки и порядок возврата Товара надлежащего и ненадлежащего качества установлены в соответствии с Гражданским кодексом РФ, Закона РФ от 07.02.1992 N 2300-1 «О защите прав потребителей», Правил, утвержденных Постановлением Правительства РФ от 31.12.2020 N 2463.
**5.2.** Требование Покупателя об обмене либо о возврате Товара рассматривается индивидуально при условии неиспользования приобретенного товара и наличии уважительных причин (технический сбой, ошибка в описании товара).
## 6. Конфиденциальность и безопасность
**6.1.** При реализации настоящего Договора Стороны обеспечивают конфиденциальность и безопасность персональных данных в соответствии с актуальной редакцией ФЗ от 27.07.2006 г. № 152-ФЗ «О персональных данных» и ФЗ от 27.07.2006 г. № 149-ФЗ «Об информации, информационных технологиях и о защите информации».
**6.2.** Стороны обязуются сохранять конфиденциальность информации, полученной в ходе исполнения настоящего Договора, и принять все возможные меры, чтобы предохранить полученную информацию от разглашения.
**6.3.** Под конфиденциальной информацией понимается любая информация, передаваемая Продавцом и Покупателем в процессе реализации Договора и подлежащая защите, исключения указаны ниже.
**6.4.** Такая информация может содержаться в предоставляемых Продавцом локальных нормативных актах, договорах, письмах, отчетах, аналитических материалах, результатах исследований, схемах, графиках, спецификациях и других документах, оформленных как на бумажных, так и на электронных носителях.
## 7. Форс-мажор
**7.1.** Стороны освобождаются от ответственности за неисполнение или ненадлежащее исполнение обязательств по Договору, если надлежащее исполнение оказалось невозможным вследствие непреодолимой силы, то есть чрезвычайных и непредотвратимых при данных условиях обстоятельств, под которыми понимаются: запретные действия властей, эпидемии, блокада, эмбарго, землетрясения, наводнения, пожары или другие стихийные бедствия.
**7.2.** В случае наступления этих обстоятельств Сторона обязана в течение 30 (Тридцати) рабочих дней уведомить об этом другую Сторону.
**7.3.** Документ, выданный уполномоченным государственным органом, является достаточным подтверждением наличия и продолжительности действия непреодолимой силы.
**7.4.** Если обстоятельства непреодолимой силы продолжают действовать более 60 (Шестидесяти) рабочих дней, то каждая Сторона вправе отказаться от настоящего Договора в одностороннем порядке.
## 8. Ответственность Сторон
**8.1.** В случае неисполнения и/или ненадлежащего исполнения своих обязательств по Договору, Стороны несут ответственность в соответствии с условиями настоящей Оферты.
**8.2.** Сторона, не исполнившая или ненадлежащим образом исполнившая обязательства по Договору, обязана возместить другой Стороне причиненные такими нарушениями убытки.
## 9. Срок действия настоящей Оферты
**9.1.** Оферта вступает в силу с момента размещения на Сайте Продавца и действует до момента её отзыва Продавцом.
**9.2.** Продавец оставляет за собой право внести изменения в условия Оферты и/или отозвать Оферту в любой момент по своему усмотрению. Сведения об изменении или отзыве Оферты доводятся до Покупателя по выбору Продавца посредством размещения на сайте Продавца в сети «Интернет», в Личном кабинете Покупателя, либо путем направления соответствующего уведомления на электронный или почтовый адрес, указанный Покупателем при заключении Договора или в ходе его исполнения.
**9.3.** Договор вступает в силу с момента Акцепта условий настоящей Оферты Покупателем и действует до полного исполнения Сторонами обязательств по Договору.
**9.4.** Изменения, внесенные Продавцом в Договор и опубликованные на сайте в форме актуализированной Оферты, считаются принятыми Покупателем в полном объеме.
## 10. Дополнительные условия
**10.1.** Договор, его заключение и исполнение регулируется действующим законодательством Российской Федерации. Все вопросы, не урегулированные настоящей Офертой или урегулированные не полностью, регулируются в соответствии с материальным правом Российской Федерации.
**10.2.** В случае возникновения спора, который может возникнуть между Сторонами в ходе исполнения ими своих обязательств по Договору, заключенному на условиях настоящей Оферты, Стороны обязаны урегулировать спор мирным путем до начала судебного разбирательства.
Судебное разбирательство осуществляется в соответствии с законодательством Российской Федерации.
Споры или разногласия, по которым Стороны не достигли договоренности, подлежат разрешению в соответствии с законодательством РФ. Досудебный порядок урегулирования спора является обязательным.
**10.3.** В качестве языка Договора, заключаемого на условиях настоящей Оферты, а также языка, используемого при любом взаимодействии Сторон (включая ведение переписки, предоставление требований / уведомлений / разъяснений, предоставление документов и т. д.), Стороны определили русский язык.
**10.4.** Все документы, подлежащие предоставлению в соответствии с условиями настоящей Оферты, должны быть составлены на русском языке либо иметь перевод на русский язык, удостоверенный в установленном порядке.
**10.5.** Бездействие одной из Сторон в случае нарушения условий настоящей Оферты не лишает права заинтересованной Стороны осуществлять защиту своих интересов позднее, а также не означает отказа от своих прав в случае совершения одной из Сторон подобных либо сходных нарушений в будущем.
**10.6.** Если на Сайте Продавца в сети «Интернет» есть ссылки на другие веб-сайты и материалы третьих лиц, такие ссылки размещены исключительно в целях информирования, и Продавец не имеет контроля в отношении содержания таких сайтов или материалов. Продавец не несет ответственность за любые убытки или ущерб, которые могут возникнуть в результате использования таких ссылок.
## 11. Реквизиты Продавца
Денисов Илья Аркадьевич, ИНН 290210610742.
-1
View File
@@ -28,7 +28,6 @@
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"@types/node": "^22.10.0",
"core-js-bundle": "^3.49.0",
"marked": "^18.0.5",
"svelte": "^5.15.0",
"svelte-check": "^4.1.0",
"typescript": "^5.7.0",
-10
View File
@@ -39,9 +39,6 @@ importers:
core-js-bundle:
specifier: ^3.49.0
version: 3.49.0
marked:
specifier: ^18.0.5
version: 18.0.5
svelte:
specifier: ^5.15.0
version: 5.56.0
@@ -1631,11 +1628,6 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
marked@18.0.5:
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
engines: {node: '>= 20'}
hasBin: true
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -3885,8 +3877,6 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
marked@18.0.5: {}
math-intrinsics@1.1.0: {}
minimatch@10.2.5:
+2 -4
View File
@@ -28,11 +28,9 @@ const DIST = 'dist';
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
// 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. The heavy parts — the dict loader, the move generator and the preload
// in the shared chunk). 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: 30, landing: 5 };
const BUDGET = { app: 123, shared: 30, 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.
+1 -14
View File
@@ -123,10 +123,7 @@
</div>
</section>
<footer class="ft">
<a class="offer" href="/offer/">{t('landing.offer')}</a>
<span>{t('about.version', { v: __APP_VERSION__ })}</span>
</footer>
<footer class="ft">{t('about.version', { v: __APP_VERSION__ })}</footer>
</main>
<style>
@@ -278,18 +275,8 @@
}
.ft {
margin-top: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
}
.ft .offer {
color: inherit;
}
.ft .offer:hover {
color: var(--accent);
}
</style>
-2
View File
@@ -86,8 +86,6 @@ export { UpdateProfileRequest } from './scrabblefb/update-profile-request.js';
export { VKLoginRequest } from './scrabblefb/vklogin-request.js';
export { Wallet } from './scrabblefb/wallet.js';
export { WalletBuyRequest } from './scrabblefb/wallet-buy-request.js';
export { WalletOrderRequest } from './scrabblefb/wallet-order-request.js';
export { WalletOrderResponse } from './scrabblefb/wallet-order-response.js';
export { WalletSegment } from './scrabblefb/wallet-segment.js';
export { WordCheckResult } from './scrabblefb/word-check-result.js';
export { YourTurnEvent } from './scrabblefb/your-turn-event.js';
@@ -1,48 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletOrderRequest {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderRequest {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest {
return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletOrderRequest(bb:flatbuffers.ByteBuffer, obj?:WalletOrderRequest):WalletOrderRequest {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletOrderRequest()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
productId():string|null
productId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
productId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletOrderRequest(builder:flatbuffers.Builder) {
builder.startObject(1);
}
static addProductId(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, productIdOffset, 0);
}
static endWalletOrderRequest(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletOrderRequest(builder:flatbuffers.Builder, productIdOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletOrderRequest.startWalletOrderRequest(builder);
WalletOrderRequest.addProductId(builder, productIdOffset);
return WalletOrderRequest.endWalletOrderRequest(builder);
}
}
@@ -1,60 +0,0 @@
// automatically generated by the FlatBuffers compiler, do not modify
import * as flatbuffers from 'flatbuffers';
export class WalletOrderResponse {
bb: flatbuffers.ByteBuffer|null = null;
bb_pos = 0;
__init(i:number, bb:flatbuffers.ByteBuffer):WalletOrderResponse {
this.bb_pos = i;
this.bb = bb;
return this;
}
static getRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse {
return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
static getSizePrefixedRootAsWalletOrderResponse(bb:flatbuffers.ByteBuffer, obj?:WalletOrderResponse):WalletOrderResponse {
bb.setPosition(bb.position() + flatbuffers.SIZE_PREFIX_LENGTH);
return (obj || new WalletOrderResponse()).__init(bb.readInt32(bb.position()) + bb.position(), bb);
}
orderId():string|null
orderId(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
orderId(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 4);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
redirectUrl():string|null
redirectUrl(optionalEncoding:flatbuffers.Encoding):string|Uint8Array|null
redirectUrl(optionalEncoding?:any):string|Uint8Array|null {
const offset = this.bb!.__offset(this.bb_pos, 6);
return offset ? this.bb!.__string(this.bb_pos + offset, optionalEncoding) : null;
}
static startWalletOrderResponse(builder:flatbuffers.Builder) {
builder.startObject(2);
}
static addOrderId(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset) {
builder.addFieldOffset(0, orderIdOffset, 0);
}
static addRedirectUrl(builder:flatbuffers.Builder, redirectUrlOffset:flatbuffers.Offset) {
builder.addFieldOffset(1, redirectUrlOffset, 0);
}
static endWalletOrderResponse(builder:flatbuffers.Builder):flatbuffers.Offset {
const offset = builder.endObject();
return offset;
}
static createWalletOrderResponse(builder:flatbuffers.Builder, orderIdOffset:flatbuffers.Offset, redirectUrlOffset:flatbuffers.Offset):flatbuffers.Offset {
WalletOrderResponse.startWalletOrderResponse(builder);
WalletOrderResponse.addOrderId(builder, orderIdOffset);
WalletOrderResponse.addRedirectUrl(builder, redirectUrlOffset);
return WalletOrderResponse.endWalletOrderResponse(builder);
}
}
-10
View File
@@ -137,9 +137,6 @@ export const app = $state<{
/** Whether an operator feedback reply awaits the player, for the lobby ⚙️ badge (combined
* with friend requests) and the Settings → Info badge. */
feedbackReplyUnread: boolean;
/** A monotone counter bumped when a payment-intake push signals the wallet changed; an open
* Wallet screen watches it and re-fetches in place. */
walletRefresh: number;
/** Whether to show the "outdated invite link" notice: set when a Telegram deep-link friend
* code is already used/expired, so the visitor lands in the lobby with a gentle pointer to
* the bot instead of a scary error on the Friends screen. */
@@ -178,7 +175,6 @@ export const app = $state<{
chatUnread: {},
messageUnread: {},
feedbackReplyUnread: false,
walletRefresh: 0,
staleInvite: false,
welcomeRedeem: false,
resync: 0,
@@ -443,12 +439,6 @@ function openStream(): void {
if (e.sub === 'profile') {
void refreshProfile();
}
// A payment-intake event credited (or refunded) the viewer's wallet: bump the signal an
// open Wallet screen watches, so it re-fetches in place (the return-focus poll is the
// fallback when the live stream is down).
if (e.sub === 'payment') {
app.walletRefresh++;
}
void refreshNotifications();
}
},
-4
View File
@@ -35,7 +35,6 @@ import type {
Variant,
WordCheckResult,
Wallet,
WalletOrder,
Catalog,
} from './model';
@@ -148,9 +147,6 @@ export interface GatewayClient {
/** walletBuy spends chips on a chip-priced value and returns the updated wallet. Gate-checked
* server-side: an untrusted/frozen context or an insufficient balance is refused. */
walletBuy(productId: string): Promise<Wallet>;
/** walletOrder opens a money order to fund a chip pack and returns the provider launch URL the
* client opens; chips are credited later, by the verified server callback. Direct rail only. */
walletOrder(productId: string): Promise<WalletOrder>;
// --- friends ---
friendsList(): Promise<AccountRef[]>;
-21
View File
@@ -17,8 +17,6 @@ import {
decodeWallet,
decodeCatalog,
encodeWalletBuy,
encodeWalletOrder,
decodeWalletOrder,
decodeSession,
decodeFeedbackState,
decodeFeedbackUnread,
@@ -75,25 +73,6 @@ describe('codec', () => {
expect(w.hints).toBe(5);
});
it('round-trips the wallet order request and response', () => {
// order request: pack id survives the wire
const req = fb.WalletOrderRequest.getRootAsWalletOrderRequest(new ByteBuffer(encodeWalletOrder('pack-7')));
expect(req.productId()).toBe('pack-7');
// order response: the created id and the provider launch URL decode back
const b = new Builder(64);
const oid = b.createString('order-123');
const url = b.createString('https://pay.example/abc');
fb.WalletOrderResponse.startWalletOrderResponse(b);
fb.WalletOrderResponse.addOrderId(b, oid);
fb.WalletOrderResponse.addRedirectUrl(b, url);
b.finish(fb.WalletOrderResponse.endWalletOrderResponse(b));
const order = decodeWalletOrder(b.asUint8Array());
expect(order.orderId).toBe('order-123');
expect(order.redirectUrl).toBe('https://pay.example/abc');
});
it('round-trips the catalog view (value + pack)', () => {
const b = new Builder(256);
-16
View File
@@ -39,7 +39,6 @@ import type {
MoveResult,
Profile,
Wallet,
WalletOrder,
WalletSegment,
Catalog,
CatalogProduct,
@@ -374,15 +373,6 @@ export function encodeWalletBuy(productId: string): Uint8Array {
return finish(b, fb.WalletBuyRequest.endWalletBuyRequest(b));
}
// encodeWalletOrder wraps the pack id for a money order (POST /user/wallet/order).
export function encodeWalletOrder(productId: string): Uint8Array {
const b = new Builder(64);
const pid = b.createString(productId);
fb.WalletOrderRequest.startWalletOrderRequest(b);
fb.WalletOrderRequest.addProductId(b, pid);
return finish(b, fb.WalletOrderRequest.endWalletOrderRequest(b));
}
// decodeWallet reads the wallet payload: the context-visible chip segments and the
// context-applicable benefits.
export function decodeWallet(buf: Uint8Array): Wallet {
@@ -401,12 +391,6 @@ export function decodeWallet(buf: Uint8Array): Wallet {
};
}
// decodeWalletOrder reads the created order: its id and the provider launch URL the client opens.
export function decodeWalletOrder(buf: Uint8Array): WalletOrder {
const o = fb.WalletOrderResponse.getRootAsWalletOrderResponse(new ByteBuffer(buf));
return { orderId: s(o.orderId()), redirectUrl: s(o.redirectUrl()) };
}
// decodeCatalog reads the storefront payload: the context-visible products, each a chip-priced
// value or a chip pack priced in the context's payment method, with its atom composition.
export function decodeCatalog(buf: Uint8Array): Catalog {
+1 -6
View File
@@ -242,11 +242,7 @@ export const en = {
'wallet.store': 'Store',
'wallet.empty': 'The store is empty for now',
'wallet.buy': 'Buy',
'wallet.offer': 'Public offer',
'wallet.platformNoBuy': 'Purchases are not available on this platform',
'wallet.iosBlockedPre': 'Purchases on iOS are prohibited by Apple policy. Try the ',
'wallet.iosBlockedLink': 'other version',
'wallet.iosBlockedPost': ' of the game.',
'wallet.soon': 'Soon',
'wallet.gpStub': 'To buy chips, install the RuStore build.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'votes',
@@ -279,7 +275,6 @@ export const en = {
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Web',
'landing.offer': 'Public offer',
'install.title': 'Install the app',
'install.subtitle': 'Put the app icon on your desktop (home screen) to open the game in one tap.',
+1 -6
View File
@@ -242,11 +242,7 @@ export const ru: Record<MessageKey, string> = {
'wallet.store': 'Магазин',
'wallet.empty': 'Магазин пока пуст',
'wallet.buy': 'Купить',
'wallet.offer': 'Публичная оферта',
'wallet.platformNoBuy': 'На данной платформе покупки невозможны',
'wallet.iosBlockedPre': 'Покупки на iOS запрещены политикой Apple. Попробуйте воспользоваться ',
'wallet.iosBlockedLink': 'другой версией',
'wallet.iosBlockedPost': ' игры.',
'wallet.soon': 'Скоро',
'wallet.gpStub': 'Чтобы покупать фишки, установите версию из RuStore.',
'wallet.cur.RUB': '₽',
'wallet.cur.VOTE': 'голосов',
@@ -279,7 +275,6 @@ export const ru: Record<MessageKey, string> = {
'landing.captionTelegram': 'Telegram',
'landing.captionVK': 'VK',
'landing.captionWeb': 'Веб-версия',
'landing.offer': 'Публичная оферта',
'install.title': 'Установить приложение',
'install.subtitle': 'Поместите иконку приложения на рабочий стол (домашний экран), чтобы открывать игру одним нажатием.',
-9
View File
@@ -41,7 +41,6 @@ import type {
Variant,
WordCheckResult,
Wallet,
WalletOrder,
Catalog,
} from '../model';
import { valueForLetter } from '../alphabet';
@@ -226,14 +225,6 @@ export class MockGateway implements GatewayClient {
}
return this.cloneWallet();
}
async walletOrder(productId: string): Promise<WalletOrder> {
const p = this.mockCatalog.products.find((x) => x.productId === productId);
if (!p || p.kind !== 'pack') throw new GatewayError('not_a_pack');
// No real provider in mock: return a stub launch URL so the e2e can assert the purchase reaches
// the provider hand-off without leaving the app.
return { orderId: 'mock-order-' + productId, redirectUrl: 'https://mock.local/robokassa?order=' + productId };
}
private cloneWallet(): Wallet {
return {
segments: this.mockWallet.segments.map((s) => ({ ...s })),
-8
View File
@@ -172,14 +172,6 @@ export interface Wallet {
/** One atom line of a storefront product: the base value type it grants ("chips"/"hints"/
* "noads_days"/"tournament") and how many of it the product carries. */
// WalletOrder is a created money order to fund a chip pack: its id and the provider launch URL the
// client opens (the Robokassa hosted-payment page). Chips arrive later, by the verified server
// callback.
export interface WalletOrder {
orderId: string;
redirectUrl: string;
}
export interface CatalogAtom {
atomType: string;
quantity: number;
-23
View File
@@ -1,23 +0,0 @@
import { describe, it, expect } from 'vitest';
import { renderOfferHtml } from './offer';
describe('renderOfferHtml', () => {
it('wraps rendered markdown in a standalone Russian HTML document', () => {
const html = renderOfferHtml('# Публичная оферта\n\nо заключении договора купли-продажи');
expect(html).toContain('<!doctype html>');
expect(html).toContain('lang="ru"');
expect(html).toContain('<title>Публичная оферта — Эрудит</title>');
// 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="/"');
});
it('renders headings, bold clause numbers and lists', () => {
const html = renderOfferHtml('## 2. Предмет\n\n**2.1.** Текст пункта.\n\n- первый\n- второй');
expect(html).toContain('<h2>2. Предмет</h2>');
expect(html).toContain('<strong>2.1.</strong>');
expect(html).toContain('<li>первый</li>');
});
});
-93
View File
@@ -1,93 +0,0 @@
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.
*
* 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
* neither the app bundle nor `app.css`.
*/
export function renderOfferHtml(markdown: string): string {
const body = marked.parse(markdown, { async: false }) as string;
return `<!doctype html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Публичная оферта Эрудит</title>
<link rel="canonical" href="https://erudit-game.ru/offer/" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#f3f4f6" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#0f1115" />
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--text: #1a1c20;
--accent: #2563eb;
--rule: #e5e7eb;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f1115;
--text: #e7e9ee;
--accent: #6ea8fe;
--rule: #242832;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
}
main {
max-width: 760px;
margin: 0 auto;
padding: 24px 20px 64px;
}
a {
color: var(--accent);
}
.back {
display: inline-block;
margin-bottom: 20px;
text-decoration: none;
}
h1 {
font-size: 1.6rem;
text-align: center;
}
h2 {
font-size: 1.2rem;
margin-top: 2em;
padding-top: 1em;
border-top: 1px solid var(--rule);
}
h3 {
font-size: 1.05rem;
margin-top: 1.5em;
}
ul {
padding-left: 1.25em;
}
li {
margin: 0.25em 0;
}
</style>
</head>
<body>
<main>
<a class="back" href="/"> На главную</a>
${body}
</main>
</body>
</html>
`;
}
-15
View File
@@ -39,7 +39,6 @@ interface TelegramWebApp {
close?: () => void;
openTelegramLink?: (url: string) => void;
openLink?: (url: string) => void;
openInvoice?: (url: string, callback?: (status: string) => void) => void;
onEvent?: (event: string, handler: () => void) => void;
setHeaderColor?: (color: string) => void;
setBackgroundColor?: (color: string) => void;
@@ -171,20 +170,6 @@ export function telegramOpenLink(url: string): boolean {
return true;
}
/**
* telegramOpenInvoice opens a Telegram Stars invoice inside the Mini App (WebApp.openInvoice) and
* calls onStatus with the outcome ('paid' | 'cancelled' | 'failed' | 'pending'). Returns false
* outside Telegram or when the SDK lacks the method. The invoice url is the createInvoiceLink the
* bot minted for the order; the chips are credited server-side by the forwarded payment, so 'paid'
* only signals to refresh the wallet the live push is the authoritative update.
*/
export function telegramOpenInvoice(url: string, onStatus?: (status: string) => void): boolean {
const w = webApp();
if (!w?.openInvoice) return false;
w.openInvoice(url, onStatus);
return true;
}
/**
* telegramOpenExternalLink opens an arbitrary external URL through the Mini App SDK's openLink,
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
-3
View File
@@ -237,9 +237,6 @@ export function createTransport(baseUrl: string): GatewayClient {
async walletBuy(productId: string) {
return codec.decodeWallet(await exec('wallet.buy', codec.encodeWalletBuy(productId)));
},
async walletOrder(productId: string) {
return codec.decodeWalletOrder(await exec('wallet.order', codec.encodeWalletOrder(productId)));
},
async friendsList() {
return codec.decodeFriendList(await exec('friends.list', codec.empty()));
-15
View File
@@ -182,21 +182,6 @@ export async function vkShowImages(url: string): Promise<boolean> {
}
}
/**
* vkShowOrderBox opens VK's payment box for a "голоса" item purchase; item is the order id the
* backend created (VK echoes it to our payment callback, which credits). Resolves true when the
* payment completes, false on cancel/failure or outside VK. The chips are credited by the server
* callback, so the wallet refreshes from the payment push regardless of this result.
*/
export async function vkShowOrderBox(item: string): Promise<boolean> {
try {
await (await bridge()).send('VKWebAppShowOrderBox', { type: 'item', item });
return true;
} catch {
return false;
}
}
/**
* vkDownloadFile downloads url as filename through the VK client (VKWebAppDownloadFile)
* the mobile in-app file delivery, where the webview ignores <a download>. Resolves false
+8 -115
View File
@@ -1,15 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import Modal from '../components/Modal.svelte';
import { app, handleError, showToast } from '../lib/app.svelte';
import { handleError } from '../lib/app.svelte';
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 { isGooglePlayBuild } from '../lib/distribution';
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
import { telegramOpenInvoice } from '../lib/telegram';
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
@@ -21,22 +17,12 @@
let wallet = $state<Wallet | null>(null);
let catalog = $state<Catalog | null>(null);
let loading = $state(true);
// The product whose purchase is in flight, or null. It both guards against a concurrent purchase
// (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);
let busy = $state(false);
// 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();
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
// client platformSubtype (server-derived) — read it from the signed vk_platform launch param.
const purchaseBlocked = context === 'vk' && normalizeSubtype(vkPlatform()) === 'ios';
// The "other version" link points at this deployment's own site root (the landing lists every
// platform), so it follows prod vs the contour without a hardcoded host.
const siteRoot = typeof location !== 'undefined' ? `${location.origin}/` : '/';
const values = $derived(catalog?.products.filter((p) => p.kind === 'value') ?? []);
const packs = $derived(catalog?.products.filter((p) => p.kind === 'pack') ?? []);
@@ -52,26 +38,7 @@
loading = false;
}
}
onMount(() => {
void load();
// Fallback to the payment push: re-fetch when the tab regains focus, i.e. the user returned
// from the provider's payment window.
const onVisible = () => {
if (!document.hidden) void load();
};
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
});
// Main path: a payment-intake push bumps app.walletRefresh; re-fetch in place when it changes
// (a credit landed while the screen is open).
let seenRefresh = app.walletRefresh;
$effect(() => {
if (app.walletRefresh !== seenRefresh) {
seenRefresh = app.walletRefresh;
void load();
}
});
onMount(load);
function sourceLabel(source: string): string {
return t(`wallet.source.${source}` as MessageKey);
@@ -102,42 +69,13 @@
async function doBuy(p: CatalogProduct) {
warnProduct = null;
if (busy) return;
busyId = p.productId;
busy = true;
try {
wallet = await gateway.walletBuy(p.productId);
} catch (e) {
handleError(e);
} finally {
busyId = null;
}
}
// onOrder funds a chip pack with money: it opens a pending order and sends the player to the
// provider's hosted-payment page. The chips are credited later, by the verified server callback;
// paying accepts the public offer (linked below the packs).
async function onOrder(p: CatalogProduct) {
if (busy) return;
busyId = p.productId;
try {
const order = await gateway.walletOrder(p.productId);
if (context === 'vk') {
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
await vkShowOrderBox(order.orderId);
} else if (context === 'telegram') {
// Telegram Stars: the bot minted an invoice link (returned as redirectUrl); open it in-app.
// The chips are credited server-side by the forwarded payment and arrive on the live push;
// refresh on 'paid' as a fallback. Outside a Stars-capable client, fall back to the link.
const opened = telegramOpenInvoice(order.redirectUrl, (status) => {
if (status === 'paid') void load();
});
if (!opened) openExternalUrl(order.redirectUrl);
} else {
openExternalUrl(order.redirectUrl);
}
} catch (e) {
handleError(e);
} finally {
busyId = null;
busy = false;
}
}
</script>
@@ -167,17 +105,11 @@
<section>
<h3>{t('wallet.store')}</h3>
{#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>
{/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
>
<button class="buy" data-testid="buy" disabled={busy} onclick={() => onBuy(p)}>{t('wallet.buy')}</button>
</div>
{/each}
@@ -188,24 +120,9 @@
<div class="row product" data-testid="product" data-kind="pack" data-pid={p.productId}>
<span class="name">{p.title}</span>
<span class="price">{formatAmount(p.moneyAmount, p.moneyCurrency)}&nbsp;{currencyLabel(p.moneyCurrency)}</span>
<button
class="buy"
class:blocked={purchaseBlocked}
class:working={busyId === p.productId}
data-testid="buy-pack"
disabled={busy}
onclick={() => {
if (purchaseBlocked) showToast(t('wallet.platformNoBuy'));
else void onOrder(p);
}}>{t('wallet.buy')}</button
>
<button class="buy" disabled>{t('wallet.soon')}</button>
</div>
{/each}
{#if packs.length > 0}
<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)}
@@ -277,24 +194,8 @@
cursor: pointer;
}
.buy:disabled {
cursor: default;
}
/* 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 {
opacity: 0.5;
}
.ios-note {
margin: 0 0 4px;
padding: 8px 12px;
color: var(--text-muted);
font-size: 0.85rem;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.ios-note a {
color: var(--accent);
cursor: default;
}
.empty,
.stub {
@@ -306,14 +207,6 @@
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
}
.offer {
margin: 2px 0 0;
text-align: center;
font-size: 0.85rem;
}
.offer a {
color: var(--text-muted);
}
.warn-body {
margin: 0 0 14px;
}
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"types": ["node"]
},
"include": ["vite.config.ts", "src/lib/offer.ts"]
"include": ["vite.config.ts"]
}
-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