6a602aefae
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 12s
CI / integration (pull_request) Successful in 16s
CI / ui (pull_request) Successful in 56s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m20s
Users who DM the bot anything but /start are relayed into a private forum supergroup, one topic per user. Operators (the chat's admins) reply in the topic and the bot copies it back to the user; an info card opening each topic carries a Block/Unblock toggle and a Clear button. State is a small JSON file on a new /data volume — the bot host has no database. Off by default (TELEGRAM_SUPPORT_CHAT_ID=0): the prod bot is unchanged until the operator sets the chat id and adds the bot as a forum admin. - internal/support: concurrency-safe JSON store (topic map, block list, relayed message ids) with field-targeted mutators and atomic save - bot/support.go: relay both ways via copyMessage, short-TTL admin cache, callback buttons, per-user topic-create lock, loop guard (skip the bot's own posts), reopen a deleted topic on the next message - config + compose + CI/prod-deploy: TELEGRAM_SUPPORT_CHAT_ID per contour + TELEGRAM_SUPPORT_STATE_DIR; bot-state named volume; /data pre-owned by UID 65532 so a fresh volume is writable under distroless - docs: ARCHITECTURE §15 + decision record, FUNCTIONAL (+ru), README
183 lines
6.0 KiB
Go
183 lines
6.0 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/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,
|
|
}, 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, 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 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,
|
|
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))
|
|
}
|
|
})
|
|
// 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()
|
|
}
|