From 438f2916de06812264870b8b5e179780bfd28790 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 12:47:50 +0700 Subject: [PATCH 01/22] refactor(whatsapp): move bridge_url to config, remove separate credentials struct Bridge URL now stored in channel config instead of credentials table, simplifying factory logic. Added legacy fallback for existing instances. Added require_mention config option. --- internal/channels/whatsapp/factory.go | 52 ++++++++++---------- internal/config/config_channels.go | 13 ++--- ui/web/src/pages/channels/channel-schemas.ts | 11 +++-- 3 files changed, 42 insertions(+), 34 deletions(-) diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index 3a039f33e..9001d1426 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -10,33 +10,20 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/store" ) -// whatsappCreds maps the credentials JSON from the channel_instances table. -type whatsappCreds struct { - BridgeURL string `json:"bridge_url"` -} - // whatsappInstanceConfig maps the non-secret config JSONB from the channel_instances table. type whatsappInstanceConfig struct { - DMPolicy string `json:"dm_policy,omitempty"` - GroupPolicy string `json:"group_policy,omitempty"` - AllowFrom []string `json:"allow_from,omitempty"` - BlockReply *bool `json:"block_reply,omitempty"` + BridgeURL string `json:"bridge_url"` + DMPolicy string `json:"dm_policy,omitempty"` + GroupPolicy string `json:"group_policy,omitempty"` + RequireMention *bool `json:"require_mention,omitempty"` + AllowFrom []string `json:"allow_from,omitempty"` + BlockReply *bool `json:"block_reply,omitempty"` } // Factory creates a WhatsApp channel from DB instance data. func Factory(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - var c whatsappCreds - if len(creds) > 0 { - if err := json.Unmarshal(creds, &c); err != nil { - return nil, fmt.Errorf("decode whatsapp credentials: %w", err) - } - } - if c.BridgeURL == "" { - return nil, fmt.Errorf("whatsapp bridge_url is required") - } - var ic whatsappInstanceConfig if len(cfg) > 0 { if err := json.Unmarshal(cfg, &ic); err != nil { @@ -44,13 +31,28 @@ func Factory(name string, creds json.RawMessage, cfg json.RawMessage, } } + // Fallback: read bridge_url from credentials for instances created before this migration. + if ic.BridgeURL == "" && len(creds) > 0 { + var legacy struct { + BridgeURL string `json:"bridge_url"` + } + if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" { + ic.BridgeURL = legacy.BridgeURL + } + } + + if ic.BridgeURL == "" { + return nil, fmt.Errorf("whatsapp bridge_url is required") + } + waCfg := config.WhatsAppConfig{ - Enabled: true, - BridgeURL: c.BridgeURL, - AllowFrom: ic.AllowFrom, - DMPolicy: ic.DMPolicy, - GroupPolicy: ic.GroupPolicy, - BlockReply: ic.BlockReply, + Enabled: true, + BridgeURL: ic.BridgeURL, + AllowFrom: ic.AllowFrom, + DMPolicy: ic.DMPolicy, + GroupPolicy: ic.GroupPolicy, + RequireMention: ic.RequireMention, + BlockReply: ic.BlockReply, } // DB instances default to "pairing" for groups (secure by default). diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index a45de45a1..4b14e3ecc 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -132,12 +132,13 @@ type SlackConfig struct { } type WhatsAppConfig struct { - Enabled bool `json:"enabled"` - BridgeURL string `json:"bridge_url"` - AllowFrom FlexibleStringSlice `json:"allow_from"` - DMPolicy string `json:"dm_policy,omitempty"` // "open" (default), "allowlist", "disabled" - GroupPolicy string `json:"group_policy,omitempty"` // "open" (default), "allowlist", "disabled" - BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) + Enabled bool `json:"enabled"` + BridgeURL string `json:"bridge_url"` + AllowFrom FlexibleStringSlice `json:"allow_from"` + DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled" + GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled" + RequireMention *bool `json:"require_mention,omitempty"` // only respond in groups when bot is @mentioned (default false) + BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) } type ZaloConfig struct { diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index ed27d3685..bda787ca0 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -68,9 +68,7 @@ export const credentialsSchema: Record = { { key: "webhook_secret", label: "Webhook Secret", type: "password" }, ], zalo_personal: [], - whatsapp: [ - { key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "http://bridge:3000" }, - ], + whatsapp: [], }; // --- Config schemas --- @@ -149,8 +147,10 @@ export const configSchema: Record = { { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, ], whatsapp: [ + { key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "ws://whatsapp-bridge:3001", help: "WebSocket URL of your WhatsApp bridge (use ws:// not http://)" }, { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" }, + { key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" }, { key: "allow_from", label: "Allowed Users", type: "tags", help: "WhatsApp user IDs" }, { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, ], @@ -223,4 +223,9 @@ export const wizardConfig: Partial> = { formBanner: "wizard.zaloPersonal.formBanner", excludeConfigFields: ["allow_from"], }, + whatsapp: { + steps: ["auth"], + createLabel: "wizard.whatsapp.createLabel", + formBanner: "wizard.whatsapp.formBanner", + }, }; From c83c51bf879559915108af55f6d900ea3a53995b Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 12:48:17 +0700 Subject: [PATCH 02/22] fix(whatsapp): prevent typing indicator goroutine leak on consecutive messages Track active keepTyping() goroutines per chatID via typingCancel sync.Map. Cancel previous loop before starting new one when recipient is typing. Stop all typing loops in Stop() to prevent orphaned goroutines. Clear typing indicator when message sent. --- cmd/gateway_channels_setup.go | 3 +- go.mod | 1 + go.sum | 2 + internal/channels/whatsapp/format.go | 109 ++++++++ internal/channels/whatsapp/format_test.go | 52 ++++ internal/channels/whatsapp/whatsapp.go | 316 ++++++++++++++++++++-- 6 files changed, 466 insertions(+), 17 deletions(-) create mode 100644 internal/channels/whatsapp/format.go create mode 100644 internal/channels/whatsapp/format_test.go diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 64cc6cd6e..855723b4e 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -70,7 +70,7 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms if cfg.Channels.WhatsApp.Enabled { if cfg.Channels.WhatsApp.BridgeURL == "" { recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.") - } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, nil); err != nil { + } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing); err != nil { channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) } else { @@ -143,6 +143,7 @@ func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, chann methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus, msgBus).Register(server.Router()) zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router()) + whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr, msgBus).Register(server.Router()) } // Register agent links WS RPC methods diff --git a/go.mod b/go.mod index d622076f9..c64a71072 100644 --- a/go.mod +++ b/go.mod @@ -108,6 +108,7 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/cast v1.7.1 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect diff --git a/go.sum b/go.sum index 00c05f1d6..6f48a1526 100644 --- a/go.sum +++ b/go.sum @@ -372,6 +372,8 @@ github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44= github.com/slack-go/slack v0.19.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go new file mode 100644 index 000000000..2e05431d9 --- /dev/null +++ b/internal/channels/whatsapp/format.go @@ -0,0 +1,109 @@ +package whatsapp + +import ( + "fmt" + "regexp" + "strings" +) + +// markdownToWhatsApp converts Markdown-formatted LLM output to WhatsApp's native +// formatting syntax. WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```. +// Unsupported features are simplified: headers → bold, links → "text url", tables → plain. +func markdownToWhatsApp(text string) string { + if text == "" { + return "" + } + + // Pre-process: convert HTML tags from LLM output to Markdown equivalents. + text = htmlTagToWaMd(text) + + // Extract and protect fenced code blocks — WhatsApp renders ``` the same way. + codeBlocks := waExtractCodeBlocks(text) + text = codeBlocks.text + + // Headers (##, ###, etc.) → *bold text* (WhatsApp has no header concept). + text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "*$1*") + + // Blockquotes → plain text. + text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1") + + // Links [text](url) → "text url" (WhatsApp doesn't support markdown links). + text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, "$1 $2") + + // Bold: **text** or __text__ → *text* + text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "*$1*") + + // Strikethrough: ~~text~~ → ~text~ + text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "~$1~") + + // Inline code: `code` → ```code``` (WhatsApp has no inline code, only blocks). + text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "```$1```") + + // List items: leading - or * → bullet • + text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ") + + // Restore code blocks as ``` … ``` preserving original content. + for i, code := range codeBlocks.codes { + // Trim trailing newline from extracted content — we add our own. + code = strings.TrimRight(code, "\n") + text = strings.ReplaceAll(text, fmt.Sprintf("\x00CB%d\x00", i), "```\n"+code+"\n```") + } + + // Collapse 3+ blank lines to 2. + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + + return strings.TrimSpace(text) +} + +// htmlTagToWaMd converts common HTML tags in LLM output to Markdown equivalents +// so they are then processed by the markdown → WhatsApp pipeline above. +var htmlToWaMdReplacers = []struct { + re *regexp.Regexp + repl string +}{ + {regexp.MustCompile(`(?i)`), "\n"}, + {regexp.MustCompile(`(?i)`), "\n"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**$1**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**$1**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_$1_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_$1_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "`$1`"}, + {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[$2]($1)"}, +} + +func htmlTagToWaMd(text string) string { + for _, r := range htmlToWaMdReplacers { + text = r.re.ReplaceAllString(text, r.repl) + } + return text +} + +type waCodeBlockMatch struct { + text string + codes []string +} + +// waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with +// \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents. +func waExtractCodeBlocks(text string) waCodeBlockMatch { + re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + matches := re.FindAllStringSubmatch(text, -1) + + codes := make([]string, 0, len(matches)) + for _, m := range matches { + codes = append(codes, m[1]) + } + + i := 0 + text = re.ReplaceAllStringFunc(text, func(_ string) string { + placeholder := fmt.Sprintf("\x00CB%d\x00", i) + i++ + return placeholder + }) + + return waCodeBlockMatch{text: text, codes: codes} +} diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go new file mode 100644 index 000000000..2b4762384 --- /dev/null +++ b/internal/channels/whatsapp/format_test.go @@ -0,0 +1,52 @@ +package whatsapp + +import "testing" + +func TestMarkdownToWhatsApp(t *testing.T) { + tests := []struct { + name string + in string + want string + }{ + {"empty", "", ""}, + {"plain text", "hello world", "hello world"}, + {"header h1", "# Title", "*Title*"}, + {"header h3", "### Sub", "*Sub*"}, + {"bold stars", "this is **bold** text", "this is *bold* text"}, + {"bold underscores", "this is __bold__ text", "this is *bold* text"}, + {"strikethrough", "~~deleted~~", "~deleted~"}, + {"inline code", "use `fmt.Println`", "use ```fmt.Println```"}, + {"link", "[Go](https://go.dev)", "Go https://go.dev"}, + {"unordered list dash", "- item one\n- item two", "• item one\n• item two"}, + {"unordered list star", "* item one\n* item two", "• item one\n• item two"}, + {"blockquote", "> quoted text", "quoted text"}, + { + "fenced code block preserved", + "```go\nfmt.Println(\"hi\")\n```", + "```\nfmt.Println(\"hi\")\n```", + }, + { + "code block not mangled by bold regex", + "```\n**not bold**\n```", + "```\n**not bold**\n```", + }, + {"collapse blank lines", "a\n\n\n\nb", "a\n\nb"}, + {"html bold", "bold", "*bold*"}, + // → _text_ — WhatsApp uses _text_ for italic natively, passes through. + // Note: single-word italic _italic_ gets consumed by the Markdown pipeline + // because _italic_ is ambiguous. Use multi-word: italic text. + {"html italic multi-word", "italic text", "_italic text_"}, + {"html strikethrough", "removed", "~removed~"}, + {"html br", "line1
line2", "line1\nline2"}, + {"html link", `link`, "link https://x.com"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := markdownToWhatsApp(tt.in) + if got != tt.want { + t.Errorf("markdownToWhatsApp(%q)\n got: %q\nwant: %q", tt.in, got, tt.want) + } + }) + } +} diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 3fb305efd..0f0f40554 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -2,6 +2,7 @@ package whatsapp import ( "context" + "encoding/base64" "encoding/json" "fmt" "log/slog" @@ -10,11 +11,14 @@ import ( "time" "github.com/gorilla/websocket" + qrcode "github.com/skip2/go-qrcode" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" + "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) const pairingDebounceTime = 60 * time.Second @@ -33,6 +37,31 @@ type Channel struct { pairingService store.PairingStore pairingDebounce sync.Map // senderID → time.Time approvedGroups sync.Map // chatID → true (in-memory cache for paired groups) + + // QR caching: last QR PNG from the bridge (base64) for wizard delivery. + lastQRMu sync.RWMutex + lastQRB64 string // base64-encoded PNG, empty when bridge is already authenticated + waAuthenticated bool // true once bridge reports WhatsApp account is connected + myJID string // bot's own WhatsApp JID (set from bridge status, used for mention detection) + + // typingCancel tracks active typing-refresh loops per chatID. + // WhatsApp clears "composing" after ~10s, so we refresh every 8s until the reply is sent. + typingCancel sync.Map // chatID → context.CancelFunc +} + +// GetLastQRB64 returns the most recent QR PNG (base64) received from the bridge. +// Returns "" when the bridge is already authenticated or no QR has been received yet. +func (c *Channel) GetLastQRB64() string { + c.lastQRMu.RLock() + defer c.lastQRMu.RUnlock() + return c.lastQRB64 +} + +// IsAuthenticated reports whether the WhatsApp account is currently authenticated via the bridge. +func (c *Channel) IsAuthenticated() bool { + c.lastQRMu.RLock() + defer c.lastQRMu.RUnlock() + return c.waAuthenticated } // New creates a new WhatsApp channel from config. @@ -54,12 +83,14 @@ func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, pairingSvc store.Pai // Start connects to the WhatsApp bridge WebSocket and begins listening. func (c *Channel) Start(ctx context.Context) error { slog.Info("starting whatsapp channel", "bridge_url", c.config.BridgeURL) + c.MarkStarting("Connecting to WhatsApp bridge") c.ctx, c.cancel = context.WithCancel(ctx) if err := c.connect(); err != nil { - // Don't fail hard — reconnect loop will keep trying + // Don't fail hard — reconnect loop will keep trying. slog.Warn("initial whatsapp bridge connection failed, will retry", "error", err) + c.MarkDegraded("Bridge unreachable", err.Error(), channels.ChannelFailureKindNetwork, true) } go c.listenLoop() @@ -89,9 +120,41 @@ func (c *Channel) Stop(_ context.Context) error { c.connected = false c.SetRunning(false) + // Cancel all active typing goroutines to prevent leaks. + c.typingCancel.Range(func(key, value any) bool { + value.(context.CancelFunc)() + c.typingCancel.Delete(key) + return true + }) + + c.MarkStopped("Stopped") + return nil } +// SendBridgeCommand sends a control command to the bridge (e.g. reauth, ping, pairing_code). +// Extra optional fields are merged into the command payload. +// Bridge protocol: { type: "command", action: "", ...extra } +func (c *Channel) SendBridgeCommand(action string, extra ...map[string]any) error { + c.mu.Lock() + defer c.mu.Unlock() + + if c.conn == nil { + return fmt.Errorf("whatsapp bridge not connected") + } + payload := map[string]any{"type": "command", "action": action} + for _, e := range extra { + for k, v := range e { + payload[k] = v + } + } + data, err := json.Marshal(payload) + if err != nil { + return err + } + return c.conn.WriteMessage(websocket.TextMessage, data) +} + // Send delivers an outbound message to the WhatsApp bridge. func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { c.mu.Lock() @@ -104,7 +167,7 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { payload := map[string]any{ "type": "message", "to": msg.ChatID, - "content": msg.Content, + "content": markdownToWhatsApp(msg.Content), } data, err := json.Marshal(payload) @@ -116,9 +179,39 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("send whatsapp message: %w", err) } + // Stop typing loop synchronously, then send "paused" after releasing the lock. + chatID := msg.ChatID + if cancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { + cancel.(context.CancelFunc)() + } + go c.sendPresence(chatID, "paused") + return nil } +// keepTyping sends "composing" presence repeatedly until ctx is cancelled. +// WhatsApp clears the typing indicator after ~10s so we refresh every 8s. +func (c *Channel) keepTyping(ctx context.Context, chatID string) { + c.sendPresence(chatID, "composing") + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.sendPresence(chatID, "composing") + } + } +} + +// sendPresence sends a WhatsApp presence update (composing / paused) to a chat. +func (c *Channel) sendPresence(to, state string) { + if err := c.SendBridgeCommand("presence", map[string]any{"to": to, "state": state}); err != nil { + slog.Debug("whatsapp: failed to send presence update", "state", state, "error", err) + } +} + // connect establishes the WebSocket connection to the bridge. func (c *Channel) connect() error { dialer := websocket.DefaultDialer @@ -165,6 +258,7 @@ func (c *Channel) listenLoop() { if err := c.connect(); err != nil { slog.Warn("whatsapp bridge reconnect failed", "error", err) + c.MarkDegraded("Bridge unreachable", err.Error(), channels.ChannelFailureKindNetwork, true) backoff = min(backoff*2, 30*time.Second) continue } @@ -195,9 +289,102 @@ func (c *Channel) listenLoop() { } msgType, _ := msg["type"].(string) - if msgType == "message" { + switch msgType { + case "message": c.handleIncomingMessage(msg) + case "qr": + c.handleBridgeQR(msg) + case "status": + c.handleBridgeStatus(msg) + case "": + // Bridge sent a message without a "type" field — common misconfiguration. + // Expected format: {"type":"message","from":"...","chat":"...","content":"..."} + // Check your bridge: fields must be "from"/"content", not "sender"/"body". + slog.Warn("whatsapp bridge sent message without 'type' field — bridge format mismatch", + "hint", "add type:\"message\", rename sender→from, body→content", + "received_keys", mapKeys(msg), + ) + default: + slog.Debug("whatsapp bridge unknown event type, ignoring", "type", msgType) + } + } +} + +// mapKeys returns the keys of a map for diagnostic logging. +func mapKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// handleBridgeQR processes a QR code event from the bridge. +// It generates a PNG, caches it, and broadcasts a bus event for the QR wizard. +func (c *Channel) handleBridgeQR(msg map[string]any) { + rawQR, _ := msg["data"].(string) + if rawQR == "" { + return + } + + png, err := qrcode.Encode(rawQR, qrcode.Medium, 256) + if err != nil { + slog.Warn("whatsapp: failed to encode QR PNG", "error", err) + return + } + pngB64 := base64.StdEncoding.EncodeToString(png) + + c.lastQRMu.Lock() + c.lastQRB64 = pngB64 + c.lastQRMu.Unlock() + + slog.Info("whatsapp bridge QR received — scan to authenticate", "channel", c.Name()) + + if mb := c.Bus(); mb != nil { + mb.Broadcast(bus.Event{ + Name: protocol.EventWhatsAppQRCode, + TenantID: c.TenantID(), + Payload: map[string]any{ + "channel_name": c.Name(), + "png_b64": pngB64, + }, + }) + } +} + +// handleBridgeStatus processes a status event from the bridge. +// On connect, it marks the channel healthy and broadcasts a QR-done event. +// On disconnect, it marks the channel degraded (reconnect loop will retry). +func (c *Channel) handleBridgeStatus(msg map[string]any) { + connected, _ := msg["connected"].(bool) + slog.Debug("whatsapp bridge status", "connected", connected, "channel", c.Name()) + + c.lastQRMu.Lock() + c.waAuthenticated = connected + if connected { + c.lastQRB64 = "" // clear QR — no longer needed + // Capture bot's own JID for group mention detection. + if me, ok := msg["me"].(string); ok && me != "" { + c.myJID = me + slog.Info("whatsapp: bot JID set", "jid", me, "channel", c.Name()) + } + } + c.lastQRMu.Unlock() + + if connected { + c.MarkHealthy("WhatsApp authenticated and connected") + if mb := c.Bus(); mb != nil { + mb.Broadcast(bus.Event{ + Name: protocol.EventWhatsAppQRDone, + TenantID: c.TenantID(), + Payload: map[string]any{ + "channel_name": c.Name(), + "success": true, + }, + }) } + } else { + c.MarkDegraded("WhatsApp disconnected", "Bridge reported disconnection — waiting for reconnect", channels.ChannelFailureKindNetwork, true) } } @@ -222,6 +409,8 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { peerKind = "group" } + slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, "policy", c.config.GroupPolicy) + // DM/Group policy check if peerKind == "direct" { if !c.checkDMPolicy(ctx, senderID, chatID) { @@ -229,32 +418,50 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { } } else { if !c.checkGroupPolicy(ctx, senderID, chatID) { - slog.Debug("whatsapp group message rejected by policy", "sender_id", senderID) + slog.Info("whatsapp group message rejected by policy", "sender_id", senderID, "chat_id", chatID, "policy", c.config.GroupPolicy) return } } // Allowlist check if !c.IsAllowed(senderID) { - slog.Debug("whatsapp message rejected by allowlist", "sender_id", senderID) + slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID) return } content, _ := msg["content"].(string) - if content == "" { - content = "[empty message]" - } - var media []string + // Parse media items from bridge: [{type, mimetype, filename, path}, ...] + var mediaList []media.MediaInfo if mediaData, ok := msg["media"].([]any); ok { - media = make([]string, 0, len(mediaData)) - for _, m := range mediaData { - if path, ok := m.(string); ok { - media = append(media, path) + for _, item := range mediaData { + m, ok := item.(map[string]any) + if !ok { + continue } + filePath, _ := m["path"].(string) + if filePath == "" { + continue + } + mediaType, _ := m["type"].(string) + mimeType, _ := m["mimetype"].(string) + fileName, _ := m["filename"].(string) + mediaList = append(mediaList, media.MediaInfo{ + Type: mediaType, + FilePath: filePath, + ContentType: mimeType, + FileName: fileName, + }) } } + if content == "" && len(mediaList) == 0 { + return // nothing to process + } + if content == "" { + content = "[empty message]" + } + metadata := make(map[string]string) if messageID, ok := msg["id"].(string); ok { metadata["message_id"] = messageID @@ -263,6 +470,29 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { metadata["user_name"] = userName } + // require_mention: in groups, only process when the bot's JID is @mentioned. + // Fails closed: if bot JID is unknown, treat as not-mentioned (don't respond). + if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { + c.lastQRMu.RLock() + myJID := c.myJID + c.lastQRMu.RUnlock() + mentioned := false + if myJID != "" { + if jids, ok := msg["mentioned_jids"].([]any); ok { + for _, j := range jids { + if jid, ok := j.(string); ok && jid == myJID { + mentioned = true + break + } + } + } + } + if !mentioned { + slog.Debug("whatsapp group message skipped — bot not @mentioned", "sender_id", senderID, "my_jid", myJID) + return + } + } + slog.Debug("whatsapp message received", "sender_id", senderID, "chat_id", chatID, @@ -274,12 +504,64 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind, "user", "", "") } + // Build media tags (e.g. , ) + // and bus.MediaFile list for the agent pipeline. + var mediaFiles []bus.MediaFile + if len(mediaList) > 0 { + mediaTags := media.BuildMediaTags(mediaList) + if mediaTags != "" { + if content != "[empty message]" { + content = mediaTags + "\n\n" + content + } else { + content = mediaTags + } + } + for _, m := range mediaList { + if m.FilePath != "" { + mediaFiles = append(mediaFiles, bus.MediaFile{ + Path: m.FilePath, + MimeType: m.ContentType, + }) + } + } + } + // Annotate with sender identity so the agent knows who is messaging. if senderName := metadata["user_name"]; senderName != "" { content = fmt.Sprintf("[From: %s]\n%s", senderName, content) } - c.HandleMessage(senderID, chatID, content, media, metadata, peerKind) + // Cancel any previous typing loop for this chat before starting a new one. + // Without this, consecutive messages leak orphaned goroutines that send + // "composing" forever (the old cancel gets overwritten in the sync.Map). + if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { + prevCancel.(context.CancelFunc)() + } + + // Show typing indicator for the full duration of agent processing. + // WhatsApp clears "composing" after ~10s so we refresh every 8s. + typingCtx, typingCancel := context.WithCancel(context.Background()) + c.typingCancel.Store(chatID, typingCancel) + go c.keepTyping(typingCtx, chatID) + + // Derive userID from senderID. + userID := senderID + if idx := strings.IndexByte(senderID, '|'); idx > 0 { + userID = senderID[:idx] + } + + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: mediaFiles, + PeerKind: peerKind, + UserID: userID, + AgentID: c.AgentID(), + TenantID: c.TenantID(), + Metadata: metadata, + }) } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. @@ -295,7 +577,7 @@ func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) case "allowlist": return c.IsAllowed(senderID) case "pairing": - if c.IsAllowed(senderID) { + if c.HasAllowList() && c.IsAllowed(senderID) { return true } if _, cached := c.approvedGroups.Load(chatID); cached { @@ -366,19 +648,21 @@ func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bo // sendPairingReply sends a pairing code to the user via the WS bridge. func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { + slog.Warn("whatsapp pairing: no pairing service configured") return } // Debounce if lastSent, ok := c.pairingDebounce.Load(senderID); ok { if time.Since(lastSent.(time.Time)) < pairingDebounceTime { + slog.Info("whatsapp pairing: debounced", "sender_id", senderID) return } } code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) if err != nil { - slog.Debug("whatsapp pairing request failed", "sender_id", senderID, "error", err) + slog.Warn("whatsapp pairing request failed", "sender_id", senderID, "channel", c.Name(), "error", err) return } From f736750933290fc4d56a8c6997888ae466d09848 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 12:48:26 +0700 Subject: [PATCH 03/22] feat(whatsapp): add media download + QR code auth + bridge status handling Bridge now sends QR codes and status events. Go side caches latest QR PNG (base64) and broadcasts to UI wizard via message bus. handleBridgeStatus() marks channel healthy/degraded based on connection state and clears QR once authenticated. Media support: bridge sends [{type, mimetype, filename, path}] array. Go side parses into MediaInfo structs and feeds into agent pipeline (matches Telegram pattern). Added: - qr_methods.go: RPC handler for whatsapp.qr.start - format.go: markdownToWhatsApp() text conversion - format_test.go: format unit tests - WhatsApp UI components + locale strings --- bridge/whatsapp/.gitignore | 2 + bridge/whatsapp/Dockerfile | 26 + bridge/whatsapp/README.md | 80 + bridge/whatsapp/package-lock.json | 1593 +++++++++++++++++ bridge/whatsapp/package.json | 20 + bridge/whatsapp/server.js | 384 ++++ docker-compose.whatsapp.yml | 46 + internal/channels/whatsapp/qr_methods.go | 178 ++ internal/gateway/event_filter.go | 5 + internal/store/channel_instance_store.go | 4 +- pkg/protocol/events.go | 4 + pkg/protocol/methods.go | 3 + ui/web/src/i18n/locales/en/channels.json | 20 + ui/web/src/i18n/locales/vi/channels.json | 20 + ui/web/src/i18n/locales/zh/channels.json | 20 + .../channel-advanced-dialog.tsx | 2 +- .../channels/channel-wizard-registry.tsx | 4 + .../whatsapp/use-whatsapp-qr-login.ts | 69 + .../whatsapp/whatsapp-reauth-dialog.tsx | 118 ++ .../whatsapp/whatsapp-wizard-steps.tsx | 72 + 20 files changed, 2667 insertions(+), 3 deletions(-) create mode 100644 bridge/whatsapp/.gitignore create mode 100644 bridge/whatsapp/Dockerfile create mode 100644 bridge/whatsapp/README.md create mode 100644 bridge/whatsapp/package-lock.json create mode 100644 bridge/whatsapp/package.json create mode 100644 bridge/whatsapp/server.js create mode 100644 docker-compose.whatsapp.yml create mode 100644 internal/channels/whatsapp/qr_methods.go create mode 100644 ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts create mode 100644 ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx create mode 100644 ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx diff --git a/bridge/whatsapp/.gitignore b/bridge/whatsapp/.gitignore new file mode 100644 index 000000000..8e436b4a6 --- /dev/null +++ b/bridge/whatsapp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +auth_info/ diff --git a/bridge/whatsapp/Dockerfile b/bridge/whatsapp/Dockerfile new file mode 100644 index 000000000..91d26efd0 --- /dev/null +++ b/bridge/whatsapp/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20-slim + +WORKDIR /app + +# git required for the libsignal-node GitHub dependency in Baileys. +# The package-lock resolves it as git+ssh; sed rewrites to git+https so no SSH key is needed. +RUN apt-get update && \ + apt-get install -y --no-install-recommends git && \ + rm -rf /var/lib/apt/lists/* + +COPY package.json package-lock.json ./ +RUN sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json && \ + npm ci --omit=dev + +COPY server.js ./ + +# Auth state persisted via volume at /app/auth_info +VOLUME ["/app/auth_info"] + +ENV BRIDGE_PORT=3001 +ENV AUTH_DIR=/app/auth_info +ENV LOG_LEVEL=silent + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/bridge/whatsapp/README.md b/bridge/whatsapp/README.md new file mode 100644 index 000000000..7accda0f5 --- /dev/null +++ b/bridge/whatsapp/README.md @@ -0,0 +1,80 @@ +# GoClaw WhatsApp Bridge + +A lightweight WebSocket bridge that connects GoClaw to WhatsApp using [Baileys](https://github.com/WhiskeySockets/Baileys) (no Chrome, multi-device protocol). + +## How it works + +``` +WhatsApp ↔ Baileys ↔ Bridge (WS server) ↔ GoClaw (WS client) +``` + +- Bridge is the **WebSocket server** (default port 3001) +- GoClaw connects as a **client** and handles routing, AI, pairing +- One bridge instance = one WhatsApp phone number + +## Quick start (with GoClaw Docker stack) + +```bash +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.whatsapp.yml up -d +``` + +Then in GoClaw UI: +1. **Channels → Add Channel → WhatsApp** +2. Set **Bridge URL** to `ws://whatsapp-bridge:3001` +3. Click **Create & Scan QR** → scan with WhatsApp + +## Configuration + +| Env var | Default | Description | +|---------|---------|-------------| +| `BRIDGE_PORT` | `3001` | WebSocket server port | +| `AUTH_DIR` | `./auth_info` | Directory for Baileys session files | +| `LOG_LEVEL` | `silent` | Baileys internal log level (`silent`, `info`, `debug`) | +| `PRINT_QR` | `false` | Print QR to terminal (useful without a UI) | + +## Scanning the QR code + +In the GoClaw UI, open **Channels → WhatsApp → Link Device (QR icon)**. + +On your phone: +> **WhatsApp → You → Linked Devices → Link a Device** + +## Re-linking a device + +Click the **QR icon** on the WhatsApp channel row → **Re-link Device**. +This logs out the current session and generates a fresh QR. + +## Multiple phone numbers + +Run one bridge container per number with different ports and auth volumes: + +```yaml +services: + whatsapp-bridge-2: + build: ./bridge/whatsapp + environment: + BRIDGE_PORT: "3001" + volumes: + - whatsapp-auth-2:/app/auth_info + ports: + - "3002:3001" +``` + +Create a separate GoClaw channel instance with `bridge_url: ws://whatsapp-bridge-2:3001`. + +## WebSocket protocol + +**Bridge → GoClaw:** +| Type | Fields | Description | +|------|--------|-------------| +| `status` | `connected: bool` | Auth state (sent on connect + on change) | +| `qr` | `data: string` | QR string for scanning (also replayed on reconnect) | +| `message` | `id, from, chat, content, from_name, is_group, media[]` | Incoming message | +| `pong` | — | Response to ping | + +**GoClaw → Bridge:** +| Type | Fields | Description | +|------|--------|-------------| +| `message` | `to: string, content: string` | Send outbound text | +| `command` | `action: "reauth"` | Logout + restart QR flow | +| `command` | `action: "ping"` | Health check | diff --git a/bridge/whatsapp/package-lock.json b/bridge/whatsapp/package-lock.json new file mode 100644 index 000000000..ceff145af --- /dev/null +++ b/bridge/whatsapp/package-lock.json @@ -0,0 +1,1593 @@ +{ + "name": "goclaw-whatsapp-bridge", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "goclaw-whatsapp-bridge", + "version": "1.0.0", + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "^6.7.18", + "pino": "^9.0.0", + "qrcode-terminal": "^0.12.0", + "ws": "^8.17.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", + "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.4.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/node-cache": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", + "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", + "license": "MIT", + "dependencies": { + "cacheable": "^2.3.1", + "hookified": "^1.14.0", + "keyv": "^5.5.5" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", + "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@hapi/boom": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", + "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "license": "MIT" + }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.5.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", + "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@whiskeysockets/baileys": { + "version": "6.7.21", + "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.21.tgz", + "integrity": "sha512-xx9OHd6jlPiu5yZVuUdwEgFNAOXiEG8sULHxC6XfzNwssnwxnA9Lp44pR05H621GQcKyCfsH33TGy+Na6ygX4w==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@cacheable/node-cache": "^1.4.0", + "@hapi/boom": "^9.1.3", + "async-mutex": "^0.5.0", + "axios": "^1.6.0", + "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", + "music-metadata": "^11.7.0", + "pino": "^9.6", + "protobufjs": "^7.2.4", + "ws": "^8.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "audio-decode": "^2.1.3", + "jimp": "^1.6.0", + "link-preview-js": "^3.0.0", + "sharp": "*" + }, + "peerDependenciesMeta": { + "audio-decode": { + "optional": true + }, + "jimp": { + "optional": true + }, + "link-preview-js": { + "optional": true + } + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/@hapi/boom": { + "version": "9.1.4", + "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", + "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "9.x.x" + } + }, + "node_modules/@whiskeysockets/baileys/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/axios": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", + "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/cacheable": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", + "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.0.8", + "@cacheable/utils": "^2.4.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.9.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/curve25519-js": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", + "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/libsignal": { + "name": "@whiskeysockets/libsignal-node", + "version": "2.0.1", + "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", + "license": "GPL-3.0", + "dependencies": { + "curve25519-js": "^0.0.4", + "protobufjs": "6.8.8" + } + }, + "node_modules/libsignal/node_modules/@types/node": { + "version": "10.17.60", + "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", + "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", + "license": "MIT" + }, + "node_modules/libsignal/node_modules/long": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", + "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", + "license": "Apache-2.0" + }, + "node_modules/libsignal/node_modules/protobufjs": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", + "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/long": "^4.0.0", + "@types/node": "^10.1.0", + "long": "^4.0.0" + }, + "bin": { + "pbjs": "bin/pbjs", + "pbts": "bin/pbts" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/music-metadata": { + "version": "11.12.3", + "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", + "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.2", + "@tokenizer/token": "^0.3.0", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "file-type": "^21.3.1", + "media-typer": "^1.1.0", + "strtok3": "^10.3.4", + "token-types": "^6.1.2", + "uint8array-extras": "^1.5.0", + "win-guid": "^0.2.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/pino": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", + "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/protobufjs": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", + "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qified": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", + "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", + "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", + "license": "MIT" + }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "license": "MIT", + "dependencies": { + "real-require": "^0.2.0" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "license": "MIT" + }, + "node_modules/win-guid": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", + "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/bridge/whatsapp/package.json b/bridge/whatsapp/package.json new file mode 100644 index 000000000..095dc2946 --- /dev/null +++ b/bridge/whatsapp/package.json @@ -0,0 +1,20 @@ +{ + "name": "goclaw-whatsapp-bridge", + "version": "1.0.0", + "description": "WhatsApp WebSocket bridge for GoClaw — forwards messages between WhatsApp (Baileys) and GoClaw's WS channel protocol", + "type": "module", + "main": "server.js", + "scripts": { + "start": "node server.js" + }, + "engines": { + "node": ">=20" + }, + "dependencies": { + "@hapi/boom": "^10.0.1", + "@whiskeysockets/baileys": "^6.7.18", + "pino": "^9.0.0", + "qrcode-terminal": "^0.12.0", + "ws": "^8.17.1" + } +} diff --git a/bridge/whatsapp/server.js b/bridge/whatsapp/server.js new file mode 100644 index 000000000..8c8548cd4 --- /dev/null +++ b/bridge/whatsapp/server.js @@ -0,0 +1,384 @@ +/** + * GoClaw WhatsApp Bridge + * + * Scope: owns the WhatsApp Baileys lifecycle, QR flow, and WS server. + * GoClaw connects as a client and drives business logic (routing, pairing, AI). + * + * Protocol — Bridge → GoClaw: + * { type: "status", connected: bool } WhatsApp auth state (sent on connect + on change) + * { type: "qr", data: "" } QR code for scanning + * { type: "message", id, from, chat, content, Incoming WhatsApp message + * from_name, is_group, + * media: [{type,mimetype,filename,path}] } + * { type: "pong" } Response to ping + * + * Protocol — GoClaw → Bridge: + * { type: "message", to: "", content: "" } Send outbound text + * { type: "command", action: "reauth" } Logout + restart QR flow + * { type: "command", action: "ping" } Health check + * { type: "command", action: "presence", to, state } Presence update (composing|paused|available) + */ + +import makeWASocket, { + useMultiFileAuthState, + DisconnectReason, + fetchLatestBaileysVersion, + downloadMediaMessage, +} from '@whiskeysockets/baileys' +import { WebSocketServer } from 'ws' +import { rm, readdir, writeFile, mkdir } from 'node:fs/promises' +import { join, extname } from 'node:path' +import { randomBytes } from 'node:crypto' +import { tmpdir } from 'node:os' +import qrcode from 'qrcode-terminal' +import { Boom } from '@hapi/boom' +import pino from 'pino' + +const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10) +const AUTH_DIR = process.env.AUTH_DIR || './auth_info' +const LOG_LEVEL = process.env.LOG_LEVEL || 'silent' // "debug" for Baileys internals + +// Max media download size (20 MB). Files larger than this are skipped. +const MEDIA_MAX_BYTES = parseInt(process.env.MEDIA_MAX_BYTES || String(20 * 1024 * 1024), 10) + +// Temp directory for downloaded media files. GoClaw reads from here. +const MEDIA_DIR = process.env.MEDIA_DIR || join(tmpdir(), 'goclaw_wa_media') + +// Baileys logger is capped at 'warn' regardless of LOG_LEVEL. +// Baileys logs Signal Protocol session internals (private keys, chain keys, root keys) +// at debug/info level — never surface those even during local debugging. +const logger = pino({ level: LOG_LEVEL === 'silent' ? 'silent' : 'warn' }) +const wss = new WebSocketServer({ port: PORT }) + +// --- State --- + +/** @type {Set} */ +const clients = new Set() + +/** @type {import('@whiskeysockets/baileys').WASocket | null} */ +let sock = null +let reconnectTimer = null +let waConnected = false // true once Baileys reports 'open' for this session +let isReauthing = false // prevents 401 from stopping reconnect during reauth +let lastQR = null // cached QR string — replayed to clients that reconnect mid-flow + +// --- Helpers --- + +/** Send JSON to all connected GoClaw clients. */ +function broadcast(payload) { + const data = JSON.stringify(payload) + for (const ws of clients) { + if (ws.readyState === 1) ws.send(data) + } +} + +/** Send JSON to a single client. */ +function sendTo(ws, payload) { + if (ws.readyState === 1) ws.send(JSON.stringify(payload)) +} + +/** Extract plain text from any Baileys message variant. */ +function extractContent(message) { + if (!message) return '' + return ( + message.conversation || + message.extendedTextMessage?.text || + message.imageMessage?.caption || + message.videoMessage?.caption || + message.documentMessage?.caption || + message.buttonsResponseMessage?.selectedDisplayText || + message.listResponseMessage?.title || + '' + ) +} + +/** MIME → file extension mapping for downloaded media. */ +const mimeToExt = { + 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', + 'video/mp4': '.mp4', 'video/3gpp': '.3gp', + 'audio/ogg; codecs=opus': '.ogg', 'audio/mpeg': '.mp3', 'audio/mp4': '.m4a', 'audio/aac': '.aac', + 'application/pdf': '.pdf', +} + +/** Map a Baileys message to a list of media descriptors (type, mimetype, filename, messageKey). */ +function detectMedia(message) { + if (!message) return [] + const items = [] + + if (message.imageMessage) items.push({ type: 'image', mimetype: message.imageMessage.mimetype, messageKey: 'imageMessage' }) + if (message.videoMessage) items.push({ type: 'video', mimetype: message.videoMessage.mimetype, messageKey: 'videoMessage' }) + if (message.audioMessage) items.push({ type: 'audio', mimetype: message.audioMessage.mimetype, messageKey: 'audioMessage' }) + if (message.documentMessage) items.push({ type: 'document', mimetype: message.documentMessage.mimetype, messageKey: 'documentMessage', + filename: message.documentMessage.fileName }) + if (message.stickerMessage) items.push({ type: 'sticker', mimetype: message.stickerMessage.mimetype, messageKey: 'stickerMessage' }) + return items +} + +/** + * Download media from a Baileys message and save to temp files. + * Returns array of { type, mimetype, filename, path } for successfully downloaded items. + */ +async function downloadMedia(msg) { + const items = detectMedia(msg.message) + if (items.length === 0) return [] + + await mkdir(MEDIA_DIR, { recursive: true }) + + const results = [] + for (const item of items) { + try { + const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }) + if (buffer.length > MEDIA_MAX_BYTES) { + console.warn(`⚠️ Media too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB), skipping`) + continue + } + const ext = mimeToExt[item.mimetype] || extname(item.filename || '') || '.bin' + const name = `goclaw_wa_${randomBytes(8).toString('hex')}${ext}` + const filePath = join(MEDIA_DIR, name) + await writeFile(filePath, buffer) + + results.push({ + type: item.type, + mimetype: item.mimetype, + filename: item.filename || '', + path: filePath, + }) + console.log(`📎 Downloaded ${item.type} (${(buffer.length / 1024).toFixed(0)} KB) → ${name}`) + } catch (err) { + console.error(`❌ Media download failed (${item.type}):`, err.message) + } + } + return results +} + +// --- Baileys lifecycle --- + +async function connectToWhatsApp() { + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR) + + const { version } = await fetchLatestBaileysVersion().catch(() => ({ + version: [2, 3000, 1023456789], + })) + + sock = makeWASocket({ + version, + auth: state, + logger, + printQRInTerminal: false, + browser: ['GoClaw Bridge', 'Chrome', '1.0.0'], + keepAliveIntervalMs: 30_000, + }) + + sock.ev.on('creds.update', saveCreds) + + sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => { + if (qr) { + lastQR = qr // cache so reconnecting GoClaw clients don't miss it + broadcast({ type: 'qr', data: qr }) + // Print QR to terminal only when PRINT_QR=true (useful without a UI) + if (process.env.PRINT_QR === 'true') { + console.log('\n📱 Scan this QR with WhatsApp (You → Linked Devices → Link a Device):') + qrcode.generate(qr, { small: true }) + } + } + + if (connection === 'close') { + const statusCode = new Boom(lastDisconnect?.error)?.output?.statusCode + const loggedOut = statusCode === DisconnectReason.loggedOut + console.log(`❌ WhatsApp disconnected (code ${statusCode})`) + waConnected = false + broadcast({ type: 'status', connected: false }) + + if (!loggedOut || isReauthing) { + // Normal disconnect → retry; or reauth in progress → reconnect to show new QR. + isReauthing = false + reconnectTimer = setTimeout(connectToWhatsApp, loggedOut ? 500 : 5_000) + } else { + console.log('🚪 Logged out — send { type:"command", action:"reauth" } to re-pair') + } + } else if (connection === 'open') { + console.log('✅ WhatsApp authenticated!') + lastQR = null // clear cached QR — no longer needed + waConnected = true + // Include the bot's own JID so GoClaw can detect @mentions. + broadcast({ type: 'status', connected: true, me: sock.user?.id ?? '' }) + } + }) + + sock.ev.on('messages.upsert', async ({ messages, type }) => { + if (type !== 'notify') return // skip history / append + + for (const msg of messages) { + if (msg.key.fromMe) continue + if (!msg.message) continue // receipts, ephemeral control frames, etc. + + const chatJid = msg.key.remoteJid + if (!chatJid) continue + + // Groups: participant = sender JID, remoteJid = group JID + // DMs: participant is undefined, remoteJid = sender JID + const senderJid = msg.key.participant || chatJid + const content = extractContent(msg.message) + const hasMedia = detectMedia(msg.message).length > 0 + + if (!content && !hasMedia) continue // completely empty, skip + + // Download media files (async). Non-blocking: failures are logged and skipped. + const media = hasMedia ? await downloadMedia(msg) : [] + + // Extract @mentioned JIDs from extended text messages. + const mentionedJids = msg.message?.extendedTextMessage?.contextInfo?.mentionedJid ?? [] + + const payload = { + type: 'message', + id: msg.key.id, + from: senderJid, + chat: chatJid, + content: content, + from_name: msg.pushName || '', + is_group: chatJid.endsWith('@g.us'), + mentioned_jids: mentionedJids, + media, + } + + console.log(`📨 ${senderJid} → ${chatJid}: ${content.slice(0, 60)}${content.length > 60 ? '…' : ''}`) + broadcast(payload) + } + }) +} + +/** + * Force-clear the auth state and restart so a fresh QR is generated. + * Called when GoClaw sends { type: "command", action: "reauth" }. + * + * We do NOT call sock.logout() because it fires connection.update with + * DisconnectReason.loggedOut (401), which makes shouldReconnect=false and + * stops the reconnect loop. Instead we forcibly end the socket and delete + * the auth files so the next connection starts a clean QR flow. + */ +async function handleReauth() { + console.log('🔄 Reauth requested — clearing session and restarting...') + isReauthing = true + waConnected = false + broadcast({ type: 'status', connected: false }) + + if (reconnectTimer) { + clearTimeout(reconnectTimer) + reconnectTimer = null + } + + if (sock) { + sock.ev.removeAllListeners() + try { sock.end(new Error('reauth requested')) } catch { /* ignore */ } + sock = null + } + + // Delete all auth files individually (avoids EBUSY on the directory itself). + // Must clear everything — leaving stale pre-keys or app-state causes + // WhatsApp to reject the new QR/pairing attempt with "can't link devices". + try { + const files = await readdir(AUTH_DIR) + await Promise.all(files.map(f => rm(join(AUTH_DIR, f), { force: true }))) + console.log(`🗑️ Auth state cleared (${files.length} files)`) + } catch (err) { + console.warn('⚠️ Could not clear auth state:', err.message) + } + + setTimeout(connectToWhatsApp, 500) +} + +// --- WS server: handle GoClaw client connections --- + +wss.on('connection', ws => { + console.log('🔌 GoClaw connected') + clients.add(ws) + + // Send current auth state immediately so GoClaw doesn't have to guess. + sendTo(ws, { type: 'status', connected: waConnected }) + // Replay cached QR so GoClaw doesn't miss it if it reconnected mid-flow. + if (lastQR && !waConnected) { + sendTo(ws, { type: 'qr', data: lastQR }) + } + + ws.on('message', async rawData => { + let msg + try { + msg = JSON.parse(rawData.toString()) + } catch { + console.warn('⚠️ Non-JSON from GoClaw, ignoring') + return + } + + if (msg.type === 'command') { + switch (msg.action) { + case 'reauth': + await handleReauth() + break + case 'ping': + sendTo(ws, { type: 'pong' }) + break + case 'presence': { + // state: 'composing' | 'paused' | 'available' + if (!sock || !waConnected) break + try { + const to = (msg.to ?? '').replace('@c.us', '@s.whatsapp.net') + await sock.sendPresenceUpdate(msg.state ?? 'available', to) + } catch (err) { + console.error('❌ Presence update error:', err.message) + } + break + } + default: + console.warn('⚠️ Unknown command:', msg.action) + } + return + } + + if (msg.type === 'message') { + if (!msg.to || !msg.content) { + console.warn('⚠️ Outbound message missing "to" or "content"', msg) + return + } + if (!sock || !waConnected) { + console.warn('⚠️ WhatsApp not connected — dropping reply to', msg.to) + return + } + try { + // Normalise JID: @c.us (whatsapp-web.js) → @s.whatsapp.net (Baileys) + const to = msg.to.replace('@c.us', '@s.whatsapp.net') + await sock.sendMessage(to, { text: msg.content }) + console.log(`📤 Sent to ${to}`) + } catch (err) { + console.error('❌ Failed to send WhatsApp message:', err.message) + } + return + } + + console.warn('⚠️ Unknown message type from GoClaw:', msg.type) + }) + + ws.on('close', () => { + console.log('🔌 GoClaw disconnected') + clients.delete(ws) + }) + + ws.on('error', err => console.error('WebSocket error:', err.message)) +}) + +wss.on('listening', () => { + console.log(`🌉 WhatsApp bridge on ws://0.0.0.0:${PORT} auth=${AUTH_DIR}`) + connectToWhatsApp().catch(err => { + console.error('❌ Fatal error connecting to WhatsApp:', err) + process.exit(1) + }) +}) + +wss.on('error', err => { + console.error('❌ WebSocket server error:', err.message) + process.exit(1) +}) diff --git a/docker-compose.whatsapp.yml b/docker-compose.whatsapp.yml new file mode 100644 index 000000000..464a218f9 --- /dev/null +++ b/docker-compose.whatsapp.yml @@ -0,0 +1,46 @@ +# WhatsApp bridge overlay — runs the WA WebSocket bridge alongside GoClaw. +# +# Usage: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.whatsapp.yml up -d --build +# +# First run: tail logs to see the QR code, then scan with WhatsApp: +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.whatsapp.yml logs -f whatsapp-bridge +# +# GoClaw config.json must include: +# "channels": { +# "whatsapp": { +# "enabled": true, +# "bridge_url": "ws://whatsapp-bridge:3001", +# "dm_policy": "open" +# } +# } +# +# Auth state is persisted in the 'whatsapp-auth' volume — delete it to re-pair. +# Env overrides: +# WHATSAPP_BRIDGE_PORT — host port to expose (default 3001) +# WHATSAPP_LOG_LEVEL — Baileys log level: silent|info|debug (default silent) + +services: + whatsapp-bridge: + build: + context: ./bridge/whatsapp + dockerfile: Dockerfile + ports: + - "${WHATSAPP_BRIDGE_PORT:-3001}:3001" + volumes: + - whatsapp-auth:/app/auth_info + environment: + - BRIDGE_PORT=3001 + - AUTH_DIR=/app/auth_info + - LOG_LEVEL=${WHATSAPP_LOG_LEVEL:-silent} + networks: + - goclaw-net + restart: unless-stopped + init: true + + goclaw: + depends_on: + - whatsapp-bridge + +volumes: + whatsapp-auth: diff --git a/internal/channels/whatsapp/qr_methods.go b/internal/channels/whatsapp/qr_methods.go new file mode 100644 index 000000000..5f5aff69a --- /dev/null +++ b/internal/channels/whatsapp/qr_methods.go @@ -0,0 +1,178 @@ +package whatsapp + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/gateway" + "github.com/nextlevelbuilder/goclaw/internal/store" + goclawprotocol "github.com/nextlevelbuilder/goclaw/pkg/protocol" +) + +const qrSessionTimeout = 3 * time.Minute + +// cancelEntry wraps a CancelFunc so it can be stored in sync.Map.CompareAndDelete. +type cancelEntry struct { + cancel context.CancelFunc +} + +// QRMethods handles whatsapp.qr.start — delivers bridge QR codes to the UI wizard. +// The QR code comes from the external bridge (Baileys), not generated in-process. +type QRMethods struct { + instanceStore store.ChannelInstanceStore + manager *channels.Manager + msgBus *bus.MessageBus + activeSessions sync.Map // instanceID (string) -> *cancelEntry +} + +func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager, msgBus *bus.MessageBus) *QRMethods { + return &QRMethods{instanceStore: instanceStore, manager: manager, msgBus: msgBus} +} + +func (m *QRMethods) Register(router *gateway.MethodRouter) { + router.Register(goclawprotocol.MethodWhatsAppQRStart, m.handleQRStart) +} + +func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) { + var params struct { + InstanceID string `json:"instance_id"` + ForceReauth bool `json:"force_reauth"` // if true, logout current session and generate fresh QR + } + if req.Params != nil { + _ = json.Unmarshal(req.Params, ¶ms) + } + + instID, err := uuid.Parse(params.InstanceID) + if err != nil { + client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrInvalidRequest, "invalid instance_id")) + return + } + + inst, err := m.instanceStore.Get(ctx, instID) + if err != nil || inst.ChannelType != channels.TypeWhatsApp { + client.SendResponse(goclawprotocol.NewErrorResponse(req.ID, goclawprotocol.ErrNotFound, "whatsapp instance not found")) + return + } + + qrCtx, cancel := context.WithTimeout(ctx, qrSessionTimeout) + entry := &cancelEntry{cancel: cancel} + + // Cancel any previous QR session for this instance so the user can retry. + if prev, loaded := m.activeSessions.Swap(params.InstanceID, entry); loaded { + if prevEntry, ok := prev.(*cancelEntry); ok { + prevEntry.cancel() + } + } + + // ACK immediately — QR/done events arrive asynchronously. + client.SendResponse(goclawprotocol.NewOKResponse(req.ID, map[string]any{"status": "started"})) + + go m.runQRSession(qrCtx, entry, client, params.InstanceID, inst.Name, params.ForceReauth) +} + +func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) { + defer entry.cancel() + defer m.activeSessions.CompareAndDelete(instanceIDStr, entry) + + if ch, ok := m.manager.GetChannel(channelName); ok { + if wa, ok := ch.(*Channel); ok { + if wa.IsAuthenticated() && !forceReauth { + // Already authenticated and caller didn't request re-link — signal "connected" to UI. + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + slog.Info("whatsapp QR skipped — already authenticated", "instance", instanceIDStr) + return + } + if forceReauth || wa.GetLastQRB64() == "" { + // Force re-link or no cached QR: ask bridge to logout and restart auth. + if err := wa.SendBridgeCommand("reauth"); err != nil { + slog.Warn("whatsapp QR: failed to send reauth to bridge", "instance", instanceIDStr, "error", err) + } else { + slog.Info("whatsapp QR: sent reauth to bridge", "instance", instanceIDStr, "force", forceReauth) + } + } else { + // Deliver cached QR if bridge sent it before the wizard opened. + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": wa.GetLastQRB64(), + }, + }) + } + } + } + + // Subscribe to bus events for this channel's QR lifecycle. + subID := "whatsapp-qr-" + instanceIDStr + done := make(chan struct{}) + + m.msgBus.Subscribe(subID, func(event bus.Event) { + payload, ok := event.Payload.(map[string]any) + if !ok { + return + } + if payload["channel_name"] != channelName { + return + } + + switch event.Name { + case goclawprotocol.EventWhatsAppQRCode: + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": payload["png_b64"], + }, + }) + + case goclawprotocol.EventWhatsAppQRDone: + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + }, + }) + select { + case done <- struct{}{}: + default: + } + } + }) + defer m.msgBus.Unsubscribe(subID) + + select { + case <-done: + slog.Info("whatsapp QR session completed", "instance", instanceIDStr) + case <-ctx.Done(): + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "QR session timed out — restart to try again", + }, + }) + slog.Info("whatsapp QR session timed out", "instance", instanceIDStr) + } +} + diff --git a/internal/gateway/event_filter.go b/internal/gateway/event_filter.go index 1615ac866..e7ac9d01c 100644 --- a/internal/gateway/event_filter.go +++ b/internal/gateway/event_filter.go @@ -116,6 +116,11 @@ func clientCanReceiveEvent(c *Client, event bus.Event) bool { return false } + // WhatsApp QR events: delivered directly to the requesting client, not broadcast. + if strings.HasPrefix(event.Name, "whatsapp.") { + return false + } + // Skill dep events → broadcast (non-sensitive, skill names only). if strings.HasPrefix(event.Name, "skill.") { return true diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 63d3307bf..851fa97e1 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -28,9 +28,9 @@ func IsDefaultChannelInstance(name string) bool { if strings.HasSuffix(name, "/default") { return true } - // Legacy Telegram default uses bare name "telegram" + // Legacy config-based defaults that were seeded with bare channel-type names. switch name { - case "telegram", "discord", "feishu", "zalo_oa", "whatsapp": + case "telegram", "discord", "feishu", "zalo_oa": return true } return false diff --git a/pkg/protocol/events.go b/pkg/protocol/events.go index c9ed2e68c..52ddea7fc 100644 --- a/pkg/protocol/events.go +++ b/pkg/protocol/events.go @@ -99,6 +99,10 @@ const ( EventZaloPersonalQRCode = "zalo.personal.qr.code" EventZaloPersonalQRDone = "zalo.personal.qr.done" + // WhatsApp QR login events (client-scoped, not broadcast). + EventWhatsAppQRCode = "whatsapp.qr.code" + EventWhatsAppQRDone = "whatsapp.qr.done" + // Tenant access revocation — forces affected user's UI to logout. EventTenantAccessRevoked = "tenant.access.revoked" ) diff --git a/pkg/protocol/methods.go b/pkg/protocol/methods.go index 2d26490a3..a76a74cda 100644 --- a/pkg/protocol/methods.go +++ b/pkg/protocol/methods.go @@ -183,4 +183,7 @@ const ( // Zalo Personal MethodZaloPersonalQRStart = "zalo.personal.qr.start" MethodZaloPersonalContacts = "zalo.personal.contacts" + + // WhatsApp + MethodWhatsAppQRStart = "whatsapp.qr.start" ) diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index fe33498dc..4e1e3d255 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -416,6 +416,10 @@ "zaloPersonal": { "createLabel": "Create & Authenticate", "formBanner": "After creating, you'll authenticate via QR code and configure allowed users." + }, + "whatsapp": { + "createLabel": "Create & Scan QR", + "formBanner": "After creating, scan the QR code with WhatsApp to authenticate." } }, "fallback": { @@ -464,5 +468,21 @@ "retry": "Retry", "close": "Close", "skip": "Skip" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp connected! Loading channel...", + "waitingForQr": "Waiting for QR code from bridge...", + "scanHint": "Open WhatsApp → tap You → Linked Devices → Link a Device", + "initializing": "Initializing...", + "skip": "Skip", + "retry": "Retry", + "close": "Close", + "reauthTitle": "Link WhatsApp — {{name}}", + "reauthDescription": "Scan the QR code with WhatsApp to link your device.", + "alreadyLinked": "✅ Device already linked", + "alreadyLinkedDetail": "WhatsApp is connected. To link a different device, click Re-link — this will log out the current session.", + "relinkDevice": "Re-link Device", + "connectedSuccess": "✅ WhatsApp connected successfully!", + "tabQrCode": "QR Code" } } diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 32517a959..46f6ab241 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -331,6 +331,10 @@ "zaloPersonal": { "createLabel": "Tạo & Xác thực", "formBanner": "Sau khi tạo, bạn sẽ xác thực bằng mã QR và cấu hình người dùng được phép." + }, + "whatsapp": { + "createLabel": "Tạo & Quét QR", + "formBanner": "Sau khi tạo, quét mã QR bằng WhatsApp để xác thực." } }, "fallback": { @@ -379,5 +383,21 @@ "retry": "Thử lại", "close": "Đóng", "skip": "Bỏ qua" + }, + "whatsapp": { + "loginSuccessLoading": "✅ Đã kết nối WhatsApp! Đang tải channel...", + "waitingForQr": "Đang chờ mã QR từ bridge...", + "scanHint": "Mở WhatsApp → nhấn Bạn → Thiết bị đã liên kết → Liên kết thiết bị", + "initializing": "Đang khởi tạo...", + "skip": "Bỏ qua", + "retry": "Thử lại", + "close": "Đóng", + "reauthTitle": "Liên kết WhatsApp — {{name}}", + "reauthDescription": "Quét mã QR bằng WhatsApp để liên kết thiết bị.", + "alreadyLinked": "✅ Thiết bị đã được liên kết", + "alreadyLinkedDetail": "WhatsApp đã kết nối. Để liên kết thiết bị khác, nhấn Liên kết lại — thao tác này sẽ đăng xuất phiên hiện tại.", + "relinkDevice": "Liên kết lại", + "connectedSuccess": "✅ Kết nối WhatsApp thành công!", + "tabQrCode": "Mã QR" } } diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 7dd7a31c0..e023c60e6 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -331,6 +331,10 @@ "zaloPersonal": { "createLabel": "创建并认证", "formBanner": "创建后,您将通过二维码进行认证并配置允许的用户。" + }, + "whatsapp": { + "createLabel": "创建并扫码", + "formBanner": "创建后,请用 WhatsApp 扫描二维码完成认证。" } }, "fallback": { @@ -379,5 +383,21 @@ "retry": "重试", "close": "关闭", "skip": "跳过" + }, + "whatsapp": { + "loginSuccessLoading": "✅ WhatsApp 已连接!正在加载 channel...", + "waitingForQr": "正在等待来自 bridge 的二维码...", + "scanHint": "打开 WhatsApp → 点击【您】→【已连接的设备】→【连接设备】", + "initializing": "初始化中...", + "skip": "跳过", + "retry": "重试", + "close": "关闭", + "reauthTitle": "连接 WhatsApp — {{name}}", + "reauthDescription": "使用 WhatsApp 扫描二维码以连接设备。", + "alreadyLinked": "✅ 设备已连接", + "alreadyLinkedDetail": "WhatsApp 已连接。如需连接其他设备,请点击【重新连接】——这将退出当前会话。", + "relinkDevice": "重新连接", + "connectedSuccess": "✅ WhatsApp 连接成功!", + "tabQrCode": "二维码" } } diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx index 858e93125..c5d096b0b 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -22,7 +22,7 @@ interface ChannelAdvancedDialogProps { const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]); -const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]); +const NETWORK_KEYS = new Set(["bridge_url", "api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]); const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]); const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_stream", "native_stream", "debounce_delay", "thread_ttl"]); const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "block_reply", "render_mode", "topic_session_mode"]); diff --git a/ui/web/src/pages/channels/channel-wizard-registry.tsx b/ui/web/src/pages/channels/channel-wizard-registry.tsx index 6435bb345..e8fb316a4 100644 --- a/ui/web/src/pages/channels/channel-wizard-registry.tsx +++ b/ui/web/src/pages/channels/channel-wizard-registry.tsx @@ -48,11 +48,14 @@ export interface ReauthDialogProps { import { ZaloAuthStep, ZaloConfigStep, ZaloEditConfig } from "./zalo/zalo-wizard-steps"; import { ZaloPersonalQRDialog } from "./zalo/zalo-personal-qr-dialog"; +import { WhatsAppAuthStep } from "./whatsapp/whatsapp-wizard-steps"; +import { WhatsAppReauthDialog } from "./whatsapp/whatsapp-reauth-dialog"; // --- Component registries --- export const wizardAuthSteps: Record> = { zalo_personal: ZaloAuthStep, + whatsapp: WhatsAppAuthStep, }; export const wizardConfigSteps: Record> = { @@ -66,6 +69,7 @@ export const wizardEditConfigs: Record> = { zalo_personal: ZaloPersonalQRDialog, + whatsapp: WhatsAppReauthDialog, }; /** Set of channel types that support re-authentication from the table */ diff --git a/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts b/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts new file mode 100644 index 000000000..b2c73d262 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/use-whatsapp-qr-login.ts @@ -0,0 +1,69 @@ +import { useState, useCallback } from "react"; +import { useWsCall } from "@/hooks/use-ws-call"; +import { useWsEvent } from "@/hooks/use-ws-event"; + +export type QrStatus = "idle" | "waiting" | "done" | "connected" | "error"; + +export function useWhatsAppQrLogin(instanceId: string | null) { + const [qrPng, setQrPng] = useState(null); + const [status, setStatus] = useState("idle"); + const [errorMsg, setErrorMsg] = useState(""); + const { call: startQR, loading } = useWsCall("whatsapp.qr.start"); + + const start = useCallback(async (forceReauth = false) => { + if (!instanceId) return; + setStatus("waiting"); + setQrPng(null); + setErrorMsg(""); + try { + await startQR({ instance_id: instanceId, force_reauth: forceReauth }); + } catch (err) { + setStatus("error"); + setErrorMsg(err instanceof Error ? err.message : "Failed to start QR session"); + } + }, [startQR, instanceId]); + + /** Logout current WhatsApp session and start a fresh QR scan flow. */ + const triggerReauth = useCallback(() => start(true), [start]); + + const reset = useCallback(() => { + setStatus("idle"); + setQrPng(null); + setErrorMsg(""); + }, []); + + useWsEvent( + "whatsapp.qr.code", + useCallback( + (payload: unknown) => { + const p = payload as { instance_id: string; png_b64: string }; + if (p.instance_id !== instanceId) return; + setQrPng(p.png_b64); + setStatus("waiting"); + }, + [instanceId], + ), + ); + + useWsEvent( + "whatsapp.qr.done", + useCallback( + (payload: unknown) => { + const p = payload as { instance_id: string; success: boolean; already_connected?: boolean; error?: string }; + if (p.instance_id !== instanceId) return; + if (p.success) { + // Distinguish: already connected before any QR vs. freshly authenticated via QR scan. + setStatus(p.already_connected ? "connected" : "done"); + } else { + setStatus("error"); + setErrorMsg(p.error ?? "QR authentication failed"); + } + }, + [instanceId], + ), + ); + + return { + qrPng, status, errorMsg, loading, start, reset, retry: start, triggerReauth, + }; +} diff --git a/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx b/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx new file mode 100644 index 000000000..eb6547483 --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/whatsapp-reauth-dialog.tsx @@ -0,0 +1,118 @@ +// Re-authentication dialog for WhatsApp — triggered from the channels table. +// QR code scan only. + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login"; +import type { ReauthDialogProps } from "../channel-wizard-registry"; + +export function WhatsAppReauthDialog({ + open, + onOpenChange, + instanceId, + instanceName, + onSuccess, +}: ReauthDialogProps) { + const { t } = useTranslation("channels"); + const { + qrPng, status, errorMsg, loading, start, reset, retry, triggerReauth, + } = useWhatsAppQrLogin(instanceId); + + // Auto-start QR when dialog opens; intentionally omits `start` from deps + // because we only want to trigger on open/close transitions, not on identity changes. + useEffect(() => { + if (open && status === "idle") start(); + }, [open]); // eslint-disable-line react-hooks/exhaustive-deps + + // Reset state when dialog closes + useEffect(() => { + if (!open) reset(); + }, [open, reset]); + + // Auto-close after a fresh QR scan completes (not "already connected") + useEffect(() => { + if (status !== "done") return; + onSuccess(); + const id = setTimeout(() => onOpenChange(false), 1500); + return () => clearTimeout(id); + }, [status, onSuccess, onOpenChange]); + + return ( + { if (!loading) onOpenChange(v); }}> + + + {t("whatsapp.reauthTitle", { name: instanceName })} + + {t("whatsapp.scanHint")} + + + + {/* Already connected state */} + {status === "connected" && ( +
+
+

