Skip to content

feat(channels): add WhatsApp channel via WhatsApp Web (whatsmeow) - #452

Open
ratnesh-maurya wants to merge 4 commits into
initializ:mainfrom
ratnesh-maurya:feat/whatsapp-channel
Open

feat(channels): add WhatsApp channel via WhatsApp Web (whatsmeow)#452
ratnesh-maurya wants to merge 4 commits into
initializ:mainfrom
ratnesh-maurya:feat/whatsapp-channel

Conversation

@ratnesh-maurya

Copy link
Copy Markdown
Member

Type of Change

  • Bug fix
  • New feature
  • Enhancement / refactor
  • New skill
  • Documentation
  • CI / build

Description

Adds WhatsApp as a fourth channel adapter alongside Slack, Telegram and MS Teams,
implementing channels.ChannelPlugin over the WhatsApp Web multidevice protocol.

40 files, +5628 / −49. 173 new tests.

Library choice: whatsmeow, not Baileys

The reference for this work was OpenClaw's WhatsApp extension, which is TypeScript
on Baileys. A direct port would mean a Node runtime in the release image, an
invented IPC protocol to maintain, and — most importantly — WhatsApp traffic
outside the egress enforcer, which wraps Go's http.Transport. whatsmeow speaks
the identical protocol in-process, so the adapter stays behind egress control and
the image stays a single static binary (CGO_ENABLED=0).

Same protocol means the same tradeoffs as OpenClaw: this is not the official
WhatsApp Cloud API.

Pinned whatsmeow version — please read

Pinned to fb386f152837 (2026-08-16), the last commit before upstream raised its
minimum to Go 1.26. Taking HEAD would force this repo's Go floor from 1.25 to
1.26 across go.work, two go.mod files, the Dockerfile and seven CI pins — a
repo-wide toolchain change riding along with a channel adapter.

Cost of the pin: ~3 weeks behind upstream. WhatsApp version-gates the Web protocol
and rejects stale clients, so when this repo moves to Go 1.26 for other reasons,
whatsmeow should move to HEAD in the same change.
The err-client-outdated path
in internal/wapair is what surfaces that failure if it arrives first.

Happy to switch to the bump instead if reviewers prefer it in one go.

Pairing

The one adapter here whose credential is a file, not an env var. QR pairing, two
entry points sharing one state machine in forge-cli/internal/wapair:

  • forge channel whatsapp-login — standalone, owns the terminal
  • an inline phase in the forge init wizard — pairs into a temp store that
    scaffold relocates into the project, since the wizard runs before the project
    directory exists (passed via a synthetic __whatsapp_session key, same
    convention as __egress_domains)

Session written 0600 under a 0700 directory.

PairSuccess is not the end of pairing. WhatsApp then drops the socket, the
client reconnects and completes a login handshake, and only after that is the
device registered server-side. An early version disconnected on PairSuccess and
produced a device the server rejected with 401 logged out from another device
seconds into the next run. wapair now waits for the post-pair reconnect, holds
the socket through a settle window so the async prekey upload lands, and verifies
IsLoggedIn() before reporting success. Regression test:
TestAwaitCompletion_WaitsForPostPairConnect.

Security posture — closed by default

  • allowed_senders empty means owner-only, not "anyone with the number".
    Matches OpenClaw's dmAllowFrom default. Opening up is the explicit
    allowed_senders: anyone.
  • Group traffic always requires an @-mention.
  • Newsletters, broadcast lists and the status feed are never answerable.
  • Messages predating startup by >5min are dropped, so a restart doesn't answer a
    replayed conversation.
  • self_chat lets the paired phone talk to its own agent. The agent's replies are
    self-sent there too, so the dedup ring — which records every outbound id before
    SendResponse returns — is the loop guard. Replies are prefixed (⚒ Forge: )
    because WhatsApp renders them on the same side as the owner's own messages.

New egress domains — justification

Adds a whatsapp capability bundle in forge-core/security/capabilities.go:

Domain Why
web.whatsapp.com the multidevice websocket (wss://web.whatsapp.com/ws/chat)
*.whatsapp.net media up/download

The wildcard is deliberate and I'd like a second opinion on it. Media hosts are
handed to the client at runtime by the server (mmg, mmg-fallback, and
regional media-*.cdn names), so pinning today's hostnames breaks on the next CDN
reshuffle. Verified against whatsmeow's source that no other hosts are dialled.

This is the first wildcard in DefaultCapabilityBundles, which surfaced a latent
bug: the init template emitted list items unquoted, and a leading * is a YAML
alias indicator, so forge init failed with "did not find expected alphabetic or
numeric character"
. Fixed by routing list scalars through the existing
yamlScalar helper. Quoting is conditional, so existing generated configs are
byte-identical.

Known gaps (documented in docs/core-concepts/channels.md)

  • No DEFER approvals or MCP consent. OpenClaw implements approvals via emoji
    reactions, which sidesteps the unreliable interactive message types — worth
    adopting, not done here.
  • No UserEmail on inbound events. WhatsApp has no email identity, so
    delegated (auth.type: user) MCP cannot resolve an on-behalf-of subject.
  • No media. Captions on inbound media are read as prompt text.
  • Group context is observed, not fetched, and lost on restart. WhatsApp has no
    server-side history API for a linked device, unlike Graph's
    /chats/{id}/messages.
  • No reconnect supervision. whatsmeow's internal retry is all there is;
    OpenClaw runs a heartbeat, watchdog and backoff. This is the gap I'd close first.

General Checklist

  • Tests pass for affected modules (go test ./...)
  • Code is formatted (gofmt -w)
  • Linter passes (golangci-lint run)
  • go vet reports no issues
  • No new egress domains added without justification — see above

go.mod did not list the indirect dependencies reached through forge-core
(otel, grpc, backoff, logr and friends), so `go mod tidy` reported a diff
and gopls surfaced "not in your go.mod file" across the package.

Builds worked because the workspace resolved them, which is why this went
unnoticed. Pre-existing and unrelated to any feature work — confirmed by
reproducing `go mod tidy -diff` on a clean checkout.
Adds a fourth channel adapter alongside Slack, Telegram and MS Teams,
implementing channels.ChannelPlugin over the WhatsApp Web multidevice
protocol.

Library choice: whatsmeow, not Baileys. OpenClaw's WhatsApp extension —
the reference for this work — is TypeScript on Baileys, but forge is
pure Go and the release image builds CGO_ENABLED=0. A Node sidecar would
mean a second runtime in the image, an invented IPC protocol, and
WhatsApp traffic outside the egress enforcer, which wraps Go's
http.Transport. whatsmeow speaks the identical protocol in-process.

whatsmeow is pinned to fb386f152837 (2026-08-16), the last commit before
upstream raised its minimum to Go 1.26. Taking HEAD would force the
repo's Go floor from 1.25 to 1.26 across go.work, two go.mod files, the
Dockerfile and seven CI pins. Revisit when the repo moves to 1.26 for
other reasons: WhatsApp version-gates the Web protocol and rejects stale
clients (see the err-client-outdated path in wapair).

Pairing is a QR scan, not a token — the one adapter here whose credential
is a file rather than an env var. Two entry points share the state
machine in internal/wapair: `forge channel whatsapp-login`, and an inline
phase in the init wizard which pairs into a temp store that scaffold then
relocates into the project (the wizard runs before the project directory
exists). The session is written 0600 under a 0700 directory; it is the
credential.

Pairing completion is NOT the PairSuccess event. WhatsApp then drops the
socket, the client reconnects and completes a login handshake, and only
after that is the device registered server-side. Disconnecting on
PairSuccess produced a device the server rejected with 401 "logged out
from another device" seconds into the next run. wapair now waits for the
post-pair reconnect, holds the socket through a settle window so the
async prekey upload lands, and verifies IsLoggedIn before reporting
success.

Admission defaults are closed. An empty allowed_senders means owner-only
rather than "anyone with the number", matching OpenClaw's dmAllowFrom
default; opening up is the explicit `allowed_senders: anyone`. Group
traffic always requires an @-mention. self_chat lets the paired phone
talk to its own agent in the "Message Yourself" chat — the agent's own
replies are self-sent there too, so the dedup ring, which records every
outbound id before SendResponse returns, is the loop guard. Messages
predating startup by more than five minutes are dropped so a restart does
not answer a replayed conversation, or its own replayed replies.

Also adds a YAML-scalar helper to the init template. The egress bundle
introduced the first wildcard domain ("*.whatsapp.net"), whose leading
"*" YAML reads as an alias reference, breaking `forge init` with "did not
find expected alphabetic or numeric character". Quoting is conditional,
so existing generated configs are unchanged.

Known gaps, documented in docs/core-concepts/channels.md:
  - No DEFER approvals or MCP consent. OpenClaw implements approvals via
    emoji reactions, which sidesteps the unreliable interactive message
    types; worth adopting, not done here.
  - No UserEmail on inbound events — WhatsApp has no email identity, so
    delegated (auth.type: user) MCP cannot resolve a subject.
  - No media. Captions on inbound media are read as prompt text.
  - Group context is built from observed traffic and lost on restart.
    WhatsApp has no server-side history fetch for a linked device, unlike
    Graph's /chats/{id}/messages.
  - No reconnect supervision. whatsmeow's internal retry is all there is;
    OpenClaw runs a heartbeat, watchdog and backoff.

Automating WhatsApp Web is against WhatsApp's Terms of Service and can
get the linked number banned. The warning is surfaced by `channel add`,
the login command's help, and the docs.
The pairing screen is the most vertically constrained view in the wizard —
the QR itself is 26 rows before any chrome — and the warning cost three
lines directly under it, on the one screen where fitting matters most.

It stays in the three places where it can be read properly: the
`channel add` setup output, the `whatsapp-login` command's help, and
docs/core-concepts/channels.md.

For reference, OpenClaw carries no equivalent warning at all; its docs
frame a separate number as an optimization ("setup and metadata are
optimized for it"), not a risk.
WhatsApp decides which side of a thread a message renders on from its
sender, and in the self-chat the agent sends AS the owner. Its replies are
therefore visually identical to the owner's own prompts — same side, same
colour — and nothing on the wire changes that.

Replies in the self-chat are now prefixed, "⚒ Forge: " by default,
matching the CLI banner's mark. Configurable via self_chat_prefix; an
explicitly empty value disables it, which is why the setting is read with
settingOrDefault rather than strOrDefault (the latter would swallow a
deliberate "").

Applied only in the self-chat: a normal DM or group already distinguishes
sender from recipient, so a marker there is noise.

Every chunk of a split reply is marked, not just the first — an unmarked
continuation is indistinguishable from something the owner typed when
scrolling back. A chunk opening with a fenced code block gets the marker
on its own line, since inlining it would put text before the opening ```
and WhatsApp would render the fence literally.
@ratnesh-maurya ratnesh-maurya changed the title Feat/whatsapp channel feat(channels): add WhatsApp channel via WhatsApp Web (whatsmeow) Sep 9, 2026
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.

1 participant