diff --git a/internal/channels/whatsapp/commands.go b/internal/channels/whatsapp/commands.go new file mode 100644 index 00000000..be27d61e --- /dev/null +++ b/internal/channels/whatsapp/commands.go @@ -0,0 +1,96 @@ +package whatsapp + +import ( + "context" + "strings" + + "go.mau.fi/whatsmeow/types" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/tools" +) + +const menuHelpText = "Available commands:\n" + + "/reset — Reset conversation history\n" + + "/stop — Stop current running task\n" + + "/stopall — Stop all running tasks\n" + + "/menu — Show this help message\n" + + "\nJust send a message to chat with the AI." + +// handleCommand checks if the message is a known slash command and handles it. +// Returns true if the message was handled (caller should stop processing). +func (c *Channel) handleCommand(ctx context.Context, text, senderID, chatID, peerKind string, chatJID types.JID) bool { + if len(text) == 0 || text[0] != '/' { + return false + } + + cmd := strings.SplitN(text, " ", 2)[0] + cmd = strings.ToLower(cmd) + + switch cmd { + case "/reset": + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: "/reset", + PeerKind: peerKind, + AgentID: c.AgentID(), + UserID: stripSenderUserID(senderID), + TenantID: c.TenantID(), + Metadata: map[string]string{ + tools.MetaCommand: "reset", + }, + }) + c.sendText(chatJID, "Conversation history has been reset.") + return true + + case "/stop": + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: "/stop", + PeerKind: peerKind, + AgentID: c.AgentID(), + UserID: stripSenderUserID(senderID), + TenantID: c.TenantID(), + Metadata: map[string]string{ + tools.MetaCommand: "stop", + }, + }) + // Feedback is sent by the consumer after cancel result is known. + return true + + case "/stopall": + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: "/stopall", + PeerKind: peerKind, + AgentID: c.AgentID(), + UserID: stripSenderUserID(senderID), + TenantID: c.TenantID(), + Metadata: map[string]string{ + tools.MetaCommand: "stopall", + }, + }) + // Feedback is sent by the consumer after cancel result is known. + return true + + case "/menu": + c.sendText(chatJID, menuHelpText) + return true + } + + return false +} + +// stripSenderUserID extracts the user ID portion before the '|' separator. +func stripSenderUserID(senderID string) string { + if idx := strings.IndexByte(senderID, '|'); idx > 0 { + return senderID[:idx] + } + return senderID +} diff --git a/internal/channels/whatsapp/inbound.go b/internal/channels/whatsapp/inbound.go index d76e5696..cfd7b4e9 100644 --- a/internal/channels/whatsapp/inbound.go +++ b/internal/channels/whatsapp/inbound.go @@ -94,6 +94,11 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { content := extractTextContent(evt.Message) + // Command interception: check for slash commands before the normal pipeline. + if handled := c.handleCommand(ctx, content, senderID, chatID, peerKind, chatJID); handled { + return + } + var mediaList []media.MediaInfo mediaList = c.downloadMedia(evt) diff --git a/internal/channels/whatsapp/outbound.go b/internal/channels/whatsapp/outbound.go index 6112bc27..07426e5f 100644 --- a/internal/channels/whatsapp/outbound.go +++ b/internal/channels/whatsapp/outbound.go @@ -117,6 +117,21 @@ func (c *Channel) Send(ctx context.Context, msg bus.OutboundMessage) error { return nil } +// sendText sends a plain text message directly to a WhatsApp JID. +// Used for command feedback (e.g. /menu, /reset confirmation). +func (c *Channel) sendText(chatJID types.JID, text string) { + if c.client == nil || !c.client.IsConnected() { + slog.Warn("whatsapp: cannot send text, not connected") + return + } + waMsg := &waE2E.Message{ + Conversation: proto.String(text), + } + if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { + slog.Error("whatsapp: failed to send command reply", "error", err, "chat", chatJID.String()) + } +} + // buildMediaMessage uploads media to WhatsApp and returns the message proto. func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) { switch {