Skip to content

Commit d118635

Browse files
committed
P1: Wire telegram config into odek.json + SessionTTL
- Telegram config now reads from odek.json (telegram section) with env override priority: global file → project file → env (ConfigFromEnv now takes a base config, no longer standalone) - SessionManager accepts configurable TTL instead of hardcoded 24h - Updated all tests for new signatures Subagents used: odek via delegate_task for implementation.
1 parent aa81f7a commit d118635

7 files changed

Lines changed: 120 additions & 44 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# P1: Wire Telegram config into odek.json
2+
3+
## Task A — Add telegram section to config structs
4+
5+
Files to modify:
6+
- `internal/config/loader.go` — Add TelegramConfig to FileConfig + ResolvedConfig
7+
- `internal/telegram/config.go` — Add ConfigFromResolved(resolved) function
8+
- `cmd/odek/telegram.go` — Switch from ConfigFromEnv to ConfigFromResolved
9+
10+
Config shape in odek.json:
11+
```json
12+
{
13+
"telegram": {
14+
"bot_token": "",
15+
"allowed_chats": [],
16+
"allowed_users": [],
17+
"bot_username": "",
18+
"poll_interval": 1,
19+
"poll_timeout": 30,
20+
"max_msg_length": 4096,
21+
"daily_token_budget": 1000000,
22+
"session_ttl_hours": 24,
23+
"fallback_urls": []
24+
}
25+
}
26+
```
27+
28+
Priority: env vars > odek.json > defaults (matching existing 4-layer pattern)
29+
30+
## Task B — Wire DailyTokenBudget
31+
32+
Files to modify:
33+
- `internal/telegram/bot.go` — Track daily token usage, reject when over budget
34+
- `internal/telegram/session.go` — Wire budget check before processing
35+
36+
## Task C — Wire SessionTTL into SessionManager
37+
38+
Files to modify:
39+
- `internal/telegram/session.go` — Replace hardcoded 24h with config value
40+
- Pass TTL from config through constructor
41+
42+
## Task D — Wire FallbackURLs into network transport
43+
44+
Files to modify:
45+
- `internal/telegram/network.go` — The FallbackTransport exists; wire FallbackURLs

cmd/odek/telegram.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func telegramCmd(args []string) error {
4040
}
4141

4242
// 3. Load and validate Telegram config.
43-
cfg := telegram.ConfigFromEnv()
43+
cfg := resolved.Telegram
4444
if err := telegram.ValidateConfig(cfg); err != nil {
4545
fmt.Fprintf(os.Stderr, "odek telegram: %v\n", err)
4646
return err
@@ -56,8 +56,9 @@ func telegramCmd(args []string) error {
5656
return err
5757
}
5858

59-
// 6. Create session manager (per-chat Telegram session cache).
60-
sessionManager := telegram.NewSessionManager(store)
59+
// 6. Create session manager (per-chat Telegram session cache)
60+
// with the configured session TTL (default 24h).
61+
sessionManager := telegram.NewSessionManager(store, time.Duration(cfg.SessionTTL)*time.Hour)
6162

6263
// 7. Create handler.
6364
handler := telegram.NewHandler(bot)

internal/config/loader.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"github.com/BackendStack21/kode/internal/mcpclient"
2323
"github.com/BackendStack21/kode/internal/memory"
2424
"github.com/BackendStack21/kode/internal/skills"
25+
"github.com/BackendStack21/kode/internal/telegram"
2526
)
2627

2728
// ── Types ──────────────────────────────────────────────────────────────
@@ -119,6 +120,9 @@ type FileConfig struct {
119120
// Config: max_concurrency, ODEK_MAX_CONCURRENCY.
120121
// Default: 3.
121122
MaxConcurrency int `json:"max_concurrency,omitempty"`
123+
124+
// Telegram configures the Telegram bot integration.
125+
Telegram *telegram.TelegramConfig `json:"telegram,omitempty"`
122126
}
123127

