feat(commands): implement session command handlers via runtime
This commit is contained in:
parent
69b95aeb84
commit
fb1d99b65a
3 changed files with 317 additions and 4 deletions
|
|
@ -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 <index>]",
|
||||
Channels: []string{"telegram", "whatsapp", "whatsapp_native"},
|
||||
Handler: func(ctx context.Context, req Request) error {
|
||||
return handleSessionCommand(ctx, req)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "show",
|
||||
|
|
@ -143,11 +168,114 @@ func commandArgs(text string) string {
|
|||
|
||||
func replyText(text string) Handler {
|
||||
return func(_ context.Context, req Request) error {
|
||||
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 <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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
157
pkg/commands/session_handlers_test.go
Normal file
157
pkg/commands/session_handlers_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue