Skip to content

feat(whatsapp): add native WhatsApp channel with whatsmeow#720

Merged
viettranx merged 22 commits intodevfrom
fix/703-whatsapp-channel
Apr 7, 2026
Merged

feat(whatsapp): add native WhatsApp channel with whatsmeow#720
viettranx merged 22 commits intodevfrom
fix/703-whatsapp-channel

Conversation

@vanducng
Copy link
Copy Markdown
Contributor

@vanducng vanducng commented Apr 6, 2026

Summary

Resolves #703 — implements a full WhatsApp channel integration for GoClaw using whatsmeow (native Go library, no external bridge service needed).

  • Native Go integration (internal/channels/whatsapp/) — connects directly to WhatsApp multi-device protocol via whatsmeow, no separate bridge process
  • QR wizard — WS RPC method (whatsapp.qr.start) + React UI for QR-code-based authentication and re-auth
  • DM/Group policies — open, pairing, allowlist, disabled — with @mention gating in groups
  • Media support — inbound + outbound images, video, audio, documents
  • Markdown → WhatsApp formatting — HTML pre-processing, code block preservation, bold/italic/strikethrough conversion
  • Typing indicators — goroutine-safe refresh loop (8s tick, WhatsApp clears at ~10s)
  • Quoted message context — includes replied-to message content for agent context
  • Group pending history — configurable message history limit for group context
  • Message chunking — splits long responses for WhatsApp's message size limits
  • i18n — all 3 locales (en, vi, zh)

What was wrong

Users trying to integrate WhatsApp (#703) had to run a separate Node.js bridge with a mismatched protocol (different field names, no type field). GoClaw didn't respond because it expected type:"message", from, content but bridges sent sender, body.

What this PR does

  1. Replaces the external bridge approach with native whatsmeow — zero external dependencies
  2. QR code auth flow (whatsmeow → Go → WebSocket → React wizard)
  3. Inbound + outbound media (images, video, audio, documents) via whatsmeow download/upload
  4. Markdown → WhatsApp formatting with HTML pre-processing, code block preservation
  5. Typing indicators with goroutine-safe refresh loop
  6. DM/Group policies (open, pairing, allowlist, disabled) with @mention gating
  7. Dual JID + LID identity detection for group mentions
  8. LID addressing support for outbound group messages
  9. Shows "GoClaw" as device name in WhatsApp Linked Devices
  10. Pairing store support (PG + SQLite) for approval-based access

Key files

Area Files
Go channel internal/channels/whatsapp/{whatsapp,factory,format,qr_methods}.go
Config internal/config/config_channels.go (WhatsAppConfig)
Protocol pkg/protocol/{events,methods}.go
WS methods internal/gateway/methods/access.go (QR handler registration)
Pairing store internal/store/{pairing_store,pg/pairing,sqlitestore/pairing}.go
UI wizard ui/web/src/pages/channels/whatsapp/ (3 files)
Event filter internal/gateway/event_filter.go

Test plan

  • go build ./... passes
  • go build -tags sqliteonly ./... passes
  • go vet ./... passes
  • go test ./internal/channels/whatsapp/... passes
  • Manual: create WhatsApp channel in UI → scan QR → send/receive messages
  • Manual: verify media (image/video/doc) round-trip
  • Manual: verify re-auth dialog from channels table
  • Manual: verify group policy + pairing flow
  • Manual: verify @mention gating in groups
  • Manual: verify quoted message context in replies

vanducng added 6 commits April 6, 2026 12:47
…tials 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.
… 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.
…dling

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
Go's regexp treats $1_ as submatch named "1_" (nonexistent → empty),
silently dropping content for <em>, <i>, <del>, <s>, <code>, and <a> tags.
Use ${1} to disambiguate. Tests added and passing.
…ling

- 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
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.
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.
vanducng added 11 commits April 6, 2026 17:44
- 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
Set wastore.DeviceProps.Os so WhatsApp displays "GoClaw" instead of
"Other device" in the phone's Linked Devices screen.
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.
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
WhatsApp channel links a personal account as a device, not a bot.
Updated comments and user-facing pairing text accordingly.
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.
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.
- 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
WhatsApp has no token — show as configured only when enabled,
not unconditionally true.
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.
@vanducng vanducng changed the title feat(whatsapp): add WhatsApp channel with Baileys bridge feat(whatsapp): add native WhatsApp channel with whatsmeow Apr 6, 2026
@vanducng vanducng marked this pull request as ready for review April 6, 2026 12:13
vanducng added 2 commits April 6, 2026 19:18
- 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
…leanup, reauth race

- 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
@reski-rukmantiyo
Copy link
Copy Markdown

nice...this is working. Working Flawless 💯

vanducng and others added 2 commits April 7, 2026 00:37
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)
@viettranx viettranx merged commit 0db1e93 into dev Apr 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

How to integrate WhatsApp Channel

3 participants