// 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" "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" ) // 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)) } b, err := bot.New(bot.Config{ Token: cfg.Token, APIBaseURL: cfg.APIBaseURL, TestEnv: cfg.TestEnv, MiniAppURL: cfg.MiniAppURL, SendRatePerSecond: cfg.SendRatePerSecond, }, logger) if err != nil { return err } 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 := 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) 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)) 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)) } }) <-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() }