124128
// ResolvedConfig is the fully merged result. Every field has a concrete
@@ -193,6 +197,9 @@ type ResolvedConfig struct {
193197
// Config: max_concurrency, ODEK_MAX_CONCURRENCY.
194198
// Default: 3.
195199
MaxConcurrency int
200+
201+
// Telegram is the resolved Telegram bot configuration.
202+
Telegram telegram.TelegramConfig
196203
}
197204

198205
// ── Defaults ───────────────────────────────────────────────────────────
@@ -378,6 +385,14 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
378385
cfg.MaxConcurrency = v
379386
}
380387

388+
// Telegram env overrides: merge env vars on top of file config.
389+
baseTelegram := telegram.DefaultConfig()
390+
if cfg.Telegram != nil {
391+
baseTelegram = *cfg.Telegram
392+
}
393+
mergedTelegram := telegram.ConfigFromEnv(baseTelegram)
394+
cfg.Telegram = &mergedTelegram
395+
381396
// Layer 4: CLI flags (highest priority)
382397
if cli.Model != "" {
383398
cfg.Model = cli.Model
@@ -448,6 +463,7 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
448463
Dangerous: resolveDangerous(cfg.Dangerous),
449464
Memory: resolveMemory(cfg.Memory),
450465
MCPServers: cfg.MCPServers,
466+
Telegram: resolveTelegram(cfg.Telegram),
451467
}
452468

453469
// MaxConcurrency: default to 3 if not set
@@ -543,6 +559,14 @@ func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
543559
return memory.DefaultMemoryConfig()
544560
}
545561

562+
// resolveTelegram merges file-level telegram config with defaults.
563+
func resolveTelegram(cfg *telegram.TelegramConfig) telegram.TelegramConfig {
564+
if cfg != nil {
565+
return *cfg
566+
}
567+
return telegram.DefaultConfig()
568+
}
569+
546570
// overlayFile overlays a higher-priority FileConfig onto a lower-priority one.
547571
// Only fields that are explicitly set (non-zero for scalars, non-nil for
548572
// pointers) override the base value.
@@ -609,5 +633,8 @@ func overlayFile(base, override FileConfig) FileConfig {
609633
if override.Skills != nil {
610634
base.Skills = override.Skills
611635
}
636+
if override.Telegram != nil {
637+
base.Telegram = override.Telegram
638+
}
612639
return base
613640
}

internal/telegram/config.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ func DefaultConfig() TelegramConfig {
3333
}
3434