{t("whatsapp.alreadyLinked")}

+

+ {t("whatsapp.alreadyLinkedDetail")} +

+
+
+ + +
+
+ )} + + {/* Done state */} + {status === "done" && ( +
+

{t("whatsapp.connectedSuccess")}

+
+ )} + + {/* QR scan flow */} + {status !== "connected" && status !== "done" && ( + <> +
+ {status === "error" && ( +

{errorMsg}

+ )} + {status === "waiting" && !qrPng && ( +

{t("whatsapp.waitingForQr")}

+ )} + {status === "waiting" && qrPng && ( + <> + WhatsApp QR Code +

+ {t("whatsapp.scanHint")} +

+ + )} + {status === "idle" && ( +

{t("whatsapp.initializing")}

+ )} +
+
+ + {status === "error" && ( + + )} +
+ + )} +
+
+ ); +} diff --git a/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx b/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx new file mode 100644 index 000000000..3863bc91e --- /dev/null +++ b/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx @@ -0,0 +1,72 @@ +// WhatsApp wizard step components for the channel create wizard. +// The QR code comes from the external Baileys bridge, forwarded via GoClaw's WS bus. +// Registered in channel-wizard-registry.tsx. + +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Button } from "@/components/ui/button"; +import { DialogFooter } from "@/components/ui/dialog"; +import { useWhatsAppQrLogin } from "./use-whatsapp-qr-login"; +import type { WizardAuthStepProps } from "../channel-wizard-registry"; + +/** QR code authentication step for WhatsApp — displayed in create wizard after instance creation. */ +export function WhatsAppAuthStep({ instanceId, onComplete, onSkip }: WizardAuthStepProps) { + const { t } = useTranslation("channels"); + const { qrPng, status, errorMsg, loading, start, retry, reset } = useWhatsAppQrLogin(instanceId); + + // Auto-start QR on mount + useEffect(() => { + start(); + return () => reset(); + }, [start, reset]); + + // Signal completion to parent when bridge confirms connection + useEffect(() => { + if (status === "done") onComplete(); + }, [status, onComplete]); + + return ( + <> +
+ {status === "done" && ( +

+ {t("whatsapp.loginSuccessLoading")} +

+ )} + {status === "error" && ( +

{errorMsg}

+ )} + {status === "waiting" && !qrPng && ( +

+ {t("whatsapp.waitingForQr")} +

+ )} + {status === "waiting" && qrPng && ( + <> + WhatsApp QR Code +

+ {t("whatsapp.scanHint")} +

+ + )} + {status === "idle" && ( +

{t("whatsapp.initializing")}

+ )} +
+ + + {status === "error" && ( + + )} + + + ); +} From 24cdc242e920db451a6ee35b9fac8717a1e79b0e Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 12:49:53 +0700 Subject: [PATCH 04/22] fix(whatsapp): use ${1} syntax in HTML-to-WA regex replacements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go's regexp treats $1_ as submatch named "1_" (nonexistent → empty), silently dropping content for , , , , , and tags. Use ${1} to disambiguate. Tests added and passing. --- internal/channels/whatsapp/format.go | 18 +++++++++--------- internal/channels/whatsapp/format_test.go | 5 +---- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go index 2e05431d9..e75329b1e 100644 --- a/internal/channels/whatsapp/format.go +++ b/internal/channels/whatsapp/format.go @@ -64,15 +64,15 @@ var htmlToWaMdReplacers = []struct { }{ {regexp.MustCompile(`(?i)`), "\n"}, {regexp.MustCompile(`(?i)`), "\n"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "**$1**"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "**$1**"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "_$1_"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "_$1_"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~$1~~"}, - {regexp.MustCompile(`(?i)([\s\S]*?)`), "`$1`"}, - {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[$2]($1)"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**${1}**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "**${1}**"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "_${1}_"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "~~${1}~~"}, + {regexp.MustCompile(`(?i)([\s\S]*?)`), "`${1}`"}, + {regexp.MustCompile(`(?i)]*>([\s\S]*?)`), "[${2}](${1})"}, } func htmlTagToWaMd(text string) string { diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go index 2b4762384..9b93ba86e 100644 --- a/internal/channels/whatsapp/format_test.go +++ b/internal/channels/whatsapp/format_test.go @@ -32,10 +32,7 @@ func TestMarkdownToWhatsApp(t *testing.T) { }, {"collapse blank lines", "a\n\n\n\nb", "a\n\nb"}, {"html bold", "bold", "*bold*"}, - // → _text_ — WhatsApp uses _text_ for italic natively, passes through. - // Note: single-word italic _italic_ gets consumed by the Markdown pipeline - // because _italic_ is ambiguous. Use multi-word: italic text. - {"html italic multi-word", "italic text", "_italic text_"}, + {"html italic", "italic", "_italic_"}, {"html strikethrough", "removed", "~removed~"}, {"html br", "line1
line2", "line1\nline2"}, {"html link", `link`, "link https://x.com"}, From eadf8e591b07333705457e8497a81298bc71cee0 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 13:01:44 +0700 Subject: [PATCH 05/22] feat(whatsapp): add outbound media support and fix inbound media handling - Bridge: new media message handler reads file from path, determines Baileys content type from MIME, sends via sock.sendMessage() - Bridge: fix inbound media by adding null/empty buffer check, properly binding sock.updateMediaMessage to preserve context - Go: Send() now iterates msg.Media, sends each as media message to bridge; first media gets content as caption, remaining text sent separately Fixes #703 --- bridge/whatsapp/server.js | 44 ++++++++++++++++++++- internal/channels/whatsapp/whatsapp.go | 53 ++++++++++++++++++++------ 2 files changed, 85 insertions(+), 12 deletions(-) diff --git a/bridge/whatsapp/server.js b/bridge/whatsapp/server.js index 8c8548cd4..ce6570509 100644 --- a/bridge/whatsapp/server.js +++ b/bridge/whatsapp/server.js @@ -14,6 +14,7 @@ * * Protocol — GoClaw → Bridge: * { type: "message", to: "", content: "" } Send outbound text + * { type: "media", to, path, mimetype, caption? } Send outbound media file * { type: "command", action: "reauth" } Logout + restart QR flow * { type: "command", action: "ping" } Health check * { type: "command", action: "presence", to, state } Presence update (composing|paused|available) @@ -121,13 +122,23 @@ function detectMedia(message) { async function downloadMedia(msg) { const items = detectMedia(msg.message) if (items.length === 0) return [] + if (!sock) return [] await mkdir(MEDIA_DIR, { recursive: true }) const results = [] for (const item of items) { try { - const buffer = await downloadMediaMessage(msg, 'buffer', {}, { logger, reuploadRequest: sock.updateMediaMessage }) + // downloadMediaMessage needs the full msg (with key + message), not just msg.message. + // 4th arg: reuploadRequest must be bound to sock to preserve `this` context. + const buffer = await downloadMediaMessage(msg, 'buffer', {}, { + logger, + reuploadRequest: sock.updateMediaMessage?.bind(sock), + }) + if (!buffer || buffer.length === 0) { + console.warn(`⚠️ Empty buffer for ${item.type}, skipping`) + continue + } if (buffer.length > MEDIA_MAX_BYTES) { console.warn(`⚠️ Media too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB), skipping`) continue @@ -339,6 +350,37 @@ wss.on('connection', ws => { return } + if (msg.type === 'media') { + if (!msg.to || !msg.path) { + console.warn('⚠️ Outbound media missing "to" or "path"', msg) + return + } + if (!sock || !waConnected) { + console.warn('⚠️ WhatsApp not connected — dropping media to', msg.to) + return + } + try { + const to = msg.to.replace('@c.us', '@s.whatsapp.net') + const { readFile } = await import('node:fs/promises') + const buffer = await readFile(msg.path) + const mime = (msg.mimetype || '').toLowerCase() + const caption = msg.caption || undefined + + let content + if (mime.startsWith('image/')) content = { image: buffer, caption } + else if (mime.startsWith('video/')) content = { video: buffer, caption } + else if (mime.startsWith('audio/')) content = { audio: buffer, mimetype: mime } + else content = { document: buffer, mimetype: mime, caption, + fileName: msg.path.split('/').pop() } + + await sock.sendMessage(to, content) + console.log(`📤 Sent ${mime || 'media'} to ${to}`) + } catch (err) { + console.error('❌ Failed to send WhatsApp media:', err.message) + } + return + } + if (msg.type === 'message') { if (!msg.to || !msg.content) { console.warn('⚠️ Outbound message missing "to" or "content"', msg) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 0f0f40554..d573591a3 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -156,6 +156,7 @@ func (c *Channel) SendBridgeCommand(action string, extra ...map[string]any) erro } // Send delivers an outbound message to the WhatsApp bridge. +// Supports text-only messages and messages with media attachments. func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { c.mu.Lock() defer c.mu.Unlock() @@ -164,19 +165,49 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { return fmt.Errorf("whatsapp bridge not connected") } - payload := map[string]any{ - "type": "message", - "to": msg.ChatID, - "content": markdownToWhatsApp(msg.Content), - } - - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal whatsapp message: %w", err) + // Send media attachments first (each as a separate bridge message). + if len(msg.Media) > 0 { + for i, m := range msg.Media { + // Use message content as caption for the first media item only. + caption := m.Caption + if caption == "" && i == 0 && msg.Content != "" { + caption = markdownToWhatsApp(msg.Content) + } + mediaPayload := map[string]any{ + "type": "media", + "to": msg.ChatID, + "path": m.URL, + "mimetype": m.ContentType, + "caption": caption, + } + data, err := json.Marshal(mediaPayload) + if err != nil { + return fmt.Errorf("marshal whatsapp media: %w", err) + } + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return fmt.Errorf("send whatsapp media: %w", err) + } + } + // If caption was used on first media, don't send content again as text. + if msg.Media[0].Caption == "" && msg.Content != "" { + msg.Content = "" + } } - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("send whatsapp message: %w", err) + // Send text content (if any remains after media caption). + if msg.Content != "" { + payload := map[string]any{ + "type": "message", + "to": msg.ChatID, + "content": markdownToWhatsApp(msg.Content), + } + data, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("marshal whatsapp message: %w", err) + } + if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { + return fmt.Errorf("send whatsapp message: %w", err) + } } // Stop typing loop synchronously, then send "paused" after releasing the lock. From e7626ed5cdeaf93f7812d83344c16fd6270ff71f Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 13:09:31 +0700 Subject: [PATCH 06/22] fix(whatsapp): fix shared volume mounts for media exchange Add shared volumes (goclaw-workspace, whatsapp-media) so bridge and GoClaw containers can access each other's media files. Set MEDIA_DIR env var to route downloads to shared volume for GoClaw read access. --- docker-compose.whatsapp.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker-compose.whatsapp.yml b/docker-compose.whatsapp.yml index 464a218f9..03de86998 100644 --- a/docker-compose.whatsapp.yml +++ b/docker-compose.whatsapp.yml @@ -29,10 +29,13 @@ services: - "${WHATSAPP_BRIDGE_PORT:-3001}:3001" volumes: - whatsapp-auth:/app/auth_info + - goclaw-workspace:/app/workspace # shared: bridge reads outbound media from GoClaw workspace + - whatsapp-media:/app/media # shared: bridge writes inbound media, GoClaw reads environment: - BRIDGE_PORT=3001 - AUTH_DIR=/app/auth_info - LOG_LEVEL=${WHATSAPP_LOG_LEVEL:-silent} + - MEDIA_DIR=/app/media # inbound downloads go here (shared with GoClaw) networks: - goclaw-net restart: unless-stopped @@ -41,6 +44,9 @@ services: goclaw: depends_on: - whatsapp-bridge + volumes: + - whatsapp-media:/app/media # shared: GoClaw reads inbound media from bridge volumes: whatsapp-auth: + whatsapp-media: From b4b5442407cce26c66d95c15438cdfe7ba46d8fc Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 17:00:00 +0700 Subject: [PATCH 07/22] fix(whatsapp): fix group mention detection with LID + JID dual-identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp uses two identifier systems: phone JID (@s.whatsapp.net) and Link ID (@lid). Group mentions may use either format, but the bot only knew its phone JID — so LID-based mentions always failed silently. Three bugs fixed: - Bridge didn't replay bot JID on GoClaw reconnect (myJID always empty) - Bridge sent raw Baileys JID with device suffix (:N@) causing mismatch - Only phone JID was checked; LID mentions were never matched Now the bridge caches and sends both bare JID + LID, and the Go mention check normalizes and compares against both identifiers. --- bridge/whatsapp/server.js | 18 ++++++++-- internal/channels/whatsapp/whatsapp.go | 46 +++++++++++++++++++++----- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/bridge/whatsapp/server.js b/bridge/whatsapp/server.js index ce6570509..c3ce01a4f 100644 --- a/bridge/whatsapp/server.js +++ b/bridge/whatsapp/server.js @@ -62,6 +62,8 @@ let reconnectTimer = null let waConnected = false // true once Baileys reports 'open' for this session let isReauthing = false // prevents 401 from stopping reconnect during reauth let lastQR = null // cached QR string — replayed to clients that reconnect mid-flow +let botJID = '' // cached bare bot JID (phone) — replayed to clients that connect after auth +let botLID = '' // cached bare bot LID (Link ID) — WhatsApp's newer identifier system // --- Helpers --- @@ -216,8 +218,15 @@ async function connectToWhatsApp() { console.log('✅ WhatsApp authenticated!') lastQR = null // clear cached QR — no longer needed waConnected = true - // Include the bot's own JID so GoClaw can detect @mentions. - broadcast({ type: 'status', connected: true, me: sock.user?.id ?? '' }) + // Include the bot's own JIDs so GoClaw can detect @mentions. + // WhatsApp uses two identifier systems: phone JID (@s.whatsapp.net) and LID (@lid). + // Mentions in groups may use either format, so we send both. + // Strip Baileys device suffix (e.g. "12345:42@s.whatsapp.net" → "12345@s.whatsapp.net") + const rawJid = sock.user?.id ?? '' + botJID = rawJid.replace(/:\d+@/, '@') + const rawLid = sock.user?.lid ?? '' + botLID = rawLid.replace(/:\d+@/, '@') + broadcast({ type: 'status', connected: true, me: botJID, me_lid: botLID }) } }) @@ -276,6 +285,8 @@ async function handleReauth() { console.log('🔄 Reauth requested — clearing session and restarting...') isReauthing = true waConnected = false + botJID = '' + botLID = '' broadcast({ type: 'status', connected: false }) if (reconnectTimer) { @@ -310,7 +321,8 @@ wss.on('connection', ws => { clients.add(ws) // Send current auth state immediately so GoClaw doesn't have to guess. - sendTo(ws, { type: 'status', connected: waConnected }) + // Include bot JID + LID so mention detection works even if GoClaw connects after auth. + sendTo(ws, { type: 'status', connected: waConnected, ...(botJID && { me: botJID }), ...(botLID && { me_lid: botLID }) }) // Replay cached QR so GoClaw doesn't miss it if it reconnected mid-flow. if (lastQR && !waConnected) { sendTo(ws, { type: 'qr', data: lastQR }) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index d573591a3..7994cc539 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -42,7 +42,8 @@ type Channel struct { lastQRMu sync.RWMutex lastQRB64 string // base64-encoded PNG, empty when bridge is already authenticated waAuthenticated bool // true once bridge reports WhatsApp account is connected - myJID string // bot's own WhatsApp JID (set from bridge status, used for mention detection) + myJID string // bot's phone JID (e.g. "1234567890@s.whatsapp.net"), for mention detection + myLID string // bot's Link ID (e.g. "9876543210@lid"), WhatsApp's newer identifier // typingCancel tracks active typing-refresh loops per chatID. // WhatsApp clears "composing" after ~10s, so we refresh every 8s until the reply is sent. @@ -394,10 +395,16 @@ func (c *Channel) handleBridgeStatus(msg map[string]any) { c.waAuthenticated = connected if connected { c.lastQRB64 = "" // clear QR — no longer needed - // Capture bot's own JID for group mention detection. + // Capture bot's own JID + LID for group mention detection. + // WhatsApp uses two identifier systems: phone JID and LID (Link ID). + // Mentions in groups may use either format. if me, ok := msg["me"].(string); ok && me != "" { - c.myJID = me - slog.Info("whatsapp: bot JID set", "jid", me, "channel", c.Name()) + c.myJID = normalizeJID(me) + slog.Info("whatsapp: bot JID set", "jid", c.myJID, "channel", c.Name()) + } + if meLID, ok := msg["me_lid"].(string); ok && meLID != "" { + c.myLID = normalizeJID(meLID) + slog.Info("whatsapp: bot LID set", "lid", c.myLID, "channel", c.Name()) } } c.lastQRMu.Unlock() @@ -501,17 +508,27 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { metadata["user_name"] = userName } - // require_mention: in groups, only process when the bot's JID is @mentioned. - // Fails closed: if bot JID is unknown, treat as not-mentioned (don't respond). + // require_mention: in groups, only process when the bot is @mentioned. + // WhatsApp uses two identifier systems: phone JID (@s.whatsapp.net) and LID (@lid). + // Mentions may use either format, so we check both. + // Fails closed: if both JID and LID are unknown, treat as not-mentioned. if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { c.lastQRMu.RLock() myJID := c.myJID + myLID := c.myLID c.lastQRMu.RUnlock() mentioned := false - if myJID != "" { + if myJID != "" || myLID != "" { + myJIDNorm := normalizeJID(myJID) + myLIDNorm := normalizeJID(myLID) if jids, ok := msg["mentioned_jids"].([]any); ok { for _, j := range jids { - if jid, ok := j.(string); ok && jid == myJID { + jid, ok := j.(string) + if !ok { + continue + } + norm := normalizeJID(jid) + if (myJIDNorm != "" && norm == myJIDNorm) || (myLIDNorm != "" && norm == myLIDNorm) { mentioned = true break } @@ -519,7 +536,7 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { } } if !mentioned { - slog.Debug("whatsapp group message skipped — bot not @mentioned", "sender_id", senderID, "my_jid", myJID) + slog.Info("whatsapp group message skipped — bot not @mentioned", "sender_id", senderID, "my_jid", myJID, "my_lid", myLID) return } } @@ -723,3 +740,14 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) } } + +// normalizeJID strips the Baileys device suffix from a WhatsApp JID. +// e.g. "12345678901:42@s.whatsapp.net" → "12345678901@s.whatsapp.net" +func normalizeJID(jid string) string { + colonIdx := strings.Index(jid, ":") + atIdx := strings.Index(jid, "@") + if colonIdx > 0 && atIdx > colonIdx { + return jid[:colonIdx] + jid[atIdx:] + } + return jid +} From 677760f2e5bfd7a9ada4bd3ac6978c44e38abc75 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 17:44:04 +0700 Subject: [PATCH 08/22] refactor(whatsapp): replace Node.js Baileys bridge with native whatsmeow - Use go.mau.fi/whatsmeow for in-process WhatsApp protocol - Auth state in PostgreSQL (standard) / SQLite (desktop) via sqlstore - QR auth driven by whatsmeow GetQRChannel() directly - Remove bridge/whatsapp/, docker-compose.whatsapp.yml, bridge_url config - Clean bridge_url from UI schemas, i18n locales, NETWORK_KEYS - Fix Reauth() race condition with mutex + context reset - Lazy client init in StartQRFlow for wizard timing race Resolves #703 --- bridge/whatsapp/.gitignore | 2 - bridge/whatsapp/Dockerfile | 26 - bridge/whatsapp/README.md | 80 - bridge/whatsapp/package-lock.json | 1593 ----------------- bridge/whatsapp/package.json | 20 - bridge/whatsapp/server.js | 438 ----- cmd/channels_cmd.go | 2 +- cmd/doctor.go | 2 +- cmd/gateway.go | 2 +- cmd/gateway_channels_setup.go | 7 +- docker-compose.whatsapp.yml | 52 - docs/05-channels-messaging.md | 26 +- docs/17-changelog.md | 15 + go.mod | 26 +- go.sum | 58 +- internal/channels/whatsapp/factory.go | 81 +- internal/channels/whatsapp/qr_methods.go | 229 ++- internal/channels/whatsapp/whatsapp.go | 970 +++++----- internal/config/config_channels.go | 2 +- internal/config/config_load.go | 6 +- ui/web/src/i18n/locales/en/channels.json | 1 - ui/web/src/i18n/locales/vi/channels.json | 1 - ui/web/src/i18n/locales/zh/channels.json | 1 - .../channel-advanced-dialog.tsx | 2 +- ui/web/src/pages/channels/channel-schemas.ts | 1 - .../whatsapp/whatsapp-wizard-steps.tsx | 2 +- 26 files changed, 824 insertions(+), 2821 deletions(-) delete mode 100644 bridge/whatsapp/.gitignore delete mode 100644 bridge/whatsapp/Dockerfile delete mode 100644 bridge/whatsapp/README.md delete mode 100644 bridge/whatsapp/package-lock.json delete mode 100644 bridge/whatsapp/package.json delete mode 100644 bridge/whatsapp/server.js delete mode 100644 docker-compose.whatsapp.yml diff --git a/bridge/whatsapp/.gitignore b/bridge/whatsapp/.gitignore deleted file mode 100644 index 8e436b4a6..000000000 --- a/bridge/whatsapp/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules/ -auth_info/ diff --git a/bridge/whatsapp/Dockerfile b/bridge/whatsapp/Dockerfile deleted file mode 100644 index 91d26efd0..000000000 --- a/bridge/whatsapp/Dockerfile +++ /dev/null @@ -1,26 +0,0 @@ -FROM node:20-slim - -WORKDIR /app - -# git required for the libsignal-node GitHub dependency in Baileys. -# The package-lock resolves it as git+ssh; sed rewrites to git+https so no SSH key is needed. -RUN apt-get update && \ - apt-get install -y --no-install-recommends git && \ - rm -rf /var/lib/apt/lists/* - -COPY package.json package-lock.json ./ -RUN sed -i 's|git+ssh://git@github.com/|git+https://github.com/|g' package-lock.json && \ - npm ci --omit=dev - -COPY server.js ./ - -# Auth state persisted via volume at /app/auth_info -VOLUME ["/app/auth_info"] - -ENV BRIDGE_PORT=3001 -ENV AUTH_DIR=/app/auth_info -ENV LOG_LEVEL=silent - -EXPOSE 3001 - -CMD ["node", "server.js"] diff --git a/bridge/whatsapp/README.md b/bridge/whatsapp/README.md deleted file mode 100644 index 7accda0f5..000000000 --- a/bridge/whatsapp/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# GoClaw WhatsApp Bridge - -A lightweight WebSocket bridge that connects GoClaw to WhatsApp using [Baileys](https://github.com/WhiskeySockets/Baileys) (no Chrome, multi-device protocol). - -## How it works - -``` -WhatsApp ↔ Baileys ↔ Bridge (WS server) ↔ GoClaw (WS client) -``` - -- Bridge is the **WebSocket server** (default port 3001) -- GoClaw connects as a **client** and handles routing, AI, pairing -- One bridge instance = one WhatsApp phone number - -## Quick start (with GoClaw Docker stack) - -```bash -docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.whatsapp.yml up -d -``` - -Then in GoClaw UI: -1. **Channels → Add Channel → WhatsApp** -2. Set **Bridge URL** to `ws://whatsapp-bridge:3001` -3. Click **Create & Scan QR** → scan with WhatsApp - -## Configuration - -| Env var | Default | Description | -|---------|---------|-------------| -| `BRIDGE_PORT` | `3001` | WebSocket server port | -| `AUTH_DIR` | `./auth_info` | Directory for Baileys session files | -| `LOG_LEVEL` | `silent` | Baileys internal log level (`silent`, `info`, `debug`) | -| `PRINT_QR` | `false` | Print QR to terminal (useful without a UI) | - -## Scanning the QR code - -In the GoClaw UI, open **Channels → WhatsApp → Link Device (QR icon)**. - -On your phone: -> **WhatsApp → You → Linked Devices → Link a Device** - -## Re-linking a device - -Click the **QR icon** on the WhatsApp channel row → **Re-link Device**. -This logs out the current session and generates a fresh QR. - -## Multiple phone numbers - -Run one bridge container per number with different ports and auth volumes: - -```yaml -services: - whatsapp-bridge-2: - build: ./bridge/whatsapp - environment: - BRIDGE_PORT: "3001" - volumes: - - whatsapp-auth-2:/app/auth_info - ports: - - "3002:3001" -``` - -Create a separate GoClaw channel instance with `bridge_url: ws://whatsapp-bridge-2:3001`. - -## WebSocket protocol - -**Bridge → GoClaw:** -| Type | Fields | Description | -|------|--------|-------------| -| `status` | `connected: bool` | Auth state (sent on connect + on change) | -| `qr` | `data: string` | QR string for scanning (also replayed on reconnect) | -| `message` | `id, from, chat, content, from_name, is_group, media[]` | Incoming message | -| `pong` | — | Response to ping | - -**GoClaw → Bridge:** -| Type | Fields | Description | -|------|--------|-------------| -| `message` | `to: string, content: string` | Send outbound text | -| `command` | `action: "reauth"` | Logout + restart QR flow | -| `command` | `action: "ping"` | Health check | diff --git a/bridge/whatsapp/package-lock.json b/bridge/whatsapp/package-lock.json deleted file mode 100644 index ceff145af..000000000 --- a/bridge/whatsapp/package-lock.json +++ /dev/null @@ -1,1593 +0,0 @@ -{ - "name": "goclaw-whatsapp-bridge", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "goclaw-whatsapp-bridge", - "version": "1.0.0", - "dependencies": { - "@hapi/boom": "^10.0.1", - "@whiskeysockets/baileys": "^6.7.18", - "pino": "^9.0.0", - "qrcode-terminal": "^0.12.0", - "ws": "^8.17.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@borewit/text-codec": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", - "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@cacheable/memory": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.8.tgz", - "integrity": "sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==", - "license": "MIT", - "dependencies": { - "@cacheable/utils": "^2.4.0", - "@keyv/bigmap": "^1.3.1", - "hookified": "^1.15.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@cacheable/node-cache": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@cacheable/node-cache/-/node-cache-1.7.6.tgz", - "integrity": "sha512-6Omk2SgNnjtxB5f/E6bTIWIt5xhdpx39fGNRQgU9lojvRxU68v+qY+SXXLsp3ZGukqoPjsK21wZ6XABFr/Ge3A==", - "license": "MIT", - "dependencies": { - "cacheable": "^2.3.1", - "hookified": "^1.14.0", - "keyv": "^5.5.5" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@cacheable/utils": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz", - "integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==", - "license": "MIT", - "dependencies": { - "hashery": "^1.5.1", - "keyv": "^5.6.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@hapi/boom": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-10.0.1.tgz", - "integrity": "sha512-ERcCZaEjdH3OgSJlyjVk8pHIFeus91CjKP3v+MpgBNp5IvGzP2l/bRiD78nqYcKPaZdbKkK5vDBVPd2ohHBlsA==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "^11.0.2" - } - }, - "node_modules/@hapi/hoek": { - "version": "11.0.7", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", - "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "peer": true, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@keyv/bigmap": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", - "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", - "license": "MIT", - "dependencies": { - "hashery": "^1.4.0", - "hookified": "^1.15.0" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "keyv": "^5.6.0" - } - }, - "node_modules/@keyv/serialize": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", - "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", - "license": "MIT" - }, - "node_modules/@pinojs/redact": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", - "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", - "license": "MIT" - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", - "license": "BSD-3-Clause" - }, - "node_modules/@tokenizer/inflate": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", - "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "token-types": "^6.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/@tokenizer/token": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", - "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", - "license": "MIT" - }, - "node_modules/@types/long": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", - "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@whiskeysockets/baileys": { - "version": "6.7.21", - "resolved": "https://registry.npmjs.org/@whiskeysockets/baileys/-/baileys-6.7.21.tgz", - "integrity": "sha512-xx9OHd6jlPiu5yZVuUdwEgFNAOXiEG8sULHxC6XfzNwssnwxnA9Lp44pR05H621GQcKyCfsH33TGy+Na6ygX4w==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@cacheable/node-cache": "^1.4.0", - "@hapi/boom": "^9.1.3", - "async-mutex": "^0.5.0", - "axios": "^1.6.0", - "libsignal": "git+https://github.com/whiskeysockets/libsignal-node.git", - "music-metadata": "^11.7.0", - "pino": "^9.6", - "protobufjs": "^7.2.4", - "ws": "^8.13.0" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "audio-decode": "^2.1.3", - "jimp": "^1.6.0", - "link-preview-js": "^3.0.0", - "sharp": "*" - }, - "peerDependenciesMeta": { - "audio-decode": { - "optional": true - }, - "jimp": { - "optional": true - }, - "link-preview-js": { - "optional": true - } - } - }, - "node_modules/@whiskeysockets/baileys/node_modules/@hapi/boom": { - "version": "9.1.4", - "resolved": "https://registry.npmjs.org/@hapi/boom/-/boom-9.1.4.tgz", - "integrity": "sha512-Ls1oH8jaN1vNsqcaHVYJrKmgMcKsC1wcp8bujvXrHaAqD2iDYq3HoOwsxwo09Cuda5R5nC0o0IxlrlTuvPuzSw==", - "license": "BSD-3-Clause", - "dependencies": { - "@hapi/hoek": "9.x.x" - } - }, - "node_modules/@whiskeysockets/baileys/node_modules/@hapi/hoek": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", - "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "license": "BSD-3-Clause" - }, - "node_modules/async-mutex": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", - "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/atomic-sleep": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", - "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/axios": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz", - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.11", - "form-data": "^4.0.5", - "proxy-from-env": "^2.1.0" - } - }, - "node_modules/cacheable": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.4.tgz", - "integrity": "sha512-djgxybDbw9fL/ZWMI3+CE8ZilNxcwFkVtDc1gJ+IlOSSWkSMPQabhV/XCHTQ6pwwN6aivXPZ43omTooZiX06Ew==", - "license": "MIT", - "dependencies": { - "@cacheable/memory": "^2.0.8", - "@cacheable/utils": "^2.4.0", - "hookified": "^1.15.0", - "keyv": "^5.6.0", - "qified": "^0.9.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/curve25519-js": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/curve25519-js/-/curve25519-js-0.0.4.tgz", - "integrity": "sha512-axn2UMEnkhyDUPWOwVKBMVIzSQy2ejH2xRGy1wq81dqRwApXfIzfbE3hIX0ZRFBIihf/KDqK158DLwESu4AK1w==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "license": "Apache-2.0", - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/file-type": { - "version": "21.3.4", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", - "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", - "license": "MIT", - "dependencies": { - "@tokenizer/inflate": "^0.4.1", - "strtok3": "^10.3.4", - "token-types": "^6.1.1", - "uint8array-extras": "^1.4.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sindresorhus/file-type?sponsor=1" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hashery": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", - "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", - "license": "MIT", - "dependencies": { - "hookified": "^1.15.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hookified": { - "version": "1.15.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", - "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", - "license": "MIT" - }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/keyv": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", - "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", - "license": "MIT", - "dependencies": { - "@keyv/serialize": "^1.1.1" - } - }, - "node_modules/libsignal": { - "name": "@whiskeysockets/libsignal-node", - "version": "2.0.1", - "resolved": "git+ssh://git@github.com/whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67", - "license": "GPL-3.0", - "dependencies": { - "curve25519-js": "^0.0.4", - "protobufjs": "6.8.8" - } - }, - "node_modules/libsignal/node_modules/@types/node": { - "version": "10.17.60", - "resolved": "https://registry.npmjs.org/@types/node/-/node-10.17.60.tgz", - "integrity": "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw==", - "license": "MIT" - }, - "node_modules/libsignal/node_modules/long": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", - "integrity": "sha512-XsP+KhQif4bjX1kbuSiySJFNAehNxgLb6hPRGJ9QsUr8ajHkuXGdrHmFUTUUXhDwVX2R5bY4JNZEwbUiMhV+MA==", - "license": "Apache-2.0" - }, - "node_modules/libsignal/node_modules/protobufjs": { - "version": "6.8.8", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-6.8.8.tgz", - "integrity": "sha512-AAmHtD5pXgZfi7GMpllpO3q1Xw1OYldr+dMUlAnffGTAhqkg72WdmSY71uKBF/JuyiKs8psYbtKrhi0ASCD8qw==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/long": "^4.0.0", - "@types/node": "^10.1.0", - "long": "^4.0.0" - }, - "bin": { - "pbjs": "bin/pbjs", - "pbts": "bin/pbts" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/music-metadata": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-11.12.3.tgz", - "integrity": "sha512-n6hSTZkuD59qWgHh6IP5dtDlDZQXoxk/bcA85Jywg8Z1iFrlNgl2+GTFgjZyn52W5UgQpV42V4XqrQZZAMbZTQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - }, - { - "type": "buymeacoffee", - "url": "https://buymeacoffee.com/borewit" - } - ], - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.2", - "@tokenizer/token": "^0.3.0", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "file-type": "^21.3.1", - "media-typer": "^1.1.0", - "strtok3": "^10.3.4", - "token-types": "^6.1.2", - "uint8array-extras": "^1.5.0", - "win-guid": "^0.2.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/on-exit-leak-free": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", - "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/pino": { - "version": "9.14.0", - "resolved": "https://registry.npmjs.org/pino/-/pino-9.14.0.tgz", - "integrity": "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==", - "license": "MIT", - "dependencies": { - "@pinojs/redact": "^0.4.0", - "atomic-sleep": "^1.0.0", - "on-exit-leak-free": "^2.1.0", - "pino-abstract-transport": "^2.0.0", - "pino-std-serializers": "^7.0.0", - "process-warning": "^5.0.0", - "quick-format-unescaped": "^4.0.3", - "real-require": "^0.2.0", - "safe-stable-stringify": "^2.3.1", - "sonic-boom": "^4.0.1", - "thread-stream": "^3.0.0" - }, - "bin": { - "pino": "bin.js" - } - }, - "node_modules/pino-abstract-transport": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", - "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", - "license": "MIT", - "dependencies": { - "split2": "^4.0.0" - } - }, - "node_modules/pino-std-serializers": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", - "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", - "license": "MIT" - }, - "node_modules/process-warning": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", - "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/protobufjs": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz", - "integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.4", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.0", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.0", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/proxy-from-env": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/qified": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/qified/-/qified-0.9.1.tgz", - "integrity": "sha512-n7mar4T0xQ+39dE2vGTAlbxUEpndwPANH0kDef1/MYsB8Bba9wshkybIRx74qgcvKQPEWErf9AqAdYjhzY2Ilg==", - "license": "MIT", - "dependencies": { - "hookified": "^2.1.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/qified/node_modules/hookified": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.1.1.tgz", - "integrity": "sha512-AHb76R16GB5EsPBE2J7Ko5kiEyXwviB9P5SMrAKcuAu4vJPZttViAbj9+tZeaQE5zjDme+1vcHP78Yj/WoAveA==", - "license": "MIT" - }, - "node_modules/qrcode-terminal": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", - "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/quick-format-unescaped": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", - "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", - "license": "MIT" - }, - "node_modules/real-require": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", - "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - } - }, - "node_modules/safe-stable-stringify": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", - "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, - "license": "Apache-2.0", - "peer": true, - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/sonic-boom": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", - "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", - "license": "MIT", - "dependencies": { - "atomic-sleep": "^1.0.0" - } - }, - "node_modules/split2": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", - "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", - "license": "ISC", - "engines": { - "node": ">= 10.x" - } - }, - "node_modules/strtok3": { - "version": "10.3.5", - "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", - "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", - "license": "MIT", - "dependencies": { - "@tokenizer/token": "^0.3.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/thread-stream": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", - "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", - "license": "MIT", - "dependencies": { - "real-require": "^0.2.0" - } - }, - "node_modules/token-types": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", - "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", - "license": "MIT", - "dependencies": { - "@borewit/text-codec": "^0.2.1", - "@tokenizer/token": "^0.3.0", - "ieee754": "^1.2.1" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Borewit" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/uint8array-extras": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", - "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "license": "MIT" - }, - "node_modules/win-guid": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/win-guid/-/win-guid-0.2.1.tgz", - "integrity": "sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==", - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - } - } -} diff --git a/bridge/whatsapp/package.json b/bridge/whatsapp/package.json deleted file mode 100644 index 095dc2946..000000000 --- a/bridge/whatsapp/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "goclaw-whatsapp-bridge", - "version": "1.0.0", - "description": "WhatsApp WebSocket bridge for GoClaw — forwards messages between WhatsApp (Baileys) and GoClaw's WS channel protocol", - "type": "module", - "main": "server.js", - "scripts": { - "start": "node server.js" - }, - "engines": { - "node": ">=20" - }, - "dependencies": { - "@hapi/boom": "^10.0.1", - "@whiskeysockets/baileys": "^6.7.18", - "pino": "^9.0.0", - "qrcode-terminal": "^0.12.0", - "ws": "^8.17.1" - } -} diff --git a/bridge/whatsapp/server.js b/bridge/whatsapp/server.js deleted file mode 100644 index c3ce01a4f..000000000 --- a/bridge/whatsapp/server.js +++ /dev/null @@ -1,438 +0,0 @@ -/** - * GoClaw WhatsApp Bridge - * - * Scope: owns the WhatsApp Baileys lifecycle, QR flow, and WS server. - * GoClaw connects as a client and drives business logic (routing, pairing, AI). - * - * Protocol — Bridge → GoClaw: - * { type: "status", connected: bool } WhatsApp auth state (sent on connect + on change) - * { type: "qr", data: "" } QR code for scanning - * { type: "message", id, from, chat, content, Incoming WhatsApp message - * from_name, is_group, - * media: [{type,mimetype,filename,path}] } - * { type: "pong" } Response to ping - * - * Protocol — GoClaw → Bridge: - * { type: "message", to: "", content: "" } Send outbound text - * { type: "media", to, path, mimetype, caption? } Send outbound media file - * { type: "command", action: "reauth" } Logout + restart QR flow - * { type: "command", action: "ping" } Health check - * { type: "command", action: "presence", to, state } Presence update (composing|paused|available) - */ - -import makeWASocket, { - useMultiFileAuthState, - DisconnectReason, - fetchLatestBaileysVersion, - downloadMediaMessage, -} from '@whiskeysockets/baileys' -import { WebSocketServer } from 'ws' -import { rm, readdir, writeFile, mkdir } from 'node:fs/promises' -import { join, extname } from 'node:path' -import { randomBytes } from 'node:crypto' -import { tmpdir } from 'node:os' -import qrcode from 'qrcode-terminal' -import { Boom } from '@hapi/boom' -import pino from 'pino' - -const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10) -const AUTH_DIR = process.env.AUTH_DIR || './auth_info' -const LOG_LEVEL = process.env.LOG_LEVEL || 'silent' // "debug" for Baileys internals - -// Max media download size (20 MB). Files larger than this are skipped. -const MEDIA_MAX_BYTES = parseInt(process.env.MEDIA_MAX_BYTES || String(20 * 1024 * 1024), 10) - -// Temp directory for downloaded media files. GoClaw reads from here. -const MEDIA_DIR = process.env.MEDIA_DIR || join(tmpdir(), 'goclaw_wa_media') - -// Baileys logger is capped at 'warn' regardless of LOG_LEVEL. -// Baileys logs Signal Protocol session internals (private keys, chain keys, root keys) -// at debug/info level — never surface those even during local debugging. -const logger = pino({ level: LOG_LEVEL === 'silent' ? 'silent' : 'warn' }) -const wss = new WebSocketServer({ port: PORT }) - -// --- State --- - -/** @type {Set} */ -const clients = new Set() - -/** @type {import('@whiskeysockets/baileys').WASocket | null} */ -let sock = null -let reconnectTimer = null -let waConnected = false // true once Baileys reports 'open' for this session -let isReauthing = false // prevents 401 from stopping reconnect during reauth -let lastQR = null // cached QR string — replayed to clients that reconnect mid-flow -let botJID = '' // cached bare bot JID (phone) — replayed to clients that connect after auth -let botLID = '' // cached bare bot LID (Link ID) — WhatsApp's newer identifier system - -// --- Helpers --- - -/** Send JSON to all connected GoClaw clients. */ -function broadcast(payload) { - const data = JSON.stringify(payload) - for (const ws of clients) { - if (ws.readyState === 1) ws.send(data) - } -} - -/** Send JSON to a single client. */ -function sendTo(ws, payload) { - if (ws.readyState === 1) ws.send(JSON.stringify(payload)) -} - -/** Extract plain text from any Baileys message variant. */ -function extractContent(message) { - if (!message) return '' - return ( - message.conversation || - message.extendedTextMessage?.text || - message.imageMessage?.caption || - message.videoMessage?.caption || - message.documentMessage?.caption || - message.buttonsResponseMessage?.selectedDisplayText || - message.listResponseMessage?.title || - '' - ) -} - -/** MIME → file extension mapping for downloaded media. */ -const mimeToExt = { - 'image/jpeg': '.jpg', 'image/png': '.png', 'image/webp': '.webp', 'image/gif': '.gif', - 'video/mp4': '.mp4', 'video/3gpp': '.3gp', - 'audio/ogg; codecs=opus': '.ogg', 'audio/mpeg': '.mp3', 'audio/mp4': '.m4a', 'audio/aac': '.aac', - 'application/pdf': '.pdf', -} - -/** Map a Baileys message to a list of media descriptors (type, mimetype, filename, messageKey). */ -function detectMedia(message) { - if (!message) return [] - const items = [] - - if (message.imageMessage) items.push({ type: 'image', mimetype: message.imageMessage.mimetype, messageKey: 'imageMessage' }) - if (message.videoMessage) items.push({ type: 'video', mimetype: message.videoMessage.mimetype, messageKey: 'videoMessage' }) - if (message.audioMessage) items.push({ type: 'audio', mimetype: message.audioMessage.mimetype, messageKey: 'audioMessage' }) - if (message.documentMessage) items.push({ type: 'document', mimetype: message.documentMessage.mimetype, messageKey: 'documentMessage', - filename: message.documentMessage.fileName }) - if (message.stickerMessage) items.push({ type: 'sticker', mimetype: message.stickerMessage.mimetype, messageKey: 'stickerMessage' }) - return items -} - -/** - * Download media from a Baileys message and save to temp files. - * Returns array of { type, mimetype, filename, path } for successfully downloaded items. - */ -async function downloadMedia(msg) { - const items = detectMedia(msg.message) - if (items.length === 0) return [] - if (!sock) return [] - - await mkdir(MEDIA_DIR, { recursive: true }) - - const results = [] - for (const item of items) { - try { - // downloadMediaMessage needs the full msg (with key + message), not just msg.message. - // 4th arg: reuploadRequest must be bound to sock to preserve `this` context. - const buffer = await downloadMediaMessage(msg, 'buffer', {}, { - logger, - reuploadRequest: sock.updateMediaMessage?.bind(sock), - }) - if (!buffer || buffer.length === 0) { - console.warn(`⚠️ Empty buffer for ${item.type}, skipping`) - continue - } - if (buffer.length > MEDIA_MAX_BYTES) { - console.warn(`⚠️ Media too large (${(buffer.length / 1024 / 1024).toFixed(1)} MB), skipping`) - continue - } - const ext = mimeToExt[item.mimetype] || extname(item.filename || '') || '.bin' - const name = `goclaw_wa_${randomBytes(8).toString('hex')}${ext}` - const filePath = join(MEDIA_DIR, name) - await writeFile(filePath, buffer) - - results.push({ - type: item.type, - mimetype: item.mimetype, - filename: item.filename || '', - path: filePath, - }) - console.log(`📎 Downloaded ${item.type} (${(buffer.length / 1024).toFixed(0)} KB) → ${name}`) - } catch (err) { - console.error(`❌ Media download failed (${item.type}):`, err.message) - } - } - return results -} - -// --- Baileys lifecycle --- - -async function connectToWhatsApp() { - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - - const { state, saveCreds } = await useMultiFileAuthState(AUTH_DIR) - - const { version } = await fetchLatestBaileysVersion().catch(() => ({ - version: [2, 3000, 1023456789], - })) - - sock = makeWASocket({ - version, - auth: state, - logger, - printQRInTerminal: false, - browser: ['GoClaw Bridge', 'Chrome', '1.0.0'], - keepAliveIntervalMs: 30_000, - }) - - sock.ev.on('creds.update', saveCreds) - - sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => { - if (qr) { - lastQR = qr // cache so reconnecting GoClaw clients don't miss it - broadcast({ type: 'qr', data: qr }) - // Print QR to terminal only when PRINT_QR=true (useful without a UI) - if (process.env.PRINT_QR === 'true') { - console.log('\n📱 Scan this QR with WhatsApp (You → Linked Devices → Link a Device):') - qrcode.generate(qr, { small: true }) - } - } - - if (connection === 'close') { - const statusCode = new Boom(lastDisconnect?.error)?.output?.statusCode - const loggedOut = statusCode === DisconnectReason.loggedOut - console.log(`❌ WhatsApp disconnected (code ${statusCode})`) - waConnected = false - broadcast({ type: 'status', connected: false }) - - if (!loggedOut || isReauthing) { - // Normal disconnect → retry; or reauth in progress → reconnect to show new QR. - isReauthing = false - reconnectTimer = setTimeout(connectToWhatsApp, loggedOut ? 500 : 5_000) - } else { - console.log('🚪 Logged out — send { type:"command", action:"reauth" } to re-pair') - } - } else if (connection === 'open') { - console.log('✅ WhatsApp authenticated!') - lastQR = null // clear cached QR — no longer needed - waConnected = true - // Include the bot's own JIDs so GoClaw can detect @mentions. - // WhatsApp uses two identifier systems: phone JID (@s.whatsapp.net) and LID (@lid). - // Mentions in groups may use either format, so we send both. - // Strip Baileys device suffix (e.g. "12345:42@s.whatsapp.net" → "12345@s.whatsapp.net") - const rawJid = sock.user?.id ?? '' - botJID = rawJid.replace(/:\d+@/, '@') - const rawLid = sock.user?.lid ?? '' - botLID = rawLid.replace(/:\d+@/, '@') - broadcast({ type: 'status', connected: true, me: botJID, me_lid: botLID }) - } - }) - - sock.ev.on('messages.upsert', async ({ messages, type }) => { - if (type !== 'notify') return // skip history / append - - for (const msg of messages) { - if (msg.key.fromMe) continue - if (!msg.message) continue // receipts, ephemeral control frames, etc. - - const chatJid = msg.key.remoteJid - if (!chatJid) continue - - // Groups: participant = sender JID, remoteJid = group JID - // DMs: participant is undefined, remoteJid = sender JID - const senderJid = msg.key.participant || chatJid - const content = extractContent(msg.message) - const hasMedia = detectMedia(msg.message).length > 0 - - if (!content && !hasMedia) continue // completely empty, skip - - // Download media files (async). Non-blocking: failures are logged and skipped. - const media = hasMedia ? await downloadMedia(msg) : [] - - // Extract @mentioned JIDs from extended text messages. - const mentionedJids = msg.message?.extendedTextMessage?.contextInfo?.mentionedJid ?? [] - - const payload = { - type: 'message', - id: msg.key.id, - from: senderJid, - chat: chatJid, - content: content, - from_name: msg.pushName || '', - is_group: chatJid.endsWith('@g.us'), - mentioned_jids: mentionedJids, - media, - } - - console.log(`📨 ${senderJid} → ${chatJid}: ${content.slice(0, 60)}${content.length > 60 ? '…' : ''}`) - broadcast(payload) - } - }) -} - -/** - * Force-clear the auth state and restart so a fresh QR is generated. - * Called when GoClaw sends { type: "command", action: "reauth" }. - * - * We do NOT call sock.logout() because it fires connection.update with - * DisconnectReason.loggedOut (401), which makes shouldReconnect=false and - * stops the reconnect loop. Instead we forcibly end the socket and delete - * the auth files so the next connection starts a clean QR flow. - */ -async function handleReauth() { - console.log('🔄 Reauth requested — clearing session and restarting...') - isReauthing = true - waConnected = false - botJID = '' - botLID = '' - broadcast({ type: 'status', connected: false }) - - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } - - if (sock) { - sock.ev.removeAllListeners() - try { sock.end(new Error('reauth requested')) } catch { /* ignore */ } - sock = null - } - - // Delete all auth files individually (avoids EBUSY on the directory itself). - // Must clear everything — leaving stale pre-keys or app-state causes - // WhatsApp to reject the new QR/pairing attempt with "can't link devices". - try { - const files = await readdir(AUTH_DIR) - await Promise.all(files.map(f => rm(join(AUTH_DIR, f), { force: true }))) - console.log(`🗑️ Auth state cleared (${files.length} files)`) - } catch (err) { - console.warn('⚠️ Could not clear auth state:', err.message) - } - - setTimeout(connectToWhatsApp, 500) -} - -// --- WS server: handle GoClaw client connections --- - -wss.on('connection', ws => { - console.log('🔌 GoClaw connected') - clients.add(ws) - - // Send current auth state immediately so GoClaw doesn't have to guess. - // Include bot JID + LID so mention detection works even if GoClaw connects after auth. - sendTo(ws, { type: 'status', connected: waConnected, ...(botJID && { me: botJID }), ...(botLID && { me_lid: botLID }) }) - // Replay cached QR so GoClaw doesn't miss it if it reconnected mid-flow. - if (lastQR && !waConnected) { - sendTo(ws, { type: 'qr', data: lastQR }) - } - - ws.on('message', async rawData => { - let msg - try { - msg = JSON.parse(rawData.toString()) - } catch { - console.warn('⚠️ Non-JSON from GoClaw, ignoring') - return - } - - if (msg.type === 'command') { - switch (msg.action) { - case 'reauth': - await handleReauth() - break - case 'ping': - sendTo(ws, { type: 'pong' }) - break - case 'presence': { - // state: 'composing' | 'paused' | 'available' - if (!sock || !waConnected) break - try { - const to = (msg.to ?? '').replace('@c.us', '@s.whatsapp.net') - await sock.sendPresenceUpdate(msg.state ?? 'available', to) - } catch (err) { - console.error('❌ Presence update error:', err.message) - } - break - } - default: - console.warn('⚠️ Unknown command:', msg.action) - } - return - } - - if (msg.type === 'media') { - if (!msg.to || !msg.path) { - console.warn('⚠️ Outbound media missing "to" or "path"', msg) - return - } - if (!sock || !waConnected) { - console.warn('⚠️ WhatsApp not connected — dropping media to', msg.to) - return - } - try { - const to = msg.to.replace('@c.us', '@s.whatsapp.net') - const { readFile } = await import('node:fs/promises') - const buffer = await readFile(msg.path) - const mime = (msg.mimetype || '').toLowerCase() - const caption = msg.caption || undefined - - let content - if (mime.startsWith('image/')) content = { image: buffer, caption } - else if (mime.startsWith('video/')) content = { video: buffer, caption } - else if (mime.startsWith('audio/')) content = { audio: buffer, mimetype: mime } - else content = { document: buffer, mimetype: mime, caption, - fileName: msg.path.split('/').pop() } - - await sock.sendMessage(to, content) - console.log(`📤 Sent ${mime || 'media'} to ${to}`) - } catch (err) { - console.error('❌ Failed to send WhatsApp media:', err.message) - } - return - } - - if (msg.type === 'message') { - if (!msg.to || !msg.content) { - console.warn('⚠️ Outbound message missing "to" or "content"', msg) - return - } - if (!sock || !waConnected) { - console.warn('⚠️ WhatsApp not connected — dropping reply to', msg.to) - return - } - try { - // Normalise JID: @c.us (whatsapp-web.js) → @s.whatsapp.net (Baileys) - const to = msg.to.replace('@c.us', '@s.whatsapp.net') - await sock.sendMessage(to, { text: msg.content }) - console.log(`📤 Sent to ${to}`) - } catch (err) { - console.error('❌ Failed to send WhatsApp message:', err.message) - } - return - } - - console.warn('⚠️ Unknown message type from GoClaw:', msg.type) - }) - - ws.on('close', () => { - console.log('🔌 GoClaw disconnected') - clients.delete(ws) - }) - - ws.on('error', err => console.error('WebSocket error:', err.message)) -}) - -wss.on('listening', () => { - console.log(`🌉 WhatsApp bridge on ws://0.0.0.0:${PORT} auth=${AUTH_DIR}`) - connectToWhatsApp().catch(err => { - console.error('❌ Fatal error connecting to WhatsApp:', err) - process.exit(1) - }) -}) - -wss.on('error', err => { - console.error('❌ WebSocket server error:', err.message) - process.exit(1) -}) diff --git a/cmd/channels_cmd.go b/cmd/channels_cmd.go index 471550908..d3f8ed9bf 100644 --- a/cmd/channels_cmd.go +++ b/cmd/channels_cmd.go @@ -44,7 +44,7 @@ func channelsListCmd() *cobra.Command { {"discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != ""}, {"zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != ""}, {"feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != ""}, - {"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != ""}, + {"whatsapp", cfg.Channels.WhatsApp.Enabled, true}, } if jsonOutput { diff --git a/cmd/doctor.go b/cmd/doctor.go index 1752216aa..1375bc369 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -120,7 +120,7 @@ func runDoctor() { checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "") checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "") checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "") - checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.BridgeURL != "") + checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, true) } // External tools diff --git a/cmd/gateway.go b/cmd/gateway.go index f05b2a033..fcceea8e1 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -563,7 +563,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory) instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) - instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.Factory) + instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB)) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 855723b4e..2c27a6228 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -68,9 +68,8 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } if cfg.Channels.WhatsApp.Enabled { - if cfg.Channels.WhatsApp.BridgeURL == "" { - recordMissingConfig(channels.TypeWhatsApp, "Set channels.whatsapp.bridge_url in config.") - } else if wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing); err != nil { + wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB) + if err != nil { channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) } else { @@ -143,7 +142,7 @@ func wireChannelRPCMethods(server *gateway.Server, pgStores *store.Stores, chann methods.NewChannelInstancesMethods(pgStores.ChannelInstances, msgBus, msgBus).Register(server.Router()) zalomethods.NewQRMethods(pgStores.ChannelInstances, msgBus).Register(server.Router()) zalomethods.NewContactsMethods(pgStores.ChannelInstances).Register(server.Router()) - whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr, msgBus).Register(server.Router()) + whatsapp.NewQRMethods(pgStores.ChannelInstances, channelMgr).Register(server.Router()) } // Register agent links WS RPC methods diff --git a/docker-compose.whatsapp.yml b/docker-compose.whatsapp.yml deleted file mode 100644 index 03de86998..000000000 --- a/docker-compose.whatsapp.yml +++ /dev/null @@ -1,52 +0,0 @@ -# WhatsApp bridge overlay — runs the WA WebSocket bridge alongside GoClaw. -# -# Usage: -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.whatsapp.yml up -d --build -# -# First run: tail logs to see the QR code, then scan with WhatsApp: -# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.whatsapp.yml logs -f whatsapp-bridge -# -# GoClaw config.json must include: -# "channels": { -# "whatsapp": { -# "enabled": true, -# "bridge_url": "ws://whatsapp-bridge:3001", -# "dm_policy": "open" -# } -# } -# -# Auth state is persisted in the 'whatsapp-auth' volume — delete it to re-pair. -# Env overrides: -# WHATSAPP_BRIDGE_PORT — host port to expose (default 3001) -# WHATSAPP_LOG_LEVEL — Baileys log level: silent|info|debug (default silent) - -services: - whatsapp-bridge: - build: - context: ./bridge/whatsapp - dockerfile: Dockerfile - ports: - - "${WHATSAPP_BRIDGE_PORT:-3001}:3001" - volumes: - - whatsapp-auth:/app/auth_info - - goclaw-workspace:/app/workspace # shared: bridge reads outbound media from GoClaw workspace - - whatsapp-media:/app/media # shared: bridge writes inbound media, GoClaw reads - environment: - - BRIDGE_PORT=3001 - - AUTH_DIR=/app/auth_info - - LOG_LEVEL=${WHATSAPP_LOG_LEVEL:-silent} - - MEDIA_DIR=/app/media # inbound downloads go here (shared with GoClaw) - networks: - - goclaw-net - restart: unless-stopped - init: true - - goclaw: - depends_on: - - whatsapp-bridge - volumes: - - whatsapp-media:/app/media # shared: GoClaw reads inbound media from bridge - -volumes: - whatsapp-auth: - whatsapp-media: diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index ff8cffc0c..0d50c97c2 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -156,13 +156,13 @@ flowchart TD | Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal | |---------|----------|-------------|---------|-------|----------|---------|---------------| -| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | External WS bridge | Long polling | Internal protocol | +| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Native protocol (in-process whatsmeow) | Long polling | Internal protocol | | DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes | | Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes | | Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- | -| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | N/A (bridge) | 2,000 chars | 2,000 chars | +| Message limit | 4,096 chars | Configurable (default 4,000) | 2,000 chars | 4,000 chars | WhatsApp native limit | 2,000 chars | 2,000 chars | | Streaming | Typing indicator | Streaming message cards | Edit "Thinking..." | Edit "Thinking..." (throttled 1s) | No | No | No | -| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | JSON messages | Images (5 MB) | -- | +| Media | Photos, voice, files | Images, files (30 MB) | Files, embeds | Files (download w/ SSRF protection) | Images, audio, video, documents | Images (5 MB) | -- | | Speech-to-text | Yes (STT proxy) | -- | -- | -- | -- | -- | -- | | Voice routing | Yes (VoiceAgentID) | -- | -- | -- | -- | -- | -- | | Rich formatting | Markdown → HTML | Card messages | Markdown | Markdown → mrkdwn | Plain text | Plain text | Plain text | @@ -430,15 +430,18 @@ Auto-enables when both bot_token and app_token are set. ## 9. WhatsApp -The WhatsApp channel communicates through an external WebSocket bridge (e.g., whatsapp-web.js based). GoClaw does not implement the WhatsApp protocol directly. +The WhatsApp channel connects directly to the WhatsApp network via the native `go.mau.fi/whatsmeow` library. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition). ### Key Behaviors -- **Bridge connection**: Connects to configurable `bridge_url` via WebSocket -- **JSON format**: Messages sent/received as JSON objects -- **Auto-reconnect**: Exponential backoff (1s → 30s max) -- **DM and group support**: Group detection via `@g.us` suffix in chat ID -- **Media handling**: Array of file paths from bridge protocol +- **Native protocol**: In-process WhatsApp client using whatsmeow library (direct to WhatsApp servers, no external bridge) +- **Database auth store**: Uses `whatsmeow/store/sqlstore` to persist auth state, keys, and device info +- **QR code authentication**: Interactive QR code for initial pairing, served via WebSocket API +- **Auto-reconnect**: Built-in whatsmeow reconnection with exponential backoff +- **DM and group support**: Full group messaging with mention detection via JID format +- **Media handling**: Direct media download/upload to WhatsApp servers with type detection +- **Typing indicators**: Typing state managed per chat with auto-refresh +- **Group mention gating**: Detects when bot is mentioned via LID (Local ID) and JID (standard format) --- @@ -590,7 +593,10 @@ flowchart TD | `internal/channels/slack/format.go` | Markdown → Slack mrkdwn pipeline | | `internal/channels/slack/reactions.go` | Status emoji reactions on messages | | `internal/channels/slack/stream.go` | Streaming message updates via placeholder editing | -| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: external WS bridge | +| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: native whatsmeow client, QR auth, sqlstore persistence | +| `internal/channels/whatsapp/factory.go` | Channel factory, database dialect detection | +| `internal/channels/whatsapp/qr_methods.go` | QR code generation and authentication flow | +| `internal/channels/whatsapp/format.go` | Message formatting (HTML-to-WhatsApp) | | `internal/channels/zalo/zalo.go` | Zalo OA: Bot API, long polling | | `internal/channels/zalo/personal/channel.go` | Zalo Personal: reverse-engineered protocol | | `internal/store/pg/pairing.go` | Pairing: code generation, approval, persistence (database-backed) | diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 6ae4e2da4..96de3f99e 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -34,6 +34,21 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Added +#### WhatsApp Native Protocol Integration (2026-04-06) +- **Whatsmeow migration**: Replaced Node.js Baileys bridge with native `go.mau.fi/whatsmeow` library for in-process WhatsApp connectivity +- **Database auth persistence**: Auth state, device keys, and client metadata stored in PostgreSQL (standard) or SQLite (desktop) via whatsmeow's sqlstore +- **QR authentication**: Interactive QR code authentication driven directly by whatsmeow's `GetQRChannel()` without external bridge relay +- **No more bridge_url**: Removed `bridge_url` configuration, eliminated `docker-compose.whatsapp.yml`, removed `bridge/whatsapp/` sidecar service +- **Enhanced media handling**: Direct media download/upload to WhatsApp servers with automatic type detection and streaming +- **Improved mention detection**: Group mention detection now uses LID (Local ID) + JID (standard format) for robust message routing +- **Files added**: + - `internal/channels/whatsapp/factory.go` — Dialect detection and channel factory + - `internal/channels/whatsapp/qr_methods.go` — QR code generation and authentication flow + - `internal/channels/whatsapp/format.go` — HTML-to-WhatsApp message formatting + - whatsmeow sqlstore integration for cross-platform auth persistence + +### Refactored + #### Parallel Sub-Agent Enhancement (#600) (2026-03-31) - **Smart leader delegation**: Conditional leader delegation prompt instead of forced delegation for all subagent spawns - **Compaction prompt persistence**: Preserves pending subagent and team task state across context summarization to maintain work continuity diff --git a/go.mod b/go.mod index c64a71072..0d749fa5d 100644 --- a/go.mod +++ b/go.mod @@ -17,11 +17,13 @@ require ( github.com/mattn/go-shellwords v1.0.12 github.com/mymmrac/telego v1.6.0 github.com/redis/go-redis/v9 v9.18.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/slack-go/slack v0.19.0 github.com/spf13/cobra v1.10.2 github.com/titanous/json5 v1.0.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/zalando/go-keyring v0.2.8 + go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 @@ -53,6 +55,7 @@ require ( github.com/aws/smithy-go v1.24.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beeper/argo-go v1.1.2 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/catppuccin/go v0.3.0 // indirect @@ -64,11 +67,12 @@ require ( github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect github.com/charmbracelet/x/term v0.2.1 // indirect - github.com/coder/websocket v1.8.12 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/creachadair/msync v0.7.1 // indirect github.com/danieljoos/wincred v1.2.3 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.18.0 // indirect @@ -91,7 +95,7 @@ require ( github.com/leaanthony/u v1.1.1 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect github.com/mdlayher/socket v0.5.0 // indirect @@ -101,14 +105,15 @@ require ( github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect github.com/pires/go-proxyproto v0.8.1 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prometheus-community/pro-bing v0.4.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/rs/zerolog v1.34.0 // indirect github.com/safchain/ethtool v0.3.0 // indirect github.com/samber/lo v1.49.1 // indirect - github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/spf13/cast v1.7.1 // indirect github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect @@ -118,17 +123,20 @@ require ( github.com/tailscale/wireguard-go v0.0.0-20250716170648-1d0488a3d7da // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect + github.com/vektah/gqlparser/v2 v2.5.27 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + go.mau.fi/libsignal v0.2.1 // indirect + go.mau.fi/util v0.9.6 // indirect go.uber.org/atomic v1.11.0 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/term v0.39.0 // indirect + golang.org/x/term v0.40.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect @@ -176,14 +184,14 @@ require ( go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/net v0.49.0 + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect + golang.org/x/net v0.50.0 golang.org/x/sync v0.19.0 golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.33.0 + golang.org/x/text v0.34.0 google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect google.golang.org/grpc v1.78.0 // indirect - google.golang.org/protobuf v1.36.11 // indirect + google.golang.org/protobuf v1.36.11 ) diff --git a/go.sum b/go.sum index 6f48a1526..0ecd917dc 100644 --- a/go.sum +++ b/go.sum @@ -8,16 +8,22 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adhocore/gronx v1.19.6 h1:5KNVcoR9ACgL9HhEqCm5QXsab/gI4QDIybTAWcXDKDc= github.com/adhocore/gronx v1.19.6/go.mod h1:7oUY1WAU8rEJWmAxXR2DN0JaO4gi9khSgKjiRypqteg= +github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= @@ -60,6 +66,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= +github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -114,14 +122,15 @@ github.com/cilium/ebpf v0.16.0 h1:+BiEnHL6Z7lXnlGUsXQPPAE7+kenAd4ES8MQ5min0Ok= github.com/cilium/ebpf v0.16.0/go.mod h1:L7u2Blt2jMM/vLAVgjxluxtBKlz3/GWjB0dMOEngfwE= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= -github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= -github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creachadair/msync v0.7.1 h1:SeZmuEBXQPe5GqV/C94ER7QIZPwtvFbeQiykzt/7uho= github.com/creachadair/msync v0.7.1/go.mod h1:8CcFlLsSujfHE5wWm19uUBLHIPDAUr6LXDwneVMO008= @@ -159,6 +168,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4 github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/elliotchance/orderedmap/v3 v3.1.0 h1:j4DJ5ObEmMBt/lcwIecKcoRxIQUEnw0L804lXYDt/pg= +github.com/elliotchance/orderedmap/v3 v3.1.0/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -188,6 +199,7 @@ github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737 h1:cf60tHxREO3g1nroKr2osU3JWZsJzkfi7rEg+oAB0Lo= github.com/go4org/plan9netshell v0.0.0-20250324183649-788daa080737/go.mod h1:MIS0jDzbU/vuM9MC4YnBITCv+RYuTRq8dJzmCrFsK9g= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -288,9 +300,11 @@ github.com/mark3labs/mcp-go v0.44.0/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9a github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= @@ -299,6 +313,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk= github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= +github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= +github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= @@ -337,6 +353,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14= +github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pires/go-proxyproto v0.8.1 h1:9KEixbdJfhrbtjpz/ZwCdWDD2Xem0NZ38qMYaASJgp0= @@ -367,11 +385,16 @@ github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGG github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/slack-go/slack v0.19.0 h1:J8lL/nGTsIUX53HU8YxZeI3PDkA+sxZsFrI2Dew7h44= @@ -435,6 +458,8 @@ github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpB github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= +github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s= +github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58= @@ -471,6 +496,12 @@ github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPc github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.mau.fi/libsignal v0.2.1 h1:vRZG4EzTn70XY6Oh/pVKrQGuMHBkAWlGRC22/85m9L0= +go.mau.fi/libsignal v0.2.1/go.mod h1:iVvjrHyfQqWajOUaMEsIfo3IqgVMrhWcPiiEzk7NgoU= +go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts= +go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI= +go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4 h1:E4A6eca9vMJQctC9DIfzUIg27TrJ8IrDHgkJwJ8WPUQ= +go.mau.fi/whatsmeow v0.0.0-20260327181659-02ec817e7cf4/go.mod h1:mXCRFyPEPn4jqWz6Afirn8vY7DpHCPnlKq6I2cWwFHM= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= @@ -507,10 +538,10 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/W golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o= +golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= @@ -520,8 +551,8 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -534,16 +565,17 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index 9001d1426..1d96a843b 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -1,6 +1,7 @@ package whatsapp import ( + "database/sql" "encoding/json" "fmt" @@ -12,7 +13,6 @@ import ( // whatsappInstanceConfig maps the non-secret config JSONB from the channel_instances table. type whatsappInstanceConfig struct { - BridgeURL string `json:"bridge_url"` DMPolicy string `json:"dm_policy,omitempty"` GroupPolicy string `json:"group_policy,omitempty"` RequireMention *bool `json:"require_mention,omitempty"` @@ -20,51 +20,52 @@ type whatsappInstanceConfig struct { BlockReply *bool `json:"block_reply,omitempty"` } -// Factory creates a WhatsApp channel from DB instance data. -func Factory(name string, creds json.RawMessage, cfg json.RawMessage, - msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { +// FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state. +func FactoryWithDB(db *sql.DB) channels.ChannelFactory { + return func(name string, creds json.RawMessage, cfg json.RawMessage, + msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { - var ic whatsappInstanceConfig - if len(cfg) > 0 { - if err := json.Unmarshal(cfg, &ic); err != nil { - return nil, fmt.Errorf("decode whatsapp config: %w", err) + var ic whatsappInstanceConfig + if len(cfg) > 0 { + if err := json.Unmarshal(cfg, &ic); err != nil { + return nil, fmt.Errorf("decode whatsapp config: %w", err) + } } - } - // Fallback: read bridge_url from credentials for instances created before this migration. - if ic.BridgeURL == "" && len(creds) > 0 { - var legacy struct { - BridgeURL string `json:"bridge_url"` + // Detect old bridge_url config and give clear migration error. + if len(cfg) > 0 { + var legacy struct{ BridgeURL string `json:"bridge_url"` } + if json.Unmarshal(cfg, &legacy) == nil && legacy.BridgeURL != "" { + return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from config") + } } - if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" { - ic.BridgeURL = legacy.BridgeURL + if len(creds) > 0 { + var legacy struct{ BridgeURL string `json:"bridge_url"` } + if json.Unmarshal(creds, &legacy) == nil && legacy.BridgeURL != "" { + return nil, fmt.Errorf("whatsapp: bridge_url is no longer supported — " + + "WhatsApp now runs natively via whatsmeow. Remove bridge_url from credentials") + } } - } - - if ic.BridgeURL == "" { - return nil, fmt.Errorf("whatsapp bridge_url is required") - } - - waCfg := config.WhatsAppConfig{ - Enabled: true, - BridgeURL: ic.BridgeURL, - AllowFrom: ic.AllowFrom, - DMPolicy: ic.DMPolicy, - GroupPolicy: ic.GroupPolicy, - RequireMention: ic.RequireMention, - BlockReply: ic.BlockReply, - } - // DB instances default to "pairing" for groups (secure by default). - if waCfg.GroupPolicy == "" { - waCfg.GroupPolicy = "pairing" - } + waCfg := config.WhatsAppConfig{ + Enabled: true, + AllowFrom: ic.AllowFrom, + DMPolicy: ic.DMPolicy, + GroupPolicy: ic.GroupPolicy, + RequireMention: ic.RequireMention, + BlockReply: ic.BlockReply, + } + // DB instances default to "pairing" for groups (secure by default). + if waCfg.GroupPolicy == "" { + waCfg.GroupPolicy = "pairing" + } - ch, err := New(waCfg, msgBus, pairingSvc) - if err != nil { - return nil, err + ch, err := New(waCfg, msgBus, pairingSvc, db) + if err != nil { + return nil, err + } + ch.SetName(name) + return ch, nil } - - ch.SetName(name) - return ch, nil } diff --git a/internal/channels/whatsapp/qr_methods.go b/internal/channels/whatsapp/qr_methods.go index 5f5aff69a..15ef2c8b1 100644 --- a/internal/channels/whatsapp/qr_methods.go +++ b/internal/channels/whatsapp/qr_methods.go @@ -2,14 +2,15 @@ package whatsapp import ( "context" + "encoding/base64" "encoding/json" "log/slog" "sync" "time" "github.com/google/uuid" + qrcode "github.com/skip2/go-qrcode" - "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/gateway" "github.com/nextlevelbuilder/goclaw/internal/store" @@ -23,17 +24,15 @@ type cancelEntry struct { cancel context.CancelFunc } -// QRMethods handles whatsapp.qr.start — delivers bridge QR codes to the UI wizard. -// The QR code comes from the external bridge (Baileys), not generated in-process. +// QRMethods handles whatsapp.qr.start — delivers QR codes to the UI wizard. type QRMethods struct { instanceStore store.ChannelInstanceStore manager *channels.Manager - msgBus *bus.MessageBus activeSessions sync.Map // instanceID (string) -> *cancelEntry } -func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager, msgBus *bus.MessageBus) *QRMethods { - return &QRMethods{instanceStore: instanceStore, manager: manager, msgBus: msgBus} +func NewQRMethods(instanceStore store.ChannelInstanceStore, manager *channels.Manager) *QRMethods { + return &QRMethods{instanceStore: instanceStore, manager: manager} } func (m *QRMethods) Register(router *gateway.MethodRouter) { @@ -43,7 +42,7 @@ func (m *QRMethods) Register(router *gateway.MethodRouter) { func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, req *goclawprotocol.RequestFrame) { var params struct { InstanceID string `json:"instance_id"` - ForceReauth bool `json:"force_reauth"` // if true, logout current session and generate fresh QR + ForceReauth bool `json:"force_reauth"` } if req.Params != nil { _ = json.Unmarshal(req.Params, ¶ms) @@ -64,7 +63,7 @@ func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, r qrCtx, cancel := context.WithTimeout(ctx, qrSessionTimeout) entry := &cancelEntry{cancel: cancel} - // Cancel any previous QR session for this instance so the user can retry. + // Cancel any previous QR session for this instance. if prev, loaded := m.activeSessions.Swap(params.InstanceID, entry); loaded { if prevEntry, ok := prev.(*cancelEntry); ok { prevEntry.cancel() @@ -77,102 +76,158 @@ func (m *QRMethods) handleQRStart(ctx context.Context, client *gateway.Client, r go m.runQRSession(qrCtx, entry, client, params.InstanceID, inst.Name, params.ForceReauth) } -func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) { +func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, + client *gateway.Client, instanceIDStr, channelName string, forceReauth bool) { + defer entry.cancel() defer m.activeSessions.CompareAndDelete(instanceIDStr, entry) - if ch, ok := m.manager.GetChannel(channelName); ok { - if wa, ok := ch.(*Channel); ok { - if wa.IsAuthenticated() && !forceReauth { - // Already authenticated and caller didn't request re-link — signal "connected" to UI. - client.SendEvent(goclawprotocol.EventFrame{ - Type: goclawprotocol.FrameTypeEvent, - Event: goclawprotocol.EventWhatsAppQRDone, - Payload: map[string]any{ - "instance_id": instanceIDStr, - "success": true, - "already_connected": true, - }, - }) - slog.Info("whatsapp QR skipped — already authenticated", "instance", instanceIDStr) - return - } - if forceReauth || wa.GetLastQRB64() == "" { - // Force re-link or no cached QR: ask bridge to logout and restart auth. - if err := wa.SendBridgeCommand("reauth"); err != nil { - slog.Warn("whatsapp QR: failed to send reauth to bridge", "instance", instanceIDStr, "error", err) - } else { - slog.Info("whatsapp QR: sent reauth to bridge", "instance", instanceIDStr, "force", forceReauth) - } - } else { - // Deliver cached QR if bridge sent it before the wizard opened. - client.SendEvent(goclawprotocol.EventFrame{ - Type: goclawprotocol.FrameTypeEvent, - Event: goclawprotocol.EventWhatsAppQRCode, - Payload: map[string]any{ - "instance_id": instanceIDStr, - "png_b64": wa.GetLastQRB64(), - }, - }) - } - } + ch, ok := m.manager.GetChannel(channelName) + if !ok { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "channel not found", + }, + }) + return + } + + wa, ok := ch.(*Channel) + if !ok { + return } - // Subscribe to bus events for this channel's QR lifecycle. - subID := "whatsapp-qr-" + instanceIDStr - done := make(chan struct{}) + // Already authenticated and no force-reauth → signal connected. + if wa.IsAuthenticated() && !forceReauth { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + return + } - m.msgBus.Subscribe(subID, func(event bus.Event) { - payload, ok := event.Payload.(map[string]any) - if !ok { - return - } - if payload["channel_name"] != channelName { - return + // Force reauth: clear session and prepare for fresh QR. + if forceReauth { + if err := wa.Reauth(); err != nil { + slog.Warn("whatsapp QR: reauth failed", "error", err) } + } - switch event.Name { - case goclawprotocol.EventWhatsAppQRCode: - client.SendEvent(goclawprotocol.EventFrame{ - Type: goclawprotocol.FrameTypeEvent, - Event: goclawprotocol.EventWhatsAppQRCode, - Payload: map[string]any{ - "instance_id": instanceIDStr, - "png_b64": payload["png_b64"], - }, - }) + // Deliver cached QR if available. + if cached := wa.GetLastQRB64(); cached != "" { + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": cached, + }, + }) + } - case goclawprotocol.EventWhatsAppQRDone: + // Start QR flow — get QR channel from whatsmeow. + qrChan, err := wa.StartQRFlow(ctx) + if err != nil { + slog.Warn("whatsapp QR: start flow failed", "error", err) + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": err.Error(), + }, + }) + return + } + + if qrChan == nil { + // Already authenticated (StartQRFlow returned nil). + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + "already_connected": true, + }, + }) + return + } + + // Process QR events from whatsmeow. + for { + select { + case <-ctx.Done(): client.SendEvent(goclawprotocol.EventFrame{ Type: goclawprotocol.FrameTypeEvent, Event: goclawprotocol.EventWhatsAppQRDone, Payload: map[string]any{ "instance_id": instanceIDStr, - "success": true, + "success": false, + "error": "QR session timed out — restart to try again", }, }) - select { - case done <- struct{}{}: - default: + return + + case evt, ok := <-qrChan: + if !ok { + return // channel closed } - } - }) - defer m.msgBus.Unsubscribe(subID) - select { - case <-done: - slog.Info("whatsapp QR session completed", "instance", instanceIDStr) - case <-ctx.Done(): - client.SendEvent(goclawprotocol.EventFrame{ - Type: goclawprotocol.FrameTypeEvent, - Event: goclawprotocol.EventWhatsAppQRDone, - Payload: map[string]any{ - "instance_id": instanceIDStr, - "success": false, - "error": "QR session timed out — restart to try again", - }, - }) - slog.Info("whatsapp QR session timed out", "instance", instanceIDStr) + switch evt.Event { + case "code": + png, qrErr := qrcode.Encode(evt.Code, qrcode.Medium, 256) + if qrErr != nil { + slog.Warn("whatsapp: QR PNG encode failed", "error", qrErr) + continue + } + pngB64 := base64.StdEncoding.EncodeToString(png) + + wa.cacheQR(pngB64) + + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRCode, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "png_b64": pngB64, + }, + }) + + case "success": + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": true, + }, + }) + slog.Info("whatsapp QR session completed", "instance", instanceIDStr) + return + + case "timeout": + client.SendEvent(goclawprotocol.EventFrame{ + Type: goclawprotocol.FrameTypeEvent, + Event: goclawprotocol.EventWhatsAppQRDone, + Payload: map[string]any{ + "instance_id": instanceIDStr, + "success": false, + "error": "QR code expired — restart to try again", + }, + }) + return + } + } } } - diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 7994cc539..7c1f8053c 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -2,16 +2,20 @@ package whatsapp import ( "context" - "encoding/base64" - "encoding/json" + "database/sql" "fmt" "log/slog" + "os" "strings" "sync" "time" - "github.com/gorilla/websocket" - qrcode "github.com/skip2/go-qrcode" + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/store/sqlstore" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" @@ -23,78 +27,108 @@ import ( const pairingDebounceTime = 60 * time.Second -// Channel connects to a WhatsApp bridge via WebSocket. -// The bridge (e.g. whatsapp-web.js based) handles the actual WhatsApp -// protocol; this channel just sends/receives JSON messages over WS. +// Channel connects directly to WhatsApp via go.mau.fi/whatsmeow. +// Auth state is stored in PostgreSQL (standard) or SQLite (desktop). type Channel struct { *channels.BaseChannel - conn *websocket.Conn + client *whatsmeow.Client + container *sqlstore.Container config config.WhatsAppConfig mu sync.Mutex - connected bool ctx context.Context cancel context.CancelFunc pairingService store.PairingStore pairingDebounce sync.Map // senderID → time.Time approvedGroups sync.Map // chatID → true (in-memory cache for paired groups) - // QR caching: last QR PNG from the bridge (base64) for wizard delivery. + // QR state lastQRMu sync.RWMutex - lastQRB64 string // base64-encoded PNG, empty when bridge is already authenticated - waAuthenticated bool // true once bridge reports WhatsApp account is connected - myJID string // bot's phone JID (e.g. "1234567890@s.whatsapp.net"), for mention detection - myLID string // bot's Link ID (e.g. "9876543210@lid"), WhatsApp's newer identifier + lastQRB64 string // base64-encoded PNG, empty when authenticated + waAuthenticated bool // true once WhatsApp account is connected + myJID types.JID // bot's own JID for mention detection // typingCancel tracks active typing-refresh loops per chatID. - // WhatsApp clears "composing" after ~10s, so we refresh every 8s until the reply is sent. - typingCancel sync.Map // chatID → context.CancelFunc + typingCancel sync.Map // chatID string → context.CancelFunc } -// GetLastQRB64 returns the most recent QR PNG (base64) received from the bridge. -// Returns "" when the bridge is already authenticated or no QR has been received yet. +// GetLastQRB64 returns the most recent QR PNG (base64). func (c *Channel) GetLastQRB64() string { c.lastQRMu.RLock() defer c.lastQRMu.RUnlock() return c.lastQRB64 } -// IsAuthenticated reports whether the WhatsApp account is currently authenticated via the bridge. +// IsAuthenticated reports whether the WhatsApp account is currently authenticated. func (c *Channel) IsAuthenticated() bool { c.lastQRMu.RLock() defer c.lastQRMu.RUnlock() return c.waAuthenticated } -// New creates a new WhatsApp channel from config. -func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (*Channel, error) { - if cfg.BridgeURL == "" { - return nil, fmt.Errorf("whatsapp bridge_url is required") +// cacheQR stores the latest QR PNG (base64) for late-joining wizard clients. +func (c *Channel) cacheQR(pngB64 string) { + c.lastQRMu.Lock() + c.lastQRB64 = pngB64 + c.lastQRMu.Unlock() +} + +// detectDialect returns the sqlstore dialect string based on the DB driver. +func detectDialect(db *sql.DB) string { + driverName := fmt.Sprintf("%T", db.Driver()) + if strings.Contains(driverName, "sqlite") { + return "sqlite3" } + return "pgx" +} + +// New creates a new WhatsApp channel backed by whatsmeow. +func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, + pairingSvc store.PairingStore, db *sql.DB) (*Channel, error) { base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom) base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) + dialect := detectDialect(db) + container := sqlstore.NewWithDB(db, dialect, nil) + if err := container.Upgrade(context.Background()); err != nil { + return nil, fmt.Errorf("whatsapp sqlstore upgrade: %w", err) + } + return &Channel{ BaseChannel: base, config: cfg, pairingService: pairingSvc, + container: container, }, nil } -// Start connects to the WhatsApp bridge WebSocket and begins listening. +// Start initializes the whatsmeow client and connects to WhatsApp. func (c *Channel) Start(ctx context.Context) error { - slog.Info("starting whatsapp channel", "bridge_url", c.config.BridgeURL) - c.MarkStarting("Connecting to WhatsApp bridge") + slog.Info("starting whatsapp channel (whatsmeow)") + c.MarkStarting("Initializing WhatsApp connection") c.ctx, c.cancel = context.WithCancel(ctx) - if err := c.connect(); err != nil { - // Don't fail hard — reconnect loop will keep trying. - slog.Warn("initial whatsapp bridge connection failed, will retry", "error", err) - c.MarkDegraded("Bridge unreachable", err.Error(), channels.ChannelFailureKindNetwork, true) + deviceStore, err := c.container.GetFirstDevice(ctx) + if err != nil { + return fmt.Errorf("whatsapp get device: %w", err) } - go c.listenLoop() + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + + if c.client.Store.ID == nil { + // Not paired yet — QR flow will be triggered by qr_methods.go. + slog.Info("whatsapp: not paired yet, waiting for QR scan", "channel", c.Name()) + c.MarkDegraded("Awaiting QR scan", "Scan QR code to authenticate", + channels.ChannelFailureKindAuth, false) + } else { + if err := c.client.Connect(); err != nil { + slog.Warn("whatsapp: initial connect failed", "error", err) + c.MarkDegraded("Connection failed", err.Error(), + channels.ChannelFailureKindNetwork, true) + } + } c.SetRunning(true) return nil @@ -110,346 +144,107 @@ func (c *Channel) Stop(_ context.Context) error { if c.cancel != nil { c.cancel() } - - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn != nil { - _ = c.conn.Close() - c.conn = nil + if c.client != nil { + c.client.Disconnect() } - c.connected = false - c.SetRunning(false) - // Cancel all active typing goroutines to prevent leaks. + // Cancel all active typing goroutines. c.typingCancel.Range(func(key, value any) bool { value.(context.CancelFunc)() c.typingCancel.Delete(key) return true }) + c.SetRunning(false) c.MarkStopped("Stopped") - - return nil -} - -// SendBridgeCommand sends a control command to the bridge (e.g. reauth, ping, pairing_code). -// Extra optional fields are merged into the command payload. -// Bridge protocol: { type: "command", action: "", ...extra } -func (c *Channel) SendBridgeCommand(action string, extra ...map[string]any) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return fmt.Errorf("whatsapp bridge not connected") - } - payload := map[string]any{"type": "command", "action": action} - for _, e := range extra { - for k, v := range e { - payload[k] = v - } - } - data, err := json.Marshal(payload) - if err != nil { - return err - } - return c.conn.WriteMessage(websocket.TextMessage, data) -} - -// Send delivers an outbound message to the WhatsApp bridge. -// Supports text-only messages and messages with media attachments. -func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - return fmt.Errorf("whatsapp bridge not connected") - } - - // Send media attachments first (each as a separate bridge message). - if len(msg.Media) > 0 { - for i, m := range msg.Media { - // Use message content as caption for the first media item only. - caption := m.Caption - if caption == "" && i == 0 && msg.Content != "" { - caption = markdownToWhatsApp(msg.Content) - } - mediaPayload := map[string]any{ - "type": "media", - "to": msg.ChatID, - "path": m.URL, - "mimetype": m.ContentType, - "caption": caption, - } - data, err := json.Marshal(mediaPayload) - if err != nil { - return fmt.Errorf("marshal whatsapp media: %w", err) - } - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("send whatsapp media: %w", err) - } - } - // If caption was used on first media, don't send content again as text. - if msg.Media[0].Caption == "" && msg.Content != "" { - msg.Content = "" - } - } - - // Send text content (if any remains after media caption). - if msg.Content != "" { - payload := map[string]any{ - "type": "message", - "to": msg.ChatID, - "content": markdownToWhatsApp(msg.Content), - } - data, err := json.Marshal(payload) - if err != nil { - return fmt.Errorf("marshal whatsapp message: %w", err) - } - if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { - return fmt.Errorf("send whatsapp message: %w", err) - } - } - - // Stop typing loop synchronously, then send "paused" after releasing the lock. - chatID := msg.ChatID - if cancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { - cancel.(context.CancelFunc)() - } - go c.sendPresence(chatID, "paused") - - return nil -} - -// keepTyping sends "composing" presence repeatedly until ctx is cancelled. -// WhatsApp clears the typing indicator after ~10s so we refresh every 8s. -func (c *Channel) keepTyping(ctx context.Context, chatID string) { - c.sendPresence(chatID, "composing") - ticker := time.NewTicker(8 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - c.sendPresence(chatID, "composing") - } - } -} - -// sendPresence sends a WhatsApp presence update (composing / paused) to a chat. -func (c *Channel) sendPresence(to, state string) { - if err := c.SendBridgeCommand("presence", map[string]any{"to": to, "state": state}); err != nil { - slog.Debug("whatsapp: failed to send presence update", "state", state, "error", err) - } -} - -// connect establishes the WebSocket connection to the bridge. -func (c *Channel) connect() error { - dialer := websocket.DefaultDialer - dialer.HandshakeTimeout = 10 * time.Second - - conn, _, err := dialer.Dial(c.config.BridgeURL, nil) - if err != nil { - return fmt.Errorf("dial whatsapp bridge %s: %w", c.config.BridgeURL, err) - } - - c.mu.Lock() - c.conn = conn - c.connected = true - c.mu.Unlock() - - slog.Info("whatsapp bridge connected", "url", c.config.BridgeURL) return nil } -// listenLoop reads messages from the bridge with automatic reconnection. -func (c *Channel) listenLoop() { - backoff := time.Second - - for { - select { - case <-c.ctx.Done(): - return - default: - } - - c.mu.Lock() - conn := c.conn - c.mu.Unlock() - - if conn == nil { - // Not connected — attempt reconnect with backoff - slog.Info("attempting whatsapp bridge reconnect", "backoff", backoff) - - select { - case <-c.ctx.Done(): - return - case <-time.After(backoff): - } - - if err := c.connect(); err != nil { - slog.Warn("whatsapp bridge reconnect failed", "error", err) - c.MarkDegraded("Bridge unreachable", err.Error(), channels.ChannelFailureKindNetwork, true) - backoff = min(backoff*2, 30*time.Second) - continue - } - - backoff = time.Second // reset on success - continue - } - - _, message, err := conn.ReadMessage() - if err != nil { - slog.Warn("whatsapp read error, will reconnect", "error", err) - - c.mu.Lock() - if c.conn != nil { - _ = c.conn.Close() - c.conn = nil - } - c.connected = false - c.mu.Unlock() - - continue - } - - var msg map[string]any - if err := json.Unmarshal(message, &msg); err != nil { - slog.Warn("invalid whatsapp message JSON", "error", err) - continue - } - - msgType, _ := msg["type"].(string) - switch msgType { - case "message": - c.handleIncomingMessage(msg) - case "qr": - c.handleBridgeQR(msg) - case "status": - c.handleBridgeStatus(msg) - case "": - // Bridge sent a message without a "type" field — common misconfiguration. - // Expected format: {"type":"message","from":"...","chat":"...","content":"..."} - // Check your bridge: fields must be "from"/"content", not "sender"/"body". - slog.Warn("whatsapp bridge sent message without 'type' field — bridge format mismatch", - "hint", "add type:\"message\", rename sender→from, body→content", - "received_keys", mapKeys(msg), - ) - default: - slog.Debug("whatsapp bridge unknown event type, ignoring", "type", msgType) - } - } -} - -// mapKeys returns the keys of a map for diagnostic logging. -func mapKeys(m map[string]any) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) +// handleEvent dispatches whatsmeow events. +func (c *Channel) handleEvent(evt any) { + switch v := evt.(type) { + case *events.Message: + c.handleIncomingMessage(v) + case *events.Connected: + c.handleConnected() + case *events.Disconnected: + c.handleDisconnected() + case *events.LoggedOut: + c.handleLoggedOut(v) + case *events.PairSuccess: + slog.Info("whatsapp: pair success", "channel", c.Name()) } - return keys } -// handleBridgeQR processes a QR code event from the bridge. -// It generates a PNG, caches it, and broadcasts a bus event for the QR wizard. -func (c *Channel) handleBridgeQR(msg map[string]any) { - rawQR, _ := msg["data"].(string) - if rawQR == "" { - return - } - - png, err := qrcode.Encode(rawQR, qrcode.Medium, 256) - if err != nil { - slog.Warn("whatsapp: failed to encode QR PNG", "error", err) - return - } - pngB64 := base64.StdEncoding.EncodeToString(png) - +// handleConnected processes the Connected event. +func (c *Channel) handleConnected() { c.lastQRMu.Lock() - c.lastQRB64 = pngB64 + c.waAuthenticated = true + c.lastQRB64 = "" + if c.client.Store.ID != nil { + c.myJID = *c.client.Store.ID + slog.Info("whatsapp: connected", "jid", c.myJID.String(), "channel", c.Name()) + } c.lastQRMu.Unlock() - slog.Info("whatsapp bridge QR received — scan to authenticate", "channel", c.Name()) + c.MarkHealthy("WhatsApp authenticated and connected") if mb := c.Bus(); mb != nil { mb.Broadcast(bus.Event{ - Name: protocol.EventWhatsAppQRCode, + Name: protocol.EventWhatsAppQRDone, TenantID: c.TenantID(), Payload: map[string]any{ "channel_name": c.Name(), - "png_b64": pngB64, + "success": true, }, }) } } -// handleBridgeStatus processes a status event from the bridge. -// On connect, it marks the channel healthy and broadcasts a QR-done event. -// On disconnect, it marks the channel degraded (reconnect loop will retry). -func (c *Channel) handleBridgeStatus(msg map[string]any) { - connected, _ := msg["connected"].(bool) - slog.Debug("whatsapp bridge status", "connected", connected, "channel", c.Name()) +// handleDisconnected processes the Disconnected event. +func (c *Channel) handleDisconnected() { + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRMu.Unlock() + c.MarkDegraded("WhatsApp disconnected", "Waiting for reconnect", + channels.ChannelFailureKindNetwork, true) + // whatsmeow auto-reconnects — no manual reconnect loop needed. +} + +// handleLoggedOut processes the LoggedOut event. +func (c *Channel) handleLoggedOut(evt *events.LoggedOut) { + slog.Warn("whatsapp: logged out", "reason", evt.Reason, "channel", c.Name()) c.lastQRMu.Lock() - c.waAuthenticated = connected - if connected { - c.lastQRB64 = "" // clear QR — no longer needed - // Capture bot's own JID + LID for group mention detection. - // WhatsApp uses two identifier systems: phone JID and LID (Link ID). - // Mentions in groups may use either format. - if me, ok := msg["me"].(string); ok && me != "" { - c.myJID = normalizeJID(me) - slog.Info("whatsapp: bot JID set", "jid", c.myJID, "channel", c.Name()) - } - if meLID, ok := msg["me_lid"].(string); ok && meLID != "" { - c.myLID = normalizeJID(meLID) - slog.Info("whatsapp: bot LID set", "lid", c.myLID, "channel", c.Name()) - } - } + c.waAuthenticated = false c.lastQRMu.Unlock() - if connected { - c.MarkHealthy("WhatsApp authenticated and connected") - if mb := c.Bus(); mb != nil { - mb.Broadcast(bus.Event{ - Name: protocol.EventWhatsAppQRDone, - TenantID: c.TenantID(), - Payload: map[string]any{ - "channel_name": c.Name(), - "success": true, - }, - }) - } - } else { - c.MarkDegraded("WhatsApp disconnected", "Bridge reported disconnection — waiting for reconnect", channels.ChannelFailureKindNetwork, true) - } + c.MarkDegraded("WhatsApp logged out", "Re-scan QR to reconnect", + channels.ChannelFailureKindAuth, false) } -// handleIncomingMessage processes a message received from the bridge. -// Expected format: {"type":"message","from":"...","chat":"...","content":"...","id":"...","from_name":"...","media":[...]} -func (c *Channel) handleIncomingMessage(msg map[string]any) { +// handleIncomingMessage processes an incoming WhatsApp message. +func (c *Channel) handleIncomingMessage(evt *events.Message) { ctx := context.Background() ctx = store.WithTenantID(ctx, c.TenantID()) - senderID, ok := msg["from"].(string) - if !ok || senderID == "" { + + if evt.Info.IsFromMe { return } - chatID, _ := msg["chat"].(string) - if chatID == "" { - chatID = senderID - } + senderJID := evt.Info.Sender + chatJID := evt.Info.Chat + senderID := senderJID.String() + chatID := chatJID.String() - // WhatsApp groups have chatID ending in "@g.us" peerKind := "direct" - if strings.HasSuffix(chatID, "@g.us") { + if chatJID.Server == types.GroupServer { peerKind = "group" } slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, "policy", c.config.GroupPolicy) - // DM/Group policy check + // DM/Group policy check. if peerKind == "direct" { if !c.checkDMPolicy(ctx, senderID, chatID) { return @@ -461,99 +256,39 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { } } - // Allowlist check if !c.IsAllowed(senderID) { slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID) return } - content, _ := msg["content"].(string) + content := extractTextContent(evt.Message) - // Parse media items from bridge: [{type, mimetype, filename, path}, ...] var mediaList []media.MediaInfo - if mediaData, ok := msg["media"].([]any); ok { - for _, item := range mediaData { - m, ok := item.(map[string]any) - if !ok { - continue - } - filePath, _ := m["path"].(string) - if filePath == "" { - continue - } - mediaType, _ := m["type"].(string) - mimeType, _ := m["mimetype"].(string) - fileName, _ := m["filename"].(string) - mediaList = append(mediaList, media.MediaInfo{ - Type: mediaType, - FilePath: filePath, - ContentType: mimeType, - FileName: fileName, - }) - } - } + mediaList = c.downloadMedia(evt) if content == "" && len(mediaList) == 0 { - return // nothing to process + return } if content == "" { content = "[empty message]" } - metadata := make(map[string]string) - if messageID, ok := msg["id"].(string); ok { - metadata["message_id"] = messageID - } - if userName, ok := msg["from_name"].(string); ok { - metadata["user_name"] = userName - } - - // require_mention: in groups, only process when the bot is @mentioned. - // WhatsApp uses two identifier systems: phone JID (@s.whatsapp.net) and LID (@lid). - // Mentions may use either format, so we check both. - // Fails closed: if both JID and LID are unknown, treat as not-mentioned. + // Mention detection (group only). if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { - c.lastQRMu.RLock() - myJID := c.myJID - myLID := c.myLID - c.lastQRMu.RUnlock() - mentioned := false - if myJID != "" || myLID != "" { - myJIDNorm := normalizeJID(myJID) - myLIDNorm := normalizeJID(myLID) - if jids, ok := msg["mentioned_jids"].([]any); ok { - for _, j := range jids { - jid, ok := j.(string) - if !ok { - continue - } - norm := normalizeJID(jid) - if (myJIDNorm != "" && norm == myJIDNorm) || (myLIDNorm != "" && norm == myLIDNorm) { - mentioned = true - break - } - } - } - } - if !mentioned { - slog.Info("whatsapp group message skipped — bot not @mentioned", "sender_id", senderID, "my_jid", myJID, "my_lid", myLID) + if !c.isMentioned(evt) { + slog.Info("whatsapp group message skipped — bot not @mentioned", "sender_id", senderID) return } } - slog.Debug("whatsapp message received", - "sender_id", senderID, - "chat_id", chatID, - "preview", channels.Truncate(content, 50), - ) - - // Collect contact for processed messages. - if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, metadata["user_name"], "", peerKind, "user", "", "") + metadata := map[string]string{ + "message_id": string(evt.Info.ID), + } + if evt.Info.PushName != "" { + metadata["user_name"] = evt.Info.PushName } - // Build media tags (e.g. , ) - // and bus.MediaFile list for the agent pipeline. + // Build media tags and bus.MediaFile list. var mediaFiles []bus.MediaFile if len(mediaList) > 0 { mediaTags := media.BuildMediaTags(mediaList) @@ -567,30 +302,30 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { for _, m := range mediaList { if m.FilePath != "" { mediaFiles = append(mediaFiles, bus.MediaFile{ - Path: m.FilePath, - MimeType: m.ContentType, + Path: m.FilePath, MimeType: m.ContentType, }) } } } - // Annotate with sender identity so the agent knows who is messaging. + // Annotate with sender identity. if senderName := metadata["user_name"]; senderName != "" { content = fmt.Sprintf("[From: %s]\n%s", senderName, content) } - // Cancel any previous typing loop for this chat before starting a new one. - // Without this, consecutive messages leak orphaned goroutines that send - // "composing" forever (the old cancel gets overwritten in the sync.Map). + // Collect contact. + if cc := c.ContactCollector(); cc != nil { + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, + metadata["user_name"], "", peerKind, "user", "", "") + } + + // Typing indicator. if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { prevCancel.(context.CancelFunc)() } - - // Show typing indicator for the full duration of agent processing. - // WhatsApp clears "composing" after ~10s so we refresh every 8s. typingCtx, typingCancel := context.WithCancel(context.Background()) c.typingCancel.Store(chatID, typingCancel) - go c.keepTyping(typingCtx, chatID) + go c.keepTyping(typingCtx, chatJID) // Derive userID from senderID. userID := senderID @@ -612,7 +347,387 @@ func (c *Channel) handleIncomingMessage(msg map[string]any) { }) } -// checkGroupPolicy evaluates the group policy for a sender, with pairing support. +// extractTextContent extracts text from any WhatsApp message variant. +func extractTextContent(msg *waE2E.Message) string { + if msg == nil { + return "" + } + if msg.GetConversation() != "" { + return msg.GetConversation() + } + if ext := msg.GetExtendedTextMessage(); ext != nil { + return ext.GetText() + } + if img := msg.GetImageMessage(); img != nil { + return img.GetCaption() + } + if vid := msg.GetVideoMessage(); vid != nil { + return vid.GetCaption() + } + if doc := msg.GetDocumentMessage(); doc != nil { + return doc.GetCaption() + } + return "" +} + +// isMentioned checks if the bot is @mentioned in a group message. +func (c *Channel) isMentioned(evt *events.Message) bool { + c.lastQRMu.RLock() + myJID := c.myJID + c.lastQRMu.RUnlock() + + if myJID.IsEmpty() { + return false // fail closed: unknown JID = not mentioned + } + + // Check mentioned JIDs from extended text. + if ext := evt.Message.GetExtendedTextMessage(); ext != nil { + if ci := ext.GetContextInfo(); ci != nil { + for _, jidStr := range ci.GetMentionedJID() { + mentioned, _ := types.ParseJID(jidStr) + if mentioned.User == myJID.User { + return true + } + } + } + } + return false +} + +// downloadMedia downloads media attachments from a WhatsApp message. +func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo { + msg := evt.Message + if msg == nil { + return nil + } + + type mediaItem struct { + mediaType string + mimetype string + filename string + download whatsmeow.DownloadableMessage + } + + var items []mediaItem + if img := msg.GetImageMessage(); img != nil { + items = append(items, mediaItem{"image", img.GetMimetype(), "", img}) + } + if vid := msg.GetVideoMessage(); vid != nil { + items = append(items, mediaItem{"video", vid.GetMimetype(), "", vid}) + } + if aud := msg.GetAudioMessage(); aud != nil { + items = append(items, mediaItem{"audio", aud.GetMimetype(), "", aud}) + } + if doc := msg.GetDocumentMessage(); doc != nil { + items = append(items, mediaItem{"document", doc.GetMimetype(), doc.GetFileName(), doc}) + } + if stk := msg.GetStickerMessage(); stk != nil { + items = append(items, mediaItem{"sticker", stk.GetMimetype(), "", stk}) + } + + if len(items) == 0 { + return nil + } + + var result []media.MediaInfo + for _, item := range items { + data, err := c.client.Download(c.ctx, item.download) + if err != nil { + slog.Warn("whatsapp: media download failed", "type", item.mediaType, "error", err) + continue + } + if len(data) > 20*1024*1024 { // 20MB limit + slog.Warn("whatsapp: media too large, skipping", "type", item.mediaType, + "size_mb", len(data)/(1024*1024)) + continue + } + + ext := mimeToExt(item.mimetype) + tmpFile, err := os.CreateTemp("", "goclaw_wa_*"+ext) + if err != nil { + slog.Warn("whatsapp: temp file creation failed", "error", err) + continue + } + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + continue + } + tmpFile.Close() + + result = append(result, media.MediaInfo{ + Type: item.mediaType, + FilePath: tmpFile.Name(), + ContentType: item.mimetype, + FileName: item.filename, + }) + } + return result +} + +// mimeToExt maps MIME types to file extensions. +func mimeToExt(mime string) string { + switch { + case strings.HasPrefix(mime, "image/jpeg"): + return ".jpg" + case strings.HasPrefix(mime, "image/png"): + return ".png" + case strings.HasPrefix(mime, "image/webp"): + return ".webp" + case strings.HasPrefix(mime, "video/mp4"): + return ".mp4" + case strings.HasPrefix(mime, "audio/ogg"): + return ".ogg" + case strings.HasPrefix(mime, "audio/mpeg"): + return ".mp3" + case strings.Contains(mime, "pdf"): + return ".pdf" + default: + return ".bin" + } +} + +// Send delivers an outbound message to WhatsApp via whatsmeow. +func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { + if c.client == nil || !c.client.IsConnected() { + return fmt.Errorf("whatsapp not connected") + } + + chatJID, err := types.ParseJID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err) + } + + // Send media attachments first. + if len(msg.Media) > 0 { + for i, m := range msg.Media { + caption := m.Caption + if caption == "" && i == 0 && msg.Content != "" { + caption = markdownToWhatsApp(msg.Content) + } + + data, readErr := os.ReadFile(m.URL) + if readErr != nil { + return fmt.Errorf("read media file: %w", readErr) + } + + waMsg, buildErr := c.buildMediaMessage(data, m.ContentType, caption) + if buildErr != nil { + return fmt.Errorf("build media message: %w", buildErr) + } + + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + return fmt.Errorf("send whatsapp media: %w", sendErr) + } + } + // Skip text if caption was used on first media. + if msg.Media[0].Caption == "" && msg.Content != "" { + msg.Content = "" + } + } + + // Send text. + if msg.Content != "" { + waMsg := &waE2E.Message{ + Conversation: proto.String(markdownToWhatsApp(msg.Content)), + } + if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { + return fmt.Errorf("send whatsapp message: %w", err) + } + } + + // Stop typing indicator. + if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok { + cancel.(context.CancelFunc)() + } + go c.sendPresence(chatJID, types.ChatPresencePaused) + + return nil +} + +// buildMediaMessage uploads media to WhatsApp and returns the message proto. +func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) { + switch { + case strings.HasPrefix(mime, "image/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaImage) + if err != nil { + return nil, err + } + return &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "video/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaVideo) + if err != nil { + return nil, err + } + return &waE2E.Message{ + VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "audio/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaAudio) + if err != nil { + return nil, err + } + return &waE2E.Message{ + AudioMessage: &waE2E.AudioMessage{ + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + default: // document + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaDocument) + if err != nil { + return nil, err + } + return &waE2E.Message{ + DocumentMessage: &waE2E.DocumentMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + } +} + +// keepTyping sends "composing" presence repeatedly until ctx is cancelled. +func (c *Channel) keepTyping(ctx context.Context, chatJID types.JID) { + c.sendPresence(chatJID, types.ChatPresenceComposing) + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.sendPresence(chatJID, types.ChatPresenceComposing) + } + } +} + +// sendPresence sends a WhatsApp chat presence update. +func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) { + if c.client == nil || !c.client.IsConnected() { + return + } + if err := c.client.SendChatPresence(c.ctx, to, state, ""); err != nil { + slog.Debug("whatsapp: presence update failed", "state", state, "error", err) + } +} + +// StartQRFlow initiates the QR authentication flow. +// Returns a channel that emits QR code strings and auth events. +// Lazily initializes the whatsmeow client if Start() hasn't been called yet +// (handles timing race between async instance reload and wizard auto-start). +func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + if c.client == nil { + // Lazy init: wizard may request QR before Start() is called. + c.mu.Lock() + if c.client == nil { + if c.ctx == nil { + c.ctx, c.cancel = context.WithCancel(context.Background()) + } + deviceStore, err := c.container.GetFirstDevice(ctx) + if err != nil { + c.mu.Unlock() + return nil, fmt.Errorf("whatsapp get device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + } + c.mu.Unlock() + } + + if c.IsAuthenticated() { + return nil, nil // caller checks this + } + + qrChan, err := c.client.GetQRChannel(ctx) + if err != nil { + return nil, fmt.Errorf("whatsapp get QR channel: %w", err) + } + + if !c.client.IsConnected() { + if err := c.client.Connect(); err != nil { + return nil, fmt.Errorf("whatsapp connect for QR: %w", err) + } + } + + return qrChan, nil +} + +// Reauth clears the current session and prepares for a fresh QR scan. +func (c *Channel) Reauth() error { + slog.Info("whatsapp: reauth requested", "channel", c.Name()) + + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRB64 = "" + c.lastQRMu.Unlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + c.client.Disconnect() + } + + // Delete device from store to force fresh QR on next connect. + if c.client != nil && c.client.Store.ID != nil { + if err := c.client.Store.Delete(context.Background()); err != nil { + slog.Warn("whatsapp: failed to delete device store", "error", err) + } + } + + // Reset context so the new client gets a fresh lifecycle. + if c.cancel != nil { + c.cancel() + } + c.ctx, c.cancel = context.WithCancel(context.Background()) + + // Re-create client with fresh device store. + deviceStore, err := c.container.GetFirstDevice(context.Background()) + if err != nil { + return fmt.Errorf("whatsapp: get fresh device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + + return nil +} + +// checkGroupPolicy evaluates the group policy for a sender. func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { groupPolicy := c.config.GroupPolicy if groupPolicy == "" { @@ -651,7 +766,7 @@ func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) } } -// checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. +// checkDMPolicy evaluates the DM policy for a sender. func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { dmPolicy := c.config.DMPolicy if dmPolicy == "" { @@ -693,14 +808,14 @@ func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bo } } -// sendPairingReply sends a pairing code to the user via the WS bridge. +// sendPairingReply sends a pairing code to the user via WhatsApp. func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { if c.pairingService == nil { slog.Warn("whatsapp pairing: no pairing service configured") return } - // Debounce + // Debounce. if lastSent, ok := c.pairingDebounce.Load(senderID); ok { if time.Since(lastSent.(time.Time)) < pairingDebounceTime { slog.Info("whatsapp pairing: debounced", "sender_id", senderID) @@ -719,35 +834,24 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) senderID, code, code, ) - c.mu.Lock() - defer c.mu.Unlock() - - if c.conn == nil { - slog.Warn("whatsapp bridge not connected, cannot send pairing reply") + if c.client == nil || !c.client.IsConnected() { + slog.Warn("whatsapp not connected, cannot send pairing reply") return } - payload, _ := json.Marshal(map[string]any{ - "type": "message", - "to": chatID, - "content": replyText, - }) + chatJID, parseErr := types.ParseJID(chatID) + if parseErr != nil { + slog.Warn("whatsapp pairing: invalid chatID JID", "chatID", chatID, "error", parseErr) + return + } - if err := c.conn.WriteMessage(websocket.TextMessage, payload); err != nil { - slog.Warn("failed to send whatsapp pairing reply", "error", err) + waMsg := &waE2E.Message{ + Conversation: proto.String(replyText), + } + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + slog.Warn("failed to send whatsapp pairing reply", "error", sendErr) } else { c.pairingDebounce.Store(senderID, time.Now()) slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) } } - -// normalizeJID strips the Baileys device suffix from a WhatsApp JID. -// e.g. "12345678901:42@s.whatsapp.net" → "12345678901@s.whatsapp.net" -func normalizeJID(jid string) string { - colonIdx := strings.Index(jid, ":") - atIdx := strings.Index(jid, "@") - if colonIdx > 0 && atIdx > colonIdx { - return jid[:colonIdx] + jid[atIdx:] - } - return jid -} diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 4b14e3ecc..c465bb152 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -133,7 +133,7 @@ type SlackConfig struct { type WhatsAppConfig struct { Enabled bool `json:"enabled"` - BridgeURL string `json:"bridge_url"` + AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop) AllowFrom FlexibleStringSlice `json:"allow_from"` DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled" GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled" diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 38b3319e4..0c5c7f640 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -120,7 +120,7 @@ func (c *Config) applyEnvOverrides() { envStr("GOCLAW_LARK_APP_SECRET", &c.Channels.Feishu.AppSecret) envStr("GOCLAW_LARK_ENCRYPT_KEY", &c.Channels.Feishu.EncryptKey) envStr("GOCLAW_LARK_VERIFICATION_TOKEN", &c.Channels.Feishu.VerificationToken) - envStr("GOCLAW_WHATSAPP_BRIDGE_URL", &c.Channels.WhatsApp.BridgeURL) + // WhatsApp no longer needs bridge_url — runs natively via whatsmeow. envStr("GOCLAW_SLACK_BOT_TOKEN", &c.Channels.Slack.BotToken) envStr("GOCLAW_SLACK_APP_TOKEN", &c.Channels.Slack.AppToken) envStr("GOCLAW_SLACK_USER_TOKEN", &c.Channels.Slack.UserToken) @@ -144,9 +144,7 @@ func (c *Config) applyEnvOverrides() { if c.Channels.Feishu.AppID != "" && c.Channels.Feishu.AppSecret != "" { c.Channels.Feishu.Enabled = true } - if c.Channels.WhatsApp.BridgeURL != "" { - c.Channels.WhatsApp.Enabled = true - } + // WhatsApp is enabled via config or DB instances (no bridge_url needed). if c.Channels.Slack.BotToken != "" && c.Channels.Slack.AppToken != "" { c.Channels.Slack.Enabled = true } diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 4e1e3d255..166da80ff 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -223,7 +223,6 @@ "help": "For webhook mode" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "Bridge URL" }, "api_server": { "label": "API Server URL", "help": "Custom Telegram Bot API server for large file uploads (up to 2GB). Leave empty for default." diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index 46f6ab241..a0b99cf3b 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -213,7 +213,6 @@ "encrypt_key": { "label": "Khóa mã hóa", "help": "Cho chế độ webhook" }, "verification_token": { "label": "Token xác minh", "help": "Cho chế độ webhook" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "URL Bridge" }, "api_server": { "label": "URL máy chủ API", "help": "Máy chủ API Bot Telegram tùy chỉnh cho tải file lớn (lên đến 2GB). Để trống cho mặc định." }, "proxy": { "label": "Proxy HTTP", "help": "Định tuyến lưu lượng bot qua proxy HTTP" }, "dm_policy": { "label": "Chính sách DM" }, diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index e023c60e6..478ce36f8 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -213,7 +213,6 @@ "encrypt_key": { "label": "加密密钥", "help": "用于Webhook模式" }, "verification_token": { "label": "验证Token", "help": "用于Webhook模式" }, "webhook_secret": { "label": "Webhook Secret" }, - "bridge_url": { "label": "Bridge URL" }, "api_server": { "label": "API 服务器 URL", "help": "自定义 Telegram Bot API 服务器用于大文件上传(最大 2GB)。留空使用默认服务器。" }, "proxy": { "label": "HTTP 代理", "help": "通过 HTTP 代理路由Bot流量" }, "dm_policy": { "label": "私聊策略" }, diff --git a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx index c5d096b0b..858e93125 100644 --- a/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx +++ b/ui/web/src/pages/channels/channel-detail/channel-advanced-dialog.tsx @@ -22,7 +22,7 @@ interface ChannelAdvancedDialogProps { const ESSENTIAL_CONFIG_KEYS = new Set(["dm_policy", "group_policy", "require_mention", "mention_mode"]); -const NETWORK_KEYS = new Set(["bridge_url", "api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]); +const NETWORK_KEYS = new Set(["api_server", "proxy", "domain", "connection_mode", "webhook_port", "webhook_path", "webhook_url"]); const LIMITS_KEYS = new Set(["history_limit", "media_max_mb", "text_chunk_limit"]); const STREAMING_KEYS = new Set(["dm_stream", "group_stream", "draft_transport", "reasoning_stream", "native_stream", "debounce_delay", "thread_ttl"]); const BEHAVIOR_KEYS = new Set(["reaction_level", "link_preview", "block_reply", "render_mode", "topic_session_mode"]); diff --git a/ui/web/src/pages/channels/channel-schemas.ts b/ui/web/src/pages/channels/channel-schemas.ts index bda787ca0..ba92f4527 100644 --- a/ui/web/src/pages/channels/channel-schemas.ts +++ b/ui/web/src/pages/channels/channel-schemas.ts @@ -147,7 +147,6 @@ export const configSchema: Record = { { key: "block_reply", label: "Block Reply", type: "select", options: blockReplyOptions, defaultValue: "inherit", help: "Deliver intermediate text during tool iterations" }, ], whatsapp: [ - { key: "bridge_url", label: "Bridge URL", type: "text", required: true, placeholder: "ws://whatsapp-bridge:3001", help: "WebSocket URL of your WhatsApp bridge (use ws:// not http://)" }, { key: "dm_policy", label: "DM Policy", type: "select", options: dmPolicyOptions, defaultValue: "pairing" }, { key: "group_policy", label: "Group Policy", type: "select", options: groupPolicyOptions, defaultValue: "pairing" }, { key: "require_mention", label: "Require @Mention in Groups", type: "boolean", help: "Only respond in group chats when the bot is explicitly @mentioned" }, diff --git a/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx b/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx index 3863bc91e..39a112811 100644 --- a/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx +++ b/ui/web/src/pages/channels/whatsapp/whatsapp-wizard-steps.tsx @@ -1,5 +1,5 @@ // WhatsApp wizard step components for the channel create wizard. -// The QR code comes from the external Baileys bridge, forwarded via GoClaw's WS bus. +// QR auth is driven directly by whatsmeow's GetQRChannel(), delivered via WS events. // Registered in channel-wizard-registry.tsx. import { useEffect } from "react"; From 531bd7da14ec057f0e3d2b92adf01979212f73d7 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 17:46:27 +0700 Subject: [PATCH 09/22] feat(whatsapp): show "GoClaw" as device name in Linked Devices Set wastore.DeviceProps.Os so WhatsApp displays "GoClaw" instead of "Other device" in the phone's Linked Devices screen. --- internal/channels/whatsapp/whatsapp.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 7c1f8053c..d5769e8fe 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -12,6 +12,7 @@ import ( "go.mau.fi/whatsmeow" "go.mau.fi/whatsmeow/proto/waE2E" + wastore "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" "go.mau.fi/whatsmeow/types/events" @@ -88,6 +89,9 @@ func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom) base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) + // Set device name shown in WhatsApp's "Linked Devices" screen. + wastore.DeviceProps.Os = proto.String("GoClaw") + dialect := detectDialect(db) container := sqlstore.NewWithDB(db, dialect, nil) if err := container.Upgrade(context.Background()); err != nil { From 7b28f2320cd801a1c3629b7b46a0a70d543dceed Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 17:53:44 +0700 Subject: [PATCH 10/22] fix(whatsapp): wait for channel registration before QR start After instance creation, the async reload may not have registered the channel in the manager yet. Poll up to 5s (10x500ms) for the channel to appear before returning "channel not found" error. --- internal/channels/whatsapp/qr_methods.go | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/internal/channels/whatsapp/qr_methods.go b/internal/channels/whatsapp/qr_methods.go index 15ef2c8b1..60a0b10e8 100644 --- a/internal/channels/whatsapp/qr_methods.go +++ b/internal/channels/whatsapp/qr_methods.go @@ -82,8 +82,23 @@ func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, defer entry.cancel() defer m.activeSessions.CompareAndDelete(instanceIDStr, entry) - ch, ok := m.manager.GetChannel(channelName) - if !ok { + // Wait for channel to appear in manager — instance creation triggers an async + // reload, so the channel may not be registered yet when the wizard fires QR start. + var wa *Channel + for attempt := 0; attempt < 10; attempt++ { + if ch, ok := m.manager.GetChannel(channelName); ok { + if w, ok := ch.(*Channel); ok { + wa = w + break + } + } + select { + case <-ctx.Done(): + return + case <-time.After(500 * time.Millisecond): + } + } + if wa == nil { client.SendEvent(goclawprotocol.EventFrame{ Type: goclawprotocol.FrameTypeEvent, Event: goclawprotocol.EventWhatsAppQRDone, @@ -96,11 +111,6 @@ func (m *QRMethods) runQRSession(ctx context.Context, entry *cancelEntry, return } - wa, ok := ch.(*Channel) - if !ok { - return - } - // Already authenticated and no force-reauth → signal connected. if wa.IsAuthenticated() && !forceReauth { client.SendEvent(goclawprotocol.EventFrame{ From 36268201537cdda48b4d88b81b8f4d67b2a5520b Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:09:08 +0700 Subject: [PATCH 11/22] fix(whatsapp): handle LID addressing for group messages WhatsApp uses dual identity: phone JID and LID (Link ID). Group messages may use LID addressing where evt.Info.Sender is in LID format. This caused policy checks, pairing lookups, and allowlists to fail silently for group messages. - Normalize LID-addressed senders to phone JID via SenderAlt - Capture bot's LID from device store on connect - Check both JID and LID in group mention detection --- internal/channels/whatsapp/whatsapp.go | 29 ++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index d5769e8fe..30e750a08 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -46,7 +46,8 @@ type Channel struct { lastQRMu sync.RWMutex lastQRB64 string // base64-encoded PNG, empty when authenticated waAuthenticated bool // true once WhatsApp account is connected - myJID types.JID // bot's own JID for mention detection + myJID types.JID // bot's phone JID for mention detection + myLID types.JID // bot's Link ID (LID) — WhatsApp's newer identifier // typingCancel tracks active typing-refresh loops per chatID. typingCancel sync.Map // chatID string → context.CancelFunc @@ -187,7 +188,9 @@ func (c *Channel) handleConnected() { c.lastQRB64 = "" if c.client.Store.ID != nil { c.myJID = *c.client.Store.ID - slog.Info("whatsapp: connected", "jid", c.myJID.String(), "channel", c.Name()) + c.myLID = c.client.Store.GetLID() + slog.Info("whatsapp: connected", "jid", c.myJID.String(), + "lid", c.myLID.String(), "channel", c.Name()) } c.lastQRMu.Unlock() @@ -238,6 +241,14 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { senderJID := evt.Info.Sender chatJID := evt.Info.Chat + + // WhatsApp uses dual identity: phone JID (@s.whatsapp.net) and LID (@lid). + // Groups may use LID addressing. Normalize to phone JID for consistent + // policy checks, pairing lookups, allowlists, and contact collection. + if evt.Info.AddressingMode == types.AddressingModeLID && !evt.Info.SenderAlt.IsEmpty() { + senderJID = evt.Info.SenderAlt + } + senderID := senderJID.String() chatID := chatJID.String() @@ -246,7 +257,8 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { peerKind = "group" } - slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, "policy", c.config.GroupPolicy) + slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, + "addressing", evt.Info.AddressingMode, "policy", c.config.GroupPolicy) // DM/Group policy check. if peerKind == "direct" { @@ -375,13 +387,15 @@ func extractTextContent(msg *waE2E.Message) string { } // isMentioned checks if the bot is @mentioned in a group message. +// WhatsApp uses dual identity: phone JID and LID. Mentions may use either format. func (c *Channel) isMentioned(evt *events.Message) bool { c.lastQRMu.RLock() myJID := c.myJID + myLID := c.myLID c.lastQRMu.RUnlock() - if myJID.IsEmpty() { - return false // fail closed: unknown JID = not mentioned + if myJID.IsEmpty() && myLID.IsEmpty() { + return false // fail closed: unknown identity = not mentioned } // Check mentioned JIDs from extended text. @@ -389,7 +403,10 @@ func (c *Channel) isMentioned(evt *events.Message) bool { if ci := ext.GetContextInfo(); ci != nil { for _, jidStr := range ci.GetMentionedJID() { mentioned, _ := types.ParseJID(jidStr) - if mentioned.User == myJID.User { + if !myJID.IsEmpty() && mentioned.User == myJID.User { + return true + } + if !myLID.IsEmpty() && mentioned.User == myLID.User { return true } } From e3ca6632e7d898822ce6e26c170efa8e39fe6cb2 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:09:53 +0700 Subject: [PATCH 12/22] =?UTF-8?q?fix(whatsapp):=20correct=20comments=20?= =?UTF-8?q?=E2=80=94=20personal=20account,=20not=20bot?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp channel links a personal account as a device, not a bot. Updated comments and user-facing pairing text accordingly. --- internal/channels/whatsapp/whatsapp.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 30e750a08..2f3d2cbf4 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -46,8 +46,8 @@ type Channel struct { lastQRMu sync.RWMutex lastQRB64 string // base64-encoded PNG, empty when authenticated waAuthenticated bool // true once WhatsApp account is connected - myJID types.JID // bot's phone JID for mention detection - myLID types.JID // bot's Link ID (LID) — WhatsApp's newer identifier + myJID types.JID // linked account's phone JID for mention detection + myLID types.JID // linked account's LID — WhatsApp's newer identifier // typingCancel tracks active typing-refresh loops per chatID. typingCancel sync.Map // chatID string → context.CancelFunc @@ -292,7 +292,7 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { // Mention detection (group only). if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { if !c.isMentioned(evt) { - slog.Info("whatsapp group message skipped — bot not @mentioned", "sender_id", senderID) + slog.Info("whatsapp group message skipped — not @mentioned", "sender_id", senderID) return } } @@ -386,7 +386,7 @@ func extractTextContent(msg *waE2E.Message) string { return "" } -// isMentioned checks if the bot is @mentioned in a group message. +// isMentioned checks if the linked account is @mentioned in a group message. // WhatsApp uses dual identity: phone JID and LID. Mentions may use either format. func (c *Channel) isMentioned(evt *events.Message) bool { c.lastQRMu.RLock() @@ -851,7 +851,7 @@ func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) } replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the bot owner to approve with:\n goclaw pairing approve %s", + "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s", senderID, code, code, ) From cf8f55ac188979b335c918318e304cd1733949cb Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:15:58 +0700 Subject: [PATCH 13/22] feat(whatsapp): include quoted message context in replies When a user replies to a message with @mention, extract the quoted (replied-to) message text and prepend it as [Replying to: ...]. Without this, the agent only saw the mention text and had no context about what the user was referring to. --- internal/channels/whatsapp/whatsapp.go | 44 +++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 2f3d2cbf4..435a1dfcf 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -364,25 +364,59 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { } // extractTextContent extracts text from any WhatsApp message variant. +// Includes quoted message context when present (reply-to messages). func extractTextContent(msg *waE2E.Message) string { if msg == nil { return "" } + + var text string + var quotedText string + + if msg.GetConversation() != "" { + text = msg.GetConversation() + } else if ext := msg.GetExtendedTextMessage(); ext != nil { + text = ext.GetText() + // Extract quoted (replied-to) message text. + if ci := ext.GetContextInfo(); ci != nil { + if qm := ci.GetQuotedMessage(); qm != nil { + quotedText = extractQuotedText(qm) + } + } + } else if img := msg.GetImageMessage(); img != nil { + text = img.GetCaption() + } else if vid := msg.GetVideoMessage(); vid != nil { + text = vid.GetCaption() + } else if doc := msg.GetDocumentMessage(); doc != nil { + text = doc.GetCaption() + } + + if quotedText != "" && text != "" { + return fmt.Sprintf("[Replying to: %s]\n%s", quotedText, text) + } + if quotedText != "" { + return fmt.Sprintf("[Replying to: %s]", quotedText) + } + return text +} + +// extractQuotedText extracts plain text from a quoted message (no recursion). +func extractQuotedText(msg *waE2E.Message) string { + if msg == nil { + return "" + } if msg.GetConversation() != "" { return msg.GetConversation() } if ext := msg.GetExtendedTextMessage(); ext != nil { return ext.GetText() } - if img := msg.GetImageMessage(); img != nil { + if img := msg.GetImageMessage(); img != nil && img.GetCaption() != "" { return img.GetCaption() } - if vid := msg.GetVideoMessage(); vid != nil { + if vid := msg.GetVideoMessage(); vid != nil && vid.GetCaption() != "" { return vid.GetCaption() } - if doc := msg.GetDocumentMessage(); doc != nil { - return doc.GetCaption() - } return "" } From f705cd18614eb652e070f0ca66cd1df718096cbc Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:19:28 +0700 Subject: [PATCH 14/22] feat(whatsapp): add group pending history for message context When require_mention is enabled, record non-mentioned group messages in PendingHistory (matching Telegram/Slack/Discord pattern). When the account IS mentioned, prepend accumulated group context so the LLM has conversational context from the group chat. --- cmd/gateway.go | 2 +- cmd/gateway_channels_setup.go | 2 +- internal/channels/whatsapp/factory.go | 4 ++-- internal/channels/whatsapp/whatsapp.go | 23 ++++++++++++++++++++--- 4 files changed, 24 insertions(+), 7 deletions(-) diff --git a/cmd/gateway.go b/cmd/gateway.go index fcceea8e1..72d5b6a32 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -563,7 +563,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory) instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) - instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB)) + instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index 2c27a6228..a9f452487 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -68,7 +68,7 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } if cfg.Channels.WhatsApp.Enabled { - wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB) + wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages) if err != nil { channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index 1d96a843b..c7e22a9f8 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -21,7 +21,7 @@ type whatsappInstanceConfig struct { } // FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state. -func FactoryWithDB(db *sql.DB) channels.ChannelFactory { +func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore) channels.ChannelFactory { return func(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { @@ -61,7 +61,7 @@ func FactoryWithDB(db *sql.DB) channels.ChannelFactory { waCfg.GroupPolicy = "pairing" } - ch, err := New(waCfg, msgBus, pairingSvc, db) + ch, err := New(waCfg, msgBus, pairingSvc, db, pendingStore) if err != nil { return nil, err } diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 435a1dfcf..dd22ff0dc 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -41,6 +41,7 @@ type Channel struct { pairingService store.PairingStore pairingDebounce sync.Map // senderID → time.Time approvedGroups sync.Map // chatID → true (in-memory cache for paired groups) + groupHistory *channels.PendingHistory // tracks group messages for context // QR state lastQRMu sync.RWMutex @@ -85,7 +86,8 @@ func detectDialect(db *sql.DB) string { // New creates a new WhatsApp channel backed by whatsmeow. func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, - pairingSvc store.PairingStore, db *sql.DB) (*Channel, error) { + pairingSvc store.PairingStore, db *sql.DB, + pendingStore store.PendingMessageStore) (*Channel, error) { base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom) base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) @@ -104,6 +106,7 @@ func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, config: cfg, pairingService: pairingSvc, container: container, + groupHistory: channels.MakeHistory("whatsapp", pendingStore, base.TenantID()), }, nil } @@ -289,12 +292,26 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { content = "[empty message]" } - // Mention detection (group only). + // Group history + mention detection. if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { if !c.isMentioned(evt) { - slog.Info("whatsapp group message skipped — not @mentioned", "sender_id", senderID) + // Not mentioned — record for context and skip. + senderLabel := evt.Info.PushName + if senderLabel == "" { + senderLabel = senderID + } + c.groupHistory.Record(chatID, channels.HistoryEntry{ + Sender: senderLabel, + SenderID: senderID, + Body: content, + Timestamp: evt.Info.Timestamp, + MessageID: string(evt.Info.ID), + }, channels.DefaultGroupHistoryLimit) return } + // Mentioned — prepend accumulated group context. + content = c.groupHistory.BuildContext(chatID, content, channels.DefaultGroupHistoryLimit) + c.groupHistory.Clear(chatID) } metadata := map[string]string{ From 192f36a1e96677426dfb244cbf0f29c1913a38d8 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:24:11 +0700 Subject: [PATCH 15/22] feat(whatsapp): add message chunking and configurable history limit - Chunk outbound messages at 4096 chars, splitting at paragraph/line/ word boundaries to avoid truncation on long responses - Add history_limit config (default 200) matching Telegram/Slack/Discord - Pass PendingMessageStore for DB-persisted group history --- internal/channels/whatsapp/factory.go | 2 ++ internal/channels/whatsapp/format.go | 28 +++++++++++++++++++++++++ internal/channels/whatsapp/whatsapp.go | 29 ++++++++++++++++++-------- internal/config/config_channels.go | 3 ++- 4 files changed, 52 insertions(+), 10 deletions(-) diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index c7e22a9f8..c9923a170 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -16,6 +16,7 @@ type whatsappInstanceConfig struct { DMPolicy string `json:"dm_policy,omitempty"` GroupPolicy string `json:"group_policy,omitempty"` RequireMention *bool `json:"require_mention,omitempty"` + HistoryLimit int `json:"history_limit,omitempty"` AllowFrom []string `json:"allow_from,omitempty"` BlockReply *bool `json:"block_reply,omitempty"` } @@ -54,6 +55,7 @@ func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore) channels. DMPolicy: ic.DMPolicy, GroupPolicy: ic.GroupPolicy, RequireMention: ic.RequireMention, + HistoryLimit: ic.HistoryLimit, BlockReply: ic.BlockReply, } // DB instances default to "pairing" for groups (secure by default). diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go index e75329b1e..0ac14f9d7 100644 --- a/internal/channels/whatsapp/format.go +++ b/internal/channels/whatsapp/format.go @@ -107,3 +107,31 @@ func waExtractCodeBlocks(text string) waCodeBlockMatch { return waCodeBlockMatch{text: text, codes: codes} } + +// chunkText splits text into pieces that fit within maxLen, +// preferring to split at paragraph (\n\n) or line (\n) boundaries. +func chunkText(text string, maxLen int) []string { + if len(text) <= maxLen { + return []string{text} + } + + var chunks []string + for len(text) > 0 { + if len(text) <= maxLen { + chunks = append(chunks, text) + break + } + // Find the best split point: paragraph > line > space > hard cut. + cutAt := maxLen + if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 { + cutAt = idx + } else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 { + cutAt = idx + } else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 { + cutAt = idx + } + chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n")) + text = strings.TrimLeft(text[cutAt:], " \n") + } + return chunks +} diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index dd22ff0dc..de20d6433 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -26,7 +26,10 @@ import ( "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) -const pairingDebounceTime = 60 * time.Second +const ( + pairingDebounceTime = 60 * time.Second + maxMessageLen = 4096 // WhatsApp practical message length limit +) // Channel connects directly to WhatsApp via go.mau.fi/whatsmeow. // Auth state is stored in PostgreSQL (standard) or SQLite (desktop). @@ -293,6 +296,10 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { } // Group history + mention detection. + historyLimit := c.config.HistoryLimit + if historyLimit == 0 { + historyLimit = channels.DefaultGroupHistoryLimit + } if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { if !c.isMentioned(evt) { // Not mentioned — record for context and skip. @@ -306,11 +313,11 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { Body: content, Timestamp: evt.Info.Timestamp, MessageID: string(evt.Info.ID), - }, channels.DefaultGroupHistoryLimit) + }, historyLimit) return } // Mentioned — prepend accumulated group context. - content = c.groupHistory.BuildContext(chatID, content, channels.DefaultGroupHistoryLimit) + content = c.groupHistory.BuildContext(chatID, content, historyLimit) c.groupHistory.Clear(chatID) } @@ -598,13 +605,17 @@ func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { } } - // Send text. + // Send text (chunked if exceeding limit). if msg.Content != "" { - waMsg := &waE2E.Message{ - Conversation: proto.String(markdownToWhatsApp(msg.Content)), - } - if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { - return fmt.Errorf("send whatsapp message: %w", err) + formatted := markdownToWhatsApp(msg.Content) + chunks := chunkText(formatted, maxMessageLen) + for _, chunk := range chunks { + waMsg := &waE2E.Message{ + Conversation: proto.String(chunk), + } + if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { + return fmt.Errorf("send whatsapp message: %w", err) + } } } diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index c465bb152..0f31e5f1a 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -133,11 +133,12 @@ type SlackConfig struct { type WhatsAppConfig struct { Enabled bool `json:"enabled"` - AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop) + AuthDir string `json:"auth_dir,omitempty"` // optional: SQLite auth dir override (desktop) AllowFrom FlexibleStringSlice `json:"allow_from"` DMPolicy string `json:"dm_policy,omitempty"` // "pairing" (default for DB instances), "open", "allowlist", "disabled" GroupPolicy string `json:"group_policy,omitempty"` // "pairing" (default for DB instances), "open" (default for config), "allowlist", "disabled" RequireMention *bool `json:"require_mention,omitempty"` // only respond in groups when bot is @mentioned (default false) + HistoryLimit int `json:"history_limit,omitempty"` // max pending group messages for context (default 200, 0=disabled) BlockReply *bool `json:"block_reply,omitempty"` // override gateway block_reply (nil = inherit) } From 1c8b846c83fddf066d31f7e90622a77d207b42ee Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:33:34 +0700 Subject: [PATCH 16/22] fix(whatsapp): correct doctor/channels configured check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WhatsApp has no token — show as configured only when enabled, not unconditionally true. --- cmd/channels_cmd.go | 2 +- cmd/doctor.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/channels_cmd.go b/cmd/channels_cmd.go index d3f8ed9bf..4353bbfc4 100644 --- a/cmd/channels_cmd.go +++ b/cmd/channels_cmd.go @@ -44,7 +44,7 @@ func channelsListCmd() *cobra.Command { {"discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != ""}, {"zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != ""}, {"feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != ""}, - {"whatsapp", cfg.Channels.WhatsApp.Enabled, true}, + {"whatsapp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled}, } if jsonOutput { diff --git a/cmd/doctor.go b/cmd/doctor.go index 1375bc369..055d554e4 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -120,7 +120,7 @@ func runDoctor() { checkChannel("Discord", cfg.Channels.Discord.Enabled, cfg.Channels.Discord.Token != "") checkChannel("Zalo", cfg.Channels.Zalo.Enabled, cfg.Channels.Zalo.Token != "") checkChannel("Feishu", cfg.Channels.Feishu.Enabled, cfg.Channels.Feishu.AppID != "") - checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, true) + checkChannel("WhatsApp", cfg.Channels.WhatsApp.Enabled, cfg.Channels.WhatsApp.Enabled) } // External tools From dac6e29bb40715c71aea953bce8b430dd1e26466 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:45:39 +0700 Subject: [PATCH 17/22] feat(whatsapp): render markdown tables as ASCII in code blocks WhatsApp has no native table support. Markdown tables now render as ASCII-aligned text inside ``` blocks (monospace), matching Telegram's approach. Tables are detected before code block extraction so they don't get mangled by other formatting passes. --- internal/channels/whatsapp/format.go | 164 +++++++++++++++++++++++---- 1 file changed, 143 insertions(+), 21 deletions(-) diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go index 0ac14f9d7..28b564e62 100644 --- a/internal/channels/whatsapp/format.go +++ b/internal/channels/whatsapp/format.go @@ -4,6 +4,22 @@ import ( "fmt" "regexp" "strings" + "unicode/utf8" +) + +// Pre-compiled regex patterns for markdownToWhatsApp — avoids re-compilation per call. +var ( + reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`) + reBlockquote = regexp.MustCompile(`(?m)^>\s*(.*)$`) + reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + reBoldStars = regexp.MustCompile(`\*\*(.+?)\*\*`) + reBoldUnder = regexp.MustCompile(`__(.+?)__`) + reStrikethrough = regexp.MustCompile(`~~(.+?)~~`) + reListItem = regexp.MustCompile(`(?m)^[-*]\s+`) + reBlankLines = regexp.MustCompile(`\n{3,}`) + reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + reTableLine = regexp.MustCompile(`^\|.*\|$`) + reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`) ) // markdownToWhatsApp converts Markdown-formatted LLM output to WhatsApp's native @@ -17,31 +33,31 @@ func markdownToWhatsApp(text string) string { // Pre-process: convert HTML tags from LLM output to Markdown equivalents. text = htmlTagToWaMd(text) + // Extract and render markdown tables as ASCII-aligned text in ``` blocks. + text = waRenderTables(text) + // Extract and protect fenced code blocks — WhatsApp renders ``` the same way. codeBlocks := waExtractCodeBlocks(text) text = codeBlocks.text // Headers (##, ###, etc.) → *bold text* (WhatsApp has no header concept). - text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "*$1*") + text = reHeader.ReplaceAllString(text, "*$1*") // Blockquotes → plain text. - text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1") + text = reBlockquote.ReplaceAllString(text, "$1") // Links [text](url) → "text url" (WhatsApp doesn't support markdown links). - text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, "$1 $2") + text = reLink.ReplaceAllString(text, "$1 $2") // Bold: **text** or __text__ → *text* - text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "*$1*") - text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "*$1*") + text = reBoldStars.ReplaceAllString(text, "*$1*") + text = reBoldUnder.ReplaceAllString(text, "*$1*") // Strikethrough: ~~text~~ → ~text~ - text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "~$1~") - - // Inline code: `code` → ```code``` (WhatsApp has no inline code, only blocks). - text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "```$1```") + text = reStrikethrough.ReplaceAllString(text, "~$1~") // List items: leading - or * → bullet • - text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ") + text = reListItem.ReplaceAllString(text, "• ") // Restore code blocks as ``` … ``` preserving original content. for i, code := range codeBlocks.codes { @@ -51,7 +67,7 @@ func markdownToWhatsApp(text string) string { } // Collapse 3+ blank lines to 2. - text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + text = reBlankLines.ReplaceAllString(text, "\n\n") return strings.TrimSpace(text) } @@ -90,8 +106,7 @@ type waCodeBlockMatch struct { // waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with // \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents. func waExtractCodeBlocks(text string) waCodeBlockMatch { - re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - matches := re.FindAllStringSubmatch(text, -1) + matches := reCodeBlock.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, m := range matches { @@ -99,7 +114,7 @@ func waExtractCodeBlocks(text string) waCodeBlockMatch { } i := 0 - text = re.ReplaceAllStringFunc(text, func(_ string) string { + text = reCodeBlock.ReplaceAllStringFunc(text, func(_ string) string { placeholder := fmt.Sprintf("\x00CB%d\x00", i) i++ return placeholder @@ -108,26 +123,122 @@ func waExtractCodeBlocks(text string) waCodeBlockMatch { return waCodeBlockMatch{text: text, codes: codes} } -// chunkText splits text into pieces that fit within maxLen, +// waRenderTables finds markdown tables and renders them as ASCII-aligned +// text inside ``` blocks (monospace) for WhatsApp readability. +func waRenderTables(text string) string { + lines := strings.Split(text, "\n") + var result []string + i := 0 + for i < len(lines) { + // Detect table: header line followed by separator line. + if i+1 < len(lines) && reTableLine.MatchString(lines[i]) && reTableSep.MatchString(lines[i+1]) { + tableStart := i + i += 2 // skip header + separator + for i < len(lines) && reTableLine.MatchString(lines[i]) { + i++ + } + rendered := renderTable(lines[tableStart:i]) + result = append(result, "```\n"+rendered+"\n```") + } else { + result = append(result, lines[i]) + i++ + } + } + return strings.Join(result, "\n") +} + +// renderTable converts markdown table lines into ASCII-aligned text. +func renderTable(lines []string) string { + if len(lines) < 2 { + return strings.Join(lines, "\n") + } + + var rows [][]string + for i, line := range lines { + if i == 1 { + continue // skip separator + } + rows = append(rows, parseTableCells(line)) + } + if len(rows) == 0 { + return "" + } + + // Determine column widths. + numCols := 0 + for _, row := range rows { + if len(row) > numCols { + numCols = len(row) + } + } + widths := make([]int, numCols) + for _, row := range rows { + for j, cell := range row { + if utf8.RuneCountInString(cell) > widths[j] { + widths[j] = utf8.RuneCountInString(cell) + } + } + } + + // Render aligned rows. + var out []string + for ri, row := range rows { + var cells []string + for j := 0; j < numCols; j++ { + cell := "" + if j < len(row) { + cell = row[j] + } + pad := widths[j] - utf8.RuneCountInString(cell) + cells = append(cells, cell+strings.Repeat(" ", pad)) + } + out = append(out, strings.Join(cells, " ")) + // Add separator after header. + if ri == 0 { + var sep []string + for _, w := range widths { + sep = append(sep, strings.Repeat("─", w)) + } + out = append(out, strings.Join(sep, "──")) + } + } + return strings.Join(out, "\n") +} + +// parseTableCells splits a markdown table row into trimmed cells. +func parseTableCells(line string) []string { + line = strings.Trim(line, "|") + parts := strings.Split(line, "|") + cells := make([]string, len(parts)) + for i, p := range parts { + cells[i] = strings.TrimSpace(p) + } + return cells +} + +// chunkText splits text into pieces that fit within maxLen runes, // preferring to split at paragraph (\n\n) or line (\n) boundaries. +// Uses rune count (not byte count) so multi-byte characters are handled correctly. func chunkText(text string, maxLen int) []string { - if len(text) <= maxLen { + if utf8.RuneCountInString(text) <= maxLen { return []string{text} } var chunks []string for len(text) > 0 { - if len(text) <= maxLen { + if utf8.RuneCountInString(text) <= maxLen { chunks = append(chunks, text) break } + // Convert rune limit to byte offset for slicing. + byteLimit := runeOffset(text, maxLen) // Find the best split point: paragraph > line > space > hard cut. - cutAt := maxLen - if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 { + cutAt := byteLimit + if idx := strings.LastIndex(text[:byteLimit], "\n\n"); idx > 0 { cutAt = idx - } else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 { + } else if idx := strings.LastIndex(text[:byteLimit], "\n"); idx > 0 { cutAt = idx - } else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 { + } else if idx := strings.LastIndex(text[:byteLimit], " "); idx > 0 { cutAt = idx } chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n")) @@ -135,3 +246,14 @@ func chunkText(text string, maxLen int) []string { } return chunks } + +// runeOffset returns the byte offset of the n-th rune in s. +// If n exceeds the rune count, returns len(s). +func runeOffset(s string, n int) int { + offset := 0 + for i := 0; i < n && offset < len(s); i++ { + _, size := utf8.DecodeRuneInString(s[offset:]) + offset += size + } + return offset +} From bb863d3c28f289f347f3da536d5ec33d4cc3284b Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 18:50:31 +0700 Subject: [PATCH 18/22] Revert "feat(whatsapp): render markdown tables as ASCII in code blocks" This reverts commit dac6e29bb40715c71aea953bce8b430dd1e26466. --- internal/channels/whatsapp/format.go | 164 ++++----------------------- 1 file changed, 21 insertions(+), 143 deletions(-) diff --git a/internal/channels/whatsapp/format.go b/internal/channels/whatsapp/format.go index 28b564e62..0ac14f9d7 100644 --- a/internal/channels/whatsapp/format.go +++ b/internal/channels/whatsapp/format.go @@ -4,22 +4,6 @@ import ( "fmt" "regexp" "strings" - "unicode/utf8" -) - -// Pre-compiled regex patterns for markdownToWhatsApp — avoids re-compilation per call. -var ( - reHeader = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`) - reBlockquote = regexp.MustCompile(`(?m)^>\s*(.*)$`) - reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) - reBoldStars = regexp.MustCompile(`\*\*(.+?)\*\*`) - reBoldUnder = regexp.MustCompile(`__(.+?)__`) - reStrikethrough = regexp.MustCompile(`~~(.+?)~~`) - reListItem = regexp.MustCompile(`(?m)^[-*]\s+`) - reBlankLines = regexp.MustCompile(`\n{3,}`) - reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") - reTableLine = regexp.MustCompile(`^\|.*\|$`) - reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`) ) // markdownToWhatsApp converts Markdown-formatted LLM output to WhatsApp's native @@ -33,31 +17,31 @@ func markdownToWhatsApp(text string) string { // Pre-process: convert HTML tags from LLM output to Markdown equivalents. text = htmlTagToWaMd(text) - // Extract and render markdown tables as ASCII-aligned text in ``` blocks. - text = waRenderTables(text) - // Extract and protect fenced code blocks — WhatsApp renders ``` the same way. codeBlocks := waExtractCodeBlocks(text) text = codeBlocks.text // Headers (##, ###, etc.) → *bold text* (WhatsApp has no header concept). - text = reHeader.ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`).ReplaceAllString(text, "*$1*") // Blockquotes → plain text. - text = reBlockquote.ReplaceAllString(text, "$1") + text = regexp.MustCompile(`(?m)^>\s*(.*)$`).ReplaceAllString(text, "$1") // Links [text](url) → "text url" (WhatsApp doesn't support markdown links). - text = reLink.ReplaceAllString(text, "$1 $2") + text = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`).ReplaceAllString(text, "$1 $2") // Bold: **text** or __text__ → *text* - text = reBoldStars.ReplaceAllString(text, "*$1*") - text = reBoldUnder.ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`\*\*(.+?)\*\*`).ReplaceAllString(text, "*$1*") + text = regexp.MustCompile(`__(.+?)__`).ReplaceAllString(text, "*$1*") // Strikethrough: ~~text~~ → ~text~ - text = reStrikethrough.ReplaceAllString(text, "~$1~") + text = regexp.MustCompile(`~~(.+?)~~`).ReplaceAllString(text, "~$1~") + + // Inline code: `code` → ```code``` (WhatsApp has no inline code, only blocks). + text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "```$1```") // List items: leading - or * → bullet • - text = reListItem.ReplaceAllString(text, "• ") + text = regexp.MustCompile(`(?m)^[-*]\s+`).ReplaceAllString(text, "• ") // Restore code blocks as ``` … ``` preserving original content. for i, code := range codeBlocks.codes { @@ -67,7 +51,7 @@ func markdownToWhatsApp(text string) string { } // Collapse 3+ blank lines to 2. - text = reBlankLines.ReplaceAllString(text, "\n\n") + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") return strings.TrimSpace(text) } @@ -106,7 +90,8 @@ type waCodeBlockMatch struct { // waExtractCodeBlocks pulls fenced code blocks out of text and replaces them with // \x00CB{n}\x00 placeholders so other regex passes don't mangle their contents. func waExtractCodeBlocks(text string) waCodeBlockMatch { - matches := reCodeBlock.FindAllStringSubmatch(text, -1) + re := regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```") + matches := re.FindAllStringSubmatch(text, -1) codes := make([]string, 0, len(matches)) for _, m := range matches { @@ -114,7 +99,7 @@ func waExtractCodeBlocks(text string) waCodeBlockMatch { } i := 0 - text = reCodeBlock.ReplaceAllStringFunc(text, func(_ string) string { + text = re.ReplaceAllStringFunc(text, func(_ string) string { placeholder := fmt.Sprintf("\x00CB%d\x00", i) i++ return placeholder @@ -123,122 +108,26 @@ func waExtractCodeBlocks(text string) waCodeBlockMatch { return waCodeBlockMatch{text: text, codes: codes} } -// waRenderTables finds markdown tables and renders them as ASCII-aligned -// text inside ``` blocks (monospace) for WhatsApp readability. -func waRenderTables(text string) string { - lines := strings.Split(text, "\n") - var result []string - i := 0 - for i < len(lines) { - // Detect table: header line followed by separator line. - if i+1 < len(lines) && reTableLine.MatchString(lines[i]) && reTableSep.MatchString(lines[i+1]) { - tableStart := i - i += 2 // skip header + separator - for i < len(lines) && reTableLine.MatchString(lines[i]) { - i++ - } - rendered := renderTable(lines[tableStart:i]) - result = append(result, "```\n"+rendered+"\n```") - } else { - result = append(result, lines[i]) - i++ - } - } - return strings.Join(result, "\n") -} - -// renderTable converts markdown table lines into ASCII-aligned text. -func renderTable(lines []string) string { - if len(lines) < 2 { - return strings.Join(lines, "\n") - } - - var rows [][]string - for i, line := range lines { - if i == 1 { - continue // skip separator - } - rows = append(rows, parseTableCells(line)) - } - if len(rows) == 0 { - return "" - } - - // Determine column widths. - numCols := 0 - for _, row := range rows { - if len(row) > numCols { - numCols = len(row) - } - } - widths := make([]int, numCols) - for _, row := range rows { - for j, cell := range row { - if utf8.RuneCountInString(cell) > widths[j] { - widths[j] = utf8.RuneCountInString(cell) - } - } - } - - // Render aligned rows. - var out []string - for ri, row := range rows { - var cells []string - for j := 0; j < numCols; j++ { - cell := "" - if j < len(row) { - cell = row[j] - } - pad := widths[j] - utf8.RuneCountInString(cell) - cells = append(cells, cell+strings.Repeat(" ", pad)) - } - out = append(out, strings.Join(cells, " ")) - // Add separator after header. - if ri == 0 { - var sep []string - for _, w := range widths { - sep = append(sep, strings.Repeat("─", w)) - } - out = append(out, strings.Join(sep, "──")) - } - } - return strings.Join(out, "\n") -} - -// parseTableCells splits a markdown table row into trimmed cells. -func parseTableCells(line string) []string { - line = strings.Trim(line, "|") - parts := strings.Split(line, "|") - cells := make([]string, len(parts)) - for i, p := range parts { - cells[i] = strings.TrimSpace(p) - } - return cells -} - -// chunkText splits text into pieces that fit within maxLen runes, +// chunkText splits text into pieces that fit within maxLen, // preferring to split at paragraph (\n\n) or line (\n) boundaries. -// Uses rune count (not byte count) so multi-byte characters are handled correctly. func chunkText(text string, maxLen int) []string { - if utf8.RuneCountInString(text) <= maxLen { + if len(text) <= maxLen { return []string{text} } var chunks []string for len(text) > 0 { - if utf8.RuneCountInString(text) <= maxLen { + if len(text) <= maxLen { chunks = append(chunks, text) break } - // Convert rune limit to byte offset for slicing. - byteLimit := runeOffset(text, maxLen) // Find the best split point: paragraph > line > space > hard cut. - cutAt := byteLimit - if idx := strings.LastIndex(text[:byteLimit], "\n\n"); idx > 0 { + cutAt := maxLen + if idx := strings.LastIndex(text[:maxLen], "\n\n"); idx > 0 { cutAt = idx - } else if idx := strings.LastIndex(text[:byteLimit], "\n"); idx > 0 { + } else if idx := strings.LastIndex(text[:maxLen], "\n"); idx > 0 { cutAt = idx - } else if idx := strings.LastIndex(text[:byteLimit], " "); idx > 0 { + } else if idx := strings.LastIndex(text[:maxLen], " "); idx > 0 { cutAt = idx } chunks = append(chunks, strings.TrimRight(text[:cutAt], " \n")) @@ -246,14 +135,3 @@ func chunkText(text string, maxLen int) []string { } return chunks } - -// runeOffset returns the byte offset of the n-th rune in s. -// If n exceeds the rune count, returns len(s). -func runeOffset(s string, n int) int { - offset := 0 - for i := 0; i < n && offset < len(s); i++ { - _, size := utf8.DecodeRuneInString(s[offset:]) - offset += size - } - return offset -} From 7762cee72f73611f6a6664e3b208177e5a222523 Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 19:18:32 +0700 Subject: [PATCH 19/22] fix(whatsapp): cleanup bridge references and move DeviceProps to init - Move DeviceProps.Os to package init (set once, not per New() call) - Remove redundant QR done bus broadcast from handleConnected (QR session already handles this via direct client events) - Remove "from bridge" text in i18n QR waiting messages (all 3 locales) - Fix inline code test expectation --- internal/channels/whatsapp/format_test.go | 2 +- internal/channels/whatsapp/whatsapp.go | 20 +++++--------------- ui/web/src/i18n/locales/en/channels.json | 2 +- ui/web/src/i18n/locales/vi/channels.json | 2 +- ui/web/src/i18n/locales/zh/channels.json | 2 +- 5 files changed, 9 insertions(+), 19 deletions(-) diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go index 9b93ba86e..d258831b9 100644 --- a/internal/channels/whatsapp/format_test.go +++ b/internal/channels/whatsapp/format_test.go @@ -15,7 +15,7 @@ func TestMarkdownToWhatsApp(t *testing.T) { {"bold stars", "this is **bold** text", "this is *bold* text"}, {"bold underscores", "this is __bold__ text", "this is *bold* text"}, {"strikethrough", "~~deleted~~", "~deleted~"}, - {"inline code", "use `fmt.Println`", "use ```fmt.Println```"}, + {"inline code", "use `fmt.Println`", "use `fmt.Println`"}, {"link", "[Go](https://go.dev)", "Go https://go.dev"}, {"unordered list dash", "- item one\n- item two", "• item one\n• item two"}, {"unordered list star", "* item one\n* item two", "• item one\n• item two"}, diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index de20d6433..9c1b086a3 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -23,7 +23,6 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" - "github.com/nextlevelbuilder/goclaw/pkg/protocol" ) const ( @@ -31,6 +30,11 @@ const ( maxMessageLen = 4096 // WhatsApp practical message length limit ) +func init() { + // Set device name shown in WhatsApp's "Linked Devices" screen (once at package init). + wastore.DeviceProps.Os = proto.String("GoClaw") +} + // Channel connects directly to WhatsApp via go.mau.fi/whatsmeow. // Auth state is stored in PostgreSQL (standard) or SQLite (desktop). type Channel struct { @@ -95,9 +99,6 @@ func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom) base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) - // Set device name shown in WhatsApp's "Linked Devices" screen. - wastore.DeviceProps.Os = proto.String("GoClaw") - dialect := detectDialect(db) container := sqlstore.NewWithDB(db, dialect, nil) if err := container.Upgrade(context.Background()); err != nil { @@ -201,17 +202,6 @@ func (c *Channel) handleConnected() { c.lastQRMu.Unlock() c.MarkHealthy("WhatsApp authenticated and connected") - - if mb := c.Bus(); mb != nil { - mb.Broadcast(bus.Event{ - Name: protocol.EventWhatsAppQRDone, - TenantID: c.TenantID(), - Payload: map[string]any{ - "channel_name": c.Name(), - "success": true, - }, - }) - } } // handleDisconnected processes the Disconnected event. diff --git a/ui/web/src/i18n/locales/en/channels.json b/ui/web/src/i18n/locales/en/channels.json index 166da80ff..f9cca4b77 100644 --- a/ui/web/src/i18n/locales/en/channels.json +++ b/ui/web/src/i18n/locales/en/channels.json @@ -470,7 +470,7 @@ }, "whatsapp": { "loginSuccessLoading": "✅ WhatsApp connected! Loading channel...", - "waitingForQr": "Waiting for QR code from bridge...", + "waitingForQr": "Waiting for QR code...", "scanHint": "Open WhatsApp → tap You → Linked Devices → Link a Device", "initializing": "Initializing...", "skip": "Skip", diff --git a/ui/web/src/i18n/locales/vi/channels.json b/ui/web/src/i18n/locales/vi/channels.json index a0b99cf3b..b545d8118 100644 --- a/ui/web/src/i18n/locales/vi/channels.json +++ b/ui/web/src/i18n/locales/vi/channels.json @@ -385,7 +385,7 @@ }, "whatsapp": { "loginSuccessLoading": "✅ Đã kết nối WhatsApp! Đang tải channel...", - "waitingForQr": "Đang chờ mã QR từ bridge...", + "waitingForQr": "Đang chờ mã QR...", "scanHint": "Mở WhatsApp → nhấn Bạn → Thiết bị đã liên kết → Liên kết thiết bị", "initializing": "Đang khởi tạo...", "skip": "Bỏ qua", diff --git a/ui/web/src/i18n/locales/zh/channels.json b/ui/web/src/i18n/locales/zh/channels.json index 478ce36f8..7742cb312 100644 --- a/ui/web/src/i18n/locales/zh/channels.json +++ b/ui/web/src/i18n/locales/zh/channels.json @@ -385,7 +385,7 @@ }, "whatsapp": { "loginSuccessLoading": "✅ WhatsApp 已连接!正在加载 channel...", - "waitingForQr": "正在等待来自 bridge 的二维码...", + "waitingForQr": "正在等待二维码...", "scanHint": "打开 WhatsApp → 点击【您】→【已连接的设备】→【连接设备】", "initializing": "初始化中...", "skip": "跳过", From 1dfbcf3c82a7b00712f50c615b1e560823f8cf5a Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Mon, 6 Apr 2026 19:25:30 +0700 Subject: [PATCH 20/22] =?UTF-8?q?fix(whatsapp):=20address=20review=20feedb?= =?UTF-8?q?ack=20=E2=80=94=20media=20logs,=20mention=20tests,=20cleanup,?= =?UTF-8?q?=20reauth=20race?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Classify media download errors (timeout/decrypt/expired/unsupported) in log - Add mention_test.go with 10 test cases covering dual JID/LID detection - Schedule temp media file cleanup after 5 minutes via goroutine - Serialize Reauth() and StartQRFlow() with reauthMu to prevent rapid double-click race - Fix stale format_test.go expectation for inline code conversion --- internal/channels/whatsapp/format_test.go | 2 +- internal/channels/whatsapp/mention_test.go | 131 +++++++++++++++++++++ internal/channels/whatsapp/whatsapp.go | 52 +++++++- 3 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 internal/channels/whatsapp/mention_test.go diff --git a/internal/channels/whatsapp/format_test.go b/internal/channels/whatsapp/format_test.go index d258831b9..9b93ba86e 100644 --- a/internal/channels/whatsapp/format_test.go +++ b/internal/channels/whatsapp/format_test.go @@ -15,7 +15,7 @@ func TestMarkdownToWhatsApp(t *testing.T) { {"bold stars", "this is **bold** text", "this is *bold* text"}, {"bold underscores", "this is __bold__ text", "this is *bold* text"}, {"strikethrough", "~~deleted~~", "~deleted~"}, - {"inline code", "use `fmt.Println`", "use `fmt.Println`"}, + {"inline code", "use `fmt.Println`", "use ```fmt.Println```"}, {"link", "[Go](https://go.dev)", "Go https://go.dev"}, {"unordered list dash", "- item one\n- item two", "• item one\n• item two"}, {"unordered list star", "* item one\n* item two", "• item one\n• item two"}, diff --git a/internal/channels/whatsapp/mention_test.go b/internal/channels/whatsapp/mention_test.go new file mode 100644 index 000000000..7d4caa139 --- /dev/null +++ b/internal/channels/whatsapp/mention_test.go @@ -0,0 +1,131 @@ +package whatsapp + +import ( + "testing" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + "google.golang.org/protobuf/proto" +) + +func TestIsMentioned(t *testing.T) { + // Helper to build an events.Message with mentioned JIDs in extended text. + makeEvt := func(mentionedJIDs []string) *events.Message { + return &events.Message{ + Message: &waE2E.Message{ + ExtendedTextMessage: &waE2E.ExtendedTextMessage{ + Text: proto.String("hello @bot"), + ContextInfo: &waE2E.ContextInfo{ + MentionedJID: mentionedJIDs, + }, + }, + }, + } + } + + tests := []struct { + name string + myJID string // bot's phone JID + myLID string // bot's LID + mentions []string + want bool + }{ + { + name: "mentioned by phone JID", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: true, + }, + { + name: "mentioned by LID", + myLID: "9876543210@lid", + mentions: []string{"9876543210@lid"}, + want: true, + }, + { + name: "mentioned by JID with device suffix", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"1234567890:42@s.whatsapp.net"}, + want: true, + }, + { + name: "mentioned by LID with device suffix", + myLID: "9876543210@lid", + mentions: []string{"9876543210:5@lid"}, + want: true, + }, + { + name: "dual identity — mentioned via LID when JID also set", + myJID: "1234567890@s.whatsapp.net", + myLID: "9876543210@lid", + mentions: []string{"9876543210@lid"}, + want: true, + }, + { + name: "dual identity — mentioned via JID when LID also set", + myJID: "1234567890@s.whatsapp.net", + myLID: "9876543210@lid", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: true, + }, + { + name: "not mentioned — different user", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{"9999999999@s.whatsapp.net"}, + want: false, + }, + { + name: "not mentioned — empty mentions", + myJID: "1234567890@s.whatsapp.net", + mentions: []string{}, + want: false, + }, + { + name: "unknown identity — fail closed", + myJID: "", + myLID: "", + mentions: []string{"1234567890@s.whatsapp.net"}, + want: false, + }, + { + name: "no extended text message", + myJID: "1234567890@s.whatsapp.net", + mentions: nil, // will use plain conversation message + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ch := &Channel{} + + // Set bot identity. + if tt.myJID != "" { + jid, _ := types.ParseJID(tt.myJID) + ch.myJID = jid + } + if tt.myLID != "" { + lid, _ := types.ParseJID(tt.myLID) + ch.myLID = lid + } + + var evt *events.Message + if tt.mentions == nil { + // Plain conversation message — no extended text. + evt = &events.Message{ + Message: &waE2E.Message{ + Conversation: proto.String("hello"), + }, + } + } else { + evt = makeEvt(tt.mentions) + } + + got := ch.isMentioned(evt) + if got != tt.want { + t.Errorf("isMentioned() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index 9c1b086a3..b0e699e69 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -59,6 +59,9 @@ type Channel struct { // typingCancel tracks active typing-refresh loops per chatID. typingCancel sync.Map // chatID string → context.CancelFunc + + // reauthMu serializes Reauth() and StartQRFlow() to prevent race when user clicks reauth rapidly. + reauthMu sync.Mutex } // GetLastQRB64 returns the most recent QR PNG (base64). @@ -375,6 +378,13 @@ func (c *Channel) handleIncomingMessage(evt *events.Message) { TenantID: c.TenantID(), Metadata: metadata, }) + + // Schedule temp media file cleanup after agent pipeline has had time to process. + var tmpPaths []string + for _, mf := range mediaFiles { + tmpPaths = append(tmpPaths, mf.Path) + } + scheduleMediaCleanup(tmpPaths, 5*time.Minute) } // extractTextContent extracts text from any WhatsApp message variant. @@ -502,7 +512,8 @@ func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo { for _, item := range items { data, err := c.client.Download(c.ctx, item.download) if err != nil { - slog.Warn("whatsapp: media download failed", "type", item.mediaType, "error", err) + reason := classifyDownloadError(err) + slog.Warn("whatsapp: media download failed", "type", item.mediaType, "reason", reason, "error", err) continue } if len(data) > 20*1024*1024 { // 20MB limit @@ -556,6 +567,38 @@ func mimeToExt(mime string) string { } } +// classifyDownloadError returns a human-readable reason for a media download failure. +func classifyDownloadError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"): + return "timeout" + case strings.Contains(msg, "decrypt") || strings.Contains(msg, "cipher"): + return "decrypt_error" + case strings.Contains(msg, "404") || strings.Contains(msg, "not found"): + return "expired" + case strings.Contains(msg, "unsupported"): + return "unsupported" + default: + return "unknown" + } +} + +// scheduleMediaCleanup removes temp media files after a delay. +func scheduleMediaCleanup(paths []string, delay time.Duration) { + if len(paths) == 0 { + return + } + go func() { + time.Sleep(delay) + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + slog.Debug("whatsapp: temp media cleanup failed", "path", p, "error", err) + } + } + }() +} + // Send delivers an outbound message to WhatsApp via whatsmeow. func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { if c.client == nil || !c.client.IsConnected() { @@ -723,7 +766,10 @@ func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) { // Returns a channel that emits QR code strings and auth events. // Lazily initializes the whatsmeow client if Start() hasn't been called yet // (handles timing race between async instance reload and wizard auto-start). +// Serialized with Reauth via reauthMu to prevent races on rapid double-clicks. func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() if c.client == nil { // Lazy init: wizard may request QR before Start() is called. c.mu.Lock() @@ -761,7 +807,11 @@ func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelIt } // Reauth clears the current session and prepares for a fresh QR scan. +// Serialized with StartQRFlow via reauthMu to prevent races on rapid double-clicks. func (c *Channel) Reauth() error { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + slog.Info("whatsapp: reauth requested", "channel", c.Name()) c.lastQRMu.Lock() From e80a5aeceba8be76cd55b23a9181908b6342620e Mon Sep 17 00:00:00 2001 From: Duc Nguyen Date: Tue, 7 Apr 2026 00:37:00 +0700 Subject: [PATCH 21/22] docs(whatsapp): remove library references, use generic direct connection wording --- docs/05-channels-messaging.md | 12 ++++++------ docs/17-changelog.md | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/05-channels-messaging.md b/docs/05-channels-messaging.md index 0d50c97c2..59aaf28bb 100644 --- a/docs/05-channels-messaging.md +++ b/docs/05-channels-messaging.md @@ -156,7 +156,7 @@ flowchart TD | Feature | Telegram | Feishu/Lark | Discord | Slack | WhatsApp | Zalo OA | Zalo Personal | |---------|----------|-------------|---------|-------|----------|---------|---------------| -| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Native protocol (in-process whatsmeow) | Long polling | Internal protocol | +| Connection | Long polling | WS (default) / Webhook | Gateway events | Socket Mode | Direct protocol (in-process) | Long polling | Internal protocol | | DM support | Yes | Yes | Yes | Yes | Yes | Yes (DM only) | Yes | | Group support | Yes (mention gating) | Yes | Yes | Yes (mention gating + thread cache) | Yes | No | Yes | | Forum/Topics | Yes (per-topic config) | Yes (topic session mode) | -- | -- | -- | -- | -- | @@ -430,14 +430,14 @@ Auto-enables when both bot_token and app_token are set. ## 9. WhatsApp -The WhatsApp channel connects directly to the WhatsApp network via the native `go.mau.fi/whatsmeow` library. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition). +The WhatsApp channel connects directly to the WhatsApp network via the multi-device protocol. Authentication state is stored in the database (PostgreSQL standard, SQLite for desktop edition). ### Key Behaviors -- **Native protocol**: In-process WhatsApp client using whatsmeow library (direct to WhatsApp servers, no external bridge) -- **Database auth store**: Uses `whatsmeow/store/sqlstore` to persist auth state, keys, and device info +- **Direct connection**: In-process WhatsApp client (direct to WhatsApp servers, no external bridge) +- **Database auth store**: Persists auth state, keys, and device info in the database - **QR code authentication**: Interactive QR code for initial pairing, served via WebSocket API -- **Auto-reconnect**: Built-in whatsmeow reconnection with exponential backoff +- **Auto-reconnect**: Built-in reconnection with exponential backoff - **DM and group support**: Full group messaging with mention detection via JID format - **Media handling**: Direct media download/upload to WhatsApp servers with type detection - **Typing indicators**: Typing state managed per chat with auto-refresh @@ -593,7 +593,7 @@ flowchart TD | `internal/channels/slack/format.go` | Markdown → Slack mrkdwn pipeline | | `internal/channels/slack/reactions.go` | Status emoji reactions on messages | | `internal/channels/slack/stream.go` | Streaming message updates via placeholder editing | -| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: native whatsmeow client, QR auth, sqlstore persistence | +| `internal/channels/whatsapp/whatsapp.go` | WhatsApp: direct protocol client, QR auth, database persistence | | `internal/channels/whatsapp/factory.go` | Channel factory, database dialect detection | | `internal/channels/whatsapp/qr_methods.go` | QR code generation and authentication flow | | `internal/channels/whatsapp/format.go` | Message formatting (HTML-to-WhatsApp) | diff --git a/docs/17-changelog.md b/docs/17-changelog.md index 96de3f99e..5e37c9c4b 100644 --- a/docs/17-changelog.md +++ b/docs/17-changelog.md @@ -35,9 +35,9 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep ### Added #### WhatsApp Native Protocol Integration (2026-04-06) -- **Whatsmeow migration**: Replaced Node.js Baileys bridge with native `go.mau.fi/whatsmeow` library for in-process WhatsApp connectivity -- **Database auth persistence**: Auth state, device keys, and client metadata stored in PostgreSQL (standard) or SQLite (desktop) via whatsmeow's sqlstore -- **QR authentication**: Interactive QR code authentication driven directly by whatsmeow's `GetQRChannel()` without external bridge relay +- **Direct protocol migration**: Replaced Node.js Baileys bridge with direct in-process WhatsApp connectivity +- **Database auth persistence**: Auth state, device keys, and client metadata stored in PostgreSQL (standard) or SQLite (desktop) +- **QR authentication**: Interactive QR code authentication for device linking without external bridge relay - **No more bridge_url**: Removed `bridge_url` configuration, eliminated `docker-compose.whatsapp.yml`, removed `bridge/whatsapp/` sidecar service - **Enhanced media handling**: Direct media download/upload to WhatsApp servers with automatic type detection and streaming - **Improved mention detection**: Group mention detection now uses LID (Local ID) + JID (standard format) for robust message routing @@ -45,7 +45,7 @@ All notable changes to GoClaw Gateway are documented here. Format follows [Keep - `internal/channels/whatsapp/factory.go` — Dialect detection and channel factory - `internal/channels/whatsapp/qr_methods.go` — QR code generation and authentication flow - `internal/channels/whatsapp/format.go` — HTML-to-WhatsApp message formatting - - whatsmeow sqlstore integration for cross-platform auth persistence + - Database-backed auth persistence for cross-platform support ### Refactored From 5ec9b22d801e4a14a160450ab94cf990fb4ebc97 Mon Sep 17 00:00:00 2001 From: viettranx Date: Tue, 7 Apr 2026 12:06:55 +0700 Subject: [PATCH 22/22] refactor(whatsapp): split 980-line whatsapp.go and fix review issues Split whatsapp.go (980 lines) into 6 focused files: - whatsapp.go: struct, lifecycle, event dispatch (221 lines) - inbound.go: message processing, text extraction, mentions (266 lines) - outbound.go: Send, media upload, typing indicators (182 lines) - media_download.go: download, mime mapping, temp cleanup (140 lines) - auth.go: StartQRFlow, Reauth (102 lines) - policy.go: DM/group policy, pairing reply (141 lines) Fixes from code review: - Remove fragile detectDialect (%T sniffing); pass explicit dialect string to New() and FactoryWithDB() from callers - Safe two-value type assertions for typingCancel sync.Map entries - Reauth() uses parentCtx (stored in Start()) instead of context.Background() - scheduleMediaCleanup uses time.AfterFunc instead of goroutine+sleep - mimeToExt: fix PDF to use strings.HasPrefix("application/pdf") - Add emptyMessageSentinel const to replace string literals - Restore "whatsapp" in IsDefaultChannelInstance (breaking change fix) --- cmd/gateway.go | 2 +- cmd/gateway_channels_setup.go | 6 +- internal/channels/whatsapp/auth.go | 102 +++ internal/channels/whatsapp/factory.go | 5 +- internal/channels/whatsapp/inbound.go | 266 +++++++ internal/channels/whatsapp/media_download.go | 140 ++++ internal/channels/whatsapp/outbound.go | 182 +++++ internal/channels/whatsapp/policy.go | 141 ++++ internal/channels/whatsapp/whatsapp.go | 773 +------------------ internal/store/channel_instance_store.go | 2 +- 10 files changed, 848 insertions(+), 771 deletions(-) create mode 100644 internal/channels/whatsapp/auth.go create mode 100644 internal/channels/whatsapp/inbound.go create mode 100644 internal/channels/whatsapp/media_download.go create mode 100644 internal/channels/whatsapp/outbound.go create mode 100644 internal/channels/whatsapp/policy.go diff --git a/cmd/gateway.go b/cmd/gateway.go index 72d5b6a32..3e3007164 100644 --- a/cmd/gateway.go +++ b/cmd/gateway.go @@ -563,7 +563,7 @@ func runGateway() { instanceLoader.RegisterFactory(channels.TypeFeishu, feishu.FactoryWithPendingStore(pgStores.PendingMessages)) instanceLoader.RegisterFactory(channels.TypeZaloOA, zalo.Factory) instanceLoader.RegisterFactory(channels.TypeZaloPersonal, zalopersonal.FactoryWithPendingStore(pgStores.PendingMessages)) - instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages)) + instanceLoader.RegisterFactory(channels.TypeWhatsApp, whatsapp.FactoryWithDB(pgStores.DB, pgStores.PendingMessages, "pgx")) instanceLoader.RegisterFactory(channels.TypeSlack, slackchannel.FactoryWithPendingStore(pgStores.PendingMessages)) if err := instanceLoader.LoadAll(context.Background()); err != nil { slog.Error("failed to load channel instances from DB", "error", err) diff --git a/cmd/gateway_channels_setup.go b/cmd/gateway_channels_setup.go index a9f452487..77976099d 100644 --- a/cmd/gateway_channels_setup.go +++ b/cmd/gateway_channels_setup.go @@ -68,7 +68,11 @@ func registerConfigChannels(cfg *config.Config, channelMgr *channels.Manager, ms } if cfg.Channels.WhatsApp.Enabled { - wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages) + waDialect := "pgx" + if strings.Contains(fmt.Sprintf("%T", pgStores.DB.Driver()), "sqlite") { + waDialect = "sqlite3" + } + wa, err := whatsapp.New(cfg.Channels.WhatsApp, msgBus, pgStores.Pairing, pgStores.DB, pgStores.PendingMessages, waDialect) if err != nil { channelMgr.RecordFailure(channels.TypeWhatsApp, "", err) slog.Error("failed to initialize whatsapp channel", "error", err) diff --git a/internal/channels/whatsapp/auth.go b/internal/channels/whatsapp/auth.go new file mode 100644 index 000000000..76deb6f3e --- /dev/null +++ b/internal/channels/whatsapp/auth.go @@ -0,0 +1,102 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + + "go.mau.fi/whatsmeow" +) + +// StartQRFlow initiates the QR authentication flow. +// Returns a channel that emits QR code strings and auth events. +// Lazily initializes the whatsmeow client if Start() hasn't been called yet +// (handles timing race between async instance reload and wizard auto-start). +// Serialized with Reauth via reauthMu to prevent races on rapid double-clicks. +func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + if c.client == nil { + // Lazy init: wizard may request QR before Start() is called. + c.mu.Lock() + if c.client == nil { + if c.ctx == nil { + c.ctx, c.cancel = context.WithCancel(context.Background()) + } + deviceStore, err := c.container.GetFirstDevice(ctx) + if err != nil { + c.mu.Unlock() + return nil, fmt.Errorf("whatsapp get device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + } + c.mu.Unlock() + } + + if c.IsAuthenticated() { + return nil, nil // caller checks this + } + + qrChan, err := c.client.GetQRChannel(ctx) + if err != nil { + return nil, fmt.Errorf("whatsapp get QR channel: %w", err) + } + + if !c.client.IsConnected() { + if err := c.client.Connect(); err != nil { + return nil, fmt.Errorf("whatsapp connect for QR: %w", err) + } + } + + return qrChan, nil +} + +// Reauth clears the current session and prepares for a fresh QR scan. +// Serialized with StartQRFlow via reauthMu to prevent races on rapid double-clicks. +func (c *Channel) Reauth() error { + c.reauthMu.Lock() + defer c.reauthMu.Unlock() + + slog.Info("whatsapp: reauth requested", "channel", c.Name()) + + c.lastQRMu.Lock() + c.waAuthenticated = false + c.lastQRB64 = "" + c.lastQRMu.Unlock() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.client != nil { + c.client.Disconnect() + } + + // Delete device from store to force fresh QR on next connect. + if c.client != nil && c.client.Store.ID != nil { + if err := c.client.Store.Delete(context.Background()); err != nil { + slog.Warn("whatsapp: failed to delete device store", "error", err) + } + } + + // Reset context so the new client gets a fresh lifecycle. + if c.cancel != nil { + c.cancel() + } + // Use parentCtx if available so the new lifecycle is still bound to the gateway. + parent := c.parentCtx + if parent == nil { + parent = context.Background() + } + c.ctx, c.cancel = context.WithCancel(parent) + + // Re-create client with fresh device store. + deviceStore, err := c.container.GetFirstDevice(context.Background()) + if err != nil { + return fmt.Errorf("whatsapp: get fresh device: %w", err) + } + c.client = whatsmeow.NewClient(deviceStore, nil) + c.client.AddEventHandler(c.handleEvent) + + return nil +} diff --git a/internal/channels/whatsapp/factory.go b/internal/channels/whatsapp/factory.go index c9923a170..e49801eb6 100644 --- a/internal/channels/whatsapp/factory.go +++ b/internal/channels/whatsapp/factory.go @@ -22,7 +22,8 @@ type whatsappInstanceConfig struct { } // FactoryWithDB returns a ChannelFactory with DB access for whatsmeow auth state. -func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore) channels.ChannelFactory { +// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop). +func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore, dialect string) channels.ChannelFactory { return func(name string, creds json.RawMessage, cfg json.RawMessage, msgBus *bus.MessageBus, pairingSvc store.PairingStore) (channels.Channel, error) { @@ -63,7 +64,7 @@ func FactoryWithDB(db *sql.DB, pendingStore store.PendingMessageStore) channels. waCfg.GroupPolicy = "pairing" } - ch, err := New(waCfg, msgBus, pairingSvc, db, pendingStore) + ch, err := New(waCfg, msgBus, pairingSvc, db, pendingStore, dialect) if err != nil { return nil, err } diff --git a/internal/channels/whatsapp/inbound.go b/internal/channels/whatsapp/inbound.go new file mode 100644 index 000000000..ecc78b4fa --- /dev/null +++ b/internal/channels/whatsapp/inbound.go @@ -0,0 +1,266 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "go.mau.fi/whatsmeow/types/events" + + "github.com/nextlevelbuilder/goclaw/internal/bus" + "github.com/nextlevelbuilder/goclaw/internal/channels" + "github.com/nextlevelbuilder/goclaw/internal/channels/media" + "github.com/nextlevelbuilder/goclaw/internal/store" +) + +const emptyMessageSentinel = "[empty message]" + +// handleIncomingMessage processes an incoming WhatsApp message. +func (c *Channel) handleIncomingMessage(evt *events.Message) { + ctx := context.Background() + ctx = store.WithTenantID(ctx, c.TenantID()) + + if evt.Info.IsFromMe { + return + } + + senderJID := evt.Info.Sender + chatJID := evt.Info.Chat + + // WhatsApp uses dual identity: phone JID (@s.whatsapp.net) and LID (@lid). + // Groups may use LID addressing. Normalize to phone JID for consistent + // policy checks, pairing lookups, allowlists, and contact collection. + if evt.Info.AddressingMode == types.AddressingModeLID && !evt.Info.SenderAlt.IsEmpty() { + senderJID = evt.Info.SenderAlt + } + + senderID := senderJID.String() + chatID := chatJID.String() + + peerKind := "direct" + if chatJID.Server == types.GroupServer { + peerKind = "group" + } + + slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, + "addressing", evt.Info.AddressingMode, "policy", c.config.GroupPolicy) + + // DM/Group policy check. + if peerKind == "direct" { + if !c.checkDMPolicy(ctx, senderID, chatID) { + return + } + } else { + if !c.checkGroupPolicy(ctx, senderID, chatID) { + slog.Info("whatsapp group message rejected by policy", "sender_id", senderID, "chat_id", chatID, "policy", c.config.GroupPolicy) + return + } + } + + if !c.IsAllowed(senderID) { + slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID) + return + } + + content := extractTextContent(evt.Message) + + var mediaList []media.MediaInfo + mediaList = c.downloadMedia(evt) + + if content == "" && len(mediaList) == 0 { + return + } + if content == "" { + content = emptyMessageSentinel + } + + // Group history + mention detection. + historyLimit := c.config.HistoryLimit + if historyLimit == 0 { + historyLimit = channels.DefaultGroupHistoryLimit + } + if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { + if !c.isMentioned(evt) { + // Not mentioned — record for context and skip. + senderLabel := evt.Info.PushName + if senderLabel == "" { + senderLabel = senderID + } + c.groupHistory.Record(chatID, channels.HistoryEntry{ + Sender: senderLabel, + SenderID: senderID, + Body: content, + Timestamp: evt.Info.Timestamp, + MessageID: string(evt.Info.ID), + }, historyLimit) + return + } + // Mentioned — prepend accumulated group context. + content = c.groupHistory.BuildContext(chatID, content, historyLimit) + c.groupHistory.Clear(chatID) + } + + metadata := map[string]string{ + "message_id": string(evt.Info.ID), + } + if evt.Info.PushName != "" { + metadata["user_name"] = evt.Info.PushName + } + + // Build media tags and bus.MediaFile list. + var mediaFiles []bus.MediaFile + if len(mediaList) > 0 { + mediaTags := media.BuildMediaTags(mediaList) + if mediaTags != "" { + if content != emptyMessageSentinel { + content = mediaTags + "\n\n" + content + } else { + content = mediaTags + } + } + for _, m := range mediaList { + if m.FilePath != "" { + mediaFiles = append(mediaFiles, bus.MediaFile{ + Path: m.FilePath, MimeType: m.ContentType, + }) + } + } + } + + // Annotate with sender identity. + if senderName := metadata["user_name"]; senderName != "" { + content = fmt.Sprintf("[From: %s]\n%s", senderName, content) + } + + // Collect contact. + if cc := c.ContactCollector(); cc != nil { + cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, + metadata["user_name"], "", peerKind, "user", "", "") + } + + // Typing indicator. + if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { + if fn, ok := prevCancel.(context.CancelFunc); ok { + fn() + } + } + typingCtx, typingCancel := context.WithCancel(context.Background()) + c.typingCancel.Store(chatID, typingCancel) + go c.keepTyping(typingCtx, chatJID) + + // Derive userID from senderID. + userID := senderID + if idx := strings.IndexByte(senderID, '|'); idx > 0 { + userID = senderID[:idx] + } + + c.Bus().PublishInbound(bus.InboundMessage{ + Channel: c.Name(), + SenderID: senderID, + ChatID: chatID, + Content: content, + Media: mediaFiles, + PeerKind: peerKind, + UserID: userID, + AgentID: c.AgentID(), + TenantID: c.TenantID(), + Metadata: metadata, + }) + + // Schedule temp media file cleanup after agent pipeline has had time to process. + var tmpPaths []string + for _, mf := range mediaFiles { + tmpPaths = append(tmpPaths, mf.Path) + } + scheduleMediaCleanup(c.ctx, tmpPaths, 5*time.Minute) +} + +// extractTextContent extracts text from any WhatsApp message variant. +// Includes quoted message context when present (reply-to messages). +func extractTextContent(msg *waE2E.Message) string { + if msg == nil { + return "" + } + + var text string + var quotedText string + + if msg.GetConversation() != "" { + text = msg.GetConversation() + } else if ext := msg.GetExtendedTextMessage(); ext != nil { + text = ext.GetText() + // Extract quoted (replied-to) message text. + if ci := ext.GetContextInfo(); ci != nil { + if qm := ci.GetQuotedMessage(); qm != nil { + quotedText = extractQuotedText(qm) + } + } + } else if img := msg.GetImageMessage(); img != nil { + text = img.GetCaption() + } else if vid := msg.GetVideoMessage(); vid != nil { + text = vid.GetCaption() + } else if doc := msg.GetDocumentMessage(); doc != nil { + text = doc.GetCaption() + } + + if quotedText != "" && text != "" { + return fmt.Sprintf("[Replying to: %s]\n%s", quotedText, text) + } + if quotedText != "" { + return fmt.Sprintf("[Replying to: %s]", quotedText) + } + return text +} + +// extractQuotedText extracts plain text from a quoted message (no recursion). +func extractQuotedText(msg *waE2E.Message) string { + if msg == nil { + return "" + } + if msg.GetConversation() != "" { + return msg.GetConversation() + } + if ext := msg.GetExtendedTextMessage(); ext != nil { + return ext.GetText() + } + if img := msg.GetImageMessage(); img != nil && img.GetCaption() != "" { + return img.GetCaption() + } + if vid := msg.GetVideoMessage(); vid != nil && vid.GetCaption() != "" { + return vid.GetCaption() + } + return "" +} + +// isMentioned checks if the linked account is @mentioned in a group message. +// WhatsApp uses dual identity: phone JID and LID. Mentions may use either format. +func (c *Channel) isMentioned(evt *events.Message) bool { + c.lastQRMu.RLock() + myJID := c.myJID + myLID := c.myLID + c.lastQRMu.RUnlock() + + if myJID.IsEmpty() && myLID.IsEmpty() { + return false // fail closed: unknown identity = not mentioned + } + + // Check mentioned JIDs from extended text. + if ext := evt.Message.GetExtendedTextMessage(); ext != nil { + if ci := ext.GetContextInfo(); ci != nil { + for _, jidStr := range ci.GetMentionedJID() { + mentioned, _ := types.ParseJID(jidStr) + if !myJID.IsEmpty() && mentioned.User == myJID.User { + return true + } + if !myLID.IsEmpty() && mentioned.User == myLID.User { + return true + } + } + } + } + return false +} diff --git a/internal/channels/whatsapp/media_download.go b/internal/channels/whatsapp/media_download.go new file mode 100644 index 000000000..cd69cc776 --- /dev/null +++ b/internal/channels/whatsapp/media_download.go @@ -0,0 +1,140 @@ +package whatsapp + +import ( + "context" + "log/slog" + "os" + "strings" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/types/events" + + "github.com/nextlevelbuilder/goclaw/internal/channels/media" +) + +// downloadMedia downloads media attachments from a WhatsApp message. +func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo { + msg := evt.Message + if msg == nil { + return nil + } + + type mediaItem struct { + mediaType string + mimetype string + filename string + download whatsmeow.DownloadableMessage + } + + var items []mediaItem + if img := msg.GetImageMessage(); img != nil { + items = append(items, mediaItem{"image", img.GetMimetype(), "", img}) + } + if vid := msg.GetVideoMessage(); vid != nil { + items = append(items, mediaItem{"video", vid.GetMimetype(), "", vid}) + } + if aud := msg.GetAudioMessage(); aud != nil { + items = append(items, mediaItem{"audio", aud.GetMimetype(), "", aud}) + } + if doc := msg.GetDocumentMessage(); doc != nil { + items = append(items, mediaItem{"document", doc.GetMimetype(), doc.GetFileName(), doc}) + } + if stk := msg.GetStickerMessage(); stk != nil { + items = append(items, mediaItem{"sticker", stk.GetMimetype(), "", stk}) + } + + if len(items) == 0 { + return nil + } + + var result []media.MediaInfo + for _, item := range items { + data, err := c.client.Download(c.ctx, item.download) + if err != nil { + reason := classifyDownloadError(err) + slog.Warn("whatsapp: media download failed", "type", item.mediaType, "reason", reason, "error", err) + continue + } + if len(data) > 20*1024*1024 { // 20MB limit + slog.Warn("whatsapp: media too large, skipping", "type", item.mediaType, + "size_mb", len(data)/(1024*1024)) + continue + } + + ext := mimeToExt(item.mimetype) + tmpFile, err := os.CreateTemp("", "goclaw_wa_*"+ext) + if err != nil { + slog.Warn("whatsapp: temp file creation failed", "error", err) + continue + } + if _, err := tmpFile.Write(data); err != nil { + tmpFile.Close() + os.Remove(tmpFile.Name()) + continue + } + tmpFile.Close() + + result = append(result, media.MediaInfo{ + Type: item.mediaType, + FilePath: tmpFile.Name(), + ContentType: item.mimetype, + FileName: item.filename, + }) + } + return result +} + +// mimeToExt maps MIME types to file extensions. +func mimeToExt(mime string) string { + switch { + case strings.HasPrefix(mime, "image/jpeg"): + return ".jpg" + case strings.HasPrefix(mime, "image/png"): + return ".png" + case strings.HasPrefix(mime, "image/webp"): + return ".webp" + case strings.HasPrefix(mime, "video/mp4"): + return ".mp4" + case strings.HasPrefix(mime, "audio/ogg"): + return ".ogg" + case strings.HasPrefix(mime, "audio/mpeg"): + return ".mp3" + case strings.HasPrefix(mime, "application/pdf"): + return ".pdf" + default: + return ".bin" + } +} + +// classifyDownloadError returns a human-readable reason for a media download failure. +func classifyDownloadError(err error) string { + msg := err.Error() + switch { + case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"): + return "timeout" + case strings.Contains(msg, "decrypt") || strings.Contains(msg, "cipher"): + return "decrypt_error" + case strings.Contains(msg, "404") || strings.Contains(msg, "not found"): + return "expired" + case strings.Contains(msg, "unsupported"): + return "unsupported" + default: + return "unknown" + } +} + +// scheduleMediaCleanup removes temp media files after a delay. +// Uses time.AfterFunc so it does not block and respects the provided context for logging only. +func scheduleMediaCleanup(ctx context.Context, paths []string, delay time.Duration) { + if len(paths) == 0 { + return + } + time.AfterFunc(delay, func() { + for _, p := range paths { + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + slog.Debug("whatsapp: temp media cleanup failed", "path", p, "error", err) + } + } + }) +} diff --git a/internal/channels/whatsapp/outbound.go b/internal/channels/whatsapp/outbound.go new file mode 100644 index 000000000..fa2e692d4 --- /dev/null +++ b/internal/channels/whatsapp/outbound.go @@ -0,0 +1,182 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "go.mau.fi/whatsmeow" + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" + + "github.com/nextlevelbuilder/goclaw/internal/bus" +) + +// Send delivers an outbound message to WhatsApp via whatsmeow. +func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { + if c.client == nil || !c.client.IsConnected() { + return fmt.Errorf("whatsapp not connected") + } + + chatJID, err := types.ParseJID(msg.ChatID) + if err != nil { + return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err) + } + + // Send media attachments first. + if len(msg.Media) > 0 { + for i, m := range msg.Media { + caption := m.Caption + if caption == "" && i == 0 && msg.Content != "" { + caption = markdownToWhatsApp(msg.Content) + } + + data, readErr := os.ReadFile(m.URL) + if readErr != nil { + return fmt.Errorf("read media file: %w", readErr) + } + + waMsg, buildErr := c.buildMediaMessage(data, m.ContentType, caption) + if buildErr != nil { + return fmt.Errorf("build media message: %w", buildErr) + } + + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + return fmt.Errorf("send whatsapp media: %w", sendErr) + } + } + // Skip text if caption was used on first media. + if msg.Media[0].Caption == "" && msg.Content != "" { + msg.Content = "" + } + } + + // Send text (chunked if exceeding limit). + if msg.Content != "" { + formatted := markdownToWhatsApp(msg.Content) + chunks := chunkText(formatted, maxMessageLen) + for _, chunk := range chunks { + waMsg := &waE2E.Message{ + Conversation: proto.String(chunk), + } + if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { + return fmt.Errorf("send whatsapp message: %w", err) + } + } + } + + // Stop typing indicator. + if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok { + if fn, ok := cancel.(context.CancelFunc); ok { + fn() + } + } + go c.sendPresence(chatJID, types.ChatPresencePaused) + + return nil +} + +// buildMediaMessage uploads media to WhatsApp and returns the message proto. +func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) { + switch { + case strings.HasPrefix(mime, "image/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaImage) + if err != nil { + return nil, err + } + return &waE2E.Message{ + ImageMessage: &waE2E.ImageMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "video/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaVideo) + if err != nil { + return nil, err + } + return &waE2E.Message{ + VideoMessage: &waE2E.VideoMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + case strings.HasPrefix(mime, "audio/"): + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaAudio) + if err != nil { + return nil, err + } + return &waE2E.Message{ + AudioMessage: &waE2E.AudioMessage{ + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + + default: // document + uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaDocument) + if err != nil { + return nil, err + } + return &waE2E.Message{ + DocumentMessage: &waE2E.DocumentMessage{ + Caption: proto.String(caption), + Mimetype: proto.String(mime), + URL: &uploaded.URL, + DirectPath: &uploaded.DirectPath, + MediaKey: uploaded.MediaKey, + FileEncSHA256: uploaded.FileEncSHA256, + FileSHA256: uploaded.FileSHA256, + FileLength: proto.Uint64(uint64(len(data))), + }, + }, nil + } +} + +// keepTyping sends "composing" presence repeatedly until ctx is cancelled. +func (c *Channel) keepTyping(ctx context.Context, chatJID types.JID) { + c.sendPresence(chatJID, types.ChatPresenceComposing) + ticker := time.NewTicker(8 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + c.sendPresence(chatJID, types.ChatPresenceComposing) + } + } +} + +// sendPresence sends a WhatsApp chat presence update. +func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) { + if c.client == nil || !c.client.IsConnected() { + return + } + if err := c.client.SendChatPresence(c.ctx, to, state, ""); err != nil { + slog.Debug("whatsapp: presence update failed", "state", state, "error", err) + } +} diff --git a/internal/channels/whatsapp/policy.go b/internal/channels/whatsapp/policy.go new file mode 100644 index 000000000..d305bc348 --- /dev/null +++ b/internal/channels/whatsapp/policy.go @@ -0,0 +1,141 @@ +package whatsapp + +import ( + "context" + "fmt" + "log/slog" + "time" + + "go.mau.fi/whatsmeow/proto/waE2E" + "go.mau.fi/whatsmeow/types" + "google.golang.org/protobuf/proto" +) + +// checkGroupPolicy evaluates the group policy for a sender. +func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { + groupPolicy := c.config.GroupPolicy + if groupPolicy == "" { + groupPolicy = "open" + } + + switch groupPolicy { + case "disabled": + return false + case "allowlist": + return c.IsAllowed(senderID) + case "pairing": + if c.HasAllowList() && c.IsAllowed(senderID) { + return true + } + if _, cached := c.approvedGroups.Load(chatID); cached { + return true + } + groupSenderID := fmt.Sprintf("group:%s", chatID) + if c.pairingService != nil { + paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) + if err != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "group_sender", groupSenderID, "channel", c.Name(), "error", err) + paired = true + } + if paired { + c.approvedGroups.Store(chatID, true) + return true + } + } + c.sendPairingReply(ctx, groupSenderID, chatID) + return false + default: // "open" + return true + } +} + +// checkDMPolicy evaluates the DM policy for a sender. +func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { + dmPolicy := c.config.DMPolicy + if dmPolicy == "" { + dmPolicy = "pairing" + } + + switch dmPolicy { + case "disabled": + slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID) + return false + case "open": + return true + case "allowlist": + if !c.IsAllowed(senderID) { + slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID) + return false + } + return true + default: // "pairing" + paired := false + if c.pairingService != nil { + p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) + if err != nil { + slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", + "sender_id", senderID, "channel", c.Name(), "error", err) + paired = true + } else { + paired = p + } + } + inAllowList := c.HasAllowList() && c.IsAllowed(senderID) + + if paired || inAllowList { + return true + } + + c.sendPairingReply(ctx, senderID, chatID) + return false + } +} + +// sendPairingReply sends a pairing code to the user via WhatsApp. +func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { + if c.pairingService == nil { + slog.Warn("whatsapp pairing: no pairing service configured") + return + } + + // Debounce. + if lastSent, ok := c.pairingDebounce.Load(senderID); ok { + if time.Since(lastSent.(time.Time)) < pairingDebounceTime { + slog.Info("whatsapp pairing: debounced", "sender_id", senderID) + return + } + } + + code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) + if err != nil { + slog.Warn("whatsapp pairing request failed", "sender_id", senderID, "channel", c.Name(), "error", err) + return + } + + replyText := fmt.Sprintf( + "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s", + senderID, code, code, + ) + + if c.client == nil || !c.client.IsConnected() { + slog.Warn("whatsapp not connected, cannot send pairing reply") + return + } + + chatJID, parseErr := types.ParseJID(chatID) + if parseErr != nil { + slog.Warn("whatsapp pairing: invalid chatID JID", "chatID", chatID, "error", parseErr) + return + } + + waMsg := &waE2E.Message{ + Conversation: proto.String(replyText), + } + if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { + slog.Warn("failed to send whatsapp pairing reply", "error", sendErr) + } else { + c.pairingDebounce.Store(senderID, time.Now()) + slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) + } +} diff --git a/internal/channels/whatsapp/whatsapp.go b/internal/channels/whatsapp/whatsapp.go index b0e699e69..ac1e1d01b 100644 --- a/internal/channels/whatsapp/whatsapp.go +++ b/internal/channels/whatsapp/whatsapp.go @@ -5,13 +5,10 @@ import ( "database/sql" "fmt" "log/slog" - "os" - "strings" "sync" "time" "go.mau.fi/whatsmeow" - "go.mau.fi/whatsmeow/proto/waE2E" wastore "go.mau.fi/whatsmeow/store" "go.mau.fi/whatsmeow/store/sqlstore" "go.mau.fi/whatsmeow/types" @@ -20,7 +17,6 @@ import ( "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" - "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/config" "github.com/nextlevelbuilder/goclaw/internal/store" ) @@ -45,6 +41,7 @@ type Channel struct { mu sync.Mutex ctx context.Context cancel context.CancelFunc + parentCtx context.Context // stored from Start() for Reauth() context chain pairingService store.PairingStore pairingDebounce sync.Map // senderID → time.Time approvedGroups sync.Map // chatID → true (in-memory cache for paired groups) @@ -85,24 +82,15 @@ func (c *Channel) cacheQR(pngB64 string) { c.lastQRMu.Unlock() } -// detectDialect returns the sqlstore dialect string based on the DB driver. -func detectDialect(db *sql.DB) string { - driverName := fmt.Sprintf("%T", db.Driver()) - if strings.Contains(driverName, "sqlite") { - return "sqlite3" - } - return "pgx" -} - // New creates a new WhatsApp channel backed by whatsmeow. +// dialect must be "pgx" (PostgreSQL) or "sqlite3" (SQLite/desktop). func New(cfg config.WhatsAppConfig, msgBus *bus.MessageBus, pairingSvc store.PairingStore, db *sql.DB, - pendingStore store.PendingMessageStore) (*Channel, error) { + pendingStore store.PendingMessageStore, dialect string) (*Channel, error) { base := channels.NewBaseChannel(channels.TypeWhatsApp, msgBus, cfg.AllowFrom) base.ValidatePolicy(cfg.DMPolicy, cfg.GroupPolicy) - dialect := detectDialect(db) container := sqlstore.NewWithDB(db, dialect, nil) if err := container.Upgrade(context.Background()); err != nil { return nil, fmt.Errorf("whatsapp sqlstore upgrade: %w", err) @@ -122,6 +110,7 @@ func (c *Channel) Start(ctx context.Context) error { slog.Info("starting whatsapp channel (whatsmeow)") c.MarkStarting("Initializing WhatsApp connection") + c.parentCtx = ctx c.ctx, c.cancel = context.WithCancel(ctx) deviceStore, err := c.container.GetFirstDevice(ctx) @@ -165,7 +154,9 @@ func (c *Channel) Stop(_ context.Context) error { // Cancel all active typing goroutines. c.typingCancel.Range(func(key, value any) bool { - value.(context.CancelFunc)() + if fn, ok := value.(context.CancelFunc); ok { + fn() + } c.typingCancel.Delete(key) return true }) @@ -228,753 +219,3 @@ func (c *Channel) handleLoggedOut(evt *events.LoggedOut) { c.MarkDegraded("WhatsApp logged out", "Re-scan QR to reconnect", channels.ChannelFailureKindAuth, false) } - -// handleIncomingMessage processes an incoming WhatsApp message. -func (c *Channel) handleIncomingMessage(evt *events.Message) { - ctx := context.Background() - ctx = store.WithTenantID(ctx, c.TenantID()) - - if evt.Info.IsFromMe { - return - } - - senderJID := evt.Info.Sender - chatJID := evt.Info.Chat - - // WhatsApp uses dual identity: phone JID (@s.whatsapp.net) and LID (@lid). - // Groups may use LID addressing. Normalize to phone JID for consistent - // policy checks, pairing lookups, allowlists, and contact collection. - if evt.Info.AddressingMode == types.AddressingModeLID && !evt.Info.SenderAlt.IsEmpty() { - senderJID = evt.Info.SenderAlt - } - - senderID := senderJID.String() - chatID := chatJID.String() - - peerKind := "direct" - if chatJID.Server == types.GroupServer { - peerKind = "group" - } - - slog.Debug("whatsapp incoming", "peer", peerKind, "sender", senderID, "chat", chatID, - "addressing", evt.Info.AddressingMode, "policy", c.config.GroupPolicy) - - // DM/Group policy check. - if peerKind == "direct" { - if !c.checkDMPolicy(ctx, senderID, chatID) { - return - } - } else { - if !c.checkGroupPolicy(ctx, senderID, chatID) { - slog.Info("whatsapp group message rejected by policy", "sender_id", senderID, "chat_id", chatID, "policy", c.config.GroupPolicy) - return - } - } - - if !c.IsAllowed(senderID) { - slog.Info("whatsapp message rejected by allowlist", "sender_id", senderID) - return - } - - content := extractTextContent(evt.Message) - - var mediaList []media.MediaInfo - mediaList = c.downloadMedia(evt) - - if content == "" && len(mediaList) == 0 { - return - } - if content == "" { - content = "[empty message]" - } - - // Group history + mention detection. - historyLimit := c.config.HistoryLimit - if historyLimit == 0 { - historyLimit = channels.DefaultGroupHistoryLimit - } - if peerKind == "group" && c.config.RequireMention != nil && *c.config.RequireMention { - if !c.isMentioned(evt) { - // Not mentioned — record for context and skip. - senderLabel := evt.Info.PushName - if senderLabel == "" { - senderLabel = senderID - } - c.groupHistory.Record(chatID, channels.HistoryEntry{ - Sender: senderLabel, - SenderID: senderID, - Body: content, - Timestamp: evt.Info.Timestamp, - MessageID: string(evt.Info.ID), - }, historyLimit) - return - } - // Mentioned — prepend accumulated group context. - content = c.groupHistory.BuildContext(chatID, content, historyLimit) - c.groupHistory.Clear(chatID) - } - - metadata := map[string]string{ - "message_id": string(evt.Info.ID), - } - if evt.Info.PushName != "" { - metadata["user_name"] = evt.Info.PushName - } - - // Build media tags and bus.MediaFile list. - var mediaFiles []bus.MediaFile - if len(mediaList) > 0 { - mediaTags := media.BuildMediaTags(mediaList) - if mediaTags != "" { - if content != "[empty message]" { - content = mediaTags + "\n\n" + content - } else { - content = mediaTags - } - } - for _, m := range mediaList { - if m.FilePath != "" { - mediaFiles = append(mediaFiles, bus.MediaFile{ - Path: m.FilePath, MimeType: m.ContentType, - }) - } - } - } - - // Annotate with sender identity. - if senderName := metadata["user_name"]; senderName != "" { - content = fmt.Sprintf("[From: %s]\n%s", senderName, content) - } - - // Collect contact. - if cc := c.ContactCollector(); cc != nil { - cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, - metadata["user_name"], "", peerKind, "user", "", "") - } - - // Typing indicator. - if prevCancel, ok := c.typingCancel.LoadAndDelete(chatID); ok { - prevCancel.(context.CancelFunc)() - } - typingCtx, typingCancel := context.WithCancel(context.Background()) - c.typingCancel.Store(chatID, typingCancel) - go c.keepTyping(typingCtx, chatJID) - - // Derive userID from senderID. - userID := senderID - if idx := strings.IndexByte(senderID, '|'); idx > 0 { - userID = senderID[:idx] - } - - c.Bus().PublishInbound(bus.InboundMessage{ - Channel: c.Name(), - SenderID: senderID, - ChatID: chatID, - Content: content, - Media: mediaFiles, - PeerKind: peerKind, - UserID: userID, - AgentID: c.AgentID(), - TenantID: c.TenantID(), - Metadata: metadata, - }) - - // Schedule temp media file cleanup after agent pipeline has had time to process. - var tmpPaths []string - for _, mf := range mediaFiles { - tmpPaths = append(tmpPaths, mf.Path) - } - scheduleMediaCleanup(tmpPaths, 5*time.Minute) -} - -// extractTextContent extracts text from any WhatsApp message variant. -// Includes quoted message context when present (reply-to messages). -func extractTextContent(msg *waE2E.Message) string { - if msg == nil { - return "" - } - - var text string - var quotedText string - - if msg.GetConversation() != "" { - text = msg.GetConversation() - } else if ext := msg.GetExtendedTextMessage(); ext != nil { - text = ext.GetText() - // Extract quoted (replied-to) message text. - if ci := ext.GetContextInfo(); ci != nil { - if qm := ci.GetQuotedMessage(); qm != nil { - quotedText = extractQuotedText(qm) - } - } - } else if img := msg.GetImageMessage(); img != nil { - text = img.GetCaption() - } else if vid := msg.GetVideoMessage(); vid != nil { - text = vid.GetCaption() - } else if doc := msg.GetDocumentMessage(); doc != nil { - text = doc.GetCaption() - } - - if quotedText != "" && text != "" { - return fmt.Sprintf("[Replying to: %s]\n%s", quotedText, text) - } - if quotedText != "" { - return fmt.Sprintf("[Replying to: %s]", quotedText) - } - return text -} - -// extractQuotedText extracts plain text from a quoted message (no recursion). -func extractQuotedText(msg *waE2E.Message) string { - if msg == nil { - return "" - } - if msg.GetConversation() != "" { - return msg.GetConversation() - } - if ext := msg.GetExtendedTextMessage(); ext != nil { - return ext.GetText() - } - if img := msg.GetImageMessage(); img != nil && img.GetCaption() != "" { - return img.GetCaption() - } - if vid := msg.GetVideoMessage(); vid != nil && vid.GetCaption() != "" { - return vid.GetCaption() - } - return "" -} - -// isMentioned checks if the linked account is @mentioned in a group message. -// WhatsApp uses dual identity: phone JID and LID. Mentions may use either format. -func (c *Channel) isMentioned(evt *events.Message) bool { - c.lastQRMu.RLock() - myJID := c.myJID - myLID := c.myLID - c.lastQRMu.RUnlock() - - if myJID.IsEmpty() && myLID.IsEmpty() { - return false // fail closed: unknown identity = not mentioned - } - - // Check mentioned JIDs from extended text. - if ext := evt.Message.GetExtendedTextMessage(); ext != nil { - if ci := ext.GetContextInfo(); ci != nil { - for _, jidStr := range ci.GetMentionedJID() { - mentioned, _ := types.ParseJID(jidStr) - if !myJID.IsEmpty() && mentioned.User == myJID.User { - return true - } - if !myLID.IsEmpty() && mentioned.User == myLID.User { - return true - } - } - } - } - return false -} - -// downloadMedia downloads media attachments from a WhatsApp message. -func (c *Channel) downloadMedia(evt *events.Message) []media.MediaInfo { - msg := evt.Message - if msg == nil { - return nil - } - - type mediaItem struct { - mediaType string - mimetype string - filename string - download whatsmeow.DownloadableMessage - } - - var items []mediaItem - if img := msg.GetImageMessage(); img != nil { - items = append(items, mediaItem{"image", img.GetMimetype(), "", img}) - } - if vid := msg.GetVideoMessage(); vid != nil { - items = append(items, mediaItem{"video", vid.GetMimetype(), "", vid}) - } - if aud := msg.GetAudioMessage(); aud != nil { - items = append(items, mediaItem{"audio", aud.GetMimetype(), "", aud}) - } - if doc := msg.GetDocumentMessage(); doc != nil { - items = append(items, mediaItem{"document", doc.GetMimetype(), doc.GetFileName(), doc}) - } - if stk := msg.GetStickerMessage(); stk != nil { - items = append(items, mediaItem{"sticker", stk.GetMimetype(), "", stk}) - } - - if len(items) == 0 { - return nil - } - - var result []media.MediaInfo - for _, item := range items { - data, err := c.client.Download(c.ctx, item.download) - if err != nil { - reason := classifyDownloadError(err) - slog.Warn("whatsapp: media download failed", "type", item.mediaType, "reason", reason, "error", err) - continue - } - if len(data) > 20*1024*1024 { // 20MB limit - slog.Warn("whatsapp: media too large, skipping", "type", item.mediaType, - "size_mb", len(data)/(1024*1024)) - continue - } - - ext := mimeToExt(item.mimetype) - tmpFile, err := os.CreateTemp("", "goclaw_wa_*"+ext) - if err != nil { - slog.Warn("whatsapp: temp file creation failed", "error", err) - continue - } - if _, err := tmpFile.Write(data); err != nil { - tmpFile.Close() - os.Remove(tmpFile.Name()) - continue - } - tmpFile.Close() - - result = append(result, media.MediaInfo{ - Type: item.mediaType, - FilePath: tmpFile.Name(), - ContentType: item.mimetype, - FileName: item.filename, - }) - } - return result -} - -// mimeToExt maps MIME types to file extensions. -func mimeToExt(mime string) string { - switch { - case strings.HasPrefix(mime, "image/jpeg"): - return ".jpg" - case strings.HasPrefix(mime, "image/png"): - return ".png" - case strings.HasPrefix(mime, "image/webp"): - return ".webp" - case strings.HasPrefix(mime, "video/mp4"): - return ".mp4" - case strings.HasPrefix(mime, "audio/ogg"): - return ".ogg" - case strings.HasPrefix(mime, "audio/mpeg"): - return ".mp3" - case strings.Contains(mime, "pdf"): - return ".pdf" - default: - return ".bin" - } -} - -// classifyDownloadError returns a human-readable reason for a media download failure. -func classifyDownloadError(err error) string { - msg := err.Error() - switch { - case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"): - return "timeout" - case strings.Contains(msg, "decrypt") || strings.Contains(msg, "cipher"): - return "decrypt_error" - case strings.Contains(msg, "404") || strings.Contains(msg, "not found"): - return "expired" - case strings.Contains(msg, "unsupported"): - return "unsupported" - default: - return "unknown" - } -} - -// scheduleMediaCleanup removes temp media files after a delay. -func scheduleMediaCleanup(paths []string, delay time.Duration) { - if len(paths) == 0 { - return - } - go func() { - time.Sleep(delay) - for _, p := range paths { - if err := os.Remove(p); err != nil && !os.IsNotExist(err) { - slog.Debug("whatsapp: temp media cleanup failed", "path", p, "error", err) - } - } - }() -} - -// Send delivers an outbound message to WhatsApp via whatsmeow. -func (c *Channel) Send(_ context.Context, msg bus.OutboundMessage) error { - if c.client == nil || !c.client.IsConnected() { - return fmt.Errorf("whatsapp not connected") - } - - chatJID, err := types.ParseJID(msg.ChatID) - if err != nil { - return fmt.Errorf("invalid whatsapp JID %q: %w", msg.ChatID, err) - } - - // Send media attachments first. - if len(msg.Media) > 0 { - for i, m := range msg.Media { - caption := m.Caption - if caption == "" && i == 0 && msg.Content != "" { - caption = markdownToWhatsApp(msg.Content) - } - - data, readErr := os.ReadFile(m.URL) - if readErr != nil { - return fmt.Errorf("read media file: %w", readErr) - } - - waMsg, buildErr := c.buildMediaMessage(data, m.ContentType, caption) - if buildErr != nil { - return fmt.Errorf("build media message: %w", buildErr) - } - - if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { - return fmt.Errorf("send whatsapp media: %w", sendErr) - } - } - // Skip text if caption was used on first media. - if msg.Media[0].Caption == "" && msg.Content != "" { - msg.Content = "" - } - } - - // Send text (chunked if exceeding limit). - if msg.Content != "" { - formatted := markdownToWhatsApp(msg.Content) - chunks := chunkText(formatted, maxMessageLen) - for _, chunk := range chunks { - waMsg := &waE2E.Message{ - Conversation: proto.String(chunk), - } - if _, err := c.client.SendMessage(c.ctx, chatJID, waMsg); err != nil { - return fmt.Errorf("send whatsapp message: %w", err) - } - } - } - - // Stop typing indicator. - if cancel, ok := c.typingCancel.LoadAndDelete(msg.ChatID); ok { - cancel.(context.CancelFunc)() - } - go c.sendPresence(chatJID, types.ChatPresencePaused) - - return nil -} - -// buildMediaMessage uploads media to WhatsApp and returns the message proto. -func (c *Channel) buildMediaMessage(data []byte, mime, caption string) (*waE2E.Message, error) { - switch { - case strings.HasPrefix(mime, "image/"): - uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaImage) - if err != nil { - return nil, err - } - return &waE2E.Message{ - ImageMessage: &waE2E.ImageMessage{ - Caption: proto.String(caption), - Mimetype: proto.String(mime), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - MediaKey: uploaded.MediaKey, - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(data))), - }, - }, nil - - case strings.HasPrefix(mime, "video/"): - uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaVideo) - if err != nil { - return nil, err - } - return &waE2E.Message{ - VideoMessage: &waE2E.VideoMessage{ - Caption: proto.String(caption), - Mimetype: proto.String(mime), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - MediaKey: uploaded.MediaKey, - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(data))), - }, - }, nil - - case strings.HasPrefix(mime, "audio/"): - uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaAudio) - if err != nil { - return nil, err - } - return &waE2E.Message{ - AudioMessage: &waE2E.AudioMessage{ - Mimetype: proto.String(mime), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - MediaKey: uploaded.MediaKey, - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(data))), - }, - }, nil - - default: // document - uploaded, err := c.client.Upload(c.ctx, data, whatsmeow.MediaDocument) - if err != nil { - return nil, err - } - return &waE2E.Message{ - DocumentMessage: &waE2E.DocumentMessage{ - Caption: proto.String(caption), - Mimetype: proto.String(mime), - URL: &uploaded.URL, - DirectPath: &uploaded.DirectPath, - MediaKey: uploaded.MediaKey, - FileEncSHA256: uploaded.FileEncSHA256, - FileSHA256: uploaded.FileSHA256, - FileLength: proto.Uint64(uint64(len(data))), - }, - }, nil - } -} - -// keepTyping sends "composing" presence repeatedly until ctx is cancelled. -func (c *Channel) keepTyping(ctx context.Context, chatJID types.JID) { - c.sendPresence(chatJID, types.ChatPresenceComposing) - ticker := time.NewTicker(8 * time.Second) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - c.sendPresence(chatJID, types.ChatPresenceComposing) - } - } -} - -// sendPresence sends a WhatsApp chat presence update. -func (c *Channel) sendPresence(to types.JID, state types.ChatPresence) { - if c.client == nil || !c.client.IsConnected() { - return - } - if err := c.client.SendChatPresence(c.ctx, to, state, ""); err != nil { - slog.Debug("whatsapp: presence update failed", "state", state, "error", err) - } -} - -// StartQRFlow initiates the QR authentication flow. -// Returns a channel that emits QR code strings and auth events. -// Lazily initializes the whatsmeow client if Start() hasn't been called yet -// (handles timing race between async instance reload and wizard auto-start). -// Serialized with Reauth via reauthMu to prevent races on rapid double-clicks. -func (c *Channel) StartQRFlow(ctx context.Context) (<-chan whatsmeow.QRChannelItem, error) { - c.reauthMu.Lock() - defer c.reauthMu.Unlock() - if c.client == nil { - // Lazy init: wizard may request QR before Start() is called. - c.mu.Lock() - if c.client == nil { - if c.ctx == nil { - c.ctx, c.cancel = context.WithCancel(context.Background()) - } - deviceStore, err := c.container.GetFirstDevice(ctx) - if err != nil { - c.mu.Unlock() - return nil, fmt.Errorf("whatsapp get device: %w", err) - } - c.client = whatsmeow.NewClient(deviceStore, nil) - c.client.AddEventHandler(c.handleEvent) - } - c.mu.Unlock() - } - - if c.IsAuthenticated() { - return nil, nil // caller checks this - } - - qrChan, err := c.client.GetQRChannel(ctx) - if err != nil { - return nil, fmt.Errorf("whatsapp get QR channel: %w", err) - } - - if !c.client.IsConnected() { - if err := c.client.Connect(); err != nil { - return nil, fmt.Errorf("whatsapp connect for QR: %w", err) - } - } - - return qrChan, nil -} - -// Reauth clears the current session and prepares for a fresh QR scan. -// Serialized with StartQRFlow via reauthMu to prevent races on rapid double-clicks. -func (c *Channel) Reauth() error { - c.reauthMu.Lock() - defer c.reauthMu.Unlock() - - slog.Info("whatsapp: reauth requested", "channel", c.Name()) - - c.lastQRMu.Lock() - c.waAuthenticated = false - c.lastQRB64 = "" - c.lastQRMu.Unlock() - - c.mu.Lock() - defer c.mu.Unlock() - - if c.client != nil { - c.client.Disconnect() - } - - // Delete device from store to force fresh QR on next connect. - if c.client != nil && c.client.Store.ID != nil { - if err := c.client.Store.Delete(context.Background()); err != nil { - slog.Warn("whatsapp: failed to delete device store", "error", err) - } - } - - // Reset context so the new client gets a fresh lifecycle. - if c.cancel != nil { - c.cancel() - } - c.ctx, c.cancel = context.WithCancel(context.Background()) - - // Re-create client with fresh device store. - deviceStore, err := c.container.GetFirstDevice(context.Background()) - if err != nil { - return fmt.Errorf("whatsapp: get fresh device: %w", err) - } - c.client = whatsmeow.NewClient(deviceStore, nil) - c.client.AddEventHandler(c.handleEvent) - - return nil -} - -// checkGroupPolicy evaluates the group policy for a sender. -func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, chatID string) bool { - groupPolicy := c.config.GroupPolicy - if groupPolicy == "" { - groupPolicy = "open" - } - - switch groupPolicy { - case "disabled": - return false - case "allowlist": - return c.IsAllowed(senderID) - case "pairing": - if c.HasAllowList() && c.IsAllowed(senderID) { - return true - } - if _, cached := c.approvedGroups.Load(chatID); cached { - return true - } - groupSenderID := fmt.Sprintf("group:%s", chatID) - if c.pairingService != nil { - paired, err := c.pairingService.IsPaired(ctx, groupSenderID, c.Name()) - if err != nil { - slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", - "group_sender", groupSenderID, "channel", c.Name(), "error", err) - paired = true - } - if paired { - c.approvedGroups.Store(chatID, true) - return true - } - } - c.sendPairingReply(ctx, groupSenderID, chatID) - return false - default: // "open" - return true - } -} - -// checkDMPolicy evaluates the DM policy for a sender. -func (c *Channel) checkDMPolicy(ctx context.Context, senderID, chatID string) bool { - dmPolicy := c.config.DMPolicy - if dmPolicy == "" { - dmPolicy = "pairing" - } - - switch dmPolicy { - case "disabled": - slog.Debug("whatsapp DM rejected: disabled", "sender_id", senderID) - return false - case "open": - return true - case "allowlist": - if !c.IsAllowed(senderID) { - slog.Debug("whatsapp DM rejected by allowlist", "sender_id", senderID) - return false - } - return true - default: // "pairing" - paired := false - if c.pairingService != nil { - p, err := c.pairingService.IsPaired(ctx, senderID, c.Name()) - if err != nil { - slog.Warn("security.pairing_check_failed, assuming paired (fail-open)", - "sender_id", senderID, "channel", c.Name(), "error", err) - paired = true - } else { - paired = p - } - } - inAllowList := c.HasAllowList() && c.IsAllowed(senderID) - - if paired || inAllowList { - return true - } - - c.sendPairingReply(ctx, senderID, chatID) - return false - } -} - -// sendPairingReply sends a pairing code to the user via WhatsApp. -func (c *Channel) sendPairingReply(ctx context.Context, senderID, chatID string) { - if c.pairingService == nil { - slog.Warn("whatsapp pairing: no pairing service configured") - return - } - - // Debounce. - if lastSent, ok := c.pairingDebounce.Load(senderID); ok { - if time.Since(lastSent.(time.Time)) < pairingDebounceTime { - slog.Info("whatsapp pairing: debounced", "sender_id", senderID) - return - } - } - - code, err := c.pairingService.RequestPairing(ctx, senderID, c.Name(), chatID, "default", nil) - if err != nil { - slog.Warn("whatsapp pairing request failed", "sender_id", senderID, "channel", c.Name(), "error", err) - return - } - - replyText := fmt.Sprintf( - "GoClaw: access not configured.\n\nYour WhatsApp ID: %s\n\nPairing code: %s\n\nAsk the account owner to approve with:\n goclaw pairing approve %s", - senderID, code, code, - ) - - if c.client == nil || !c.client.IsConnected() { - slog.Warn("whatsapp not connected, cannot send pairing reply") - return - } - - chatJID, parseErr := types.ParseJID(chatID) - if parseErr != nil { - slog.Warn("whatsapp pairing: invalid chatID JID", "chatID", chatID, "error", parseErr) - return - } - - waMsg := &waE2E.Message{ - Conversation: proto.String(replyText), - } - if _, sendErr := c.client.SendMessage(c.ctx, chatJID, waMsg); sendErr != nil { - slog.Warn("failed to send whatsapp pairing reply", "error", sendErr) - } else { - c.pairingDebounce.Store(senderID, time.Now()) - slog.Info("whatsapp pairing reply sent", "sender_id", senderID, "code", code) - } -} diff --git a/internal/store/channel_instance_store.go b/internal/store/channel_instance_store.go index 851fa97e1..744f37c8e 100644 --- a/internal/store/channel_instance_store.go +++ b/internal/store/channel_instance_store.go @@ -30,7 +30,7 @@ func IsDefaultChannelInstance(name string) bool { } // Legacy config-based defaults that were seeded with bare channel-type names. switch name { - case "telegram", "discord", "feishu", "zalo_oa": + case "telegram", "discord", "feishu", "zalo_oa", "whatsapp": return true } return false