Skip to content

feat: add WhatsApp Business Cloud API adapter#102

Open
ghellach wants to merge 4 commits intovercel:mainfrom
ghellach:feat/adapter-whatsapp
Open

feat: add WhatsApp Business Cloud API adapter#102
ghellach wants to merge 4 commits intovercel:mainfrom
ghellach:feat/adapter-whatsapp

Conversation

@ghellach
Copy link

@ghellach ghellach commented Feb 25, 2026

Summary

  • Add @chat-adapter/whatsapp package implementing the WhatsApp Business Cloud API adapter using the Meta Graph API (v21.0)
  • Support for sending/receiving text messages, reactions (add/remove), interactive reply buttons (max 3), typing indicators, read receipts, and DMs
  • Webhook verification via GET challenge-response and POST HMAC-SHA256 signature validation
  • Card rendering with interactive buttons when possible, falling back to formatted text
  • Media download support for images, documents, audio, video, and stickers via downloadMedia() with lazy fetchData() on attachments
  • Location message support with structured text and Google Maps URLs
  • All WhatsApp DMs treated as mentions (isMention: true) for correct SDK routing
  • Full test suite (58 tests) covering adapter logic, media attachments, card rendering, and markdown conversion
  • Documentation and configuration updates across README, CLAUDE.md, turbo.json, vitest workspace, and docs site

Details

Capabilities

Feature Support
Post message Yes
Edit message No (WhatsApp limitation — sends new message)
Delete message No (WhatsApp limitation)
Reactions Yes (add and remove)
Typing indicator Yes
DMs Yes (all WhatsApp conversations are 1:1)
Cards/Buttons Partial (max 3 reply buttons, text fallback otherwise)
Media download Yes (images, documents, audio, video, stickers)
Location Yes (with Google Maps URL)
Streaming No
Message history No (not exposed by Cloud API)

Media support

Inbound media messages (images, documents, audio, video, stickers) are exposed as Attachment objects on the Message with:

  • type"image", "file", "audio", "video"
  • mimeType — from WhatsApp webhook payload
  • fetchData() — lazy download via two-step Graph API (get URL, then fetch binary)

Location messages include a Google Maps URL and structured text with name/address/coordinates.

Thread ID format

whatsapp:{phoneNumberId}:{userWaId}

Environment variables

  • WHATSAPP_ACCESS_TOKEN — Meta Graph API access token
  • WHATSAPP_APP_SECRET — App secret for webhook signature verification
  • WHATSAPP_PHONE_NUMBER_ID — Phone number ID for sending messages
  • WHATSAPP_VERIFY_TOKEN — Token for webhook URL verification

Test plan

  • Unit tests pass for adapter core (encode/decode thread IDs, webhook verification, message parsing)
  • Unit tests pass for media attachments (image, document, audio, video, sticker, location)
  • Unit tests pass for isMention routing
  • Unit tests pass for card rendering (interactive buttons, text fallback, truncation)
  • Unit tests pass for markdown conversion (WhatsApp *bold*/~strike~ ↔ standard markdown)
  • pnpm typecheck passes across all 23 packages
  • pnpm check (lint/format) passes
  • pnpm knip (unused exports/deps) passes
  • pnpm test passes (58 WhatsApp adapter tests + all existing tests)
  • Manual testing with WhatsApp Business test account (text, images, reactions, conversations)

🤖 Generated with Claude Code

Add @chat-adapter/whatsapp with support for sending/receiving messages,
reactions, interactive reply buttons, typing indicators, and webhook
verification via the Meta Graph API. Includes full test suite,
documentation updates, and workspace/turbo configuration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Contributor

vercel bot commented Feb 25, 2026

@ghellach is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

…pp adapter

- Add downloadMedia() public method for fetching images, documents,
  audio, video, and stickers via the Graph API (two-step: URL then binary)
