2026-02-20 15:25:44 +00:00
|
|
|
package whatsapp
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
|
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-22 14:25:07 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type WhatsAppChannel struct {
|
|
|
|
|
*channels.BaseChannel
|
|
|
|
|
conn *websocket.Conn
|
2026-04-11 16:57:26 +00:00
|
|
|
config *config.WhatsAppSettings
|
2026-02-20 15:25:44 +00:00
|
|
|
url string
|
2026-02-22 14:25:07 +00:00
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
2026-02-20 15:25:44 +00:00
|
|
|
mu sync.Mutex
|
|
|
|
|
connected bool
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
func NewWhatsAppChannel(
|
|
|
|
|
bc *config.Channel,
|
|
|
|
|
cfg *config.WhatsAppSettings,
|
|
|
|
|
bus *bus.MessageBus,
|
|
|
|
|
) (*WhatsAppChannel, error) {
|
2026-02-26 05:24:51 +00:00
|
|
|
base := channels.NewBaseChannel(
|
|
|
|
|
"whatsapp",
|
|
|
|
|
cfg,
|
|
|
|
|
bus,
|
2026-04-11 16:57:26 +00:00
|
|
|
bc.AllowFrom,
|
2026-02-26 05:24:51 +00:00
|
|
|
channels.WithMaxMessageLength(65536),
|
2026-04-11 16:57:26 +00:00
|
|
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
2026-02-26 05:24:51 +00:00
|
|
|
)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return &WhatsAppChannel{
|
|
|
|
|
BaseChannel: base,
|
|
|
|
|
config: cfg,
|
|
|
|
|
url: cfg.BridgeURL,
|
|
|
|
|
connected: false,
|
|
|
|
|
}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *WhatsAppChannel) Start(ctx context.Context) error {
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.InfoCF("whatsapp", "Starting WhatsApp channel", map[string]any{
|
|
|
|
|
"bridge_url": c.url,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
dialer := websocket.DefaultDialer
|
|
|
|
|
dialer.HandshakeTimeout = 10 * time.Second
|
|
|
|
|
|
2026-02-26 15:24:35 +00:00
|
|
|
conn, resp, err := dialer.Dial(c.url, nil)
|
|
|
|
|
if resp != nil {
|
2026-06-26 14:15:03 +00:00
|
|
|
_ = resp.Body.Close()
|
2026-02-26 15:24:35 +00:00
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
if err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
c.cancel()
|
2026-02-20 15:25:44 +00:00
|
|
|
return fmt.Errorf("failed to connect to WhatsApp bridge: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
c.conn = conn
|
|
|
|
|
c.connected = true
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
c.SetRunning(true)
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.InfoC("whatsapp", "WhatsApp channel connected")
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
go c.listen()
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *WhatsAppChannel) Stop(ctx context.Context) error {
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.InfoC("whatsapp", "Stopping WhatsApp channel...")
|
|
|
|
|
|
|
|
|
|
// Cancel context first to signal listen goroutine to exit
|
|
|
|
|
if c.cancel != nil {
|
|
|
|
|
c.cancel()
|
|
|
|
|
}
|
2026-02-20 15:25:44 +00:00
|
|
|
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if c.conn != nil {
|
|
|
|
|
if err := c.conn.Close(); err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.ErrorCF("whatsapp", "Error closing WhatsApp connection", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
c.conn = nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.connected = false
|
|
|
|
|
c.SetRunning(false)
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *WhatsAppChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-02-22 17:45:48 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-22 17:45:48 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Check ctx before acquiring lock
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, ctx.Err()
|
2026-02-22 17:45:48 +00:00
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
c.mu.Lock()
|
|
|
|
|
defer c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if c.conn == nil {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("whatsapp connection not established: %w", channels.ErrTemporary)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
payload := map[string]any{
|
2026-02-20 15:25:44 +00:00
|
|
|
"type": "message",
|
|
|
|
|
"to": msg.ChatID,
|
|
|
|
|
"content": msg.Content,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
data, err := json.Marshal(payload)
|
|
|
|
|
if err != nil {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("failed to marshal message: %w", err)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
2026-02-20 15:25:44 +00:00
|
|
|
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
_ = c.conn.SetWriteDeadline(time.Time{})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("whatsapp send: %w", channels.ErrTemporary)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
2026-02-22 14:25:07 +00:00
|
|
|
_ = c.conn.SetWriteDeadline(time.Time{})
|
2026-02-20 15:25:44 +00:00
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
func (c *WhatsAppChannel) listen() {
|
2026-02-20 15:25:44 +00:00
|
|
|
for {
|
|
|
|
|
select {
|
2026-02-22 14:25:07 +00:00
|
|
|
case <-c.ctx.Done():
|
2026-02-20 15:25:44 +00:00
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
c.mu.Lock()
|
|
|
|
|
conn := c.conn
|
|
|
|
|
c.mu.Unlock()
|
|
|
|
|
|
|
|
|
|
if conn == nil {
|
|
|
|
|
time.Sleep(1 * time.Second)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, message, err := conn.ReadMessage()
|
|
|
|
|
if err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.ErrorCF("whatsapp", "WhatsApp read error", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
2026-02-20 15:25:44 +00:00
|
|
|
time.Sleep(2 * time.Second)
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
var msg map[string]any
|
2026-02-20 15:25:44 +00:00
|
|
|
if err := json.Unmarshal(message, &msg); err != nil {
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.ErrorCF("whatsapp", "Failed to unmarshal WhatsApp message", map[string]any{
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
2026-02-20 15:25:44 +00:00
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msgType, ok := msg["type"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if msgType == "message" {
|
|
|
|
|
c.handleIncomingMessage(msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 08:35:56 +00:00
|
|
|
func (c *WhatsAppChannel) handleIncomingMessage(msg map[string]any) {
|
2026-02-20 15:25:44 +00:00
|
|
|
senderID, ok := msg["from"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chatID, ok := msg["chat"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
chatID = senderID
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
content, ok := msg["content"].(string)
|
|
|
|
|
if !ok {
|
|
|
|
|
content = ""
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var mediaPaths []string
|
2026-02-21 08:35:56 +00:00
|
|
|
if mediaData, ok := msg["media"].([]any); ok {
|
2026-02-20 15:25:44 +00:00
|
|
|
mediaPaths = make([]string, 0, len(mediaData))
|
|
|
|
|
for _, m := range mediaData {
|
|
|
|
|
if path, ok := m.(string); ok {
|
|
|
|
|
mediaPaths = append(mediaPaths, path)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata := make(map[string]string)
|
2026-02-22 13:57:12 +00:00
|
|
|
var messageID string
|
|
|
|
|
if mid, ok := msg["id"].(string); ok {
|
|
|
|
|
messageID = mid
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
if userName, ok := msg["from_name"].(string); ok {
|
|
|
|
|
metadata["user_name"] = userName
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
logger.InfoCF("whatsapp", "WhatsApp message received", map[string]any{
|
|
|
|
|
"sender": senderID,
|
|
|
|
|
"preview": utils.Truncate(content, 50),
|
|
|
|
|
})
|
2026-02-20 15:25:44 +00:00
|
|
|
|
2026-02-22 22:56:48 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "whatsapp",
|
|
|
|
|
PlatformID: senderID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("whatsapp", senderID),
|
|
|
|
|
}
|
|
|
|
|
if display, ok := metadata["user_name"]; ok {
|
|
|
|
|
sender.DisplayName = display
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: "whatsapp",
|
|
|
|
|
ChatID: chatID,
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: messageID,
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
|
|
|
|
if chatID == senderID {
|
|
|
|
|
inboundCtx.ChatType = "direct"
|
|
|
|
|
} else {
|
|
|
|
|
inboundCtx.ChatType = "group"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
c.HandleInboundContext(c.ctx, chatID, content, mediaPaths, inboundCtx, sender)
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|