refactor(channels): forward generic commands to agent-centric executor path

This commit is contained in:
mingmxren 2026-03-01 16:11:22 +08:00
parent 72bf2e0901
commit 6993c2669b
6 changed files with 157 additions and 91 deletions

View file

@ -2,12 +2,10 @@ package telegram
import ( import (
"context" "context"
"strconv"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/logger"
) )
func (c *TelegramChannel) DispatchCommand(ctx context.Context, req commands.Request) commands.Result { func (c *TelegramChannel) DispatchCommand(ctx context.Context, req commands.Request) commands.Result {
@ -18,38 +16,7 @@ func (c *TelegramChannel) DispatchCommand(ctx context.Context, req commands.Requ
} }
func (c *TelegramChannel) dispatchCommand(ctx context.Context, message telego.Message) bool { func (c *TelegramChannel) dispatchCommand(ctx context.Context, message telego.Message) bool {
senderID := "" // Generic slash commands are now executed in the agent-centric command path.
if message.From != nil { // Channel adapters must not consume them locally.
senderID = strconv.FormatInt(message.From.ID, 10)
}
res := c.DispatchCommand(ctx, commands.Request{
Channel: "telegram",
ChatID: strconv.FormatInt(message.Chat.ID, 10),
SenderID: senderID,
Text: message.Text,
MessageID: strconv.Itoa(message.MessageID),
Reply: func(text string) error {
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},
Text: text,
ReplyParameters: &telego.ReplyParameters{
MessageID: message.MessageID,
},
})
return err
},
})
if !res.Matched {
return false return false
}
if res.Err != nil {
logger.ErrorCF("telegram", "Command execution failed", map[string]any{
"command": res.Command,
"error": res.Err.Error(),
})
}
return true
} }

View file

@ -3,13 +3,16 @@ package telegram
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/commands"
) )
func TestDispatchCommand_UsesDispatcher(t *testing.T) { func TestDispatchCommand_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
ch := &TelegramChannel{} ch := &TelegramChannel{}
called := false called := false
ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result { ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result {
@ -26,7 +29,50 @@ func TestDispatchCommand_UsesDispatcher(t *testing.T) {
} }
handled := ch.dispatchCommand(context.Background(), msg) handled := ch.dispatchCommand(context.Background(), msg)
if !handled || !called { if handled {
t.Fatalf("handled=%v", handled)
}
if called {
t.Fatalf("handled=%v called=%v", handled, called) t.Fatalf("handled=%v called=%v", handled, called)
} }
} }
func TestHandleMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
messageBus := bus.NewMessageBus()
ch := &TelegramChannel{
BaseChannel: channels.NewBaseChannel("telegram", nil, messageBus, nil),
chatIDs: make(map[string]int64),
ctx: context.Background(),
}
msg := &telego.Message{
Text: "/new",
MessageID: 9,
Chat: telego.Chat{
ID: 123,
Type: "private",
},
From: &telego.User{
ID: 42,
FirstName: "Alice",
},
}
if err := ch.handleMessage(context.Background(), msg); err != nil {
t.Fatalf("handleMessage error: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message to be forwarded")
}
if inbound.Channel != "telegram" {
t.Fatalf("channel=%q", inbound.Channel)
}
if inbound.Content != "/new" {
t.Fatalf("content=%q", inbound.Content)
}
}

View file

@ -262,23 +262,9 @@ func (c *WhatsAppChannel) tryHandleCommand(
ctx context.Context, ctx context.Context,
text, chatID, senderID, messageID string, text, chatID, senderID, messageID string,
) bool { ) bool {
res := c.DispatchCommand(ctx, commands.Request{ // Generic slash commands are now executed in the agent-centric command path.
Channel: "whatsapp", // Channel adapters must not consume them locally.
ChatID: chatID, return false
SenderID: senderID,
Text: text,
MessageID: messageID,
Reply: func(text string) error {
return c.Send(ctx, bus.OutboundMessage{ChatID: chatID, Content: text})
},
})
if res.Err != nil {
logger.WarnCF("whatsapp", "Command execution failed", map[string]any{
"command": res.Command,
"error": res.Err.Error(),
})
}
return res.Matched
} }
func (c *WhatsAppChannel) DispatchCommand(ctx context.Context, req commands.Request) commands.Result { func (c *WhatsAppChannel) DispatchCommand(ctx context.Context, req commands.Request) commands.Result {

View file

@ -3,11 +3,15 @@ package whatsapp
import ( import (
"context" "context"
"testing" "testing"
"time"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/config"
) )
func TestTryHandleCommand_UsesDispatcher(t *testing.T) { func TestTryHandleCommand_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
ch := &WhatsAppChannel{} ch := &WhatsAppChannel{}
called := false called := false
ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result { ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result {
@ -16,19 +20,49 @@ func TestTryHandleCommand_UsesDispatcher(t *testing.T) {
}) })
handled := ch.tryHandleCommand(context.Background(), "/help", "chat1", "user1", "mid1") handled := ch.tryHandleCommand(context.Background(), "/help", "chat1", "user1", "mid1")
if !handled || !called { if handled {
t.Fatalf("handled=%v", handled)
}
if called {
t.Fatalf("handled=%v called=%v", handled, called) t.Fatalf("handled=%v called=%v", handled, called)
} }
} }
func TestTryHandleCommand_MatchedWithoutHandler_DoesNotFallThrough(t *testing.T) { func TestHandleIncomingMessage_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
ch := &WhatsAppChannel{} messageBus := bus.NewMessageBus()
ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result { called := false
return commands.Result{Matched: true, Handled: false, Command: "unknown"} ch := &WhatsAppChannel{
BaseChannel: channels.NewBaseChannel("whatsapp", config.WhatsAppConfig{}, messageBus, nil),
dispatcher: commands.DispatchFunc(func(context.Context, commands.Request) commands.Result {
called = true
return commands.Result{Matched: true, Handled: true}
}),
ctx: context.Background(),
}
ch.handleIncomingMessage(map[string]any{
"type": "message",
"id": "mid1",
"from": "user1",
"chat": "chat1",
"content": "/help",
}) })
handled := ch.tryHandleCommand(context.Background(), "/unknown", "chat1", "user1", "mid1") if called {
if !handled { t.Fatal("expected generic command dispatch to be bypassed")
t.Fatal("expected matched command to be treated as handled") }
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message to be forwarded")
}
if inbound.Channel != "whatsapp" {
t.Fatalf("channel=%q", inbound.Channel)
}
if inbound.Content != "/help" {
t.Fatalf("content=%q", inbound.Content)
} }
} }

