Skip to content

Commit 9537c8f

Browse files
committed
Fix /stats command and make daily token budget unlimited by default
- Add CreatedAt field to ChatSession for tracking session start time - Fix SessionManager.Save to preserve original CreatedAt (was overwriting) - Wire /stats command in telegram.go to read real session data from store - Add formatStats helper for Telegram stats output - Set daily_token_budget default to 0 (unlimited) instead of 1,000,000 - Clean up stale tracking file on restart - Fix resolveTelegram to properly merge file config with defaults
1 parent f7f5021 commit 9537c8f

10 files changed

Lines changed: 171 additions & 20 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ bin/
22
/odek
33
*.test
44
coverage.out
5+
odek-bin

cmd/odek/main.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,20 @@ const defaultConfigTemplate = `{
505505
"max_concurrency": 3,
506506
"timeout_seconds": 120,
507507
"max_iterations": 15
508+
},
509+
"telegram": {
510+
"bot_token": "",
511+
"allowed_chats": [],
512+
"allowed_users": [],
513+
"bot_username": "",
514+
"poll_interval": 1,
515+
"poll_timeout": 30,
516+
"max_msg_length": 4096,
517+
"daily_token_budget": 0,
518+
"session_ttl_hours": 24,
519+
"fallback_urls": [],
520+
"log_level": "info",
521+
"log_file": ""
508522
}
509523
}`
510524

cmd/odek/telegram.go

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os/signal"
88
"strings"
99
"sync"
10+
"sync/atomic"
1011
"syscall"
1112
"time"
1213

@@ -105,12 +106,32 @@ func telegramCmd(args []string) error {
105106
return "", nil
106107
}
107108

109+
// restartRequested is set atomically when a /restart command is received.
110+
// Checked after the update loop exits to decide between restart and exit.
111+
var restartRequested atomic.Bool
112+
108113
handler.OnCommand = func(chatID int64, cmdName string, argsStr string) (string, error) {
109114
cmd := telegram.FindCommand(cmdName)
110115
if cmd == nil {
111116
return fmt.Sprintf("Unknown command: /%s", cmdName), nil
112117
}
113118

119+
// Handle /restart — send confirmation directly, then trigger SIGHUP.
120+
if cmdName == "restart" {
121+
// Send the restart message directly via the bot to ensure it's
122+
// delivered before the process re-execs.
123+
if _, err := bot.SendMessage(chatID,
124+
"🔄 *Restarting...*\n\nThe bot will restart momentarily. This may take a few seconds.",
125+
nil); err != nil {
126+
handlerLog.Error("send restart message failed", "chat_id", chatID, "error", err)
127+
}
128+
// Signal SIGHUP to self — the signal handler will cancel the
129+
// context, stopping the poller, and the main loop will re-exec.
130+
restartRequested.Store(true)
131+
syscall.Kill(os.Getpid(), syscall.SIGHUP)
132+
return "", nil
133+
}
134+
114135
// Handle /new — clear session and reset trust in the approver.
115136
if cmdName == "new" {
116137
sessionManager.Delete(chatID)
@@ -119,6 +140,15 @@ func telegramCmd(args []string) error {
119140
}
120141
}
121142

143+
// Handle /stats — read from session store.
144+
if cmdName == "stats" {
145+
cs, err := sessionManager.Load(chatID)
146+
if err != nil || cs == nil {
147+
return "📊 *Session Stats*\n\nNo active session yet. Send a message to start one.", nil
148+
}
149+
return formatStats(cs), nil
150+
}
151+
122152
return cmd.Handler(argsStr)
123153
}
124154

