feat(commands): implement session command handlers via runtime

This commit is contained in:
mingmxren 2026-03-01 15:12:56 +08:00
parent 69b95aeb84
commit fb1d99b65a
3 changed files with 317 additions and 4 deletions

View file

@ -3,11 +3,30 @@ package commands
import ( import (
"context" "context"
"fmt" "fmt"
"strconv"
"strings" "strings"
"github.com/sipeed/picoclaw/pkg/config" "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 { func BuiltinDefinitions(cfg *config.Config) []Definition {
return []Definition{ return []Definition{
{ {
@ -36,12 +55,18 @@ func BuiltinDefinitions(cfg *config.Config) []Definition {
Description: "Start a new chat session", Description: "Start a new chat session",
Usage: "/new", Usage: "/new",
Channels: []string{"telegram", "whatsapp", "whatsapp_native"}, Channels: []string{"telegram", "whatsapp", "whatsapp_native"},
Handler: func(ctx context.Context, req Request) error {
return handleNewCommand(ctx, req, cfg)
},
}, },
{ {
Name: "session", Name: "session",
Description: "Manage chat sessions", Description: "Manage chat sessions",
Usage: "/session [list|resume <index>]", Usage: "/session [list|resume <index>]",
Channels: []string{"telegram", "whatsapp", "whatsapp_native"}, Channels: []string{"telegram", "whatsapp", "whatsapp_native"},
Handler: func(ctx context.Context, req Request) error {
return handleSessionCommand(ctx, req)
},
}, },
{ {
Name: "show", Name: "show",
@ -143,13 +168,116 @@ func commandArgs(text string) string {
func replyText(text string) Handler { func replyText(text string) Handler {
return func(_ context.Context, req Request) error { return func(_ context.Context, req Request) error {
if req.Reply == nil { return reply(req, text)
return nil
}
return req.Reply(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 <index>]")
}
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>")
}
index, err := strconv.Atoi(args[1])
if err != nil || index < 1 {
return reply(req, "Usage: /session resume <index>")
}
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 <index>]")
}
}
func reply(req Request, text string) error {
if req.Reply == nil {
return nil
}
return req.Reply(text)
}
func enabledChannels(cfg *config.Config) []string { func enabledChannels(cfg *config.Config) []string {
enabled := make([]string, 0, 8) enabled := make([]string, 0, 8)
if cfg.Channels.Telegram.Enabled { if cfg.Channels.Telegram.Enabled {

View file

@ -31,3 +31,31 @@ func TestBuiltinDefinitions_WhatsAppOnlyHasBasicCommands(t *testing.T) {
t.Fatalf("whatsapp should not include show/list, got %+v", names) 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")
}
}

View file

@ -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)
}
}