feat(payments): Telegram Stars payment rail
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 20s
CI / ui (pull_request) Successful in 1m10s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m57s

Accept real money via Telegram Stars (XTR) — the third intake rail
alongside Robokassa (direct) and VK Votes.

Only the bot reaches Telegram, so the rail funnels through the reverse
mTLS bot-link:
- the gateway mints the invoice on a CreateInvoice command (the bot
  calls createInvoiceLink, XTR; the link goes to WebApp.openInvoice);
- the bot gates each pre_checkout_query via a ValidatePreCheckout unary
  (the order must exist, be still creditable and not already paid — the
  reusable-invoice double-pay guard; the decline reason is localised to
  the order account's language);
- a completed successful_payment is queued in a durable pure-Go SQLite
  outbox and forwarded via a ForwardPayment unary, credited once
  (idempotent on telegram_payment_charge_id, honours an expired order),
  re-driven on restart and every 30s.

The rail is wired by TELEGRAM_STARS_OUTBOX_DIR (default /data) but stays
inert until a chip pack carries an XTR price, so seeding a Stars price in
the admin is the go-live.

Tests: backend integration (order->forward->credit once, duplicate,
pre_checkout gate) + bot outbox unit (idempotent, restart re-drive) +
executor createInvoice. Docs: PAYMENTS(+ru) §9, ARCHITECTURE, the
platform/telegram README, PLAN.
This commit is contained in:
Ilia Denisov
2026-07-09 21:35:29 +02:00
parent 5612bb624d
commit 6e03ce0131
32 changed files with 1830 additions and 94 deletions
+29 -3
View File
@@ -16,6 +16,7 @@ import (
"go.uber.org/zap"
"golang.org/x/time/rate"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/support"
)
@@ -49,6 +50,10 @@ type Config struct {
// SupportStore persists the support relay's state (topic mapping, block list,
// relayed message ids); required when SupportChatID is set, ignored otherwise.
SupportStore *support.Store
// AcceptPayments enables the Telegram Stars rail: the bot then subscribes to
// pre_checkout_query updates and handles pre_checkout / successful_payment. The runtime
// dependencies (the validator, forwarder and outbox) are wired with SetPaymentHandlers.
AcceptPayments bool
}
// EligibilityResolver answers whether the Telegram user identified by externalID
@@ -91,6 +96,12 @@ type Bot struct {
supportLocks *keyedMutex
// admins caches the support chat's administrator ids (who may reply and act).
admins *adminCache
// precheck validates a Stars pre_checkout order and forward delivers a completed payment; both
// are late-bound (SetPaymentHandlers) over the bot-link, which is built after the bot. outbox
// durably records completed payments before they are forwarded. All nil when the Stars rail is off.
precheck PreCheckoutValidator
forward PaymentForwarder
outbox *outbox.Store
}
// New builds the bot wrapper, registering the /start handler and a default handler
@@ -123,9 +134,10 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
// callback handler by their "sup:" data prefix.
opts = append(opts, tgbot.WithCallbackQueryDataHandler(supportCallbackPrefix, tgbot.MatchTypePrefix, t.handleSupportCallback))
}
// Allowed updates default to "all except chat_member". Specify an explicit set only
// when we need chat_member (moderated chat) — and then re-add callback_query (which
// the explicit set would otherwise drop) when the support relay needs it.
// Allowed updates default to "all except chat_member" (which already includes
// pre_checkout_query and message-borne successful_payment). Specify an explicit set only when we
// need chat_member (moderated chat) — and then re-add callback_query and, for the Stars rail,
// pre_checkout_query, which the explicit set would otherwise drop.
if cfg.ChatID != 0 {
allowed := tgbot.AllowedUpdates{
models.AllowedUpdateMessage,
@@ -135,6 +147,9 @@ func New(cfg Config, log *zap.Logger) (*Bot, error) {
if t.supportEnabled() {
allowed = append(allowed, models.AllowedUpdateCallbackQuery)
}
if cfg.AcceptPayments {
allowed = append(allowed, models.AllowedUpdatePreCheckoutQuery)
}
opts = append(opts, tgbot.WithAllowedUpdates(allowed))
}
if cfg.TestEnv {
@@ -346,6 +361,17 @@ func (t *Bot) handleUpdate(ctx context.Context, api *tgbot.Bot, update *models.U
t.handleChatMember(ctx, update.ChatMember)
return
}
// Telegram Stars: the pre_checkout gate (validated against the backend) and the completed
// payment (persisted to the outbox and forwarded) — before the support relay, so a payment
// message is never mistaken for a support DM or given a launch reply.
if update.PreCheckoutQuery != nil {
t.handlePreCheckout(ctx, update.PreCheckoutQuery)
return
}
if update.Message != nil && update.Message.SuccessfulPayment != nil {
t.handleSuccessfulPayment(ctx, update.Message)
return
}
// Support relay (when enabled): a non-/start message — /start has its own handler —
// is either an operator's reply in the support chat or a user's direct message to
// relay. Everything else falls through to the Mini App launch reply.
+166
View File
@@ -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."
}
+30 -2
View File
@@ -83,6 +83,34 @@ func (c *Client) ResolveChatEligibility(ctx context.Context, externalID string)
return resp.GetEligible(), nil
}
// ValidatePreCheckout asks the gateway whether a Telegram Stars pre_checkout_query for orderID
// paying amount in currency may be approved, before the charge. The bot calls it on every
// pre_checkout_query over the same mTLS connection and approves only on ok; the reason is a short
// decline message (already localised by the backend) to show the payer.
func (c *Client) ValidatePreCheckout(ctx context.Context, orderID string, amount int64, currency string) (ok bool, reason string, err error) {
resp, err := c.client.ValidatePreCheckout(ctx, &botlinkv1.PreCheckoutRequest{OrderId: orderID, Amount: amount, Currency: currency})
if err != nil {
return false, "", err
}
return resp.GetOk(), resp.GetReason(), nil
}
// ForwardPayment delivers a completed Stars payment to the gateway for crediting. The bot calls it
// from the outbox drain; it reports whether the order was credited (or already had been). A non-nil
// error is a transient failure the bot retries.
func (c *Client) ForwardPayment(ctx context.Context, orderID, chargeID string, amount, telegramUserID int64) (credited bool, err error) {
resp, err := c.client.ForwardPayment(ctx, &botlinkv1.ForwardPaymentRequest{
OrderId: orderID,
TelegramPaymentChargeId: chargeID,
Amount: amount,
TelegramUserId: telegramUserID,
})
if err != nil {
return false, err
}
return resp.GetCredited(), nil
}
// Run keeps the bot-link command stream open, re-opening it after each break, until
// ctx is cancelled. The gRPC connection auto-reconnects the transport underneath.
func (c *Client) Run(ctx context.Context) error {
@@ -121,8 +149,8 @@ func (c *Client) serve(ctx context.Context, client botlinkv1.BotLinkClient) erro
if cmd == nil {
continue
}
delivered, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered}
delivered, result, herr := c.exec.Handle(ctx, cmd)
ack := &botlinkv1.Ack{CommandId: cmd.GetCommandId(), Delivered: delivered, Result: result}
if herr != nil {
ack.Error = herr.Error()
}
+31 -7
View File
@@ -27,6 +27,10 @@ type Sender interface {
// chat, but only when they are currently in it; it reports whether a restriction
// was applied.
ApplyChatGate(ctx context.Context, userID int64, allow bool) (bool, error)
// CreateInvoiceLink mints a Telegram Stars invoice link (XTR) for amountStars, tagged
// with payload (the order id, echoed back in pre_checkout and successful_payment), and
// returns the link.
CreateInvoiceLink(ctx context.Context, title, description, payload string, amountStars int64) (string, error)
}
// Executor turns a bot-link Command into a Bot API send. The delivered flag mirrors
@@ -48,22 +52,42 @@ func NewExecutor(sender Sender, channelID int64, log *zap.Logger) *Executor {
return &Executor{sender: sender, channelID: channelID, log: log}
}
// Handle dispatches one command to the matching Bot API send.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, error) {
// Handle dispatches one command to the matching Bot API call. It returns whether the command was
// delivered, an optional result string (the created invoice link for a create_invoice command; empty
// otherwise), and an error for an unexpected or malformed failure.
func (e *Executor) Handle(ctx context.Context, cmd *botlinkv1.Command) (bool, string, error) {
switch p := cmd.GetPayload().(type) {
case *botlinkv1.Command_Notify:
return e.notify(ctx, p.Notify)
d, err := e.notify(ctx, p.Notify)
return d, "", err
case *botlinkv1.Command_SendToUser:
return e.sendToUser(ctx, p.SendToUser)
d, err := e.sendToUser(ctx, p.SendToUser)
return d, "", err
case *botlinkv1.Command_SendToChannel:
return e.sendToChannel(ctx, p.SendToChannel)
d, err := e.sendToChannel(ctx, p.SendToChannel)
return d, "", err
case *botlinkv1.Command_ChatGate:
return e.chatGate(ctx, p.ChatGate)
d, err := e.chatGate(ctx, p.ChatGate)
return d, "", err
case *botlinkv1.Command_CreateInvoice:
return e.createInvoice(ctx, p.CreateInvoice)
default:
return false, fmt.Errorf("botlink: empty command")
return false, "", fmt.Errorf("botlink: empty command")
}
}
// createInvoice mints a Telegram Stars invoice link for the order and returns it in the Ack result.
// A Bot API failure is a hard error carried back in the Ack, so the gateway's synchronous mint fails
// (rather than returning an empty link).
func (e *Executor) createInvoice(ctx context.Context, req *botlinkv1.CreateInvoiceCommand) (bool, string, error) {
link, err := e.sender.CreateInvoiceLink(ctx, req.GetTitle(), req.GetDescription(), req.GetPayload(), req.GetAmount())
if err != nil {
e.log.Warn("create invoice link failed", zap.String("order", req.GetPayload()), zap.Error(err))
return false, "", err
}
return true, link, nil
}
// chatGate applies a chat-gate command: it parses the target Telegram user id and
// sets their write access in the moderated chat (a no-op when they are not in it). A
// Bot API failure is logged and reported as not-delivered, not a hard error.
@@ -13,11 +13,13 @@ import (
// fakeSender records the delivery calls the executor makes.
type fakeSender struct {
notify []notifyCall
text []textCall
gate []gateCall
applied bool // ApplyChatGate's reported result
err error
notify []notifyCall
text []textCall
gate []gateCall
invoice []invoiceCall
invoiceLink string // CreateInvoiceLink's returned link
applied bool // ApplyChatGate's reported result
err error
}
type notifyCall struct {
@@ -32,6 +34,10 @@ type gateCall struct {
userID int64
allow bool
}
type invoiceCall struct {
title, description, payload string
amount int64
}
func (f *fakeSender) Notify(_ context.Context, chatID int64, text, buttonText, startParam string) error {
f.notify = append(f.notify, notifyCall{chatID, text, buttonText, startParam})
@@ -48,6 +54,11 @@ func (f *fakeSender) ApplyChatGate(_ context.Context, userID int64, allow bool)
return f.applied, f.err
}
func (f *fakeSender) CreateInvoiceLink(_ context.Context, title, description, payload string, amountStars int64) (string, error) {
f.invoice = append(f.invoice, invoiceCall{title, description, payload, amountStars})
return f.invoiceLink, f.err
}
func yourTurnPayload(gameID string) []byte {
b := flatbuffers.NewBuilder(0)
gid := b.CreateString(gameID)
@@ -67,7 +78,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
const gameID = "7c9e6679-7425-40de-944b-e07fc1f90ae7"
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "your_turn", yourTurnPayload(gameID), "en"))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -85,7 +96,7 @@ func TestExecutorNotifyDelivers(t *testing.T) {
func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
delivered, _, err := exec.Handle(context.Background(), notifyCmd("12345", "opponent_moved", nil, "en"))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -99,7 +110,7 @@ func TestExecutorNotifySkipsUnrenderedKind(t *testing.T) {
func TestExecutorNotifyInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
if _, _, err := exec.Handle(context.Background(), notifyCmd("not-a-number", "your_turn", yourTurnPayload("g"), "en")); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
@@ -108,7 +119,7 @@ func TestExecutorSendToUser(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToUser{SendToUser: &telegramv1.SendToUserRequest{ExternalId: "999", Text: "hi"}}}
delivered, err := exec.Handle(context.Background(), cmd)
delivered, _, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -126,7 +137,7 @@ func chatGateCmd(externalID string, allow bool) *botlinkv1.Command {
func TestExecutorChatGateApplied(t *testing.T) {
sender := &fakeSender{applied: true}
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("777", true))
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("777", true))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -138,7 +149,7 @@ func TestExecutorChatGateApplied(t *testing.T) {
func TestExecutorChatGateNotInChat(t *testing.T) {
sender := &fakeSender{applied: false} // user not in the chat
exec := NewExecutor(sender, 0, nil)
delivered, err := exec.Handle(context.Background(), chatGateCmd("888", false))
delivered, _, err := exec.Handle(context.Background(), chatGateCmd("888", false))
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -152,24 +163,53 @@ func TestExecutorChatGateNotInChat(t *testing.T) {
func TestExecutorChatGateInvalidExternalID(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
if _, _, err := exec.Handle(context.Background(), chatGateCmd("not-a-number", true)); err == nil {
t.Error("expected an error for a non-numeric external_id")
}
}
func TestExecutorCreateInvoice(t *testing.T) {
sender := &fakeSender{invoiceLink: "https://t.me/$abc"}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "50 chips", Description: "50 chips", Payload: "order-1", Amount: 40,
}}}
delivered, result, err := exec.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
if !delivered || result != "https://t.me/$abc" {
t.Errorf("create invoice = %v / %q, want true / the link", delivered, result)
}
if len(sender.invoice) != 1 || sender.invoice[0].payload != "order-1" || sender.invoice[0].amount != 40 {
t.Errorf("invoice calls = %+v", sender.invoice)
}
}
func TestExecutorCreateInvoiceError(t *testing.T) {
sender := &fakeSender{err: context.DeadlineExceeded}
exec := NewExecutor(sender, 0, nil)
cmd := &botlinkv1.Command{Payload: &botlinkv1.Command_CreateInvoice{CreateInvoice: &botlinkv1.CreateInvoiceCommand{
Title: "x", Description: "x", Payload: "order-2", Amount: 10,
}}}
if _, _, err := exec.Handle(context.Background(), cmd); err == nil {
t.Error("expected an error when minting the invoice fails")
}
}
func TestExecutorSendToChannel(t *testing.T) {
channelCmd := &botlinkv1.Command{Payload: &botlinkv1.Command_SendToChannel{SendToChannel: &telegramv1.SendToGameChannelRequest{Text: "news"}}}
t.Run("unconfigured", func(t *testing.T) {
exec := NewExecutor(&fakeSender{}, 0, nil)
if _, err := exec.Handle(context.Background(), channelCmd); err == nil {
if _, _, err := exec.Handle(context.Background(), channelCmd); err == nil {
t.Error("expected an error when no channel is configured")
}
})
t.Run("configured", func(t *testing.T) {
sender := &fakeSender{}
exec := NewExecutor(sender, 555, nil)
delivered, err := exec.Handle(context.Background(), channelCmd)
delivered, _, err := exec.Handle(context.Background(), channelCmd)
if err != nil {
t.Fatalf("handle: %v", err)
}
@@ -52,6 +52,11 @@ type BotConfig struct {
// (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the
// container user (UID 65532) and backed by a persistent volume.
SupportStateDir string
// StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file
// (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by
// the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any
// payment not yet forwarded to the gateway.
StarsOutboxDir string
// PromoBotToken is the API token of the optional standalone promo bot run in this
// container — a second bot whose only job is to answer /start with a button that
// opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables
@@ -157,6 +162,7 @@ func LoadBot() (BotConfig, error) {
BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"),
PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam),
SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"),
StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"),
LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"),
BotLink: BotLinkClientConfig{
GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"),
+104
View File
@@ -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)
}
}