@@ -160,12 +190,17 @@ func telegramCmd(args []string) error {
160190
ctx, cancel := context.WithCancel(context.Background())
161191
defer cancel()
162192

163-
// 15. Handle SIGINT/SIGTERM for graceful shutdown.
193+
// 15. Handle SIGINT/SIGTERM/SIGHUP for graceful shutdown and restart.
194+
// SIGHUP triggers a full process restart (used by /restart command).
164195
sigCh := make(chan os.Signal, 1)
165-
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
196+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
166197
go func() {
167-
<-sigCh
168-
fmt.Fprintf(os.Stderr, "\nodek telegram: shutting down...\n")
198+
sig := <-sigCh
199+
if sig == syscall.SIGHUP {
200+
fmt.Fprintf(os.Stderr, "\nodek telegram: restart requested...\n")
201+
} else {
202+
fmt.Fprintf(os.Stderr, "\nodek telegram: shutting down...\n")
203+
}
169204
cancel()
170205
}()
171206

@@ -178,6 +213,17 @@ func telegramCmd(args []string) error {
178213
handler.HandleUpdate(upd)
179214
}
180215

216+
// 18. If restart was requested (via /restart command), re-exec the binary.
217+
// This preserves the exact same arguments so the bot comes back with
218+
// the same configuration. If syscall.Exec fails, fall through to exit.
219+
if restartRequested.Load() {
220+
fmt.Fprintf(os.Stderr, "odek telegram: re-executing %s %v...\n", os.Args[0], os.Args[1:])
221+
if err := syscall.Exec(os.Args[0], os.Args, os.Environ()); err != nil {
222+
fmt.Fprintf(os.Stderr, "odek telegram: restart failed: %v\n", err)
223+
return err
224+
}
225+
}
226+
181227
return nil
182228
}
183229

@@ -277,6 +323,25 @@ func handleChatMessage(
277323
}
278324
}
279325

