Adding a thinking message placeholder to the WhatsApp native channel.

This commit is contained in:
Aditya Kalro 2026-03-30 10:00:14 -07:00
parent e33515faa0
commit 77993e5f4f
7 changed files with 246 additions and 4 deletions

View file

@ -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)

View file

@ -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.
</details>

View file

@ -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.

View file

@ -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)
}
}

View file

@ -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

View file

@ -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"`
}

View file

@ -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,