- Populate message attachments with lazy fetchData() for all media types
- Add location support with Google Maps URL and structured text
- Add audio, video, sticker, and location fields to WhatsAppInboundMessage
- Set isMention: true on all messages (WhatsApp DMs are always direct)
- Update parseMessage to include attachments and isMention
- Add 10 new tests covering all media types, locations, and isMention
- Update docs feature matrix to reflect media receive support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ghellach ghellach marked this pull request as ready for review February 25, 2026 03:34
Copilot AI review requested due to automatic review settings February 25, 2026 03:34
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds a new @chat-adapter/whatsapp package that integrates the Chat SDK with the WhatsApp Business Cloud API (Meta Graph API), plus the necessary monorepo wiring (tests, env vars, docs).

Changes:

  • Introduces packages/adapter-whatsapp with adapter implementation, card rendering, markdown conversion, and a dedicated test suite.
  • Wires the new package into the workspace tooling (Vitest workspace, Turbo env passthrough, lockfile).
  • Updates docs/README/CLAUDE.md to include WhatsApp as a supported platform and document its package.

Reviewed changes

Copilot reviewed 17 out of 18 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
vitest.workspace.ts Adds the WhatsApp adapter package to the Vitest workspace.
turbo.json Adds WhatsApp-related environment variables to Turbo’s global env passthrough list.
pnpm-lock.yaml Adds the new package importer and updates dependency snapshots accordingly.
packages/adapter-whatsapp/vitest.config.ts Defines Vitest config/coverage settings for the new adapter package.
packages/adapter-whatsapp/tsup.config.ts Adds build configuration for bundling the adapter.
packages/adapter-whatsapp/tsconfig.json Adds TypeScript configuration for the new package.
packages/adapter-whatsapp/src/types.ts Introduces WhatsApp webhook/media/type definitions used by the adapter.
packages/adapter-whatsapp/src/markdown.ts Implements WhatsApp-specific markdown conversion via AST conversion.
packages/adapter-whatsapp/src/markdown.test.ts Tests markdown conversion behavior.
packages/adapter-whatsapp/src/index.ts Implements the WhatsApp adapter: webhook handling, send/reaction/typing/read, thread ID encode/decode, media download, message parsing.
packages/adapter-whatsapp/src/index.test.ts Tests thread ID logic, parsing behavior, attachments, and webhook verification challenge.
packages/adapter-whatsapp/src/cards.ts Converts Card elements into WhatsApp interactive payloads or text fallback.
packages/adapter-whatsapp/src/cards.test.ts Tests card conversion and fallback logic.
packages/adapter-whatsapp/package.json Adds the new package manifest, scripts, and dependencies.
apps/docs/content/docs/index.mdx Adds WhatsApp to the platform list and package table in docs landing page.
apps/docs/content/docs/adapters/index.mdx Adds WhatsApp to the adapters overview matrices and adapter list.
README.md Adds WhatsApp to the supported platforms and adapter list.
CLAUDE.md Adds WhatsApp adapter package and env vars to repo documentation.
Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

text: { preview_url: false, body: text },
}
);

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

sendTextMessage() assumes response.messages[0] always exists. If the Graph API returns an unexpected payload (or partial error), this will throw a confusing Cannot read properties of undefined. Consider validating response.messages?.length and throwing a clearer error when no message ID is returned.

