// Package config loads the bot's settings from the process environment. // // The environment is the only source: there is no configuration file and no // command-line flag. That keeps the token out of the repository and out of the // image, and it is what the container platform hands the process anyway. package config import ( "errors" "fmt" "log/slog" "os" "strings" "time" ) // The environment variables read by [Load]. const ( // EnvToken holds the token issued by @BotFather. Required; [Load] fails // when it is missing or blank. EnvToken = "TELEGRAM_BOT_TOKEN" // EnvCepisBaseURL points the lookups at another instance of the API, // typically a local one during development. EnvCepisBaseURL = "CEPIS_BASE_URL" // EnvTelegramAPIBaseURL points the bot at another Bot API host. It // exists for local Bot API servers and for the test suite, which stands // up a fake Telegram in-process. EnvTelegramAPIBaseURL = "TELEGRAM_API_BASE_URL" // EnvLogLevel selects the minimum level written to stdout: debug, info, // warn or error. EnvLogLevel = "LOG_LEVEL" ) // ErrMissingToken is returned by [Load] when [EnvToken] is absent or blank. var ErrMissingToken = errors.New("config: " + EnvToken + " is not set") // Config is the fully resolved configuration of one bot process. // // Only Token, CepisBaseURL, TelegramAPIBaseURL and LogLevel come from the // environment. The remaining fields are tuning that has no reason to differ // between deployments; they are exported so the test suite can shorten the // waits it would otherwise sit through. type Config struct { // Token is the @BotFather token. It is never logged: [Config.LogValue] // redacts it, and the Telegram client scrubs it out of error messages. Token string // CepisBaseURL is where address lookups go. CepisBaseURL string // TelegramAPIBaseURL is the Bot API host, without a trailing slash and // without the /bot prefix. TelegramAPIBaseURL string // PollTimeout is the long-polling window asked of getUpdates. Telegram // holds the request open for this long when there is nothing to send, // which is what makes polling cheap. PollTimeout time.Duration // RequestTimeout caps a single non-polling HTTP request. RequestTimeout time.Duration // MaxAttempts is how many times a transient failure is retried, the // first attempt included. MaxAttempts int // BackoffBase and BackoffMax bound the jittered wait between attempts. BackoffBase time.Duration BackoffMax time.Duration // ShutdownGrace is how long an in-flight update may take to finish // after a termination signal, and how long the final offset // confirmation may take. ShutdownGrace time.Duration // LogLevel is the minimum level written to stdout. LogLevel slog.Level } // Defaults returns the configuration with everything filled in except Token, // which has no safe default. func Defaults() Config { return Config{ CepisBaseURL: "https://cep.is", TelegramAPIBaseURL: "https://api.telegram.org", PollTimeout: 30 * time.Second, RequestTimeout: 10 * time.Second, MaxAttempts: 3, BackoffBase: 500 * time.Millisecond, BackoffMax: 8 * time.Second, ShutdownGrace: 10 * time.Second, LogLevel: slog.LevelInfo, } } // Load reads the environment on top of [Defaults]. // // It fails with [ErrMissingToken] when the token is absent, before any network // is touched, so a misconfigured deployment dies at start-up rather than // looking healthy while answering nobody. func Load() (Config, error) { cfg := Defaults() cfg.Token = strings.TrimSpace(os.Getenv(EnvToken)) if cfg.Token == "" { return Config{}, ErrMissingToken } if value := strings.TrimSpace(os.Getenv(EnvCepisBaseURL)); value != "" { cfg.CepisBaseURL = strings.TrimRight(value, "/") } if value := strings.TrimSpace(os.Getenv(EnvTelegramAPIBaseURL)); value != "" { cfg.TelegramAPIBaseURL = strings.TrimRight(value, "/") } if value := strings.TrimSpace(os.Getenv(EnvLogLevel)); value != "" { level, err := parseLevel(value) if err != nil { return Config{}, err } cfg.LogLevel = level } return cfg, nil } // LogValue implements [log/slog.LogValuer] so that logging the whole // configuration cannot leak the token, however carelessly it is done. func (c Config) LogValue() slog.Value { return slog.GroupValue( slog.String("cepis_base_url", c.CepisBaseURL), slog.String("telegram_api_base_url", c.TelegramAPIBaseURL), slog.Duration("poll_timeout", c.PollTimeout), slog.Int("max_attempts", c.MaxAttempts), slog.String("log_level", c.LogLevel.String()), slog.Bool("token_set", c.Token != ""), ) } func parseLevel(value string) (slog.Level, error) { switch strings.ToLower(value) { case "debug": return slog.LevelDebug, nil case "info": return slog.LevelInfo, nil case "warn", "warning": return slog.LevelWarn, nil case "error": return slog.LevelError, nil } return 0, fmt.Errorf("config: %s must be debug, info, warn or error, got %q", EnvLogLevel, value) }