Release v1.14.0 — monetization launch (E5-E8) #237
@@ -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
|
`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
|
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`,
|
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
|
already deployed) and proxied to the backend intake. The **Telegram Stars rail** is delivered on
|
||||||
hiding the ad banner on a no-ads purchase (a spend-path `NotifyBanner`, deferred with the owner's
|
`feature/payment-intake-tg-stars`: only the bot reaches Telegram, so the **invoice is minted by the
|
||||||
agreement).
|
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,
|
**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,
|
verified provider callbacks, idempotency, the TG bot SQLite outbox, the event dispatcher,
|
||||||
@@ -547,12 +558,14 @@ receipts, and refunds.
|
|||||||
|
|
||||||
**TG bot outbox (`platform/telegram/`).**
|
**TG bot outbox (`platform/telegram/`).**
|
||||||
|
|
||||||
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. Add a
|
- The bot receives `successful_payment` (and `pre_checkout_query`) via Bot API. A **SQLite**
|
||||||
**SQLite** store on the bot's disk: on receipt, persist → ack the Telegram update → forward
|
store on the bot's disk (`internal/outbox`): on receipt, persist → forward over the reverse
|
||||||
to a backend payments-intake endpoint (internal, authenticated over the existing reverse
|
mTLS bot-link to the **gateway** (a `ForwardPayment` unary; the bot cannot dial the backend
|
||||||
mTLS bot-link) → on backend ack, mark `forwarded`. Retries with backoff; re-drive
|
directly) → the gateway proxies to the backend payments-intake REST → on a durable response,
|
||||||
undelivered on restart. Backend intake dedups by `telegram_payment_charge_id`.
|
mark `forwarded`. Re-drive undelivered on startup and on a 30 s tick. Backend intake dedups by
|
||||||
- Provide the invoice creation path (Stars) from the Mini App via the bot as needed.
|
`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.**
|
**Events & notifications.**
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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).
|
// refused and credits nothing (§9: verify the amount after matching by order id).
|
||||||
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
func TestPaymentsFundAmountMismatch(t *testing.T) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package payments
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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())
|
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
|
// 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
|
// many were swept. It backs the periodic pending reaper; expiry is cosmetic (a late valid callback
|
||||||
// still credits — see Fund).
|
// still credits — see Fund).
|
||||||
|
|||||||
@@ -80,6 +80,10 @@ func (s *Server) registerRoutes() {
|
|||||||
// the Robokassa Result callback when a merchant is configured.
|
// the Robokassa Result callback when a merchant is configured.
|
||||||
u.POST("/wallet/order", s.handleWalletOrder)
|
u.POST("/wallet/order", s.handleWalletOrder)
|
||||||
s.internal.POST("/payments/vk/callback", s.handleVKCallback)
|
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 != "" {
|
if s.robokassa.MerchantLogin != "" {
|
||||||
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
s.internal.POST("/payments/robokassa/result", s.handleRobokassaResult)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
providerRobokassa = "robokassa" // the direct (RUB) rail
|
providerRobokassa = "robokassa" // the direct (RUB) rail
|
||||||
providerVK = "vk" // the VK Votes 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.
|
// 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"`
|
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 {
|
type walletOrderResponse struct {
|
||||||
OrderID string `json:"order_id"`
|
OrderID string `json:"order_id"`
|
||||||
RedirectURL string `json:"redirect_url"`
|
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
|
// handleWalletOrder opens a pending order to fund a chip pack and returns the rail's launch
|
||||||
// hosted-payment URL for the client to open. It is direct-rail only for now (VK/TG land later);
|
// details for the client: the Robokassa hosted-payment URL (direct), the order id for
|
||||||
// it enforces the wallet gate and D36 (a direct purchase requires a confirmed email anchor). No
|
// VKWebAppShowOrderBox (VK), or the pack title and star amount the gateway mints into a Stars
|
||||||
// chips are credited here — only later, by the verified Result callback.
|
// 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) {
|
func (s *Server) handleWalletOrder(c *gin.Context) {
|
||||||
uid, ok := userID(c)
|
uid, ok := userID(c)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -80,6 +90,7 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, walletOrderResponse{
|
c.JSON(http.StatusOK, walletOrderResponse{
|
||||||
OrderID: res.OrderID.String(),
|
OrderID: res.OrderID.String(),
|
||||||
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
RedirectURL: s.robokassa.PaymentURL(res.OrderID, res.Amount.Major(), res.Title),
|
||||||
|
Rail: providerRobokassa,
|
||||||
})
|
})
|
||||||
case payments.SourceVK:
|
case payments.SourceVK:
|
||||||
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
|
res, err := s.payments.CreateOrder(ctx, uid, cxt, present, productID, providerVK)
|
||||||
@@ -88,7 +99,23 @@ func (s *Server) handleWalletOrder(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// The client passes the order id to VKWebAppShowOrderBox as the item; there is no redirect.
|
// 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:
|
default:
|
||||||
c.AbortWithStatusJSON(http.StatusNotImplemented, errorResponse{Error: errorBody{Code: "rail_unavailable", Message: "this payment method is not available yet"}})
|
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))
|
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})
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ services:
|
|||||||
# the manage-topics and delete-messages rights.
|
# the manage-topics and delete-messages rights.
|
||||||
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
TELEGRAM_SUPPORT_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
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_PROMO_BOT_TOKEN: ${TELEGRAM_PROMO_BOT_TOKEN:-}
|
||||||
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
TELEGRAM_BOT_USERNAME: ${TELEGRAM_BOT_USERNAME:-}
|
||||||
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
TELEGRAM_BOT_LINK: ${TELEGRAM_BOT_LINK:-}
|
||||||
|
|||||||
@@ -385,6 +385,10 @@ services:
|
|||||||
# set the bot must be an admin there with the manage-topics and delete-messages rights.
|
# 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_CHAT_ID: ${TELEGRAM_SUPPORT_CHAT_ID:-}
|
||||||
TELEGRAM_SUPPORT_STATE_DIR: /data
|
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
|
# 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
|
# 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).
|
# @username and the Mini App link (reused from the UI's VITE_TELEGRAM_LINK).
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
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
|
**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`
|
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.
|
button would sign initData with the promo token); it is self-contained — no bot-link, no gateway.
|
||||||
|
|||||||
+15
-6
@@ -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 —
|
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.
|
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
|
**TG Stars.** Only the **bot** reaches Telegram, so the whole rail funnels through the reverse
|
||||||
the bot host is weak and can lose connectivity, so the bot is a durable link. Store-and-
|
mTLS **bot-link** (bot ↔ gateway; the bot cannot dial the backend). The invoice is minted by the
|
||||||
forward on **SQLite** on the bot's disk: receive → store → ack the Telegram update → forward
|
bot: on the order path the gateway sends a `CreateInvoice` command and the bot returns a
|
||||||
to payments (idempotent, dedup by `telegram_payment_charge_id`) → ack → mark `forwarded`.
|
`createInvoiceLink` (XTR) in its Ack, which the Mini App opens with `WebApp.openInvoice`. Before any
|
||||||
Retries with backoff; re-drives undelivered on restart. At-least-once delivery + idempotent
|
star moves the bot answers `pre_checkout_query` via a bot→gateway `ValidatePreCheckout` unary
|
||||||
intake = credited exactly once.
|
(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
|
**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
|
dispatcher fans out over channels — the live gRPC stream if the user is in-app, else the
|
||||||
|
|||||||
+16
-6
@@ -220,12 +220,22 @@ provider_payment_id)`.
|
|||||||
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
Валидный колбэк исполняется **всегда**, даже на истёкшем заказе (`expired` ≠ отмена —
|
||||||
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
деньги реальны, Фишки должны быть выданы). Пользователь видит только успешные покупки.
|
||||||
|
|
||||||
**Outbox TG-бота.** `successful_payment` приходит только боту (Bot API, не Mini App), а
|
**TG Stars.** До Telegram дотягивается только **бот**, поэтому весь рельс идёт через обратный
|
||||||
хост бота слабый и может терять связь, поэтому бот — durable-звено. Store-and-forward на
|
mTLS **bot-link** (бот ↔ gateway; напрямую к бэкенду бот не ходит). Инвойс создаёт бот: на пути
|
||||||
**SQLite** на диске бота: получил → сохранил → подтвердил апдейт Telegram → форвардит в
|
заказа gateway шлёт команду `CreateInvoice`, а бот возвращает `createInvoiceLink` (XTR) в Ack,
|
||||||
платёжный домен (идемпотентно, дедуп по `telegram_payment_charge_id`) → ack → пометил
|
который Mini App открывает через `WebApp.openInvoice`. До списания звёзд бот отвечает на
|
||||||
`forwarded`. Ретраи с backoff; дореталивает недоставленное при рестарте. Доставка
|
`pre_checkout_query` через унарный вызов бот→gateway `ValidatePreCheckout` (за ним — приём): одобрить,
|
||||||
at-least-once + идемпотентный приём = начисление ровно один раз.
|
только если заказ существует, ещё оплачиваем и **не оплачен ранее** — ссылка 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);
|
**События.** Платёжный домен пишет `payment_events` (succeeded / failed / refunded);
|
||||||
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
диспетчер рассылает по каналам — live gRPC-стрим, если пользователь в аппе, иначе
|
||||||
|
|||||||
@@ -153,6 +153,16 @@ func run(ctx context.Context, cfg config.Config, logger *zap.Logger) error {
|
|||||||
r, rerr := backend.ChatEligibility(ctx, externalID)
|
r, rerr := backend.ChatEligibility(ctx, externalID)
|
||||||
return r.Registered, r.Eligible, rerr
|
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)
|
tlsCfg, terr := mtls.ServerConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile)
|
||||||
if terr != nil {
|
if terr != nil {
|
||||||
return terr
|
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)
|
vkidExchanger = vkid.New(cfg.VKID.AppID, cfg.VKID.ClientSecret, cfg.VKID.RedirectURI)
|
||||||
logger.Info("vk id web login enabled")
|
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{
|
edge := connectsrv.NewServer(connectsrv.Deps{
|
||||||
Registry: registry,
|
Registry: registry,
|
||||||
Sessions: sessions,
|
Sessions: sessions,
|
||||||
|
|||||||
@@ -401,19 +401,60 @@ type walletOrderBody struct {
|
|||||||
ProductID string `json:"product_id"`
|
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 {
|
type WalletOrderResp struct {
|
||||||
OrderID string `json:"order_id"`
|
OrderID string `json:"order_id"`
|
||||||
RedirectURL string `json:"redirect_url"`
|
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) {
|
func (c *Client) WalletOrder(ctx context.Context, userID, productID string) (WalletOrderResp, error) {
|
||||||
var out WalletOrderResp
|
var out WalletOrderResp
|
||||||
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
|
err := c.do(ctx, http.MethodPost, "/api/v1/user/wallet/order", userID, "", walletOrderBody{ProductID: productID}, &out)
|
||||||
return out, err
|
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.
|
// robokassaResultResp is the backend intake's reply: the body to echo back to Robokassa.
|
||||||
type robokassaResultResp struct {
|
type robokassaResultResp struct {
|
||||||
Response string `json:"response"`
|
Response string `json:"response"`
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
"go.opentelemetry.io/otel/metric"
|
"go.opentelemetry.io/otel/metric"
|
||||||
@@ -31,6 +32,11 @@ var ErrNoBot = errors.New("botlink: no bot connected")
|
|||||||
// (at-most-once under backpressure).
|
// (at-most-once under backpressure).
|
||||||
const outboundBuffer = 64
|
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
|
// EligibilityResolver answers a Telegram identity's moderated-chat write eligibility
|
||||||
// for the bot's join-time ResolveChatEligibility query: registered reports whether the
|
// 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
|
// 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.
|
// chat-access endpoint.
|
||||||
type EligibilityResolver func(ctx context.Context, externalID string) (registered, eligible bool, err error)
|
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
|
// 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
|
// expected today; the registry already holds a set so adding more later needs no
|
||||||
// rewrite.
|
// rewrite.
|
||||||
@@ -46,6 +63,10 @@ type Hub struct {
|
|||||||
|
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
eligibility EligibilityResolver
|
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
|
mu sync.Mutex
|
||||||
links map[*link]struct{}
|
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
|
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.
|
// register adds a connected bot.
|
||||||
func (h *Hub) register(l *link) {
|
func (h *Hub) register(l *link) {
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func NewRegistry(backend *backendclient.Client, tg TelegramValidator, opts ...Op
|
|||||||
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
|
r.ops[MsgWalletGet] = Op{Handler: walletHandler(backend), Auth: true}
|
||||||
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
|
r.ops[MsgWalletCatalog] = Op{Handler: walletCatalogHandler(backend), Auth: true}
|
||||||
r.ops[MsgWalletBuy] = Op{Handler: walletBuyHandler(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[MsgBlockStatus] = Op{Handler: blockStatusHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
r.ops[MsgGameSubmitPlay] = Op{Handler: submitPlayHandler(backend), Auth: true}
|
||||||
r.ops[MsgGameState] = Op{Handler: gameStateHandler(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.
|
// Lookup returns the operation for messageType, and whether it is registered.
|
||||||
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
func (r *Registry) Lookup(messageType string) (Op, bool) {
|
||||||
op, ok := r.ops[messageType]
|
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) {
|
return func(ctx context.Context, req Request) ([]byte, error) {
|
||||||
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
|
in := fb.GetRootAsWalletOrderRequest(req.Payload, 0)
|
||||||
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
|
o, err := backend.WalletOrder(ctx, req.UserID, string(in.ProductId()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
return encodeWalletOrder(o), nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-3
@@ -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 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
|
||||||
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
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/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/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 h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
|
||||||
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
|
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/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 h1:d87N7Rmpjj9FgL7bvEaqLdaIaNch2hC6HvkbKGhn7Hk=
|
||||||
github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
|
github.com/iliadenisov/alphabet v1.1.0/go.mod h1:h6BhDBiJBLhMEb5XfsqJXZop3hhwXaD8lc5yf38Baqw=
|
||||||
github.com/iliadenisov/dafsa v1.1.0 h1:NV1ZOstMdHXI/cCyAZKOD3qnKLoYdMUunA0+Baj7vR4=
|
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/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/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-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 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M=
|
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/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 h1:UKQoHmY6be/R3tSvD2nQYrH41k43OJkidwEiC74KIzk=
|
||||||
github.com/volatiletech/strmangle v0.0.1/go.mod h1:F6RA6IkB5vq0yTG4GQ0UsbbRcl3ni9P76i+JrTBKFFg=
|
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 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
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/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/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.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 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
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=
|
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 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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/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/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/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
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 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
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=
|
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 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
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 h1:3NTZpeTxYVWNSokW3MKeyVkz/j7uYXYiMtXRUfmjbgI=
|
||||||
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk=
|
||||||
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4=
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ type Command struct {
|
|||||||
// *Command_SendToUser
|
// *Command_SendToUser
|
||||||
// *Command_SendToChannel
|
// *Command_SendToChannel
|
||||||
// *Command_ChatGate
|
// *Command_ChatGate
|
||||||
|
// *Command_CreateInvoice
|
||||||
Payload isCommand_Payload `protobuf_oneof:"payload"`
|
Payload isCommand_Payload `protobuf_oneof:"payload"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -310,6 +311,15 @@ func (x *Command) GetChatGate() *ChatGateCommand {
|
|||||||
return nil
|
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 {
|
type isCommand_Payload interface {
|
||||||
isCommand_Payload()
|
isCommand_Payload()
|
||||||
}
|
}
|
||||||
@@ -330,6 +340,10 @@ type Command_ChatGate struct {
|
|||||||
ChatGate *ChatGateCommand `protobuf:"bytes,5,opt,name=chat_gate,json=chatGate,proto3,oneof"`
|
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_Notify) isCommand_Payload() {}
|
||||||
|
|
||||||
func (*Command_SendToUser) isCommand_Payload() {}
|
func (*Command_SendToUser) isCommand_Payload() {}
|
||||||
@@ -338,15 +352,20 @@ func (*Command_SendToChannel) isCommand_Payload() {}
|
|||||||
|
|
||||||
func (*Command_ChatGate) 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
|
// 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
|
// 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
|
// 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 {
|
type Ack struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
CommandId string `protobuf:"bytes,1,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
|
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"`
|
Delivered bool `protobuf:"varint,2,opt,name=delivered,proto3" json:"delivered,omitempty"`
|
||||||
Error string `protobuf:"bytes,3,opt,name=error,proto3" json:"error,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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -402,6 +421,13 @@ func (x *Ack) GetError() string {
|
|||||||
return ""
|
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
|
// 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
|
// 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
|
// 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
|
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
|
var File_botlink_v1_botlink_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
const file_botlink_v1_botlink_proto_rawDesc = "" +
|
||||||
@@ -576,7 +910,7 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
|
|||||||
"\x05Hello\x12\x1f\n" +
|
"\x05Hello\x12\x1f\n" +
|
||||||
"\vinstance_id\x18\x01 \x01(\tR\n" +
|
"\vinstance_id\x18\x01 \x01(\tR\n" +
|
||||||
"instanceId\x12!\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" +
|
"\aCommand\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12=\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" +
|
"\fsend_to_user\x18\x03 \x01(\v2'.scrabble.telegram.v1.SendToUserRequestH\x00R\n" +
|
||||||
"sendToUser\x12X\n" +
|
"sendToUser\x12X\n" +
|
||||||
"\x0fsend_to_channel\x18\x04 \x01(\v2..scrabble.telegram.v1.SendToGameChannelRequestH\x00R\rsendToChannel\x12C\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" +
|
"\tchat_gate\x18\x05 \x01(\v2$.scrabble.botlink.v1.ChatGateCommandH\x00R\bchatGate\x12R\n" +
|
||||||
"\apayload\"X\n" +
|
"\x0ecreate_invoice\x18\x06 \x01(\v2).scrabble.botlink.v1.CreateInvoiceCommandH\x00R\rcreateInvoiceB\t\n" +
|
||||||
|
"\apayload\"p\n" +
|
||||||
"\x03Ack\x12\x1d\n" +
|
"\x03Ack\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
|
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x1c\n" +
|
||||||
"\tdelivered\x18\x02 \x01(\bR\tdelivered\x12\x14\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" +
|
"\x0fChatGateCommand\x12\x1f\n" +
|
||||||
"\vexternal_id\x18\x01 \x01(\tR\n" +
|
"\vexternal_id\x18\x01 \x01(\tR\n" +
|
||||||
"externalId\x12\x14\n" +
|
"externalId\x12\x14\n" +
|
||||||
@@ -602,10 +938,31 @@ const file_botlink_v1_botlink_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"registered\x18\x01 \x01(\bR\n" +
|
"registered\x18\x01 \x01(\bR\n" +
|
||||||
"registered\x12\x1a\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" +
|
"\aBotLink\x12D\n" +
|
||||||
"\x04Link\x12\x1c.scrabble.botlink.v1.FromBot\x1a\x1a.scrabble.botlink.v1.ToBot(\x010\x01\x12s\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 (
|
var (
|
||||||
file_botlink_v1_botlink_proto_rawDescOnce sync.Once
|
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
|
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{
|
var file_botlink_v1_botlink_proto_goTypes = []any{
|
||||||
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
(*FromBot)(nil), // 0: scrabble.botlink.v1.FromBot
|
||||||
(*ToBot)(nil), // 1: scrabble.botlink.v1.ToBot
|
(*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
|
(*ChatGateCommand)(nil), // 5: scrabble.botlink.v1.ChatGateCommand
|
||||||
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
|
(*ChatEligibilityRequest)(nil), // 6: scrabble.botlink.v1.ChatEligibilityRequest
|
||||||
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
|
(*ChatEligibilityResponse)(nil), // 7: scrabble.botlink.v1.ChatEligibilityResponse
|
||||||
(*v1.NotifyRequest)(nil), // 8: scrabble.telegram.v1.NotifyRequest
|
(*CreateInvoiceCommand)(nil), // 8: scrabble.botlink.v1.CreateInvoiceCommand
|
||||||
(*v1.SendToUserRequest)(nil), // 9: scrabble.telegram.v1.SendToUserRequest
|
(*PreCheckoutRequest)(nil), // 9: scrabble.botlink.v1.PreCheckoutRequest
|
||||||
(*v1.SendToGameChannelRequest)(nil), // 10: scrabble.telegram.v1.SendToGameChannelRequest
|
(*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{
|
var file_botlink_v1_botlink_proto_depIdxs = []int32{
|
||||||
2, // 0: scrabble.botlink.v1.FromBot.hello:type_name -> scrabble.botlink.v1.Hello
|
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
|
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
|
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
|
13, // 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
|
14, // 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
|
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
|
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
|
8, // 7: scrabble.botlink.v1.Command.create_invoice:type_name -> scrabble.botlink.v1.CreateInvoiceCommand
|
||||||
6, // 8: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
0, // 8: scrabble.botlink.v1.BotLink.Link:input_type -> scrabble.botlink.v1.FromBot
|
||||||
1, // 9: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
6, // 9: scrabble.botlink.v1.BotLink.ResolveChatEligibility:input_type -> scrabble.botlink.v1.ChatEligibilityRequest
|
||||||
7, // 10: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
9, // 10: scrabble.botlink.v1.BotLink.ValidatePreCheckout:input_type -> scrabble.botlink.v1.PreCheckoutRequest
|
||||||
9, // [9:11] is the sub-list for method output_type
|
11, // 11: scrabble.botlink.v1.BotLink.ForwardPayment:input_type -> scrabble.botlink.v1.ForwardPaymentRequest
|
||||||
7, // [7:9] is the sub-list for method input_type
|
1, // 12: scrabble.botlink.v1.BotLink.Link:output_type -> scrabble.botlink.v1.ToBot
|
||||||
7, // [7:7] is the sub-list for extension type_name
|
7, // 13: scrabble.botlink.v1.BotLink.ResolveChatEligibility:output_type -> scrabble.botlink.v1.ChatEligibilityResponse
|
||||||
7, // [7:7] is the sub-list for extension extendee
|
10, // 14: scrabble.botlink.v1.BotLink.ValidatePreCheckout:output_type -> scrabble.botlink.v1.PreCheckoutResponse
|
||||||
0, // [0:7] is the sub-list for field type_name
|
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() }
|
func init() { file_botlink_v1_botlink_proto_init() }
|
||||||
@@ -666,6 +1033,7 @@ func file_botlink_v1_botlink_proto_init() {
|
|||||||
(*Command_SendToUser)(nil),
|
(*Command_SendToUser)(nil),
|
||||||
(*Command_SendToChannel)(nil),
|
(*Command_SendToChannel)(nil),
|
||||||
(*Command_ChatGate)(nil),
|
(*Command_ChatGate)(nil),
|
||||||
|
(*Command_CreateInvoice)(nil),
|
||||||
}
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
@@ -673,7 +1041,7 @@ func file_botlink_v1_botlink_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_botlink_v1_botlink_proto_rawDesc), len(file_botlink_v1_botlink_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 8,
|
NumMessages: 13,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ service BotLink {
|
|||||||
// same mTLS channel when a user joins the chat, to decide whether to grant the
|
// 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).
|
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||||
rpc ResolveChatEligibility(ChatEligibilityRequest) returns (ChatEligibilityResponse);
|
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
|
// 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.SendToUserRequest send_to_user = 3;
|
||||||
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
|
scrabble.telegram.v1.SendToGameChannelRequest send_to_channel = 4;
|
||||||
ChatGateCommand chat_gate = 5;
|
ChatGateCommand chat_gate = 5;
|
||||||
|
CreateInvoiceCommand create_invoice = 6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ack reports the outcome of the Command with command_id. delivered mirrors the
|
// 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
|
// 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
|
// 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 {
|
message Ack {
|
||||||
string command_id = 1;
|
string command_id = 1;
|
||||||
bool delivered = 2;
|
bool delivered = 2;
|
||||||
string error = 3;
|
string error = 3;
|
||||||
|
string result = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
|
// ChatGateCommand sets a Telegram user's write access in the moderated discussion
|
||||||
@@ -99,3 +121,48 @@ message ChatEligibilityResponse {
|
|||||||
bool registered = 1;
|
bool registered = 1;
|
||||||
bool eligible = 2;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const _ = grpc.SupportPackageIsVersion9
|
|||||||
const (
|
const (
|
||||||
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
|
BotLink_Link_FullMethodName = "/scrabble.botlink.v1.BotLink/Link"
|
||||||
BotLink_ResolveChatEligibility_FullMethodName = "/scrabble.botlink.v1.BotLink/ResolveChatEligibility"
|
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.
|
// 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
|
// 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).
|
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||||
ResolveChatEligibility(ctx context.Context, in *ChatEligibilityRequest, opts ...grpc.CallOption) (*ChatEligibilityResponse, error)
|
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 {
|
type botLinkClient struct {
|
||||||
@@ -81,6 +99,26 @@ func (c *botLinkClient) ResolveChatEligibility(ctx context.Context, in *ChatElig
|
|||||||
return out, nil
|
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.
|
// BotLinkServer is the server API for BotLink service.
|
||||||
// All implementations must embed UnimplementedBotLinkServer
|
// All implementations must embed UnimplementedBotLinkServer
|
||||||
// for forward compatibility.
|
// 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
|
// 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).
|
// write permission. Delivery of the answer is request/response (not best-effort).
|
||||||
ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error)
|
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()
|
mustEmbedUnimplementedBotLinkServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +169,12 @@ func (UnimplementedBotLinkServer) Link(grpc.BidiStreamingServer[FromBot, ToBot])
|
|||||||
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
|
func (UnimplementedBotLinkServer) ResolveChatEligibility(context.Context, *ChatEligibilityRequest) (*ChatEligibilityResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method ResolveChatEligibility not implemented")
|
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) mustEmbedUnimplementedBotLinkServer() {}
|
||||||
func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
|
func (UnimplementedBotLinkServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -161,6 +221,42 @@ func _BotLink_ResolveChatEligibility_Handler(srv interface{}, ctx context.Contex
|
|||||||
return interceptor(ctx, in, info, handler)
|
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.
|
// BotLink_ServiceDesc is the grpc.ServiceDesc for BotLink service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -172,6 +268,14 @@ var BotLink_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "ResolveChatEligibility",
|
MethodName: "ResolveChatEligibility",
|
||||||
Handler: _BotLink_ResolveChatEligibility_Handler,
|
Handler: _BotLink_ResolveChatEligibility_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ValidatePreCheckout",
|
||||||
|
Handler: _BotLink_ValidatePreCheckout_Handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ForwardPayment",
|
||||||
|
Handler: _BotLink_ForwardPayment_Handler,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Streams: []grpc.StreamDesc{
|
Streams: []grpc.StreamDesc{
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.
|
the seeded Mini App rather than the bot profile.
|
||||||
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
- **Rate limiting.** Outbound sends are throttled (`TELEGRAM_SEND_RATE_PER_SECOND`,
|
||||||
default 25) to respect the Bot API flood limits.
|
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
|
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
|
`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
|
gateway also implements `SendToUser` / `SendToGameChannel` as the backend's admin
|
||||||
relay.
|
relay.
|
||||||
- `pkg/proto/botlink/v1`, service `BotLink` — the reverse bidi stream the **bot** dials
|
- `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
|
on the gateway (`Hello` / `Command` / `Ack`), carrying a `ChatGateCommand` (set a user's
|
||||||
a user's chat write access) and a unary `ResolveChatEligibility` (the bot's join-time
|
chat write access) and a `CreateInvoiceCommand` (mint a Stars invoice link, returned in the
|
||||||
query) over the same mTLS channel. Generated Go is committed under `pkg`.
|
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
|
## 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_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_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_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_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_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 |
|
| `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 |
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"scrabble/platform/telegram/internal/bot"
|
"scrabble/platform/telegram/internal/bot"
|
||||||
"scrabble/platform/telegram/internal/botlink"
|
"scrabble/platform/telegram/internal/botlink"
|
||||||
"scrabble/platform/telegram/internal/config"
|
"scrabble/platform/telegram/internal/config"
|
||||||
|
"scrabble/platform/telegram/internal/outbox"
|
||||||
"scrabble/platform/telegram/internal/promobot"
|
"scrabble/platform/telegram/internal/promobot"
|
||||||
"scrabble/platform/telegram/internal/support"
|
"scrabble/platform/telegram/internal/support"
|
||||||
)
|
)
|
||||||
@@ -90,11 +91,24 @@ func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
|
|||||||
GameChannelID: cfg.GameChannelID,
|
GameChannelID: cfg.GameChannelID,
|
||||||
SupportChatID: cfg.SupportChatID,
|
SupportChatID: cfg.SupportChatID,
|
||||||
SupportStore: supportStore,
|
SupportStore: supportStore,
|
||||||
|
AcceptPayments: cfg.StarsOutboxDir != "",
|
||||||
}, logger)
|
}, logger)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 <->
|
// the bot after the client is built — the late binding that breaks the bot <->
|
||||||
// client construction cycle.
|
// client construction cycle.
|
||||||
b.SetEligibilityResolver(client.ResolveChatEligibility)
|
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
|
// 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
|
// /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))
|
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 promo bot runs its own getUpdates long-poll on its own token (no 409 with
|
||||||
// the main bot's lease).
|
// the main bot's lease).
|
||||||
if promo != nil {
|
if promo != nil {
|
||||||
|
|||||||
@@ -12,3 +12,16 @@ require (
|
|||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
scrabble/pkg v0.0.0
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
|
|
||||||
|
"scrabble/platform/telegram/internal/outbox"
|
||||||
"scrabble/platform/telegram/internal/support"
|
"scrabble/platform/telegram/internal/support"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +50,10 @@ type Config struct {
|
|||||||
// SupportStore persists the support relay's state (topic mapping, block list,
|
// SupportStore persists the support relay's state (topic mapping, block list,
|
||||||
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
// relayed message ids); required when SupportChatID is set, ignored otherwise.
|
||||||
SupportStore *support.Store
|
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
|
// EligibilityResolver answers whether the Telegram user identified by externalID
|
||||||
@@ -91,6 +96,12 @@ type Bot struct {
|
|||||||
supportLocks *keyedMutex
|
supportLocks *keyedMutex
|
||||||
// admins caches the support chat's administrator ids (who may reply and act).
|
// admins caches the support chat's administrator ids (who may reply and act).
|
||||||
admins *adminCache
|
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
|
// 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.
|
// callback handler by their "sup:" data prefix.
|
||||||
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
|
||||||
}
|
}
|
||||||
// Allowed updates default to "all except chat_member". Specify an explicit set only
|
// Allowed updates default to "all except chat_member" (which already includes
|
||||||
// when we need chat_member (moderated chat) — and then re-add callback_query (which
|
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
|
||||||
// the explicit set would otherwise drop) when the support relay needs it.
|
// 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 {
|
if cfg.ChatID != 0 {
|
||||||
allowed := tgbot.AllowedUpdates{
|
allowed := tgbot.AllowedUpdates{
|
||||||
models.AllowedUpdateMessage,
|
models.AllowedUpdateMessage,
|
||||||
@@ -135,6 +147,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
|
|||||||
if t.supportEnabled() {
|
if t.supportEnabled() {
|
||||||
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
|
||||||
}
|
}
|
||||||
|
if cfg.AcceptPayments {
|
||||||
|
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
|
||||||
|
}
|
||||||
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
|
||||||
}
|
}
|
||||||
if cfg.TestEnv {
|
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)
|
t.handleChatMember(ctx, update.ChatMember)
|
||||||
return
|
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 —
|
// Support relay (when enabled): a non-/start message — /start has its own handler —
|
||||||
// is either an operator's reply in the support chat or a user's direct message to
|
// is either an operator's reply in the support chat or a user's direct message to
|
||||||
// relay. Everything else falls through to the Mini App launch reply.
|
// relay. Everything else falls through to the Mini App launch reply.
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -83,6 +83,34 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
|
|||||||
return resp.GetEligible(), nil
|
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
|
// 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.
|
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
|
||||||
func (c *Client) Run(ctx context.Context) error {
|
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 {
|
if cmd == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delivered, herr := c.exec.Handle(ctx, cmd)
|
delivered, result, herr := c.exec.Handle(ctx, cmd)
|
||||||
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
|
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
ack.Error = herr.Error()
|
ack.Error = herr.Error()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ type Sender interface {
|
|||||||
// chat, but only when they are currently in it; it reports whether a restriction
|
// chat, but only when they are currently in it; it reports whether a restriction
|
||||||
// was applied.
|
// was applied.
|
||||||
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
|
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
|
// 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}
|
return &Executor{sender: sender, channelID: channelID, log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle dispatches one command to the matching Bot API send.
|
// Handle dispatches one command to the matching Bot API call. It returns whether the command was
|
||||||
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
|
// 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) {
|
switch p := cmd.GetPayload().(type) {
|
||||||
case *botlinkv1.Command_Notify:
|
case *botlinkv1.Command_Notify:
|
||||||
return e.notify(ctx, p.Notify)
|
d, err := e.notify(ctx, p.Notify)
|
||||||
|
return d, "", err
|
||||||
case *botlinkv1.Command_SendToUser:
|
case *botlinkv1.Command_SendToUser:
|
||||||
return e.sendToUser(ctx, p.SendToUser)
|
d, err := e.sendToUser(ctx, p.SendToUser)
|
||||||
|
return d, "", err
|
||||||
case *botlinkv1.Command_SendToChannel:
|
case *botlinkv1.Command_SendToChannel:
|
||||||
return e.sendToChannel(ctx, p.SendToChannel)
|
d, err := e.sendToChannel(ctx, p.SendToChannel)
|
||||||
|
return d, "", err
|
||||||
case *botlinkv1.Command_ChatGate:
|
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:
|
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
|
// 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
|
// 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.
|
// Bot API failure is logged and reported as not-delivered, not a hard error.
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import (
|
|||||||
|
|
||||||
// fakeSender records the delivery calls the executor makes.
|
// fakeSender records the delivery calls the executor makes.
|
||||||
type fakeSender struct {
|
type fakeSender struct {
|
||||||
notify []notifyCall
|
notify []notifyCall
|
||||||
text []textCall
|
text []textCall
|
||||||
gate []gateCall
|
gate []gateCall
|
||||||
applied bool // ApplyChatGate's reported result
|
invoice []invoiceCall
|
||||||
err error
|
invoiceLink string // CreateInvoiceLink's returned link
|
||||||
|
applied bool // ApplyChatGate's reported result
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
type notifyCall struct {
|
type notifyCall struct {
|
||||||
@@ -32,6 +34,10 @@ type gateCall struct {
|
|||||||
userID int64
|
userID int64
|
||||||
allow bool
|
allow bool
|
||||||
}
|
}
|
||||||
|
type invoiceCall struct {
|
||||||
|
title, description, payload string
|
||||||
|
amount int64
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
|
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
|
||||||
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
|
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
|
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 {
|
func yourTurnPayload(gameID string) []byte {
|
||||||
b := flatbuffers.NewBuilder(0)
|
b := flatbuffers.NewBuilder(0)
|
||||||
gid := b.CreateString(gameID)
|
gid := b.CreateString(gameID)
|
||||||
@@ -67,7 +78,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
|||||||
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||||
sender := &fakeSender{}
|
sender := &fakeSender{}
|
||||||
exec := NewExecutor(sender, 0, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -85,7 +96,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
|
|||||||
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
||||||
sender := &fakeSender{}
|
sender := &fakeSender{}
|
||||||
exec := NewExecutor(sender, 0, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -99,7 +110,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
|
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
|
||||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
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")
|
t.Error("expected an error for a non-numeric external_id")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,7 +119,7 @@ func TestExecutorSendToUser(t *testing.T) {
|
|||||||
sender := &fakeSender{}
|
sender := &fakeSender{}
|
||||||
exec := NewExecutor(sender, 0, nil)
|
exec := NewExecutor(sender, 0, nil)
|
||||||
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -126,7 +137,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
|
|||||||
func TestExecutorChatGateApplied(t *testing.T) {
|
func TestExecutorChatGateApplied(t *testing.T) {
|
||||||
sender := &fakeSender{applied: true}
|
sender := &fakeSender{applied: true}
|
||||||
exec := NewExecutor(sender, 0, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -138,7 +149,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
|
|||||||
func TestExecutorChatGateNotInChat(t *testing.T) {
|
func TestExecutorChatGateNotInChat(t *testing.T) {
|
||||||
sender := &fakeSender{applied: false} // user not in the chat
|
sender := &fakeSender{applied: false} // user not in the chat
|
||||||
exec := NewExecutor(sender, 0, nil)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
@@ -152,24 +163,53 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
|
|||||||
|
|
||||||
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
|
||||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
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")
|
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) {
|
func TestExecutorSendToChannel(t *testing.T) {
|
||||||
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
|
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
|
||||||
|
|
||||||
t.Run("unconfigured", func(t *testing.T) {
|
t.Run("unconfigured", func(t *testing.T) {
|
||||||
exec := NewExecutor(&fakeSender{}, 0, nil)
|
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.Error("expected an error when no channel is configured")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
t.Run("configured", func(t *testing.T) {
|
t.Run("configured", func(t *testing.T) {
|
||||||
sender := &fakeSender{}
|
sender := &fakeSender{}
|
||||||
exec := NewExecutor(sender, 555, nil)
|
exec := NewExecutor(sender, 555, nil)
|
||||||
delivered, err := exec.Handle(context.Background(), channelCmd)
|
delivered, _, err := exec.Handle(context.Background(), channelCmd)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("handle: %v", err)
|
t.Fatalf("handle: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type BotConfig struct {
|
|||||||
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
|
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
|
||||||
// container user (UID 65532) and backed by a persistent volume.
|
// container user (UID 65532) and backed by a persistent volume.
|
||||||
SupportStateDir string
|
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
|
// 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
|
// 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
|
// 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"),
|
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
|
||||||
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
|
||||||
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
|
||||||
|
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
|
||||||
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
|
||||||
BotLink: BotLinkClientConfig{
|
BotLink: BotLinkClientConfig{
|
||||||
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,11 @@ const DIST = 'dist';
|
|||||||
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
||||||
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
|
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
|
||||||
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
||||||
// in the shared chunk). The heavy parts — the dict loader, the move generator and the preload
|
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
|
||||||
|
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
||||||
|
// always-loaded Wallet screen. The heavy parts — the dict loader, the move generator and the preload
|
||||||
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
// orchestration — still stay in lazy chunks. Scoped CSS lands in the CSS chunk, not this JS budget.
|
||||||
const BUDGET = { app: 123, shared: 30, landing: 5 };
|
const BUDGET = { app: 125, shared: 30, landing: 5 };
|
||||||
|
|
||||||
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
||||||
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface TelegramWebApp {
|
|||||||
close?: () => void;
|
close?: () => void;
|
||||||
openTelegramLink?: (url: string) => void;
|
openTelegramLink?: (url: string) => void;
|
||||||
openLink?: (url: string) => void;
|
openLink?: (url: string) => void;
|
||||||
|
openInvoice?: (url: string, callback?: (status: string) => void) => void;
|
||||||
onEvent?: (event: string, handler: () => void) => void;
|
onEvent?: (event: string, handler: () => void) => void;
|
||||||
setHeaderColor?: (color: string) => void;
|
setHeaderColor?: (color: string) => void;
|
||||||
setBackgroundColor?: (color: string) => void;
|
setBackgroundColor?: (color: string) => void;
|
||||||
@@ -170,6 +171,20 @@ export function telegramOpenLink(url: string): boolean {
|
|||||||
return true;
|
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,
|
* 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
|
* so Telegram opens it directly in its in-app browser instead of the WebView's generic "open
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
import { isGooglePlayBuild } from '../lib/distribution';
|
import { isGooglePlayBuild } from '../lib/distribution';
|
||||||
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
import { onExternalLinkClick, openExternalUrl } from '../lib/links';
|
||||||
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
import { vkPlatform, vkShowOrderBox } from '../lib/vk';
|
||||||
|
import { telegramOpenInvoice } from '../lib/telegram';
|
||||||
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
import type { Wallet, Catalog, CatalogProduct } from '../lib/model';
|
||||||
|
|
||||||
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
// The Wallet section: the context-visible chip balances, the active benefits, and the storefront.
|
||||||
@@ -20,7 +21,10 @@
|
|||||||
let wallet = $state<Wallet | null>(null);
|
let wallet = $state<Wallet | null>(null);
|
||||||
let catalog = $state<Catalog | null>(null);
|
let catalog = $state<Catalog | null>(null);
|
||||||
let loading = $state(true);
|
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<string | null>(null);
|
||||||
|
const busy = $derived(busyId !== null);
|
||||||
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
// The value awaiting a web-spend warning confirmation (a spend that would draw vk/tg chips).
|
||||||
let warnProduct = $state<CatalogProduct | null>(null);
|
let warnProduct = $state<CatalogProduct | null>(null);
|
||||||
|
|
||||||
@@ -98,13 +102,13 @@
|
|||||||
async function doBuy(p: CatalogProduct) {
|
async function doBuy(p: CatalogProduct) {
|
||||||
warnProduct = null;
|
warnProduct = null;
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busyId = p.productId;
|
||||||
try {
|
try {
|
||||||
wallet = await gateway.walletBuy(p.productId);
|
wallet = await gateway.walletBuy(p.productId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busyId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,19 +117,27 @@
|
|||||||
// paying accepts the public offer (linked below the packs).
|
// paying accepts the public offer (linked below the packs).
|
||||||
async function onOrder(p: CatalogProduct) {
|
async function onOrder(p: CatalogProduct) {
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busyId = p.productId;
|
||||||
try {
|
try {
|
||||||
const order = await gateway.walletOrder(p.productId);
|
const order = await gateway.walletOrder(p.productId);
|
||||||
if (context === 'vk') {
|
if (context === 'vk') {
|
||||||
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
|
// VK settles the payment in-app via the bridge; the chips arrive on the server callback.
|
||||||
await vkShowOrderBox(order.orderId);
|
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 {
|
} else {
|
||||||
openExternalUrl(order.redirectUrl);
|
openExternalUrl(order.redirectUrl);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError(e);
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
busy = false;
|
busyId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -163,7 +175,9 @@
|
|||||||
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
<div class="row product" data-testid="product" data-kind="value" data-pid={p.productId}>
|
||||||
<span class="name">{p.title}</span>
|
<span class="name">{p.title}</span>
|
||||||
<span class="price">🪙 {p.chips}</span>
|
<span class="price">🪙 {p.chips}</span>
|
||||||
<button class="buy" data-testid="buy" disabled={busy} onclick={() => onBuy(p)}>{t('wallet.buy')}</button>
|
<button class="buy" class:working={busyId === p.productId} data-testid="buy" disabled={busy} onclick={() => onBuy(p)}
|
||||||
|
>{t('wallet.buy')}</button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -177,6 +191,7 @@
|
|||||||
<button
|
<button
|
||||||
class="buy"
|
class="buy"
|
||||||
class:blocked={purchaseBlocked}
|
class:blocked={purchaseBlocked}
|
||||||
|
class:working={busyId === p.productId}
|
||||||
data-testid="buy-pack"
|
data-testid="buy-pack"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
@@ -262,10 +277,12 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.buy:disabled {
|
.buy:disabled {
|
||||||
opacity: 0.5;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
.buy.blocked {
|
/* The dimmed look is scoped: the tapped button while its purchase is in flight (working), or a
|
||||||
|
blocked pack on VK iOS — never every buy button at once when one purchase is busy. */
|
||||||
|
.buy.blocked,
|
||||||
|
.buy.working {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.ios-note {
|
.ios-note {
|
||||||
|
|||||||
Reference in New Issue
Block a user