Files
scrabble-game/platform/telegram/cmd/bot/main.go
T
Ilia Denisov 6e03ce0131
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
feat(payments): Telegram Stars payment rail
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.
2026-07-09 21:35:29 +02:00

209 lines
7.2 KiB
Go

// Command bot is the remote-side Telegram bot. It runs the Bot API long-poll
// (Mini App launch + /start deep-links) and dials the gateway over the reverse mTLS
// bot-link to receive and execute send commands (out-of-app push and admin sends).
// It holds the bot token and is the only component that reaches the Telegram Bot
// API, so it runs on a host with native Telegram access (no VPN) and needs no
// inbound port. See platform/telegram/README.md.
package main
import (
"context"
"log"
"os/signal"
"path/filepath"
"sync"
"syscall"
"time"
"go.uber.org/zap"
"google.golang.org/grpc/credentials"
"scrabble/pkg/mtls"
pkgtel "scrabble/pkg/telemetry"
"scrabble/platform/telegram/internal/bot"
"scrabble/platform/telegram/internal/botlink"
"scrabble/platform/telegram/internal/config"
"scrabble/platform/telegram/internal/outbox"
"scrabble/platform/telegram/internal/promobot"
"scrabble/platform/telegram/internal/support"
)
// telemetryShutdownTimeout bounds the OpenTelemetry flush during process exit.
const telemetryShutdownTimeout = 5 * time.Second
func main() {
cfg, err := config.LoadBot()
if err != nil {
log.Fatalf("bot: load config: %v", err)
}
logger, err := newLogger(cfg.LogLevel)
if err != nil {
log.Fatalf("bot: build logger: %v", err)
}
defer func() { _ = logger.Sync() }()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := run(ctx, cfg, logger); err != nil {
logger.Fatal("bot: terminated", zap.Error(err))
}
}
// run wires the bot long-poll and the bot-link client and runs both until the
// context is cancelled.
func run(ctx context.Context, cfg config.BotConfig, logger *zap.Logger) error {
tel, err := pkgtel.New(ctx, cfg.Telemetry)
if err != nil {
return err
}
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), telemetryShutdownTimeout)
defer cancel()
if err := tel.Shutdown(shutdownCtx); err != nil {
logger.Warn("telemetry shutdown", zap.Error(err))
}
}()
if err := tel.StartRuntimeMetrics(); err != nil {
logger.Warn("telemetry: start runtime metrics", zap.Error(err))
}
// The support relay keeps its per-user state in a JSON file on a writable volume
// (the bot host has no database). A corrupt file is logged and the relay starts
// empty rather than crash-looping the bot.
var supportStore *support.Store
if cfg.SupportChatID != 0 {
statePath := filepath.Join(cfg.SupportStateDir, "support.json")
supportStore, err = support.Open(statePath)
if err != nil {
logger.Warn("support: state load failed, starting empty", zap.String("path", statePath), zap.Error(err))
supportStore = support.New(statePath)
}
}
b, err := bot.New(bot.Config{
Token: cfg.Token,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
MiniAppURL: cfg.MiniAppURL,
SendRatePerSecond: cfg.SendRatePerSecond,
ChatID: cfg.ChatID,
GameChannelID: cfg.GameChannelID,
SupportChatID: cfg.SupportChatID,
SupportStore: supportStore,
AcceptPayments: cfg.StarsOutboxDir != "",
}, logger)
if err != nil {
return err
}
// The Telegram Stars payment outbox: a durable SQLite store on the bot host's writable volume.
// Opened when the rail is enabled; a failure to open is fatal, since accepting Stars without a
// durable outbox would risk losing a paid-for order.
var paymentOutbox *outbox.Store
if cfg.StarsOutboxDir != "" {
paymentOutbox, err = outbox.Open(filepath.Join(cfg.StarsOutboxDir, "stars.db"))
if err != nil {
return err
}
defer func() { _ = paymentOutbox.Close() }()
}
tlsCfg, err := mtls.ClientConfig(cfg.BotLink.CertFile, cfg.BotLink.KeyFile, cfg.BotLink.CAFile, cfg.BotLink.ServerName)
if err != nil {
return err
}
exec := botlink.NewExecutor(b, cfg.GameChannelID, logger)
client, err := botlink.NewClient(botlink.ClientConfig{
GatewayAddr: cfg.BotLink.GatewayAddr,
InstanceID: cfg.BotLink.InstanceID,
OwnsUpdates: cfg.OwnsUpdates,
Creds: credentials.NewTLS(tlsCfg),
ReconnectDelay: cfg.BotLink.ReconnectDelay,
}, exec, logger)
if err != nil {
return err
}
defer func() { _ = client.Close() }()
// The chat-join eligibility query rides the same bot-link connection; wire it into
// the bot after the client is built — the late binding that breaks the bot <->
// client construction cycle.
b.SetEligibilityResolver(client.ResolveChatEligibility)
// The Telegram Stars payment path rides the same bot-link: pre_checkout validation and
// completed-payment forwarding, backed by the durable outbox — wired here for the same
// late-binding reason.
if paymentOutbox != nil {
b.SetPaymentHandlers(client.ValidatePreCheckout, client.ForwardPayment, paymentOutbox)
}
// The optional standalone promo bot: a second bot (its own token) that only answers
// /start with a button opening the main bot's Mini App. It is self-contained — no
// bot-link, no gateway — so onboarding works even when the game is down.
var promo *promobot.Bot
if cfg.PromoBotToken != "" {
// The promo bot is auxiliary and shares this process with the main bot, so its
// construction failure (a bad or unreachable promo token — tgbot.New validates it
// with getMe) is logged and the promo bot is skipped, never fatal: it must not
// take the main game bot down with it.
promo, err = promobot.New(promobot.Config{
Token: cfg.PromoBotToken,
APIBaseURL: cfg.APIBaseURL,
TestEnv: cfg.TestEnv,
BotUsername: cfg.BotUsername,
BotLinkURL: cfg.BotLinkURL,
StartParam: cfg.PromoStartParam,
SendRatePerSecond: cfg.SendRatePerSecond,
}, logger)
if err != nil {
logger.Error("promo bot disabled: construction failed (the main bot is unaffected)", zap.Error(err))
promo = nil
}
}
logger.Info("telegram bot starting",
zap.String("gateway", cfg.BotLink.GatewayAddr),
zap.String("miniapp_url", cfg.MiniAppURL),
zap.Bool("owns_updates", cfg.OwnsUpdates),
zap.Bool("test_env", cfg.TestEnv),
zap.Bool("chat_gating", cfg.ChatID != 0),
zap.Bool("support_relay", cfg.SupportChatID != 0),
zap.Bool("promo_bot", promo != nil))
var wg sync.WaitGroup
// The long-poll holds the exclusive getUpdates lease (one bot per token); a bot
// that does not own it only delivers sends over the bot-link.
if cfg.OwnsUpdates {
wg.Go(func() { b.Run(ctx) })
}
wg.Go(func() {
if err := client.Run(ctx); err != nil && ctx.Err() == nil {
logger.Error("bot-link client stopped", zap.Error(err))
}
})
// Re-drive the Stars payment outbox: at startup (recovering payments left by a restart or a past
// outage) and periodically thereafter.
if paymentOutbox != nil {
wg.Go(func() { b.RunPaymentDrainer(ctx) })
}
// The promo bot runs its own getUpdates long-poll on its own token (no 409 with
// the main bot's lease).
if promo != nil {
wg.Go(func() { promo.Run(ctx) })
}
<-ctx.Done()
wg.Wait()
return nil
}
// newLogger builds a production JSON logger at the given level.
func newLogger(level string) (*zap.Logger, error) {
var lvl zap.AtomicLevel
if err := lvl.UnmarshalText([]byte(level)); err != nil {
return nil, err
}
cfg := zap.NewProductionConfig()
cfg.Level = lvl
return cfg.Build()
}