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 (
|
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,11 +168,114 @@ 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 {
|
||||||
|
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 {
|
if req.Reply == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return req.Reply(text)
|
return req.Reply(text)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func enabledChannels(cfg *config.Config) []string {
|
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)
|
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