diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 6c3591246..254612f00 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -3,11 +3,30 @@ package commands import ( "context" "fmt" + "strconv" "strings" "github.com/sipeed/picoclaw/pkg/config" ) +type runtimeContextKey struct{} + +// WithRuntime attaches command runtime capabilities to ctx for command handlers. +func WithRuntime(ctx context.Context, runtime Runtime) context.Context { + if ctx == nil { + ctx = context.Background() + } + return context.WithValue(ctx, runtimeContextKey{}, runtime) +} + +func runtimeFromContext(ctx context.Context) Runtime { + if ctx == nil { + return nil + } + runtime, _ := ctx.Value(runtimeContextKey{}).(Runtime) + return runtime +} + func BuiltinDefinitions(cfg *config.Config) []Definition { return []Definition{ { @@ -36,12 +55,18 @@ func BuiltinDefinitions(cfg *config.Config) []Definition { Description: "Start a new chat session", Usage: "/new", Channels: []string{"telegram", "whatsapp", "whatsapp_native"}, + Handler: func(ctx context.Context, req Request) error { + return handleNewCommand(ctx, req, cfg) + }, }, { Name: "session", Description: "Manage chat sessions", Usage: "/session [list|resume ]", Channels: []string{"telegram", "whatsapp", "whatsapp_native"}, + Handler: func(ctx context.Context, req Request) error { + return handleSessionCommand(ctx, req) + }, }, { Name: "show", @@ -143,13 +168,116 @@ func commandArgs(text string) string { func replyText(text string) Handler { return func(_ context.Context, req Request) error { - if req.Reply == nil { - return nil - } - return req.Reply(text) + return reply(req, text) } } +func handleNewCommand(ctx context.Context, req Request, fallbackCfg *config.Config) error { + runtime := runtimeFromContext(ctx) + if runtime == nil || runtime.SessionOps() == nil || strings.TrimSpace(runtime.ScopeKey()) == "" { + return reply(req, "Command unavailable in current context.") + } + + scopeKey := runtime.ScopeKey() + newSessionKey, err := runtime.SessionOps().StartNew(scopeKey) + if err != nil { + return reply(req, fmt.Sprintf("Failed to start new session: %v", err)) + } + + backlogLimit := config.DefaultSessionBacklogLimit + cfg := fallbackCfg + if runtime.Config() != nil { + cfg = runtime.Config() + } + if cfg != nil { + backlogLimit = cfg.Session.EffectiveBacklogLimit() + } + + pruned, err := runtime.SessionOps().Prune(scopeKey, backlogLimit) + if err != nil { + return reply(req, fmt.Sprintf( + "Started new session (%s), but pruning old sessions failed: %v", + newSessionKey, + err, + )) + } + + if len(pruned) == 0 { + return reply(req, fmt.Sprintf("Started new session: %s", newSessionKey)) + } + return reply(req, fmt.Sprintf("Started new session: %s (pruned %d old session(s))", newSessionKey, len(pruned))) +} + +func handleSessionCommand(ctx context.Context, req Request) error { + runtime := runtimeFromContext(ctx) + if runtime == nil || runtime.SessionOps() == nil || strings.TrimSpace(runtime.ScopeKey()) == "" { + return reply(req, "Command unavailable in current context.") + } + + args := strings.Fields(commandArgs(req.Text)) + if len(args) < 1 { + return reply(req, "Usage: /session [list|resume ]") + } + + scopeKey := runtime.ScopeKey() + switch args[0] { + case "list": + list, err := runtime.SessionOps().List(scopeKey) + if err != nil { + return reply(req, fmt.Sprintf("Failed to list sessions: %v", err)) + } + if len(list) == 0 { + return reply(req, "No sessions found for current chat.") + } + + lines := make([]string, 0, len(list)+1) + lines = append(lines, "Sessions for current chat:") + for _, item := range list { + activeMarker := " " + if item.Active { + activeMarker = "*" + } + updated := "-" + if !item.UpdatedAt.IsZero() { + updated = item.UpdatedAt.Format("2006-01-02 15:04") + } + lines = append(lines, fmt.Sprintf( + "%d. [%s] %s (%d msgs, updated %s)", + item.Ordinal, + activeMarker, + item.SessionKey, + item.MessageCnt, + updated, + )) + } + return reply(req, strings.Join(lines, "\n")) + + case "resume": + if len(args) != 2 { + return reply(req, "Usage: /session resume ") + } + index, err := strconv.Atoi(args[1]) + if err != nil || index < 1 { + return reply(req, "Usage: /session resume ") + } + sessionKey, err := runtime.SessionOps().Resume(scopeKey, index) + if err != nil { + return reply(req, fmt.Sprintf("Failed to resume session %d: %v", index, err)) + } + return reply(req, fmt.Sprintf("Resumed session %d: %s", index, sessionKey)) + + default: + return reply(req, "Usage: /session [list|resume ]") + } +} + +func reply(req Request, text string) error { + if req.Reply == nil { + return nil + } + return req.Reply(text) +} + func enabledChannels(cfg *config.Config) []string { enabled := make([]string, 0, 8) if cfg.Channels.Telegram.Enabled { diff --git a/pkg/commands/builtin_test.go b/pkg/commands/builtin_test.go index 44c1cca5a..37b3b3867 100644 --- a/pkg/commands/builtin_test.go +++ b/pkg/commands/builtin_test.go @@ -31,3 +31,31 @@ func TestBuiltinDefinitions_WhatsAppOnlyHasBasicCommands(t *testing.T) { t.Fatalf("whatsapp should not include show/list, got %+v", names) } } + +func TestBuiltinDefinitions_SessionCommandsHaveHandlers(t *testing.T) { + defs := BuiltinDefinitions(nil) + + defByName := map[string]Definition{} + for _, def := range defs { + defByName[def.Name] = def + } + + newDef, ok := defByName["new"] + if !ok { + t.Fatalf("missing /new definition") + } + if newDef.Handler == nil { + t.Fatalf("/new should provide a runtime-backed handler") + } + if !contains(newDef.Aliases, "reset") { + t.Fatalf("/new aliases=%v, want alias \"reset\"", newDef.Aliases) + } + + sessionDef, ok := defByName["session"] + if !ok { + t.Fatalf("missing /session definition") + } + if sessionDef.Handler == nil { + t.Fatalf("/session should provide a runtime-backed handler") + } +} diff --git a/pkg/commands/session_handlers_test.go b/pkg/commands/session_handlers_test.go new file mode 100644 index 000000000..5dbbc3989 --- /dev/null +++ b/pkg/commands/session_handlers_test.go @@ -0,0 +1,157 @@ +package commands + +import ( + "context" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/session" +) + +type sessionHandlerFakeSessionOps struct { + startNewScopeKeys []string + startNewValue string + startNewErr error + + pruneScopeKeys []string + pruneLimits []int + pruneValue []string + pruneErr error + + resumeScopeKeys []string + resumeIndices []int + resumeValue string + resumeErr error +} + +func (f *sessionHandlerFakeSessionOps) ResolveActive(scopeKey string) (string, error) { + return "", nil +} + +func (f *sessionHandlerFakeSessionOps) StartNew(scopeKey string) (string, error) { + f.startNewScopeKeys = append(f.startNewScopeKeys, scopeKey) + return f.startNewValue, f.startNewErr +} + +func (f *sessionHandlerFakeSessionOps) List(scopeKey string) ([]session.SessionMeta, error) { + return nil, nil +} + +func (f *sessionHandlerFakeSessionOps) Resume(scopeKey string, index int) (string, error) { + f.resumeScopeKeys = append(f.resumeScopeKeys, scopeKey) + f.resumeIndices = append(f.resumeIndices, index) + return f.resumeValue, f.resumeErr +} + +func (f *sessionHandlerFakeSessionOps) Prune(scopeKey string, limit int) ([]string, error) { + f.pruneScopeKeys = append(f.pruneScopeKeys, scopeKey) + f.pruneLimits = append(f.pruneLimits, limit) + return f.pruneValue, f.pruneErr +} + +type sessionHandlerFakeRuntime struct { + channel string + scope string + ops SessionOps + cfg *config.Config +} + +func (f *sessionHandlerFakeRuntime) Channel() string { + return f.channel +} + +func (f *sessionHandlerFakeRuntime) ScopeKey() string { + return f.scope +} + +func (f *sessionHandlerFakeRuntime) SessionOps() SessionOps { + return f.ops +} + +func (f *sessionHandlerFakeRuntime) Config() *config.Config { + return f.cfg +} + +func TestSessionHandlers_New_UsesRuntimeSessionOps(t *testing.T) { + t.Helper() + + ops := &sessionHandlerFakeSessionOps{ + startNewValue: "scope#2", + pruneValue: []string{"scope#1"}, + } + runtime := &sessionHandlerFakeRuntime{ + channel: "whatsapp", + scope: "scope", + ops: ops, + cfg: &config.Config{ + Session: config.SessionConfig{BacklogLimit: 7}, + }, + } + + ctx := WithRuntime(context.Background(), runtime) + + var reply string + ex := NewExecutor(NewRegistry(BuiltinDefinitions(nil))) + res := ex.Execute(ctx, Request{ + Channel: "whatsapp", + Text: "/new", + Reply: func(text string) error { + reply = text + return nil + }, + }) + + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if len(ops.startNewScopeKeys) != 1 || ops.startNewScopeKeys[0] != "scope" { + t.Fatalf("startNew calls=%v, want [scope]", ops.startNewScopeKeys) + } + if len(ops.pruneScopeKeys) != 1 || ops.pruneScopeKeys[0] != "scope" { + t.Fatalf("prune scope calls=%v, want [scope]", ops.pruneScopeKeys) + } + if len(ops.pruneLimits) != 1 || ops.pruneLimits[0] != 7 { + t.Fatalf("prune limits=%v, want [7]", ops.pruneLimits) + } + if reply != "Started new session: scope#2 (pruned 1 old session(s))" { + t.Fatalf("reply=%q", reply) + } +} + +func TestSessionHandlers_SessionResume_UsesRuntimeSessionOps(t *testing.T) { + t.Helper() + + ops := &sessionHandlerFakeSessionOps{resumeValue: "scope#3"} + runtime := &sessionHandlerFakeRuntime{ + channel: "whatsapp", + scope: "scope", + ops: ops, + cfg: &config.Config{}, + } + + ctx := WithRuntime(context.Background(), runtime) + + var reply string + ex := NewExecutor(NewRegistry(BuiltinDefinitions(nil))) + res := ex.Execute(ctx, Request{ + Channel: "whatsapp", + Text: "/session resume 3", + Reply: func(text string) error { + reply = text + return nil + }, + }) + + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if len(ops.resumeScopeKeys) != 1 || ops.resumeScopeKeys[0] != "scope" { + t.Fatalf("resume scope calls=%v, want [scope]", ops.resumeScopeKeys) + } + if len(ops.resumeIndices) != 1 || ops.resumeIndices[0] != 3 { + t.Fatalf("resume indices=%v, want [3]", ops.resumeIndices) + } + if reply != "Resumed session 3: scope#3" { + t.Fatalf("reply=%q", reply) + } +}