feat: add WhatsApp channel via Baileys - #98
Conversation
Adds WhatsApp as a second messaging surface alongside Slack.
Uses the same Baileys v7 backend as OpenClaw/Hermes.
New files:
- src/whatsapp/client.ts — Baileys socket lifecycle, reconnection, inbound
- src/whatsapp/types.ts — WhatsApp-specific type exports
- src/whatsapp/router.ts — routes WhatsApp events to agent handler
- src/whatsapp/conversation.ts — maps JIDs to conversation targets
- src/surface/whatsapp.ts — AgentSurface implementation for WhatsApp
Changed files:
- src/types.ts — extend BotEvent.source with 'whatsapp'
- src/config.ts — add whatsapp.allowFrom config key
- src/main.ts — wire WhatsApp client when DIGBY_WHATSAPP_ENABLED=true
- package.json — add baileys, pino, @hapi/boom deps
- .gitignore — exclude whatsapp-auth/ directory
Setup:
DIGBY_WHATSAPP_ENABLED=true
DIGBY_WHATSAPP_AUTH_DIR=/data/whatsapp-auth (or leave default)
On first run, scan the QR code printed to stdout/logs to link a
WhatsApp account. Auth persists in auth dir; subsequent restarts
auto-reconnect. Add allowed JIDs/numbers to digby.json:
{"whatsapp": {"allowFrom": ["+447700900000"]}}
There was a problem hiding this comment.
8 issues found across 12 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/types.ts">
<violation number="1" location="src/types.ts:3">
P2: After the type error is fixed, WhatsApp runs will still use the Slack system prompt, causing output such as `*text*` to be converted to WhatsApp italics instead of bold. A WhatsApp-specific prompt branch would avoid relying on the non-Linear fallback.</violation>
<violation number="2" location="src/types.ts:3">
P2: When a WhatsApp message arrives while a run is busy, the queued trigger tells the agent it is a Linear message. Including a WhatsApp label in `formatQueuedFollowUpPrompt` would preserve the correct channel context.</violation>
</file>
<file name="src/whatsapp/router.ts">
<violation number="1" location="src/whatsapp/router.ts:19">
P2: Distinct WhatsApp messages with the same channel and timestamp are dropped before dispatch because the deduplication key has no message ID. Deduplication should use `msg.key.id` (or pass that ID through `WhatsAppEvent`) rather than treating the timestamp as unique.</violation>
</file>
<file name="src/surface/whatsapp.ts">
<violation number="1" location="src/surface/whatsapp.ts:78">
P2: WhatsApp users receive no live tool/retry progress while a run is active because `emitProgress()` only changes an in-memory buffer. The surface should publish progress updates (or explicitly update a tracked placeholder) so the advertised streaming behavior works.</violation>
<violation number="2" location="src/surface/whatsapp.ts:123">
P2: Responses longer than 4000 characters are silently truncated instead of being delivered in 4000-character chunks. Passing the full `displayText` to `WhatsAppClient.sendMessage()` would let the transport's existing chunking logic preserve the complete response.</violation>
</file>
<file name="src/whatsapp/client.ts">
<violation number="1" location="src/whatsapp/client.ts:137">
P1: Enabling WhatsApp without populating `allowFrom` lets any inbound contact invoke the agent, despite the setup requiring allowed numbers/JIDs. The empty-list case would be safer as fail-closed (or startup should reject the configuration) so an operator mistake cannot expose the root-capable agent.</violation>
<violation number="2" location="src/whatsapp/client.ts:258">
P2: Markdown bold is rendered as italic because the italic replacement runs after the bold replacement and rewrites the generated WhatsApp bold markers. Applying single-asterisk italic conversion before the bold conversions in both WhatsApp formatters would preserve `*bold*`.</violation>
</file>
<file name="src/main.ts">
<violation number="1" location="src/main.ts:741">
P2: A transient Baileys version or auth initialization failure prevents the whole process from reaching `Ready`, taking Slack down even though WhatsApp is optional. Isolating WhatsApp startup from the main boot path or adding an initial retry/backoff around initialization would keep the existing channel available.</violation>
</file>
Heads up: you’re close to your included review allowance. Set a flex budget so reviews don’t pause.
Re-trigger cubic
| const jid = msg.key.remoteJid; | ||
| if (!jid) continue; | ||
|
|
||
| if (this.allowFromJids.size > 0 && !this.allowFromJids.has(jid)) { |
There was a problem hiding this comment.
P1: Enabling WhatsApp without populating allowFrom lets any inbound contact invoke the agent, despite the setup requiring allowed numbers/JIDs. The empty-list case would be safer as fail-closed (or startup should reject the configuration) so an operator mistake cannot expose the root-capable agent.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/whatsapp/client.ts, line 137:
<comment>Enabling WhatsApp without populating `allowFrom` lets any inbound contact invoke the agent, despite the setup requiring allowed numbers/JIDs. The empty-list case would be safer as fail-closed (or startup should reject the configuration) so an operator mistake cannot expose the root-capable agent.</comment>
<file context>
@@ -0,0 +1,298 @@
+ const jid = msg.key.remoteJid;
+ if (!jid) continue;
+
+ if (this.allowFromJids.size > 0 && !this.allowFromJids.has(jid)) {
+ const senderJid = msg.key.participant;
+ if (!senderJid || !this.allowFromJids.has(senderJid)) {
</file context>
| export interface BotEvent { | ||
| type: "mention" | "dm" | "channel" | "agent_session"; | ||
| source: "slack" | "linear"; | ||
| source: "slack" | "linear" | "whatsapp"; |
There was a problem hiding this comment.
P2: After the type error is fixed, WhatsApp runs will still use the Slack system prompt, causing output such as *text* to be converted to WhatsApp italics instead of bold. A WhatsApp-specific prompt branch would avoid relying on the non-Linear fallback.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/types.ts, line 3:
<comment>After the type error is fixed, WhatsApp runs will still use the Slack system prompt, causing output such as `*text*` to be converted to WhatsApp italics instead of bold. A WhatsApp-specific prompt branch would avoid relying on the non-Linear fallback.</comment>
<file context>
@@ -1,6 +1,6 @@
export interface BotEvent {
type: "mention" | "dm" | "channel" | "agent_session";
- source: "slack" | "linear";
+ source: "slack" | "linear" | "whatsapp";
channel: string;
ts: string;
</file context>
| export interface BotEvent { | ||
| type: "mention" | "dm" | "channel" | "agent_session"; | ||
| source: "slack" | "linear"; | ||
| source: "slack" | "linear" | "whatsapp"; |
There was a problem hiding this comment.
P2: When a WhatsApp message arrives while a run is busy, the queued trigger tells the agent it is a Linear message. Including a WhatsApp label in formatQueuedFollowUpPrompt would preserve the correct channel context.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/types.ts, line 3:
<comment>When a WhatsApp message arrives while a run is busy, the queued trigger tells the agent it is a Linear message. Including a WhatsApp label in `formatQueuedFollowUpPrompt` would preserve the correct channel context.</comment>
<file context>
@@ -1,6 +1,6 @@
export interface BotEvent {
type: "mention" | "dm" | "channel" | "agent_session";
- source: "slack" | "linear";
+ source: "slack" | "linear" | "whatsapp";
channel: string;
ts: string;
</file context>
| const seenEvents = new QuickLRU<string, true>({ maxSize: 100 }); | ||
|
|
||
| return (channel: string, ts: string): boolean => { | ||
| const key = `${channel}:${ts}`; |
There was a problem hiding this comment.
P2: Distinct WhatsApp messages with the same channel and timestamp are dropped before dispatch because the deduplication key has no message ID. Deduplication should use msg.key.id (or pass that ID through WhatsAppEvent) rather than treating the timestamp as unique.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/whatsapp/router.ts, line 19:
<comment>Distinct WhatsApp messages with the same channel and timestamp are dropped before dispatch because the deduplication key has no message ID. Deduplication should use `msg.key.id` (or pass that ID through `WhatsAppEvent`) rather than treating the timestamp as unique.</comment>
<file context>
@@ -0,0 +1,83 @@
+ const seenEvents = new QuickLRU<string, true>({ maxSize: 100 });
+
+ return (channel: string, ts: string): boolean => {
+ const key = `${channel}:${ts}`;
+ if (seenEvents.has(key)) return true;
+ seenEvents.set(key, true);
</file context>
| if (this.accumulatedText === THINKING_PLACEHOLDER) { | ||
| this.accumulatedText = waText; | ||
| } else { | ||
| this.accumulatedText = this.accumulatedText ? `${this.accumulatedText}\n${waText}` : waText; |
There was a problem hiding this comment.
P2: WhatsApp users receive no live tool/retry progress while a run is active because emitProgress() only changes an in-memory buffer. The surface should publish progress updates (or explicitly update a tracked placeholder) so the advertised streaming behavior works.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/surface/whatsapp.ts, line 78:
<comment>WhatsApp users receive no live tool/retry progress while a run is active because `emitProgress()` only changes an in-memory buffer. The surface should publish progress updates (or explicitly update a tracked placeholder) so the advertised streaming behavior works.</comment>
<file context>
@@ -0,0 +1,182 @@
+ if (this.accumulatedText === THINKING_PLACEHOLDER) {
+ this.accumulatedText = waText;
+ } else {
+ this.accumulatedText = this.accumulatedText ? `${this.accumulatedText}\n${waText}` : waText;
+ }
+ }
</file context>
| if (!this.suppressed && this.accumulatedText.trim() && this.accumulatedText !== THINKING_PLACEHOLDER) { | ||
| this.enqueue(async () => { | ||
| try { | ||
| const display = this.truncate(this.displayText, MAX_MESSAGE_LENGTH); |
There was a problem hiding this comment.
P2: Responses longer than 4000 characters are silently truncated instead of being delivered in 4000-character chunks. Passing the full displayText to WhatsAppClient.sendMessage() would let the transport's existing chunking logic preserve the complete response.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/surface/whatsapp.ts, line 123:
<comment>Responses longer than 4000 characters are silently truncated instead of being delivered in 4000-character chunks. Passing the full `displayText` to `WhatsAppClient.sendMessage()` would let the transport's existing chunking logic preserve the complete response.</comment>
<file context>
@@ -0,0 +1,182 @@
+ if (!this.suppressed && this.accumulatedText.trim() && this.accumulatedText !== THINKING_PLACEHOLDER) {
+ this.enqueue(async () => {
+ try {
+ const display = this.truncate(this.displayText, MAX_MESSAGE_LENGTH);
+ await this.client.sendMessage(this.jid, display);
+ } catch (err) {
</file context>
| .replace(/\*\*([^*]+)\*\*/g, "*$1*") | ||
| .replace(/__([^_]+)__/g, "*$1*") | ||
| // Italic: *text* (single) or _text_ -> _text_ | ||
| .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "_$1_") |
There was a problem hiding this comment.
P2: Markdown bold is rendered as italic because the italic replacement runs after the bold replacement and rewrites the generated WhatsApp bold markers. Applying single-asterisk italic conversion before the bold conversions in both WhatsApp formatters would preserve *bold*.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/whatsapp/client.ts, line 258:
<comment>Markdown bold is rendered as italic because the italic replacement runs after the bold replacement and rewrites the generated WhatsApp bold markers. Applying single-asterisk italic conversion before the bold conversions in both WhatsApp formatters would preserve `*bold*`.</comment>
<file context>
@@ -0,0 +1,298 @@
+ .replace(/\*\*([^*]+)\*\*/g, "*$1*")
+ .replace(/__([^_]+)__/g, "*$1*")
+ // Italic: *text* (single) or _text_ -> _text_
+ .replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "_$1_")
+ // Strikethrough: ~~text~~ -> ~text~
+ .replace(/~~([^~]+)~~/g, "~$1~")
</file context>
| }, | ||
| }; | ||
|
|
||
| await whatsappClient.start(); |
There was a problem hiding this comment.
P2: A transient Baileys version or auth initialization failure prevents the whole process from reaching Ready, taking Slack down even though WhatsApp is optional. Isolating WhatsApp startup from the main boot path or adding an initial retry/backoff around initialization would keep the existing channel available.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/main.ts, line 741:
<comment>A transient Baileys version or auth initialization failure prevents the whole process from reaching `Ready`, taking Slack down even though WhatsApp is optional. Isolating WhatsApp startup from the main boot path or adding an initial retry/backoff around initialization would keep the existing channel available.</comment>
<file context>
@@ -601,6 +601,150 @@ if (LINEAR_API_KEY && LINEAR_WEBHOOK_SECRET) {
+ },
+ };
+
+ await whatsappClient.start();
+ setupWhatsAppRouter(whatsappClient, whatsappHandler, startupTs);
+ log.info("WhatsApp agent enabled");
</file context>
- Use URI encoding for JID→channel conversion to preserve @ and . - Centralize JID reconstruction in channelIdToJid() utility - Replace manual Set LRU with QuickLRU for seenMessageIds - Remove double-send bug: emitThinking() no longer sends a message Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
WhatsApp channel
Adds WhatsApp as a second messaging surface alongside Slack. Uses the same Baileys v7 backend as OpenClaw and Hermes — no Business API required, links to an existing WhatsApp account via QR code.
What's in this PR
New files (src/whatsapp/)
client.ts— Baileys socket lifecycle, QR auth, reconnection with exponential backoff, inbound message filteringtypes.ts— type exportsrouter.ts— routes WhatsApp events to the agent handler (mirrors Slack router)conversation.ts— maps JIDs to conversation targetssrc/surface/whatsapp.ts— AgentSurface implementation (streaming 'Thinking...' updates, WhatsApp markdown formatting)Changed files
src/types.ts—BotEvent.sourceextended with'whatsapp'src/config.ts—DigbyConfig.whatsapp.allowFromarraysrc/main.ts— WhatsApp client wired up whenDIGBY_WHATSAPP_ENABLED=truepackage.json—baileys@^7.0.0-rc13,pino@^9.7.0,@hapi/boom@^10.0.1.gitignore— excludeswhatsapp-auth/Setup (operator steps required before enabling)
Set env vars in ECS task definition:
DIGBY_WHATSAPP_ENABLED=trueDIGBY_WHATSAPP_AUTH_DIR=/data/whatsapp-auth(optional — defaults to{workingDir}/whatsapp-auth)On first deploy, watch the ECS logs — a QR code will be printed to stdout. Scan it with a WhatsApp-linked phone.
Auth persists on EFS at the auth dir. Subsequent restarts auto-reconnect without a new QR.
Add allowed numbers/JIDs to
digby.json:{ "whatsapp": { "allowFrom": ["+447700900000", "447700900000-1234567@g.us"] } }Phone numbers in E.164 format (with or without
+) are auto-normalised to JID format.Notes
*bold*,_italic_,code, multi-line code blocksSummary by cubic
Adds WhatsApp as a second messaging channel using
baileysv7. It’s opt-in, links via QR, and routes DMs and group chats through the existing agent flow.New Features
whatsapp.allowFrom.channelIdToJid(); logging/context for chronological WhatsApp messages.DIGBY_WHATSAPP_ENABLED;.gitignoreexcludeswhatsapp-auth/.Migration
DIGBY_WHATSAPP_ENABLED=trueand optionallyDIGBY_WHATSAPP_AUTH_DIR=/data/whatsapp-auth.digby.json:{"whatsapp": {"allowFrom": ["+447700900000", "123456789@g.us"]}}(numbers auto-normalized).Written for commit 36d096d. Summary will update on new commits.