Skip to content

Commit ea83c1f

Browse files
committed
telegram: add session management commands (/sessions, /resume, /prune)
- SessionManager: ListSessions, ResumeSession(id), PruneSessions(days) - GetOrCreate now falls back to disk store on cache miss — sessions survive bot restarts without explicit /resume - /sessions lists recent sessions with turn count, age, task preview - /resume <id> switches to a session by ID or task prefix match - /prune [days] removes sessions older than N days (default: 30) - Updated /start to list new commands
1 parent 5bbfcf8 commit ea83c1f

4 files changed

Lines changed: 356 additions & 3 deletions

File tree

cmd/odek/telegram.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"os/signal"
88
"sort"
9+
"strconv"
910
"strings"
1011
"sync"
1112
"sync/atomic"
@@ -150,6 +151,72 @@ func telegramCmd(args []string) error {
150151
return formatStats(cs), nil
151152
}
152153

154+
// Handle /sessions — list recent sessions from the store.
155+
if cmdName == "sessions" {
156+
infos, err := sessionManager.ListSessions(10)
157+
if err != nil {
158+
return fmt.Sprintf("❌ Failed to list sessions: %v", err), nil
159+
}
160+
if len(infos) == 0 {
161+
return "📋 *Sessions*\n\nNo sessions found. Start a conversation first.", nil
162+
}
163+
var b strings.Builder
164+
b.WriteString("📋 *Sessions*\n\n")
165+
for _, s := range infos {
166+
ago := time.Since(s.UpdatedAt).Round(time.Minute)
167+
fmt.Fprintf(&b, "`%s` — %d turns, %s ago\n", s.ID, s.Turns, ago)
168+
if s.Task != "" {
169+
taskPreview := s.Task
170+
if len(taskPreview) > 50 {
171+
taskPreview = taskPreview[:50] + "…"
172+
}
173+
fmt.Fprintf(&b, " _%s_\n", taskPreview)
174+
}
175+
}
176+
b.WriteString("\nUse `/resume <id>` to continue a session.")
177+
return b.String(), nil
178+
}
179+
180+
// Handle /resume <id> — switch to a different session.
181+
if cmdName == "resume" {
182+
sessionID := strings.TrimSpace(argsStr)
183+
if sessionID == "" {
184+
return "❗ Usage: `/resume <session-id>`\n\nUse `/sessions` to see available sessions.", nil
185+
}
186+
cs, err := sessionManager.ResumeSession(chatID, sessionID)
187+
if err != nil {
188+
return fmt.Sprintf("❌ %v", err), nil
189+
}
190+
taskPreview := cs.Messages[0].Content
191+
if len(taskPreview) > 80 {
192+
taskPreview = taskPreview[:80] + "…"
193+
}
194+
return fmt.Sprintf(
195+
"✅ *Session resumed*: `%s`\n\n%d turns • %d messages\n_%s_\n\nSend a message to continue.",
196+
cs.SessionID, cs.TurnCount, len(cs.Messages), taskPreview,
197+
), nil
198+
}
199+
200+
// Handle /prune [days] — clean up old sessions.
201+
if cmdName == "prune" {
202+
days := 30
203+
if strings.TrimSpace(argsStr) != "" {
204+
if d, err := strconv.Atoi(strings.TrimSpace(argsStr)); err == nil && d > 0 {
205+
days = d
206+
} else {
207+
return "❗ Usage: `/prune [days]`\n\nExample: `/prune 7` to remove sessions older than 7 days.", nil
208+
}
209+
}
210+
removed, err := sessionManager.PruneSessions(days)
211+
if err != nil {
212+
return fmt.Sprintf("❌ Failed to prune sessions: %v", err), nil
213+
}
214+
if removed == 0 {
215+
return fmt.Sprintf("📋 *Prune* — No sessions older than %d days found.", days), nil
216+
}
217+
return fmt.Sprintf("🧹 *Pruned* — Removed %d session(s) older than %d days.", removed, days), nil
218+
}
219+
153220
return cmd.Handler(argsStr)
154221
}
155222

internal/telegram/commands.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,21 @@ func init() {
5454
Description: "Restart the bot process gracefully",
5555
Handler: restartHandler,
5656
},
57+
{
58+
Command: "sessions",
59+
Description: "List recent conversation sessions",
60+
Handler: sessionsHandler,
61+
},
62+
{
63+
Command: "resume",
64+
Description: "Resume a previous session by ID",
65+
Handler: resumeHandler,
66+
},
67+
{
68+
Command: "prune",
69+
Description: "Clean up old sessions (default: 30 days)",
70+
Handler: pruneHandler,
71+
},
5772
}
5873
}
5974

@@ -64,6 +79,9 @@ func startHandler(args string) (string, error) {
6479
"/help — Show available commands\n" +
6580
"/new — Reset conversation\n" +
6681
"/stats — Show session statistics\n" +
82+
"/sessions — List recent sessions\n" +
83+
"/resume <id> — Resume a previous session\n" +
84+
"/prune [days] — Clean up old sessions\n" +
6785
"/stop — Cancel running task\n\n" +
6886
"Send me a message and I will help!", nil
6987
}
@@ -104,6 +122,18 @@ func restartHandler(args string) (string, error) {
104122
return "🔄 *Restarting...*\n\nThe bot will restart momentarily. This may take a few seconds.", nil
105123
}
106124