View file

@ -5,11 +5,20 @@ package whatsapp
import ( import (
"context" "context"
"testing" "testing"
"time"
"go.mau.fi/whatsmeow/proto/waE2E"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
"google.golang.org/protobuf/proto"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/commands" "github.com/sipeed/picoclaw/pkg/commands"
"github.com/sipeed/picoclaw/pkg/config"
) )
func TestTryHandleCommand_UsesDispatcher(t *testing.T) { func TestTryHandleCommand_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
ch := &WhatsAppNativeChannel{} ch := &WhatsAppNativeChannel{}
called := false called := false
ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result { ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result {
@ -18,19 +27,57 @@ func TestTryHandleCommand_UsesDispatcher(t *testing.T) {
}) })
handled := ch.tryHandleCommand(context.Background(), "/help", "chat1", "user1", "mid1") handled := ch.tryHandleCommand(context.Background(), "/help", "chat1", "user1", "mid1")
if !handled || !called { if handled {
t.Fatalf("handled=%v", handled)
}
if called {
t.Fatalf("handled=%v called=%v", handled, called) t.Fatalf("handled=%v called=%v", handled, called)
} }
} }
func TestTryHandleCommand_MatchedWithoutHandler_DoesNotFallThrough(t *testing.T) { func TestHandleIncoming_DoesNotConsumeGenericCommandsLocally(t *testing.T) {
ch := &WhatsAppNativeChannel{} messageBus := bus.NewMessageBus()
ch.dispatcher = commands.DispatchFunc(func(context.Context, commands.Request) commands.Result { called := false
return commands.Result{Matched: true, Handled: false, Command: "unknown"} ch := &WhatsAppNativeChannel{
}) BaseChannel: channels.NewBaseChannel("whatsapp_native", config.WhatsAppConfig{}, messageBus, nil),
dispatcher: commands.DispatchFunc(func(context.Context, commands.Request) commands.Result {
called = true
return commands.Result{Matched: true, Handled: true}
}),
runCtx: context.Background(),
}
handled := ch.tryHandleCommand(context.Background(), "/unknown", "chat1", "user1", "mid1") evt := &events.Message{
if !handled { Info: types.MessageInfo{
t.Fatal("expected matched command to be treated as handled") MessageSource: types.MessageSource{
Sender: types.NewJID("1001", types.DefaultUserServer),
Chat: types.NewJID("1001", types.DefaultUserServer),
},
ID: "mid1",
PushName: "Alice",
},
Message: &waE2E.Message{
Conversation: proto.String("/new"),
},
}
ch.handleIncoming(evt)
if called {
t.Fatal("expected generic command dispatch to be bypassed")
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
inbound, ok := messageBus.ConsumeInbound(ctx)
if !ok {
t.Fatal("expected inbound message to be forwarded")
}
if inbound.Channel != "whatsapp_native" {
t.Fatalf("channel=%q", inbound.Channel)
}
if inbound.Content != "/new" {
t.Fatalf("content=%q", inbound.Content)
} }
} }

View file

@ -406,23 +406,9 @@ func (c *WhatsAppNativeChannel) tryHandleCommand(
ctx context.Context, ctx context.Context,
text, chatID, senderID, messageID string, text, chatID, senderID, messageID string,
) bool { ) bool {
res := c.DispatchCommand(ctx, commands.Request{ // Generic slash commands are now executed in the agent-centric command path.
Channel: "whatsapp_native", // Channel adapters must not consume them locally.
ChatID: chatID, return false
SenderID: senderID,
Text: text,
MessageID: messageID,
Reply: func(text string) error {
return c.Send(ctx, bus.OutboundMessage{ChatID: chatID, Content: text})
},
})
if res.Err != nil {
logger.WarnCF("whatsapp", "Command execution failed", map[string]any{
"command": res.Command,
"error": res.Err.Error(),
})
}
return res.Matched
} }
func (c *WhatsAppNativeChannel) DispatchCommand(ctx context.Context, req commands.Request) commands.Result { func (c *WhatsAppNativeChannel) DispatchCommand(ctx context.Context, req commands.Request) commands.Result {