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
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user