125+
func sessionsHandler(args string) (string, error) {
126+
return "📋 *Sessions* — Listing sessions is handled inline.", nil
127+
}
128+
129+
func resumeHandler(args string) (string, error) {
130+
return "✅ *Resume* — Session resume is handled inline.", nil
131+
}
132+
133+
func pruneHandler(args string) (string, error) {
134+
return "🧹 *Prune* — Session cleanup is handled inline.", nil
135+
}
136+
107137
// FindCommand returns the command descriptor with the matching name, or nil.
108138
func FindCommand(name string) *CommandDescriptor {
109139
for i := range DefaultCommands {

internal/telegram/session.go

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package telegram
33

44
import (
55
"fmt"
6+
"strings"
67
"sync"
78
"time"
89

@@ -53,9 +54,10 @@ func NewSessionManager(store *session.Store, ttl time.Duration) *SessionManager
5354
// ── Methods ────────────────────────────────────────────────────────────
5455

5556
// GetOrCreate returns the ChatSession for the given chatID.
56-
// It checks the in-memory cache first; if the entry exists and hasn't
57-
// expired (24h), it is returned directly. Otherwise a new ChatSession
58-
// is created with a "tg-<chatID>" session ID, cached, and returned.
57+
// Checks the in-memory cache first, then the backing session store,
58+
// and only creates a new empty session as a last resort. This ensures
59+
// conversations survive bot restarts without the user needing to ask
60+
// for resume explicitly.
5961
func (sm *SessionManager) GetOrCreate(chatID int64) (*ChatSession, error) {
6062
sm.Mu.RLock()
6163
cs, ok := sm.Cache[chatID]
@@ -65,6 +67,11 @@ func (sm *SessionManager) GetOrCreate(chatID int64) (*ChatSession, error) {
6567
return cs, nil
6668
}
6769

70+
// Missed cache — try the backing store before creating fresh.
71+
if loaded, _ := sm.Load(chatID); loaded != nil {
72+
return loaded, nil
73+
}
74+
6875
cs = &ChatSession{
6976
ChatID: chatID,
7077
SessionID: fmt.Sprintf("tg-%d", chatID),
@@ -177,3 +184,93 @@ func (sm *SessionManager) AppendMessage(chatID int64, role string, content strin
177184
cs.Messages = append(cs.Messages, llm.Message{Role: role, Content: content})
178185
return sm.Save(chatID, cs.Messages)
179186
}
187+
188+
// ── Session Management ─────────────────────────────────────────────────
189+
190+
// SessionInfo is a lightweight summary of a session for listing.
191+
type SessionInfo struct {
192+
ID string // session ID (e.g. "tg-12345")
193+
Task string // first user message or label
194+
CreatedAt time.Time // when the session started
195+
UpdatedAt time.Time // last activity
196+
Turns int // number of user turns
197+
}
198+
199+
// ListSessions returns metadata for all sessions in the backing store,
200+
// sorted by most-recent-first, limited to `limit` entries. If limit <= 0,
201+
// all sessions are returned.
202+
func (sm *SessionManager) ListSessions(limit int) ([]SessionInfo, error) {
203+
sessions, err := sm.Store.List(limit)
204+
if err != nil {
205+
return nil, fmt.Errorf("list sessions: %w", err)
206+
}
207+
infos := make([]SessionInfo, len(sessions))
208+
for i, s := range sessions {
209+
infos[i] = SessionInfo{
210+
ID: s.ID,
211+
Task: s.Task,
212+
CreatedAt: s.CreatedAt,
213+
UpdatedAt: s.UpdatedAt,
214+
Turns: s.Turns,
215+
}
216+
}
217+
return infos, nil
218+
}
219+
220+
// ResumeSession loads a session from the backing store and binds it to
221+
// the given chatID. This replaces any existing session for that chat.
222+
// sessionID can be a partial prefix match — the first matching session
223+
// (by ID prefix or task contains) is used. Returns the new ChatSession
224+
// or an error if no matching session is found.
225+
func (sm *SessionManager) ResumeSession(chatID int64, sessionID string) (*ChatSession, error) {
226+
if sessionID == "" {
227+
return nil, fmt.Errorf("session ID required — use /sessions to list")
228+
}
229+
230+
// Try direct load first.
231+
sess, err := sm.Store.Load(sessionID)
232+
if err != nil || sess == nil {
233+
// Prefix match: search all sessions for a match.
234+
all, listErr := sm.Store.List(0)
235+
if listErr != nil {
236+
return nil, fmt.Errorf("list sessions: %w", listErr)
237+
}
238+
for _, s := range all {
239+
if strings.HasPrefix(s.ID, sessionID) ||
240+
strings.Contains(strings.ToLower(s.Task), strings.ToLower(sessionID)) {
241+
sess = &s
242+
break
243+
}
244+
}
245+
}
246+
247+
if sess == nil {
248+
return nil, fmt.Errorf("no session found matching %q", sessionID)
249+
}
250+
251+
// Build ChatSession and cache it.
252+
cs := &ChatSession{
253+
ChatID: chatID,
254+
SessionID: sess.ID,
255+
Messages: sess.Messages,
256+
CreatedAt: sess.CreatedAt,
257+
LastActive: time.Now(),
258+
TurnCount: sess.Turns,
259+
}
260+
261+
sm.Mu.Lock()
262+
sm.Cache[chatID] = cs
263+
sm.Mu.Unlock()
264+
265+
return cs, nil
266+
}
267+
268+
// PruneSessions deletes sessions that haven't been updated in `days` days
269+
// or more. Returns the number of sessions removed.
270+
func (sm *SessionManager) PruneSessions(days int) (int, error) {
271+
if days <= 0 {
272+
days = 30
273+
}
274+
before := time.Now().Add(-time.Duration(days) * 24 * time.Hour)
275+
return sm.Store.Cleanup(before)
276+
}

0 commit comments

Comments
 (0)