From 77993e5f4f47ac7f09429fb65e6014c4e27efe20 Mon Sep 17 00:00:00 2001 From: Aditya Kalro Date: Mon, 30 Mar 2026 10:00:14 -0700 Subject: [PATCH] Adding a thinking message placeholder to the WhatsApp native channel. --- Makefile | 2 - README.md | 6 +- pkg/channels/README.md | 2 +- .../whatsapp_native/whatsapp_command_test.go | 82 ++++++++++ .../whatsapp_native/whatsapp_native.go | 151 ++++++++++++++++++ pkg/config/config.go | 2 + pkg/config/defaults.go | 5 + 7 files changed, 246 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 521f6d774..0f9c4bc0a 100644 --- a/Makefile +++ b/Makefile @@ -137,8 +137,6 @@ build-whatsapp-native: generate GOOS=linux GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./$(CMD_DIR) GOOS=linux GOARCH=loong64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-loong64 ./$(CMD_DIR) GOOS=linux GOARCH=riscv64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-riscv64 ./$(CMD_DIR) - GOOS=linux GOARCH=mipsle GOMIPS=softfloat $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle ./$(CMD_DIR) - $(call PATCH_MIPS_FLAGS,$(BUILD_DIR)/$(BINARY_NAME)-linux-mipsle) GOOS=darwin GOARCH=arm64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./$(CMD_DIR) GOOS=windows GOARCH=amd64 $(GO) build -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./$(CMD_DIR) ## @$(GO) build $(GOFLAGS) -tags whatsapp_native -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) diff --git a/README.md b/README.md index d9785f200..3a35c590d 100644 --- a/README.md +++ b/README.md @@ -493,12 +493,16 @@ PicoClaw can connect to WhatsApp in two ways: "enabled": true, "use_native": true, "session_store_path": "", - "allow_from": [] + "allow_from": [], + "typing": { "enabled": true }, + "placeholder": { "enabled": true, "text": "Thinking... 💭" } } } } ``` +Native mode supports **typing** (composing presence via whatsmeow) and **placeholder** messages that are edited into the final reply, same idea as Telegram. Keys: `channels.whatsapp.typing` (`enabled`) and `channels.whatsapp.placeholder` (`enabled`, optional `text`; default text matches Telegram). + If `session_store_path` is empty, the session is stored in `<workspace>/whatsapp/`. Run `picoclaw gateway`; on first run, scan the QR code printed in the terminal with WhatsApp → Linked Devices. diff --git a/pkg/channels/README.md b/pkg/channels/README.md index b7c56660b..83debc859 100644 --- a/pkg/channels/README.md +++ b/pkg/channels/README.md @@ -1379,6 +1379,6 @@ agentLoop.Stop() // Stop Agent 6. **DingTalk uses Stream mode**: DingTalk uses the SDK's Stream/WebSocket mode (not HTTP webhook), so it does not implement `WebhookHandler`. -7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in 6 channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico) can actually use placeholder message editing. The rest are reserved fields. +7. **PlaceholderConfig vs implementation**: `PlaceholderConfig` appears in multiple channel configs (Telegram, Discord, Slack, LINE, OneBot, Pico, WhatsApp native), but only channels that implement both `PlaceholderCapable` + `MessageEditor` (Telegram, Discord, Pico, **WhatsApp native** with build tag `whatsapp_native`) can actually use placeholder message editing. The rest are reserved fields. WhatsApp native also implements `TypingCapable` (`channels.whatsapp.typing`). 8. **ReasoningChannelID**: Most channel configs include a `reasoning_channel_id` field to route LLM reasoning/thinking output to a designated channel (WhatsApp, Telegram, Feishu, Discord, MaixCam, QQ, DingTalk, Slack, LINE, OneBot, WeCom, WeComApp). Note: `PicoConfig` does not currently expose this field. `BaseChannel` exposes this via the `WithReasoningChannelID` option and `ReasoningChannelID()` method. \ No newline at end of file diff --git a/pkg/channels/whatsapp_native/whatsapp_command_test.go b/pkg/channels/whatsapp_native/whatsapp_command_test.go index 3ff195827..c40887f65 100644 --- a/pkg/channels/whatsapp_native/whatsapp_command_test.go +++ b/pkg/channels/whatsapp_native/whatsapp_command_test.go @@ -5,6 +5,7 @@ package whatsapp import ( "context" "database/sql" + "errors" "testing" "time" @@ -187,3 +188,84 @@ func TestHandleIncoming_ImageOnlyNoCaptionDownloadFails_NoInbound(t *testing.T) // expected: no message } } + +func TestDefaultPlaceholderMessageText(t *testing.T) { + if got := defaultPlaceholderMessageText(config.PlaceholderConfig{}); got != defaultPlaceholderText { + t.Fatalf("empty config: got %q want %q", got, defaultPlaceholderText) + } + if got := defaultPlaceholderMessageText(config.PlaceholderConfig{Text: "Custom"}); got != "Custom" { + t.Fatalf("custom text: got %q", got) + } +} + +func TestSendPlaceholder_DisabledReturnsEmptyID(t *testing.T) { + messageBus := bus.NewMessageBus() + cfg := config.WhatsAppConfig{ + Placeholder: config.PlaceholderConfig{Enabled: false}, + } + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", cfg, messageBus, nil), + config: cfg, + runCtx: context.Background(), + } + ch.SetRunning(true) + + id, err := ch.SendPlaceholder(context.Background(), "1001@s.whatsapp.net") + if err != nil { + t.Fatalf("SendPlaceholder: %v", err) + } + if id != "" { + t.Fatalf("want empty id, got %q", id) + } +} + +func TestSendPlaceholder_EnabledNotRunning_ReturnsErrNotRunning(t *testing.T) { + messageBus := bus.NewMessageBus() + cfg := config.WhatsAppConfig{ + Placeholder: config.PlaceholderConfig{Enabled: true}, + } + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", cfg, messageBus, nil), + config: cfg, + runCtx: context.Background(), + } + // IsRunning false by default + + _, err := ch.SendPlaceholder(context.Background(), "1001@s.whatsapp.net") + if !errors.Is(err, channels.ErrNotRunning) { + t.Fatalf("want ErrNotRunning, got %v", err) + } +} + +func TestSendPlaceholder_EnabledUnpairedClient_ReturnsTemporary(t *testing.T) { + messageBus := bus.NewMessageBus() + cfg := config.WhatsAppConfig{ + Placeholder: config.PlaceholderConfig{Enabled: true}, + } + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", cfg, messageBus, nil), + config: cfg, + runCtx: context.Background(), + client: whatsappTestClient(t), + } + ch.SetRunning(true) + + _, err := ch.SendPlaceholder(context.Background(), "1001@s.whatsapp.net") + if err == nil || !errors.Is(err, channels.ErrTemporary) { + t.Fatalf("want wrapped ErrTemporary, got %v", err) + } +} + +func TestEditMessage_NotRunning_ReturnsErrNotRunning(t *testing.T) { + messageBus := bus.NewMessageBus() + cfg := config.WhatsAppConfig{} + ch := &WhatsAppNativeChannel{ + BaseChannel: channels.NewBaseChannel("whatsapp_native", cfg, messageBus, nil), + config: cfg, + runCtx: context.Background(), + } + err := ch.EditMessage(context.Background(), "1001@s.whatsapp.net", "mid", "hi") + if !errors.Is(err, channels.ErrNotRunning) { + t.Fatalf("want ErrNotRunning, got %v", err) + } +} diff --git a/pkg/channels/whatsapp_native/whatsapp_native.go b/pkg/channels/whatsapp_native/whatsapp_native.go index fdbb9d9ae..8dbc80232 100644 --- a/pkg/channels/whatsapp_native/whatsapp_native.go +++ b/pkg/channels/whatsapp_native/whatsapp_native.go @@ -45,6 +45,9 @@ const ( reconnectInitial = 5 * time.Second reconnectMax = 5 * time.Minute reconnectMultiplier = 2.0 + + maxTypingDuration = 5 * time.Minute + defaultPlaceholderText = "Thinking... 💭" ) // WhatsAppNativeChannel implements the WhatsApp channel using whatsmeow (in-process, no external bridge). @@ -525,6 +528,154 @@ func imageFilenameFromMime(mimetype string) string { } } +// defaultPlaceholderMessageText returns the placeholder body (config or Telegram-style default). +func defaultPlaceholderMessageText(cfg config.PlaceholderConfig) string { + if cfg.Text != "" { + return cfg.Text + } + return defaultPlaceholderText +} + +// StartTyping implements channels.TypingCapable. +// It sends composing presence immediately and refreshes every ~4s until stop() or maxTypingDuration. +// stop is idempotent and sends ChatPresencePaused once. +func (c *WhatsAppNativeChannel) StartTyping(ctx context.Context, chatID string) (func(), error) { + if !c.config.Typing.Enabled { + return func() {}, nil + } + to, err := parseJID(chatID) + if err != nil { + return func() {}, err + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + if client == nil || !c.IsRunning() || !client.IsConnected() || client.Store.ID == nil { + return func() {}, nil + } + + _ = client.SendChatPresence(ctx, to, types.ChatPresenceComposing, types.ChatPresenceMediaText) + + typingCtx, cancel := context.WithCancel(ctx) + maxCtx, maxCancel := context.WithTimeout(typingCtx, maxTypingDuration) + + var stopOnce sync.Once + stop := func() { + stopOnce.Do(func() { + cancel() + maxCancel() + c.mu.Lock() + cl := c.client + c.mu.Unlock() + if cl != nil && cl.IsConnected() && cl.Store.ID != nil { + _ = cl.SendChatPresence(context.Background(), to, types.ChatPresencePaused, types.ChatPresenceMediaText) + } + }) + } + + go func() { + defer maxCancel() + ticker := time.NewTicker(4 * time.Second) + defer ticker.Stop() + for { + select { + case <-maxCtx.Done(): + return + case <-typingCtx.Done(): + return + case <-ticker.C: + c.mu.Lock() + cl := c.client + c.mu.Unlock() + if cl == nil || !cl.IsConnected() || cl.Store.ID == nil { + return + } + _ = cl.SendChatPresence(typingCtx, to, types.ChatPresenceComposing, types.ChatPresenceMediaText) + } + } + }() + + return stop, nil +} + +// SendPlaceholder implements channels.PlaceholderCapable. +func (c *WhatsAppNativeChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { + if !c.config.Placeholder.Enabled { + return "", nil + } + text := defaultPlaceholderMessageText(c.config.Placeholder) + + if !c.IsRunning() { + return "", channels.ErrNotRunning + } + select { + case <-ctx.Done(): + return "", ctx.Err() + default: + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + + if client == nil || !client.IsConnected() { + return "", fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + } + if client.Store.ID == nil { + return "", fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + } + + to, err := parseJID(chatID) + if err != nil { + return "", fmt.Errorf("invalid chat id %q: %w", chatID, err) + } + + waMsg := &waE2E.Message{ + Conversation: proto.String(text), + } + resp, err := client.SendMessage(ctx, to, waMsg) + if err != nil { + return "", fmt.Errorf("whatsapp send placeholder: %w", channels.ErrTemporary) + } + return string(resp.ID), nil +} + +// EditMessage implements channels.MessageEditor. +func (c *WhatsAppNativeChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error { + if !c.IsRunning() { + return channels.ErrNotRunning + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + c.mu.Lock() + client := c.client + c.mu.Unlock() + + if client == nil || !client.IsConnected() { + return fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary) + } + if client.Store.ID == nil { + return fmt.Errorf("whatsapp not yet paired (QR login pending): %w", channels.ErrTemporary) + } + + to, err := parseJID(chatID) + if err != nil { + return fmt.Errorf("invalid chat id %q: %w", chatID, err) + } + + id := types.MessageID(messageID) + edit := client.BuildEdit(to, id, &waE2E.Message{Conversation: proto.String(content)}) + if _, err = client.SendMessage(ctx, to, edit); err != nil { + return fmt.Errorf("whatsapp edit message: %w", channels.ErrTemporary) + } + return nil +} + func (c *WhatsAppNativeChannel) Send(ctx context.Context, msg bus.OutboundMessage) error { if !c.IsRunning() { return channels.ErrNotRunning diff --git a/pkg/config/config.go b/pkg/config/config.go index 33a5db8ae..5368ea0db 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -323,6 +323,8 @@ type WhatsAppConfig struct { UseNative bool `json:"use_native" env:"PICOCLAW_CHANNELS_WHATSAPP_USE_NATIVE"` SessionStorePath string `json:"session_store_path" env:"PICOCLAW_CHANNELS_WHATSAPP_SESSION_STORE_PATH"` AllowFrom FlexibleStringSlice `json:"allow_from" env:"PICOCLAW_CHANNELS_WHATSAPP_ALLOW_FROM"` + Typing TypingConfig `json:"typing,omitempty"` + Placeholder PlaceholderConfig `json:"placeholder,omitempty"` ReasoningChannelID string `json:"reasoning_channel_id" env:"PICOCLAW_CHANNELS_WHATSAPP_REASONING_CHANNEL_ID"` } diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index d44c73577..865273242 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -52,6 +52,11 @@ func DefaultConfig() *Config { UseNative: false, SessionStorePath: "", AllowFrom: FlexibleStringSlice{}, + Typing: TypingConfig{Enabled: true}, + Placeholder: PlaceholderConfig{ + Enabled: true, + Text: "Thinking... 💭", + }, }, Telegram: TelegramConfig{ Enabled: false,