1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
|
// 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<token> 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)
}
|