// Package config loads the environment configuration for the two Telegram // platform binaries: the home validator (HMAC of Mini App / Login Widget data, no // Telegram egress) and the remote bot (Bot API long-poll + sendMessage, dialing // the gateway over the reverse mTLS bot-link). The bot token lives in both // processes — the validator needs it only as the HMAC secret (ARCHITECTURE.md §12). package config import ( "fmt" "os" "strconv" "strings" "time" pkgtel "scrabble/pkg/telemetry" ) // ValidatorConfig is the home validator's runtime configuration. type ValidatorConfig struct { // Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). The // validator uses it only as the HMAC secret for Mini App initData and Login // Widget validation; it never calls the Bot API. Token string // GRPCAddr is the listen address of the validator gRPC server the gateway calls // (TELEGRAM_VALIDATOR_GRPC_ADDR, default :9091). GRPCAddr string // LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info). LogLevel string // Telemetry configures the OpenTelemetry providers (shared bootstrap). Telemetry pkgtel.Config } // BotConfig is the remote bot's runtime configuration. type BotConfig struct { // Token is the Telegram Bot API token (TELEGRAM_BOT_TOKEN, required). Token string // GameChannelID is the chat id of the bot's game channel for the admin channel // post (TELEGRAM_GAME_CHANNEL_ID, optional; 0 disables channel posts). GameChannelID int64 // ChatID is the chat id of the moderated discussion supergroup (a channel's linked // chat) whose write access the bot gates by registration and moderation // (TELEGRAM_CHAT_ID, optional; 0 disables chat gating). The bot must be an admin // there with the "Ban users" right, and "chat_member" in its allowed updates. ChatID int64 // SupportChatID is the chat id of the private forum supergroup the bot relays // direct user messages into — one forum topic per user — and reads operator // replies from (TELEGRAM_SUPPORT_CHAT_ID, optional; 0 disables the support relay). // The bot must be an administrator there with the manage-topics and // delete-messages rights. Distinct from ChatID (the moderated discussion chat). SupportChatID int64 // SupportStateDir is the directory holding the support relay's JSON state file // (TELEGRAM_SUPPORT_STATE_DIR, default /data). It must be writable by the // container user (UID 65532) and backed by a persistent volume. SupportStateDir string // StarsOutboxDir is the directory holding the Telegram Stars payment outbox SQLite file // (TELEGRAM_STARS_OUTBOX_DIR, optional; empty disables the Stars rail). It must be writable by // the container user (UID 65532) and backed by a persistent volume — a lost outbox loses any // payment not yet forwarded to the gateway. StarsOutboxDir string // PromoBotToken is the API token of the optional standalone promo bot run in this // container — a second bot whose only job is to answer /start with a button that // opens the main bot's Mini App (TELEGRAM_PROMO_BOT_TOKEN, optional; empty disables // the promo bot). PromoBotToken string // BotUsername is the main bot's @username without the leading @, used in the promo // bot's message text (TELEGRAM_BOT_USERNAME; required when the promo bot runs). BotUsername string // BotLinkURL is the main bot's Mini App direct link — the same value the UI builds // share links from (VITE_TELEGRAM_LINK), e.g. https://telegram.me//. The promo // button appends ?startapp= to it (TELEGRAM_BOT_LINK; required when the // promo bot runs). It is distinct from the BotLink mTLS dial config below. BotLinkURL string // PromoStartParam is the promo button's launch payload, appended as // ?startapp= (TELEGRAM_PROMO_START_PARAM, default // "verudit_ru-scrabble_en"). It is a variant-seed deep link the backend decodes to // seed a brand-new user's variant preferences; its labels must match the backend's // known variants. Empty falls back to forwarding the user's own /start payload. PromoStartParam string // MiniAppURL is the HTTPS origin of the Mini App registered with BotFather; it is // the base of every launch button (TELEGRAM_MINIAPP_URL, required). MiniAppURL string // APIBaseURL overrides the Bot API host (TELEGRAM_API_BASE_URL, optional; // default https://api.telegram.org). APIBaseURL string // TestEnv routes the Bot API client to Telegram's test environment // (TELEGRAM_TEST_ENV=true, default false). TestEnv bool // OwnsUpdates reports whether this bot runs the exclusive getUpdates long-poll // (TELEGRAM_OWNS_UPDATES, default true). Exactly one bot per token must own it. OwnsUpdates bool // SendRatePerSecond caps outbound Bot API sends to respect Telegram flood limits // (TELEGRAM_SEND_RATE_PER_SECOND, default 25; 0 disables the limiter). SendRatePerSecond int // BotLink configures the reverse mTLS channel the bot dials. BotLink BotLinkClientConfig // LogLevel is the zap log level (TELEGRAM_LOG_LEVEL, default info). LogLevel string // Telemetry configures the OpenTelemetry providers (shared bootstrap). Telemetry pkgtel.Config } // BotLinkClientConfig is the bot's dial side of the reverse bot-link. type BotLinkClientConfig struct { // GatewayAddr is the gateway bot-link endpoint to dial (TELEGRAM_GATEWAY_ADDR, // required), e.g. "gateway.example.com:9443". GatewayAddr string // ServerName is the gateway certificate's expected SNI / CN // (TELEGRAM_BOTLINK_SERVER_NAME, required). ServerName string // InstanceID identifies this bot to the gateway (TELEGRAM_INSTANCE_ID, default // the hostname). InstanceID string // CertFile, KeyFile and CAFile are the bot client certificate, its key and the // CA bundle that signs the gateway server certificate (required). CertFile string KeyFile string CAFile string // ReconnectDelay is the pause before re-dialing after the stream ends // (TELEGRAM_BOTLINK_RECONNECT_DELAY, default 2s). ReconnectDelay time.Duration } const ( defaultValidatorGRPCAddr = ":9091" defaultBotReconnectDelay = 2 * time.Second defaultSendRatePerSecond = 25 // defaultPromoStartParam is the promo button's default launch payload: a variant-seed // deep link the backend decodes to add English Scrabble to a brand-new user's variant // preferences alongside the default Erudit. Override with TELEGRAM_PROMO_START_PARAM. defaultPromoStartParam = "verudit_ru-scrabble_en" ) // LoadValidator reads the validator configuration from the environment. func LoadValidator() (ValidatorConfig, error) { cfg := ValidatorConfig{ Token: os.Getenv("TELEGRAM_BOT_TOKEN"), GRPCAddr: envOr("TELEGRAM_VALIDATOR_GRPC_ADDR", defaultValidatorGRPCAddr), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), } tel, err := loadTelemetry("scrabble-telegram-validator") if err != nil { return ValidatorConfig{}, err } cfg.Telemetry = tel if cfg.Token == "" { return ValidatorConfig{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required") } return cfg, nil } // LoadBot reads the bot configuration from the environment. func LoadBot() (BotConfig, error) { cfg := BotConfig{ Token: os.Getenv("TELEGRAM_BOT_TOKEN"), MiniAppURL: os.Getenv("TELEGRAM_MINIAPP_URL"), APIBaseURL: os.Getenv("TELEGRAM_API_BASE_URL"), TestEnv: os.Getenv("TELEGRAM_TEST_ENV") == "true", OwnsUpdates: os.Getenv("TELEGRAM_OWNS_UPDATES") != "false", SendRatePerSecond: defaultSendRatePerSecond, PromoBotToken: os.Getenv("TELEGRAM_PROMO_BOT_TOKEN"), BotUsername: strings.TrimPrefix(os.Getenv("TELEGRAM_BOT_USERNAME"), "@"), BotLinkURL: os.Getenv("TELEGRAM_BOT_LINK"), PromoStartParam: envOr("TELEGRAM_PROMO_START_PARAM", defaultPromoStartParam), SupportStateDir: envOr("TELEGRAM_SUPPORT_STATE_DIR", "/data"), StarsOutboxDir: os.Getenv("TELEGRAM_STARS_OUTBOX_DIR"), LogLevel: envOr("TELEGRAM_LOG_LEVEL", "info"), BotLink: BotLinkClientConfig{ GatewayAddr: os.Getenv("TELEGRAM_GATEWAY_ADDR"), ServerName: os.Getenv("TELEGRAM_BOTLINK_SERVER_NAME"), InstanceID: envOr("TELEGRAM_INSTANCE_ID", hostname()), CertFile: os.Getenv("TELEGRAM_BOTLINK_TLS_CERT"), KeyFile: os.Getenv("TELEGRAM_BOTLINK_TLS_KEY"), CAFile: os.Getenv("TELEGRAM_BOTLINK_TLS_CA"), }, } var err error if cfg.GameChannelID, err = envInt64("TELEGRAM_GAME_CHANNEL_ID", 0); err != nil { return BotConfig{}, err } if cfg.ChatID, err = envInt64("TELEGRAM_CHAT_ID", 0); err != nil { return BotConfig{}, err } if cfg.SupportChatID, err = envInt64("TELEGRAM_SUPPORT_CHAT_ID", 0); err != nil { return BotConfig{}, err } if cfg.SendRatePerSecond, err = envInt("TELEGRAM_SEND_RATE_PER_SECOND", defaultSendRatePerSecond); err != nil { return BotConfig{}, err } if cfg.BotLink.ReconnectDelay, err = envDuration("TELEGRAM_BOTLINK_RECONNECT_DELAY", defaultBotReconnectDelay); err != nil { return BotConfig{}, err } tel, err := loadTelemetry("scrabble-telegram-bot") if err != nil { return BotConfig{}, err } cfg.Telemetry = tel if cfg.Token == "" { return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOT_TOKEN is required") } if cfg.MiniAppURL == "" { return BotConfig{}, fmt.Errorf("config: TELEGRAM_MINIAPP_URL is required") } if cfg.BotLink.GatewayAddr == "" { return BotConfig{}, fmt.Errorf("config: TELEGRAM_GATEWAY_ADDR is required") } if cfg.BotLink.ServerName == "" { return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_SERVER_NAME is required") } if cfg.BotLink.CertFile == "" || cfg.BotLink.KeyFile == "" || cfg.BotLink.CAFile == "" { return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOTLINK_TLS_CERT, _KEY and _CA are required") } if cfg.PromoBotToken != "" && (cfg.BotUsername == "" || cfg.BotLinkURL == "") { return BotConfig{}, fmt.Errorf("config: TELEGRAM_BOT_USERNAME and TELEGRAM_BOT_LINK are required when TELEGRAM_PROMO_BOT_TOKEN is set") } return cfg, nil } // loadTelemetry builds the shared OpenTelemetry config with the given default // service name, applying the TELEGRAM_* overrides and validating the result. func loadTelemetry(defaultService string) (pkgtel.Config, error) { tel := pkgtel.DefaultConfig(defaultService) tel.ServiceName = envOr("TELEGRAM_SERVICE_NAME", tel.ServiceName) tel.TracesExporter = envOr("TELEGRAM_OTEL_TRACES_EXPORTER", tel.TracesExporter) tel.MetricsExporter = envOr("TELEGRAM_OTEL_METRICS_EXPORTER", tel.MetricsExporter) if err := tel.Validate(); err != nil { return pkgtel.Config{}, fmt.Errorf("config: %w", err) } return tel, nil } // hostname returns the machine hostname, or "telegram-bot" when it cannot be read. func hostname() string { if h, err := os.Hostname(); err == nil && h != "" { return h } return "telegram-bot" } // envOr returns the environment value for key, or def when it is unset or empty. func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } // envInt parses the environment variable named key as an int, returning fallback // when unset and an error when set but malformed. func envInt(key string, fallback int) (int, error) { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback, nil } n, err := strconv.Atoi(v) if err != nil { return 0, fmt.Errorf("config: %s %q: %w", key, v, err) } return n, nil } // envInt64 parses the environment variable named key as an int64, returning // fallback when unset and an error when set but malformed. func envInt64(key string, fallback int64) (int64, error) { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback, nil } n, err := strconv.ParseInt(v, 10, 64) if err != nil { return 0, fmt.Errorf("config: %s %q: %w", key, v, err) } return n, nil } // envDuration parses the environment variable named key as a Go duration, // returning fallback when unset and an error when set but malformed. func envDuration(key string, fallback time.Duration) (time.Duration, error) { v := strings.TrimSpace(os.Getenv(key)) if v == "" { return fallback, nil } d, err := time.ParseDuration(v) if err != nil { return 0, fmt.Errorf("config: %s %q: %w", key, v, err) } return d, nil }