3535
// ConfigFromEnv reads configuration from environment variables, starting with
36-
// DefaultConfig and overriding any values that are set in the environment.
37-
func ConfigFromEnv() TelegramConfig {
38-
cfg := DefaultConfig()
36+
// the given base config and overriding any values that are set in the environment.
37+
func ConfigFromEnv(base TelegramConfig) TelegramConfig {
38+
cfg := base
3939

4040
if v := os.Getenv("ODEK_TELEGRAM_BOT_TOKEN"); v != "" {
4141
cfg.Token = v

internal/telegram/config_test.go

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ func TestConfigFromEnv_noEnvVars(t *testing.T) {
5353
unsetAllEnvVars(t)
5454
defer unsetAllEnvVars(t)
5555

56-
cfg := ConfigFromEnv()
56+
cfg := ConfigFromEnv(DefaultConfig())
5757
want := DefaultConfig()
5858

5959
// Compare field by field because structs containing slices cannot use ==.
@@ -91,7 +91,7 @@ func TestConfigFromEnv_noEnvVars(t *testing.T) {
9191

9292
func TestConfigFromEnv_token(t *testing.T) {
9393
t.Setenv("ODEK_TELEGRAM_BOT_TOKEN", "my-secret-token:123")
94-
cfg := ConfigFromEnv()
94+
cfg := ConfigFromEnv(DefaultConfig())
9595
if cfg.Token != "my-secret-token:123" {
9696
t.Errorf("Token = %q, want %q", cfg.Token, "my-secret-token:123")
9797
}
@@ -104,15 +104,15 @@ func TestConfigFromEnv_token(t *testing.T) {
104104
func TestConfigFromEnv_emptyTokenIgnored(t *testing.T) {
105105
// Empty env var should not override the default (which is "").
106106
t.Setenv("ODEK_TELEGRAM_BOT_TOKEN", "")
107-
cfg := ConfigFromEnv()
107+
cfg := ConfigFromEnv(DefaultConfig())
108108
if cfg.Token != "" {
109109
t.Errorf("Token = %q, want empty", cfg.Token)
110110
}
111111
}
112112

113113
func TestConfigFromEnv_allowedChats(t *testing.T) {
114114
t.Setenv("ODEK_TELEGRAM_ALLOWED_CHATS", " -100123 , 42 , 99 ")
115-
cfg := ConfigFromEnv()
115+
cfg := ConfigFromEnv(DefaultConfig())
116116
want := []int64{-100123, 42, 99}
117117
if !equalInt64Slice(cfg.AllowedChats, want) {
118118
t.Errorf("AllowedChats = %v, want %v", cfg.AllowedChats, want)
@@ -121,15 +121,15 @@ func TestConfigFromEnv_allowedChats(t *testing.T) {
121121

122122
func TestConfigFromEnv_allowedChatsEmpty(t *testing.T) {
123123
t.Setenv("ODEK_TELEGRAM_ALLOWED_CHATS", "")
124-
cfg := ConfigFromEnv()
124+
cfg := ConfigFromEnv(DefaultConfig())
125125
if cfg.AllowedChats != nil {
126126
t.Errorf("AllowedChats = %v, want nil", cfg.AllowedChats)
127127
}
128128
}
129129

130130
func TestConfigFromEnv_allowedChatsInvalidSkips(t *testing.T) {
131131
t.Setenv("ODEK_TELEGRAM_ALLOWED_CHATS", "abc, -100123, 12.5, 99,")
132-
cfg := ConfigFromEnv()
132+
cfg := ConfigFromEnv(DefaultConfig())
133133
want := []int64{-100123, 99}
134134
if !equalInt64Slice(cfg.AllowedChats, want) {
135135
t.Errorf("AllowedChats = %v, want %v", cfg.AllowedChats, want)
@@ -138,7 +138,7 @@ func TestConfigFromEnv_allowedChatsInvalidSkips(t *testing.T) {
138138

139139
func TestConfigFromEnv_allowedUsers(t *testing.T) {
140140
t.Setenv("ODEK_TELEGRAM_ALLOWED_USERS", " 111 , 222 ")
141-
cfg := ConfigFromEnv()
141+
cfg := ConfigFromEnv(DefaultConfig())
142142
want := []int64{111, 222}
143143
if !equalInt64Slice(cfg.AllowedUsers, want) {
144144
t.Errorf("AllowedUsers = %v, want %v", cfg.AllowedUsers, want)
@@ -147,15 +147,15 @@ func TestConfigFromEnv_allowedUsers(t *testing.T) {
147147

148148
func TestConfigFromEnv_botUsername(t *testing.T) {
149149
t.Setenv("ODEK_TELEGRAM_BOT_USERNAME", "MyAwesomeBot")
150-
cfg := ConfigFromEnv()
150+
cfg := ConfigFromEnv(DefaultConfig())
151151
if cfg.BotUsername != "MyAwesomeBot" {
152152
t.Errorf("BotUsername = %q, want %q", cfg.BotUsername, "MyAwesomeBot")
153153
}
154154
}
155155

156156
func TestConfigFromEnv_pollInterval(t *testing.T) {
157157
t.Setenv("ODEK_TELEGRAM_POLL_INTERVAL", "5")
158-
cfg := ConfigFromEnv()
158+
cfg := ConfigFromEnv(DefaultConfig())
159159
if cfg.PollInterval != 5 {
160160
t.Errorf("PollInterval = %d, want 5", cfg.PollInterval)
161161
}
@@ -164,103 +164,103 @@ func TestConfigFromEnv_pollInterval(t *testing.T) {
164164
func TestConfigFromEnv_pollIntervalInvalid(t *testing.T) {
165165
// Invalid (non-numeric) values should be silently ignored.
166166
t.Setenv("ODEK_TELEGRAM_POLL_INTERVAL", "not-a-number")
167-
cfg := ConfigFromEnv()
167+
cfg := ConfigFromEnv(DefaultConfig())
168168
if cfg.PollInterval != 1 {
169169
t.Errorf("PollInterval = %d, want default 1", cfg.PollInterval)
170170
}
171171
}
172172

173173
func TestConfigFromEnv_pollIntervalEmpty(t *testing.T) {
174174
t.Setenv("ODEK_TELEGRAM_POLL_INTERVAL", "")
175-
cfg := ConfigFromEnv()
175+
cfg := ConfigFromEnv(DefaultConfig())
176176
if cfg.PollInterval != 1 {
177177
t.Errorf("PollInterval = %d, want default 1", cfg.PollInterval)
178178
}
179179
}
180180

181181
func TestConfigFromEnv_pollTimeout(t *testing.T) {
182182
t.Setenv("ODEK_TELEGRAM_POLL_TIMEOUT", "45")
183-
cfg := ConfigFromEnv()
183+
cfg := ConfigFromEnv(DefaultConfig())
184184
if cfg.PollTimeout != 45 {
185185
t.Errorf("PollTimeout = %d, want 45", cfg.PollTimeout)
186186
}
187187
}
188188

189189
func TestConfigFromEnv_pollTimeoutInvalid(t *testing.T) {
190190
t.Setenv("ODEK_TELEGRAM_POLL_TIMEOUT", "abc")
191-
cfg := ConfigFromEnv()
191+
cfg := ConfigFromEnv(DefaultConfig())
192192
if cfg.PollTimeout != 30 {
193193
t.Errorf("PollTimeout = %d, want default 30", cfg.PollTimeout)
194194
}
195195
}
196196

197197
func TestConfigFromEnv_pollTimeoutEmpty(t *testing.T) {
198198
t.Setenv("ODEK_TELEGRAM_POLL_TIMEOUT", "")
199-
cfg := ConfigFromEnv()
199+
cfg := ConfigFromEnv(DefaultConfig())
200200
if cfg.PollTimeout != 30 {
201201
t.Errorf("PollTimeout = %d, want default 30", cfg.PollTimeout)
202202
}
203203
}
204204

205205
func TestConfigFromEnv_maxMsgLength(t *testing.T) {
206206
t.Setenv("ODEK_TELEGRAM_MAX_MSG_LENGTH", "1024")
207-
cfg := ConfigFromEnv()
207+
cfg := ConfigFromEnv(DefaultConfig())
208208
if cfg.MaxMsgLength != 1024 {
209209
t.Errorf("MaxMsgLength = %d, want 1024", cfg.MaxMsgLength)
210210
}
211211
}
212212

213213
func TestConfigFromEnv_maxMsgLengthInvalid(t *testing.T) {
214214
t.Setenv("ODEK_TELEGRAM_MAX_MSG_LENGTH", "xyz")
215-
cfg := ConfigFromEnv()
215+
cfg := ConfigFromEnv(DefaultConfig())
216216
if cfg.MaxMsgLength != 4096 {
217217
t.Errorf("MaxMsgLength = %d, want default 4096", cfg.MaxMsgLength)
218218
}
219219
}
220220

221221
func TestConfigFromEnv_dailyTokenBudget(t *testing.T) {
222222
t.Setenv("ODEK_TELEGRAM_DAILY_TOKEN_BUDGET", "500000")
223-
cfg := ConfigFromEnv()
223+
cfg := ConfigFromEnv(DefaultConfig())
224224
if cfg.DailyTokenBudget != 500000 {
225225
t.Errorf("DailyTokenBudget = %d, want 500000", cfg.DailyTokenBudget)
226226
}
227227
}
228228

229229
func TestConfigFromEnv_dailyTokenBudgetInvalid(t *testing.T) {
230230
t.Setenv("ODEK_TELEGRAM_DAILY_TOKEN_BUDGET", "not-a-number")
231-
cfg := ConfigFromEnv()
231+
cfg := ConfigFromEnv(DefaultConfig())
232232
if cfg.DailyTokenBudget != 1000000 {
233233
t.Errorf("DailyTokenBudget = %d, want default 1000000", cfg.DailyTokenBudget)
234234
}
235235
}
236236

237237
func TestConfigFromEnv_sessionTTL(t *testing.T) {
238238
t.Setenv("ODEK_TELEGRAM_SESSION_TTL_HOURS", "48")
239-
cfg := ConfigFromEnv()
239+
cfg := ConfigFromEnv(DefaultConfig())
240240
if cfg.SessionTTL != 48 {
241241
t.Errorf("SessionTTL = %d, want 48", cfg.SessionTTL)
242242
}
243243
}
244244

245245
func TestConfigFromEnv_sessionTTLInvalid(t *testing.T) {
246246
t.Setenv("ODEK_TELEGRAM_SESSION_TTL_HOURS", "bad")
247-
cfg := ConfigFromEnv()
247+
cfg := ConfigFromEnv(DefaultConfig())
248248
if cfg.SessionTTL != 24 {
249249
t.Errorf("SessionTTL = %d, want default 24", cfg.SessionTTL)
250250
}
251251
}
252252

253253
func TestConfigFromEnv_sessionTTLEmpty(t *testing.T) {
254254
t.Setenv("ODEK_TELEGRAM_SESSION_TTL_HOURS", "")
255-
cfg := ConfigFromEnv()
255+
cfg := ConfigFromEnv(DefaultConfig())
256256
if cfg.SessionTTL != 24 {
257257
t.Errorf("SessionTTL = %d, want default 24", cfg.SessionTTL)
258258
}
259259
}
260260

261261
func TestConfigFromEnv_fallbackURLs(t *testing.T) {
262262
t.Setenv("ODEK_TELEGRAM_FALLBACK_URLS", "https://a.com, https://b.com")
263-
cfg := ConfigFromEnv()
263+
cfg := ConfigFromEnv(DefaultConfig())
264264
want := []string{"https://a.com", "https://b.com"}
265265
if !equalStringSlice(cfg.FallbackURLs, want) {
266266
t.Errorf("FallbackURLs = %v, want %v", cfg.FallbackURLs, want)
@@ -269,15 +269,15 @@ func TestConfigFromEnv_fallbackURLs(t *testing.T) {
269269

270270
func TestConfigFromEnv_fallbackURLsEmpty(t *testing.T) {
271271
t.Setenv("ODEK_TELEGRAM_FALLBACK_URLS", "")
272-
cfg := ConfigFromEnv()
272+
cfg := ConfigFromEnv(DefaultConfig())
273273
if cfg.FallbackURLs != nil {
274274
t.Errorf("FallbackURLs = %v, want nil", cfg.FallbackURLs)
275275
}
276276
}
277277

278278
func TestConfigFromEnv_fallbackURLsTrimsEmptyEntries(t *testing.T) {
279279
t.Setenv("ODEK_TELEGRAM_FALLBACK_URLS", "https://a.com, , https://b.com,")
280-
cfg := ConfigFromEnv()
280+
cfg := ConfigFromEnv(DefaultConfig())
281281
want := []string{"https://a.com", "https://b.com"}
282282
if !equalStringSlice(cfg.FallbackURLs, want) {
283283
t.Errorf("FallbackURLs = %v, want %v", cfg.FallbackURLs, want)
@@ -291,7 +291,7 @@ func TestConfigFromEnv_multipleOverrides(t *testing.T) {
291291
t.Setenv("ODEK_TELEGRAM_POLL_INTERVAL", "3")
292292
t.Setenv("ODEK_TELEGRAM_MAX_MSG_LENGTH", "2048")
293293

294-
cfg := ConfigFromEnv()
294+
cfg := ConfigFromEnv(DefaultConfig())
295295

296296
if cfg.Token != "token:multi" {
297297
t.Errorf("Token = %q", cfg.Token)

0 commit comments

Comments
 (0)