326+
// formatStats formats session statistics for the Telegram stats command.
327+
func formatStats(cs *telegram.ChatSession) string {
328+
duration := time.Since(cs.CreatedAt).Truncate(time.Second)
329+
330+
return fmt.Sprintf(
331+
"📊 *Session Stats*\n\n"+
332+
"Messages: %d\n"+
333+
"Turns: %d\n"+
334+
"Started: %s\n"+
335+
"Duration: %s\n"+
336+
"Last active: %s",
337+
len(cs.Messages),
338+
cs.TurnCount,
339+
cs.CreatedAt.Format("Jan 02, 2006 15:04 UTC"),
340+
duration.String(),
341+
cs.LastActive.Format("15:04 UTC"),
342+
)
343+
}
344+
280345
// reportError sends an error message to the given chat and logs to stderr.
281346
func reportError(bot *telegram.Bot, chatID int64, msg string) {
282347
fmt.Fprintf(os.Stderr, "odek telegram: %s\n", msg)

internal/config/loader.go

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -560,11 +560,52 @@ func resolveMemory(cfg *memory.MemoryConfig) memory.MemoryConfig {
560560
}
561561

562562
// resolveTelegram merges file-level telegram config with defaults.
563+
// Starts from DefaultConfig and overlays any non-zero fields from the
564+
// file config, so users only need to specify the fields they want to
565+
// override.
563566
func resolveTelegram(cfg *telegram.TelegramConfig) telegram.TelegramConfig {
564-
if cfg != nil {
565-
return *cfg
567+
base := telegram.DefaultConfig()
568+
if cfg == nil {
569+
return base
570+
}
571+
// Overlay non-zero fields from the file config.
572+
if cfg.Token != "" {
573+
base.Token = cfg.Token
574+
}
575+
if len(cfg.AllowedChats) > 0 {
576+
base.AllowedChats = cfg.AllowedChats
577+
}
578+
if len(cfg.AllowedUsers) > 0 {
579+
base.AllowedUsers = cfg.AllowedUsers
580+
}
581+
if cfg.BotUsername != "" {
582+
base.BotUsername = cfg.BotUsername
583+
}
584+
if cfg.PollInterval > 0 {
585+
base.PollInterval = cfg.PollInterval
566586
}
567-
return telegram.DefaultConfig()
587+
if cfg.PollTimeout > 0 {
588+
base.PollTimeout = cfg.PollTimeout
589+
}
590+
if cfg.MaxMsgLength > 0 {
591+
base.MaxMsgLength = cfg.MaxMsgLength
592+
}
593+
if cfg.DailyTokenBudget > 0 {
594+
base.DailyTokenBudget = cfg.DailyTokenBudget
595+
}
596+
if cfg.SessionTTL > 0 {
597+
base.SessionTTL = cfg.SessionTTL
598+
}
599+
if len(cfg.FallbackURLs) > 0 {
600+
base.FallbackURLs = cfg.FallbackURLs
601+
}
602+
if cfg.LogLevel != "" {
603+
base.LogLevel = cfg.LogLevel
604+
}
605+
if cfg.LogFile != "" {
606+
base.LogFile = cfg.LogFile
607+
}
608+
return base
568609
}
569610

570611
// overlayFile overlays a higher-priority FileConfig onto a lower-priority one.

internal/telegram/bot.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ type Bot struct {
2424
}
2525

2626
// NewBot creates a new Bot with the given token and a default HTTP client
27-
// with a 30-second timeout.
27+
// with a 60-second timeout (generous for long-polling getUpdates calls).
2828
func NewBot(token string) *Bot {
2929
return &Bot{
3030
Token: token,
3131
BaseURL: fmt.Sprintf("https://api.telegram.org/bot%s", token),
3232
FileBaseURL: fmt.Sprintf("https://api.telegram.org/file/bot%s", token),
3333
Client: &http.Client{
34-
Timeout: 30 * time.Second,
34+
Timeout: 60 * time.Second,
3535
},
3636
log: NewNopLogger(),
3737
}

internal/telegram/commands.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ func init() {
4949
Description: "Toggle agent modes (sandbox, verbose)",
5050
Handler: modeHandler,
5151
},
52+
{
53+
Command: "restart",
54+
Description: "Restart the bot process gracefully",
55+
Handler: restartHandler,
56+
},
5257
}
5358
}
5459

@@ -91,6 +96,14 @@ func modeHandler(args string) (string, error) {
9196
return "⚙️ *Agent Modes*\n\nSelect a mode to toggle:", nil
9297
}
9398

99+
// restartHandler handles the /restart command.
100+
// The actual restart signal is sent by the caller (telegramCmd) after
101+
// this response is delivered to the chat. This handler just returns
102+
// a confirmation message — the caller sends SIGHUP to trigger restart.
103+
func restartHandler(args string) (string, error) {
104+
return "🔄 *Restarting...*\n\nThe bot will restart momentarily. This may take a few seconds.", nil
105+
}
106+
94107
// FindCommand returns the command descriptor with the matching name, or nil.
95108
func FindCommand(name string) *CommandDescriptor {
96109
for i := range DefaultCommands {

internal/telegram/commands_test.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import (
1010
// ---------------------------------------------------------------------------
1111

1212
func TestFindCommand_Exists(t *testing.T) {
13-
knownCommands := []string{"start", "help", "new", "stats", "stop", "mode"}
13+
knownCommands := []string{"start", "help", "new", "stats", "stop", "mode", "restart"}
1414

1515
for _, name := range knownCommands {
1616
t.Run(name, func(t *testing.T) {
@@ -29,7 +29,7 @@ func TestFindCommand_Exists(t *testing.T) {
2929
}
3030

3131
func TestFindCommand_Unknown(t *testing.T) {
32-
unknownNames := []string{"unknown", "foo", "delete", "", "restart"}
32+
unknownNames := []string{"unknown", "foo", "delete", ""}
3333

3434
for _, name := range unknownNames {
3535
t.Run(name, func(t *testing.T) {
@@ -163,6 +163,20 @@ func TestModeHandler_ContainsMode(t *testing.T) {
163163
}
164164
}
165165

166+
func TestRestartHandler_ContainsRestart(t *testing.T) {
167+
cmd := FindCommand("restart")
168+
if cmd == nil {
169+
t.Fatal("restart command not found")
170+
}
171+
got, err := cmd.Handler("")
172+
if err != nil {
173+
t.Fatalf("restart handler returned error: %v", err)
174+
}
175+
if !strings.Contains(got, "Restarting") && !strings.Contains(got, "restart") {
176+
t.Errorf("restart handler output does not contain 'Restarting':\n%s", got)
177+
}
178+
}
179+
166180
// ---------------------------------------------------------------------------
167181
// All handler outputs are non-empty and error-free
168182
// ---------------------------------------------------------------------------

internal/telegram/config.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ type TelegramConfig struct {
1616
PollInterval int `json:"poll_interval"` // seconds, default 1
1717
PollTimeout int `json:"poll_timeout"` // seconds, default 30
1818
MaxMsgLength int `json:"max_msg_length"` // default 4096
19-
DailyTokenBudget int64 `json:"daily_token_budget"` // default 1000000
19+
DailyTokenBudget int64 `json:"daily_token_budget"` // 0 = unlimited (default)
2020
SessionTTL int `json:"session_ttl_hours"` // hours, default 24
2121
FallbackURLs []string `json:"fallback_urls"`
2222
LogLevel string `json:"log_level"` // "debug","info","warn","error" (default "info")
@@ -29,7 +29,7 @@ func DefaultConfig() TelegramConfig {
2929
PollInterval: 1,
3030
PollTimeout: 30,
3131
MaxMsgLength: 4096,
32-
DailyTokenBudget: 1000000,
32+
DailyTokenBudget: 0, // 0 = unlimited
3333
SessionTTL: 24,
3434
}
3535
}

internal/telegram/config_test.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ func TestDefaultConfig(t *testing.T) {
2424
if cfg.MaxMsgLength != 4096 {
2525
t.Errorf("DefaultConfig().MaxMsgLength = %d, want 4096", cfg.MaxMsgLength)
2626
}
27-
if cfg.DailyTokenBudget != 1000000 {
28-
t.Errorf("DefaultConfig().DailyTokenBudget = %d, want 1000000", cfg.DailyTokenBudget)
27+
if cfg.DailyTokenBudget != 0 {
28+
t.Errorf("DefaultConfig().DailyTokenBudget = %d, want 0 (unlimited)", cfg.DailyTokenBudget)
2929
}
3030
if cfg.SessionTTL != 24 {
3131
t.Errorf("DefaultConfig().SessionTTL = %d, want 24", cfg.SessionTTL)
@@ -229,8 +229,8 @@ func TestConfigFromEnv_dailyTokenBudget(t *testing.T) {
229229
func TestConfigFromEnv_dailyTokenBudgetInvalid(t *testing.T) {
230230
t.Setenv("ODEK_TELEGRAM_DAILY_TOKEN_BUDGET", "not-a-number")
231231
cfg := ConfigFromEnv(DefaultConfig())
232-
if cfg.DailyTokenBudget != 1000000 {
233-
t.Errorf("DailyTokenBudget = %d, want default 1000000", cfg.DailyTokenBudget)
232+
if cfg.DailyTokenBudget != 0 {
233+
t.Errorf("DailyTokenBudget = %d, want default 0 (unlimited)", cfg.DailyTokenBudget)
234234
}
235235
}
236236

@@ -309,8 +309,8 @@ func TestConfigFromEnv_multipleOverrides(t *testing.T) {
309309
if cfg.PollTimeout != 30 {
310310
t.Errorf("PollTimeout = %d, want 30", cfg.PollTimeout)
311311
}
312-
if cfg.DailyTokenBudget != 1000000 {
313-
t.Errorf("DailyTokenBudget = %d, want 1000000", cfg.DailyTokenBudget)
312+
if cfg.DailyTokenBudget != 0 {
313+
t.Errorf("DailyTokenBudget = %d, want 0 (unlimited)", cfg.DailyTokenBudget)
314314
}
315315
}
316316

internal/telegram/session.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type ChatSession struct {
2828
ChatID int64
2929
SessionID string
3030
Messages []llm.Message
31+
CreatedAt time.Time
3132
LastActive time.Time
3233
TurnCount int
3334
}
@@ -68,6 +69,7 @@ func (sm *SessionManager) GetOrCreate(chatID int64) (*ChatSession, error) {
6869
ChatID: chatID,
6970
SessionID: fmt.Sprintf("tg-%d", chatID),
7071
Messages: make([]llm.Message, 0),
72+
CreatedAt: time.Now(),
7173
LastActive: time.Now(),
7274
TurnCount: 0,
7375
}
@@ -104,7 +106,7 @@ func (sm *SessionManager) Save(chatID int64, messages []llm.Message) error {
104106
now := time.Now()
105107
sess := &session.Session{
106108
ID: cs.SessionID,
107-
CreatedAt: now,
109+
CreatedAt: cs.CreatedAt,
108110
UpdatedAt: now,
109111
Model: "",
110112
Turns: cs.TurnCount,
@@ -139,6 +141,7 @@ func (sm *SessionManager) Load(chatID int64) (*ChatSession, error) {
139141
ChatID: chatID,
140142
SessionID: sess.ID,
141143
Messages: sess.Messages,
144+
CreatedAt: sess.CreatedAt,
142145
LastActive: sess.UpdatedAt,
143146
TurnCount: sess.Turns,
144147
}

0 commit comments

Comments
 (0)