Suggested change
if (!response.messages?.length || !response.messages[0]?.id) {
throw new Error(
"WhatsApp sendTextMessage: Graph API did not return a message ID"
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +667 to +668
const messageId = response.messages[0].id;

Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

sendInteractiveMessage() also assumes response.messages[0] exists. Like sendTextMessage(), it should validate the response shape and throw a clear error if no message ID is returned.

Suggested change
const messageId = response.messages[0].id;
const messageId = response.messages?.[0]?.id;
if (!messageId) {
throw new Error(
"WhatsApp API response did not include a message ID for the interactive message"
);
}

Copilot uses AI. Check for mistakes.
Comment on lines 189 to 193
return [`*${text.content}*`];
case "muted":
return [`_${text.content}_`];
default:
return [text.content];
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

cardToWhatsAppText() escapes formatting characters in titles/subtitles/fields, but renderText() returns text.content unescaped (including in the bold/muted cases). This can accidentally introduce WhatsApp formatting or break the output when content contains *, _, or ~. Consider applying the same escapeWhatsApp() to text.content in all renderText() branches.

Suggested change
return [`*${text.content}*`];
case "muted":
return [`_${text.content}_`];
default:
return [text.content];
return [`*${escapeWhatsApp(text.content)}*`];
case "muted":
return [`_${escapeWhatsApp(text.content)}_`];
default:
return [escapeWhatsApp(text.content)];

Copilot uses AI. Check for mistakes.
* Escape WhatsApp formatting characters.
*/
function escapeWhatsApp(text: string): string {
return text.replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/~/g, "\\~");
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

escapeWhatsApp() is documented as escaping WhatsApp formatting characters, but it only escapes *, _, and ~. WhatsApp also supports monospace formatting via backticks, so unescaped ` can still affect rendering. Consider escaping backticks as well (and possibly escaping backslashes first so existing escapes aren’t broken).

Suggested change
return text.replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/~/g, "\\~");
return text
.replace(/\\/g, "\\\\")
.replace(/\*/g, "\\*")
.replace(/_/g, "\\_")
.replace(/~/g, "\\~")
.replace(/`/g, "\\`");

Copilot uses AI. Check for mistakes.
Comment on lines 417 to 420
const raw: WhatsAppRawMessage = {
message: inbound,
contact,
phoneNumberId: this.phoneNumberId,
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

In buildMessage(), raw.phoneNumberId is set to this.phoneNumberId, but the threadId is encoded using the webhook’s phoneNumberId argument. If these ever differ (e.g., multi-number setups or misconfig), the raw payload won’t match the thread context. Prefer using the phoneNumberId associated with the inbound webhook event when building WhatsAppRawMessage.

Suggested change
const raw: WhatsAppRawMessage = {
message: inbound,
contact,
phoneNumberId: this.phoneNumberId,
const phoneNumberIdFromThreadId = threadId.split(":")[0];
const raw: WhatsAppRawMessage = {
message: inbound,
contact,
phoneNumberId: phoneNumberIdFromThreadId,

Copilot uses AI. Check for mistakes.
Comment on lines 893 to 896

return new Message<WhatsAppRawMessage>({
id: raw.message.id,
threadId: "",
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

parseMessage() constructs a Message with threadId: "". Other adapters encode the correct thread ID during parsing; leaving this empty can break downstream logic that expects a valid thread association (e.g., serialization, routing, thread-level ops). Consider encoding the WhatsApp thread ID from raw.phoneNumberId + raw.message.from.

Suggested change
return new Message<WhatsAppRawMessage>({
id: raw.message.id,
threadId: "",
const threadId = this.encodeThreadId({
phoneNumberId: raw.phoneNumberId,
userWaId: raw.message.from,
});
return new Message<WhatsAppRawMessage>({
id: raw.message.id,
threadId,

Copilot uses AI. Check for mistakes.
Comment on lines 841 to 852
const parts = withoutPrefix.split(":");
if (parts.length < 2 || !parts[0] || !parts[1]) {
throw new ValidationError(
"whatsapp",
`Invalid WhatsApp thread ID format: ${threadId}`
);
}

return {
phoneNumberId: parts[0],
userWaId: parts[1],
};
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

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

decodeThreadId() accepts thread IDs with more than two colon-separated parts (it only checks parts.length < 2). This can silently ignore extra segments and decode to the wrong userWaId. Consider validating the exact expected format (whatsapp:{phoneNumberId}:{userWaId}) by requiring parts.length === 2 after stripping the prefix.

Copilot uses AI. Check for mistakes.
ghellach and others added 2 commits February 24, 2026 22:46
- Validate Graph API response before accessing messages[0].id in
  sendTextMessage and sendInteractiveMessage
- Escape backticks and backslashes in escapeWhatsApp()
- Apply escapeWhatsApp() to renderText() content in all style branches
- Use webhook phoneNumberId in buildMessage() instead of this.phoneNumberId
- Encode proper threadId in parseMessage() instead of empty string
- Strict decodeThreadId() validation (exactly 2 segments after prefix)
- Add tests for extra segments in decodeThreadId and threadId in parseMessage

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.

2 participants