Adding a thinking message placeholder to the WhatsApp native channel.
This commit is contained in:
parent
e33515faa0
commit
77993e5f4f
7 changed files with 246 additions and 4 deletions
2
Makefile
2
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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue