feat(channels): add WhatsApp channel via WhatsApp Web (whatsmeow) - #452
Open
ratnesh-maurya wants to merge 4 commits into
Open
feat(channels): add WhatsApp channel via WhatsApp Web (whatsmeow)#452ratnesh-maurya wants to merge 4 commits into
ratnesh-maurya wants to merge 4 commits into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Type of Change
Description
Adds WhatsApp as a fourth channel adapter alongside Slack, Telegram and MS Teams,
implementing
channels.ChannelPluginover 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.whatsmeowspeaksthe 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 itsminimum to Go 1.26. Taking HEAD would force this repo's Go floor from 1.25 to
1.26 across
go.work, twogo.modfiles, the Dockerfile and seven CI pins — arepo-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-outdatedpathin
internal/wapairis 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 terminalforge initwizard — pairs into a temp store thatscaffoldrelocates into the project, since the wizard runs before the projectdirectory exists (passed via a synthetic
__whatsapp_sessionkey, sameconvention as
__egress_domains)Session written
0600under a0700directory.PairSuccessis not the end of pairing. WhatsApp then drops the socket, theclient reconnects and completes a login handshake, and only after that is the
device registered server-side. An early version disconnected on
PairSuccessandproduced a device the server rejected with
401 logged out from another deviceseconds into the next run.
wapairnow waits for the post-pair reconnect, holdsthe 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_sendersempty means owner-only, not "anyone with the number".Matches OpenClaw's
dmAllowFromdefault. Opening up is the explicitallowed_senders: anyone.replayed conversation.
self_chatlets the paired phone talk to its own agent. The agent's replies areself-sent there too, so the dedup ring — which records every outbound id before
SendResponsereturns — 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
whatsappcapability bundle inforge-core/security/capabilities.go:web.whatsapp.comwss://web.whatsapp.com/ws/chat)*.whatsapp.netThe 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, andregional
media-*.cdnnames), so pinning today's hostnames breaks on the next CDNreshuffle. Verified against whatsmeow's source that no other hosts are dialled.
This is the first wildcard in
DefaultCapabilityBundles, which surfaced a latentbug: the init template emitted list items unquoted, and a leading
*is a YAMLalias indicator, so
forge initfailed with "did not find expected alphabetic ornumeric character". Fixed by routing list scalars through the existing
yamlScalarhelper. Quoting is conditional, so existing generated configs arebyte-identical.
Known gaps (documented in
docs/core-concepts/channels.md)reactions, which sidesteps the unreliable interactive message types — worth
adopting, not done here.
UserEmailon inbound events. WhatsApp has no email identity, sodelegated (
auth.type: user) MCP cannot resolve an on-behalf-of subject.server-side history API for a linked device, unlike Graph's
/chats/{id}/messages.OpenClaw runs a heartbeat, watchdog and backoff. This is the gap I'd close first.
General Checklist
go test ./...)gofmt -w)golangci-lint run)go vetreports no issues