Skip to content

feat: add WhatsApp channel via Baileys - #98

Draft
digby-bw wants to merge 2 commits into
mainfrom
feature/whatsapp-channel
Draft

feat: add WhatsApp channel via Baileys#98
digby-bw wants to merge 2 commits into
mainfrom
feature/whatsapp-channel

Conversation

@digby-bw

@digby-bw digby-bw commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 filtering
  • types.ts — type exports
  • router.ts — routes WhatsApp events to the agent handler (mirrors Slack router)
  • conversation.ts — maps JIDs to conversation targets
  • src/surface/whatsapp.ts — AgentSurface implementation (streaming 'Thinking...' updates, WhatsApp markdown formatting)

Changed files

  • src/types.tsBotEvent.source extended with 'whatsapp'
  • src/config.tsDigbyConfig.whatsapp.allowFrom array
  • src/main.ts — WhatsApp client wired up when DIGBY_WHATSAPP_ENABLED=true
  • package.jsonbaileys@^7.0.0-rc13, pino@^9.7.0, @hapi/boom@^10.0.1
  • .gitignore — excludes whatsapp-auth/

Setup (operator steps required before enabling)

  1. Set env vars in ECS task definition:

    • DIGBY_WHATSAPP_ENABLED=true
    • DIGBY_WHATSAPP_AUTH_DIR=/data/whatsapp-auth (optional — defaults to {workingDir}/whatsapp-auth)
  2. On first deploy, watch the ECS logs — a QR code will be printed to stdout. Scan it with a WhatsApp-linked phone.

  3. Auth persists on EFS at the auth dir. Subsequent restarts auto-reconnect without a new QR.

  4. 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

  • WhatsApp formatting uses *bold*, _italic_, code, multi-line code blocks
  • Long responses chunked at 4000 chars
  • The feature is off by default — existing Slack-only deployments are unaffected
  • Recommend a dedicated phone/eSIM for the linked WhatsApp account (same advice as OpenClaw)

Summary by cubic

Adds WhatsApp as a second messaging channel using baileys v7. It’s opt-in, links via QR, and routes DMs and group chats through the existing agent flow.

  • New Features

    • WhatsApp client with QR auth, reconnection/backoff, and allowlist via whatsapp.allowFrom.
    • Router that dedupes with QuickLRU, queues runs, and supports a “stop” command.
    • WhatsApp surface: Markdown formatting, 4k chunking, file sends, and “Thinking…” updates without extra send.
    • Robust JID↔channel mapping using URI encoding and centralized channelIdToJid(); logging/context for chronological WhatsApp messages.
    • Feature behind DIGBY_WHATSAPP_ENABLED; .gitignore excludes whatsapp-auth/.
  • Migration

    • Set DIGBY_WHATSAPP_ENABLED=true and optionally DIGBY_WHATSAPP_AUTH_DIR=/data/whatsapp-auth.
    • First run: scan the QR in logs to link the account; auth persists in the auth dir.
    • Configure digby.json: {"whatsapp": {"allowFrom": ["+447700900000", "123456789@g.us"]}} (numbers auto-normalized).
    • Off by default; Slack-only setups are unchanged.

Written for commit 36d096d. Summary will update on new commits.

Review in cubic

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"]}}

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread src/whatsapp/client.ts
const jid = msg.key.remoteJid;
if (!jid) continue;

if (this.allowFromJids.size > 0 && !this.allowFromJids.has(jid)) {

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/types.ts
export interface BotEvent {
type: "mention" | "dm" | "channel" | "agent_session";
source: "slack" | "linear";
source: "slack" | "linear" | "whatsapp";

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/types.ts
export interface BotEvent {
type: "mention" | "dm" | "channel" | "agent_session";
source: "slack" | "linear";
source: "slack" | "linear" | "whatsapp";

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/whatsapp/router.ts
const seenEvents = new QuickLRU<string, true>({ maxSize: 100 });

return (channel: string, ts: string): boolean => {
const key = `${channel}:${ts}`;

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/surface/whatsapp.ts
if (this.accumulatedText === THINKING_PLACEHOLDER) {
this.accumulatedText = waText;
} else {
this.accumulatedText = this.accumulatedText ? `${this.accumulatedText}\n${waText}` : waText;

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/surface/whatsapp.ts
Comment thread src/surface/whatsapp.ts
if (!this.suppressed && this.accumulatedText.trim() && this.accumulatedText !== THINKING_PLACEHOLDER) {
this.enqueue(async () => {
try {
const display = this.truncate(this.displayText, MAX_MESSAGE_LENGTH);

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/whatsapp/client.ts
.replace(/\*\*([^*]+)\*\*/g, "*$1*")
.replace(/__([^_]+)__/g, "*$1*")
// Italic: *text* (single) or _text_ -> _text_
.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, "_$1_")

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread src/whatsapp/client.ts
Comment thread src/main.ts
},
};

await whatsappClient.start();

@cubic-dev-ai cubic-dev-ai Bot Jul 28, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

- 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>
@grrowl
grrowl marked this pull request as draft July 29, 2026 00:58
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