From 6e03ce013107d775e476c6522cdc1cbf63f7c1a8 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 21:35:29 +0200 Subject: [PATCH 1/4] feat(payments): Telegram Stars payment rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept real money via Telegram Stars (XTR) — the third intake rail alongside Robokassa (direct) and VK Votes. Only the bot reaches Telegram, so the rail funnels through the reverse mTLS bot-link: - the gateway mints the invoice on a CreateInvoice command (the bot calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice); - the bot gates each pre_checkout_query via a ValidatePreCheckout unary (the order must exist, be still creditable and not already paid — the reusable-invoice double-pay guard; the decline reason is localised to the order account's language); - a completed successful_payment is queued in a durable pure-Go SQLite outbox and forwarded via a ForwardPayment unary, credited once (idempotent on telegram_payment_charge_id, honours an expired order), re-driven on restart and every 30s. The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays inert until a chip pack carries an XTR price, so seeding a Stars price in the admin is the go-live. Tests: backend integration (order->forward->credit once, duplicate, pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) + executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the platform/telegram README, PLAN. --- PLAN.md | 31 +- .../internal/inttest/payments_intake_test.go | 95 ++++ backend/internal/payments/service_intake.go | 44 ++ backend/internal/server/handlers.go | 4 + backend/internal/server/handlers_intake.go | 188 +++++++- deploy/docker-compose.bot.yml | 4 + deploy/docker-compose.yml | 4 + docs/ARCHITECTURE.md | 8 +- docs/PAYMENTS.md | 21 +- docs/PAYMENTS_ru.md | 22 +- gateway/cmd/gateway/main.go | 17 +- gateway/internal/backendclient/api.go | 49 ++- gateway/internal/botlink/command.go | 15 + gateway/internal/botlink/hub.go | 120 +++++ gateway/internal/transcode/transcode.go | 39 +- go.work.sum | 23 +- pkg/proto/botlink/v1/botlink.pb.go | 416 +++++++++++++++++- pkg/proto/botlink/v1/botlink.proto | 69 ++- pkg/proto/botlink/v1/botlink_grpc.pb.go | 104 +++++ platform/telegram/README.md | 19 +- platform/telegram/cmd/bot/main.go | 25 ++ platform/telegram/go.mod | 13 + platform/telegram/internal/bot/bot.go | 32 +- platform/telegram/internal/bot/payments.go | 166 +++++++ platform/telegram/internal/botlink/client.go | 32 +- .../telegram/internal/botlink/executor.go | 38 +- .../internal/botlink/executor_test.go | 68 ++- platform/telegram/internal/config/config.go | 6 + platform/telegram/internal/outbox/store.go | 104 +++++ .../telegram/internal/outbox/store_test.go | 124 ++++++ ui/src/lib/telegram.ts | 15 + ui/src/screens/Wallet.svelte | 9 + 32 files changed, 1830 insertions(+), 94 deletions(-) create mode 100644 platform/telegram/internal/bot/payments.go create mode 100644 platform/telegram/internal/outbox/store.go create mode 100644 platform/telegram/internal/outbox/store_test.go diff --git a/PLAN.md b/PLAN.md index 51a5d9c..744990e 100644 --- a/PLAN.md +++ b/PLAN.md @@ -522,9 +522,20 @@ 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. Remaining: the TG-Stars rail and refunds; and -hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, deferred with the owner's -agreement). +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). **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, @@ -547,12 +558,14 @@ receipts, and refunds. **TG bot outbox (`platform/telegram/`).** -- 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. +- 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. **Events & notifications.** diff --git a/backend/internal/inttest/payments_intake_test.go b/backend/internal/inttest/payments_intake_test.go index 3eb97dd..46206e8 100644 --- a/backend/internal/inttest/payments_intake_test.go +++ b/backend/internal/inttest/payments_intake_test.go @@ -129,6 +129,101 @@ func TestPaymentsVKOrderFundCredits(t *testing.T) { } } +// 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) { diff --git a/backend/internal/payments/service_intake.go b/backend/internal/payments/service_intake.go index 6be8372..631228c 100644 --- a/backend/internal/payments/service_intake.go +++ b/backend/internal/payments/service_intake.go @@ -2,6 +2,7 @@ package payments import ( "context" + "errors" "fmt" "github.com/google/uuid" @@ -80,6 +81,49 @@ func (s *Service) Fund(ctx context.Context, orderID uuid.UUID, provider, provide 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). diff --git a/backend/internal/server/handlers.go b/backend/internal/server/handlers.go index 3ab4b46..693fabb 100644 --- a/backend/internal/server/handlers.go +++ b/backend/internal/server/handlers.go @@ -80,6 +80,10 @@ func (s *Server) registerRoutes() { // 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) } diff --git a/backend/internal/server/handlers_intake.go b/backend/internal/server/handlers_intake.go index 70be4cf..10f6491 100644 --- a/backend/internal/server/handlers_intake.go +++ b/backend/internal/server/handlers_intake.go @@ -18,6 +18,7 @@ import ( 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. @@ -25,16 +26,25 @@ type walletOrderRequest struct { ProductID string `json:"product_id"` } -// walletOrderResponse returns the created order id and the provider launch URL the client opens. +// 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"` + 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 Robokassa -// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later); -// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No -// chips are credited here — only later, by the verified Result callback. +// 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 { @@ -80,6 +90,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) { 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) @@ -88,7 +99,23 @@ func (s *Server) handleWalletOrder(c *gin.Context) { 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()}) + 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"}}) } @@ -225,3 +252,148 @@ func (s *Server) handleVKCallback(c *gin.Context) { 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}) +} diff --git a/deploy/docker-compose.bot.yml b/deploy/docker-compose.bot.yml index 46a04f2..e3e9ca9 100644 --- a/deploy/docker-compose.bot.yml +++ b/deploy/docker-compose.bot.yml @@ -28,6 +28,10 @@ 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:-} diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 024d719..a4c7d79 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -385,6 +385,10 @@ 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). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 10c6621..191b83a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -971,7 +971,13 @@ 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). An optional +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 **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. diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md index f6175df..14f62b8 100644 --- a/docs/PAYMENTS.md +++ b/docs/PAYMENTS.md @@ -219,12 +219,21 @@ 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 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. +**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. **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 diff --git a/docs/PAYMENTS_ru.md b/docs/PAYMENTS_ru.md index 2159845..84cd43b 100644 --- a/docs/PAYMENTS_ru.md +++ b/docs/PAYMENTS_ru.md @@ -220,12 +220,22 @@ provider_payment_id)`. Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена — деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки. -**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а -хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на -**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в -платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил -`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка -at-least-once + идемпотентный приём = начисление ровно один раз. +**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`) = начисление ровно один +раз. **События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded); диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе diff --git a/gateway/cmd/gateway/main.go b/gateway/cmd/gateway/main.go index c7a270b..d08031d 100644 --- a/gateway/cmd/gateway/main.go +++ b/gateway/cmd/gateway/main.go @@ -153,6 +153,16 @@ 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 @@ -199,7 +209,12 @@ 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") } - registry := transcode.NewRegistry(backend, validator, transcode.WithVKAuth(cfg.VKAppSecret), transcode.WithVKLink(vkidExchanger)) + 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...) edge := connectsrv.NewServer(connectsrv.Deps{ Registry: registry, Sessions: sessions, diff --git a/gateway/internal/backendclient/api.go b/gateway/internal/backendclient/api.go index ca5466c..ca83a1d 100644 --- a/gateway/internal/backendclient/api.go +++ b/gateway/internal/backendclient/api.go @@ -401,19 +401,60 @@ type walletOrderBody struct { ProductID string `json:"product_id"` } -// WalletOrderResp is a created order: its id and the provider launch URL the client opens. +// 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"` + 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 provider launch URL. +// 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"` diff --git a/gateway/internal/botlink/command.go b/gateway/internal/botlink/command.go index b2c01b1..5cbe9f4 100644 --- a/gateway/internal/botlink/command.go +++ b/gateway/internal/botlink/command.go @@ -48,3 +48,18 @@ 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, + }}, + } +} diff --git a/gateway/internal/botlink/hub.go b/gateway/internal/botlink/hub.go index fc45b05..91e9b3a 100644 --- a/gateway/internal/botlink/hub.go +++ b/gateway/internal/botlink/hub.go @@ -13,6 +13,7 @@ import ( "strconv" "sync" "sync/atomic" + "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" @@ -31,6 +32,11 @@ 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 @@ -38,6 +44,17 @@ const outboundBuffer = 64 // 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. @@ -46,6 +63,10 @@ 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{} @@ -147,6 +168,105 @@ 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() diff --git a/gateway/internal/transcode/transcode.go b/gateway/internal/transcode/transcode.go index 0b6a399..452b374 100644 --- a/gateway/internal/transcode/transcode.go +++ b/gateway/internal/transcode/transcode.go @@ -112,7 +112,7 @@ 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), 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,6 +170,17 @@ 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] @@ -333,13 +344,37 @@ func walletBuyHandler(backend *backendclient.Client) Handler { } } -func walletOrderHandler(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 } } diff --git a/go.work.sum b/go.work.sum index d1da155..1d39efb 100644 --- a/go.work.sum +++ b/go.work.sum @@ -73,10 +73,12 @@ 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= @@ -95,6 +97,7 @@ 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= @@ -146,8 +149,6 @@ 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= @@ -176,6 +177,7 @@ 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= @@ -183,10 +185,14 @@ 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= @@ -200,6 +206,17 @@ 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= diff --git a/pkg/proto/botlink/v1/botlink.pb.go b/pkg/proto/botlink/v1/botlink.pb.go index 46bd55d..61d1c52 100644 --- a/pkg/proto/botlink/v1/botlink.pb.go +++ b/pkg/proto/botlink/v1/botlink.pb.go @@ -225,6 +225,7 @@ type Command struct { // *Command_SendToUser // *Command_SendToChannel // *Command_ChatGate + // *Command_CreateInvoice Payload isCommand_Payload `protobuf_oneof:"payload"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -310,6 +311,15 @@ 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() } @@ -330,6 +340,10 @@ 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() {} @@ -338,15 +352,20 @@ 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. +// 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. 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 } @@ -402,6 +421,13 @@ 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 @@ -562,6 +588,314 @@ 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 = "" + @@ -576,7 +910,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\"\xde\x02\n" + + "\fowns_updates\x18\x02 \x01(\bR\vownsUpdates\"\xb2\x03\n" + "\aCommand\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12=\n" + @@ -584,13 +918,15 @@ 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\bchatGateB\t\n" + - "\apayload\"X\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" + "\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\"H\n" + + "\x05error\x18\x03 \x01(\tR\x05error\x12\x16\n" + + "\x06result\x18\x04 \x01(\tR\x06result\"H\n" + "\x0fChatGateCommand\x12\x1f\n" + "\vexternal_id\x18\x01 \x01(\tR\n" + "externalId\x12\x14\n" + @@ -602,10 +938,31 @@ const file_botlink_v1_botlink_proto_rawDesc = "" + "\n" + "registered\x18\x01 \x01(\bR\n" + "registered\x12\x1a\n" + - "\beligible\x18\x02 \x01(\bR\beligible2\xc4\x01\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" + "\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.ChatEligibilityResponseB)Z'scrabble/pkg/proto/botlink/v1;botlinkv1b\x06proto3" + "\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" var ( file_botlink_v1_botlink_proto_rawDescOnce sync.Once @@ -619,7 +976,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, 8) +var file_botlink_v1_botlink_proto_msgTypes = make([]protoimpl.MessageInfo, 13) var file_botlink_v1_botlink_proto_goTypes = []any{ (*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot (*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot @@ -629,27 +986,37 @@ 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 - (*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest - (*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest - (*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest + (*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 } 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 - 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 + 13, // 3: scrabble.botlink.v1.Command.notify:type_name -> scrabble.telegram.v1.NotifyRequest + 14, // 4: scrabble.botlink.v1.Command.send_to_user:type_name -> scrabble.telegram.v1.SendToUserRequest + 15, // 5: scrabble.botlink.v1.Command.send_to_channel:type_name -> scrabble.telegram.v1.SendToGameChannelRequest 5, // 6: scrabble.botlink.v1.Command.chat_gate:type_name -> scrabble.botlink.v1.ChatGateCommand - 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 + 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 } func init() { file_botlink_v1_botlink_proto_init() } @@ -666,6 +1033,7 @@ 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{ @@ -673,7 +1041,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: 8, + NumMessages: 13, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/proto/botlink/v1/botlink.proto b/pkg/proto/botlink/v1/botlink.proto index 9c4f868..3f37723 100644 --- a/pkg/proto/botlink/v1/botlink.proto +++ b/pkg/proto/botlink/v1/botlink.proto @@ -27,6 +27,24 @@ 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 @@ -61,17 +79,21 @@ 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. +// 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. 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 @@ -99,3 +121,48 @@ 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; +} diff --git a/pkg/proto/botlink/v1/botlink_grpc.pb.go b/pkg/proto/botlink/v1/botlink_grpc.pb.go index 80f26ca..06ac77c 100644 --- a/pkg/proto/botlink/v1/botlink_grpc.pb.go +++ b/pkg/proto/botlink/v1/botlink_grpc.pb.go @@ -28,6 +28,8 @@ 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. @@ -48,6 +50,22 @@ 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 { @@ -81,6 +99,26 @@ 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. @@ -99,6 +137,22 @@ 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() } @@ -115,6 +169,12 @@ 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() {} @@ -161,6 +221,42 @@ 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) @@ -172,6 +268,14 @@ 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{ { diff --git a/platform/telegram/README.md b/platform/telegram/README.md index 13ea0b0..39f8299 100644 --- a/platform/telegram/README.md +++ b/platform/telegram/README.md @@ -91,6 +91,16 @@ 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 @@ -104,9 +114,11 @@ 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`), 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`. + 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`. ## Deep-link scheme @@ -153,6 +165,7 @@ 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 | diff --git a/platform/telegram/cmd/bot/main.go b/platform/telegram/cmd/bot/main.go index 41a4853..152fad7 100644 --- a/platform/telegram/cmd/bot/main.go +++ b/platform/telegram/cmd/bot/main.go @@ -23,6 +23,7 @@ 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" ) @@ -90,11 +91,24 @@ 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 @@ -115,6 +129,12 @@ 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 @@ -160,6 +180,11 @@ 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 { diff --git a/platform/telegram/go.mod b/platform/telegram/go.mod index 3f66833..2eba279 100644 --- a/platform/telegram/go.mod +++ b/platform/telegram/go.mod @@ -12,3 +12,16 @@ 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 +) diff --git a/platform/telegram/internal/bot/bot.go b/platform/telegram/internal/bot/bot.go index f2cc25e..0ca666b 100644 --- a/platform/telegram/internal/bot/bot.go +++ b/platform/telegram/internal/bot/bot.go @@ -16,6 +16,7 @@ import ( "go.uber.org/zap" "golang.org/x/time/rate" + "scrabble/platform/telegram/internal/outbox" "scrabble/platform/telegram/internal/support" ) @@ -49,6 +50,10 @@ 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 @@ -91,6 +96,12 @@ 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 @@ -123,9 +134,10 @@ 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". 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. + // 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. if cfg.ChatID != 0 { allowed := tgbot.AllowedUpdates{ models.AllowedUpdateMessage, @@ -135,6 +147,9 @@ 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 { @@ -346,6 +361,17 @@ 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. diff --git a/platform/telegram/internal/bot/payments.go b/platform/telegram/internal/bot/payments.go new file mode 100644 index 0000000..4f26ab9 --- /dev/null +++ b/platform/telegram/internal/bot/payments.go @@ -0,0 +1,166 @@ +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." +} diff --git a/platform/telegram/internal/botlink/client.go b/platform/telegram/internal/botlink/client.go index 8de04f6..92fa84c 100644 --- a/platform/telegram/internal/botlink/client.go +++ b/platform/telegram/internal/botlink/client.go @@ -83,6 +83,34 @@ 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 { @@ -121,8 +149,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro if cmd == nil { continue } - delivered, herr := c.exec.Handle(ctx, cmd) - ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered} + delivered, result, herr := c.exec.Handle(ctx, cmd) + ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result} if herr != nil { ack.Error = herr.Error() } diff --git a/platform/telegram/internal/botlink/executor.go b/platform/telegram/internal/botlink/executor.go index 1175622..d93a764 100644 --- a/platform/telegram/internal/botlink/executor.go +++ b/platform/telegram/internal/botlink/executor.go @@ -27,6 +27,10 @@ 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 @@ -48,22 +52,42 @@ 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 send. -func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) { +// 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) { switch p := cmd.GetPayload().(type) { case *botlinkv1.Command_Notify: - return e.notify(ctx, p.Notify) + d, err := e.notify(ctx, p.Notify) + return d, "", err case *botlinkv1.Command_SendToUser: - return e.sendToUser(ctx, p.SendToUser) + d, err := e.sendToUser(ctx, p.SendToUser) + return d, "", err case *botlinkv1.Command_SendToChannel: - return e.sendToChannel(ctx, p.SendToChannel) + d, err := e.sendToChannel(ctx, p.SendToChannel) + return d, "", err case *botlinkv1.Command_ChatGate: - return e.chatGate(ctx, p.ChatGate) + d, err := e.chatGate(ctx, p.ChatGate) + return d, "", err + case *botlinkv1.Command_CreateInvoice: + return e.createInvoice(ctx, p.CreateInvoice) 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. diff --git a/platform/telegram/internal/botlink/executor_test.go b/platform/telegram/internal/botlink/executor_test.go index 502eb84..1fc7258 100644 --- a/platform/telegram/internal/botlink/executor_test.go +++ b/platform/telegram/internal/botlink/executor_test.go @@ -13,11 +13,13 @@ import ( // fakeSender records the delivery calls the executor makes. type fakeSender struct { - notify []notifyCall - text []textCall - gate []gateCall - applied bool // ApplyChatGate's reported result - err error + notify []notifyCall + text []textCall + gate []gateCall + invoice []invoiceCall + invoiceLink string // CreateInvoiceLink's returned link + applied bool // ApplyChatGate's reported result + err error } type notifyCall struct { @@ -32,6 +34,10 @@ 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}) @@ -48,6 +54,11 @@ 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) @@ -67,7 +78,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) } @@ -85,7 +96,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) } @@ -99,7 +110,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") } } @@ -108,7 +119,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) } @@ -126,7 +137,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) } @@ -138,7 +149,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) } @@ -152,24 +163,53 @@ 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) } diff --git a/platform/telegram/internal/config/config.go b/platform/telegram/internal/config/config.go index da961b9..b82d5ec 100644 --- a/platform/telegram/internal/config/config.go +++ b/platform/telegram/internal/config/config.go @@ -52,6 +52,11 @@ 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 @@ -157,6 +162,7 @@ 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"), diff --git a/platform/telegram/internal/outbox/store.go b/platform/telegram/internal/outbox/store.go new file mode 100644 index 0000000..8679965 --- /dev/null +++ b/platform/telegram/internal/outbox/store.go @@ -0,0 +1,104 @@ +// 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 +} diff --git a/platform/telegram/internal/outbox/store_test.go b/platform/telegram/internal/outbox/store_test.go new file mode 100644 index 0000000..7d92d4f --- /dev/null +++ b/platform/telegram/internal/outbox/store_test.go @@ -0,0 +1,124 @@ +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) + } +} diff --git a/ui/src/lib/telegram.ts b/ui/src/lib/telegram.ts index 74f23da..a570a82 100644 --- a/ui/src/lib/telegram.ts +++ b/ui/src/lib/telegram.ts @@ -39,6 +39,7 @@ 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; @@ -170,6 +171,20 @@ 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 diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 61be5e9..3627786 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -9,6 +9,7 @@ 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. @@ -119,6 +120,14 @@ 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); } From 219fc7c827038ba0352cbdd834f682404b1f3f5e Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 21:55:53 +0200 Subject: [PATCH 2/4] chore(deploy): re-roll the test contour to pick up the TEST_AWG_CONF DNS fix The contour VPN sidecar's netns resolv.conf was pinned to 1.1.1.1 by a DNS= directive in TEST_AWG_CONF, so internal names (gateway, otelcol) did not resolve and the bot-link had been down since ~2026-06-29. The directive is removed; this empty commit re-rolls the contour so the vpn recreates with Docker's 127.0.0.11 resolver and the bot-link (and the Stars rail over it) comes up. From 55dbb1005ef72195d55cbe51f46b6bc3689ba6d3 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Thu, 9 Jul 2026 22:26:28 +0200 Subject: [PATCH 3/4] fix(ui): scope the wallet buy-button busy dim to the tapped button Tapping one pack's Buy dimmed every buy button at once (the shared busy flag drove disabled + the :disabled opacity on all of them), so it looked like both packs reacted to one tap. Track the in-flight product id (busyId): the concurrency guard stays global, but the pressed/dimmed look is now scoped to the tapped button. Payments were never affected. --- ui/src/screens/Wallet.svelte | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/ui/src/screens/Wallet.svelte b/ui/src/screens/Wallet.svelte index 3627786..bded17e 100644 --- a/ui/src/screens/Wallet.svelte +++ b/ui/src/screens/Wallet.svelte @@ -21,7 +21,10 @@ let wallet = $state(null); let catalog = $state(null); let loading = $state(true); - let busy = $state(false); + // 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(null); + const busy = $derived(busyId !== null); // The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips). let warnProduct = $state(null); @@ -99,13 +102,13 @@ async function doBuy(p: CatalogProduct) { warnProduct = null; if (busy) return; - busy = true; + busyId = p.productId; try { wallet = await gateway.walletBuy(p.productId); } catch (e) { handleError(e); } finally { - busy = false; + busyId = null; } } @@ -114,7 +117,7 @@ // paying accepts the public offer (linked below the packs). async function onOrder(p: CatalogProduct) { if (busy) return; - busy = true; + busyId = p.productId; try { const order = await gateway.walletOrder(p.productId); if (context === 'vk') { @@ -134,7 +137,7 @@ } catch (e) { handleError(e); } finally { - busy = false; + busyId = null; } } @@ -172,7 +175,9 @@
{p.title} 🪙 {p.chips} - +
{/each} @@ -186,6 +191,7 @@