Files
scrabble-game/platform/telegram/cmd/bot/main.go
T
Ilia Denisov 6aeb529f13
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 53s
CI / gate (pull_request) Successful in 1s
CI / deploy (pull_request) Failing after 2m6s
feat(telegram): split connector into home validator + remote bot
Move all Telegram egress off the main host. The single connector held the
bot token, long-polled Telegram and answered the gateway/backend over the
trusted internal network, so the whole component (including login validation)
shared fate with its VPN sidecar. Split it into two binaries that share the
token:

- cmd/validator (home, no VPN): Mini App initData + Login Widget HMAC only,
  never calls the Bot API. The gateway dials it for Telegram auth, so game
  login is now independent of Telegram reachability.
- cmd/bot (remote): Bot API long-poll + sendMessage, the only component
  reaching Telegram. It holds no inbound port — it dials the gateway over a
  new reverse mTLS bot-link (pkg/proto/botlink/v1) and executes the send
  commands the gateway pushes.

The gateway funnels sends to the bot-link: out-of-app push is fire-and-forget
(at-most-once, dropped if no bot is connected); the backend admin broadcasts
reach a gateway-served relay that forwards them and awaits the bot's ack
(SendToUser/SendToGameChannel contract preserved). mTLS (pkg/mtls) is the one
inter-service link that leaves the trusted segment; validator<->gateway and
the relay stay plaintext internal. The bot is Telegram-rate-limited.

One bot now; the gateway bot registry, an owns_updates flag and per-command
ids leave seams for N later. Webhook rejected (one URL per token, adds inbound
+ a static address).

The unified test contour runs the split (the bot keeps its VPN sidecar and
dials the gateway by its internal name; bot-link certs from deploy/gen-certs.sh,
generated in CI). The prod wiring — the bot on a separate host (no VPN), the
gateway bot-link port published, PROD_ certs with scheduled rotation, an SSH
deploy of both hosts together — is the deferred final stage (PRERELEASE.md TX,
Stage 18).

Docs: ARCHITECTURE, PRERELEASE (phase TX), platform/telegram + gateway +
backend + deploy READMEs, FUNCTIONAL(+ru), CLAUDE.md, .env.example.
2026-06-21 00:19:07 +02:00

124 lines
3.6 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"
"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()
}