feat: add WhatsApp Business Cloud API adapter#102
feat: add WhatsApp Business Cloud API adapter#102ghellach wants to merge 4 commits intovercel:mainfrom
Conversation
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>
|
@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>
There was a problem hiding this comment.
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-whatsappwith 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 }, | ||
| } | ||
| ); | ||
|
|
There was a problem hiding this comment.
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.
| if (!response.messages?.length || !response.messages[0]?.id) { | |
| throw new Error( | |
| "WhatsApp sendTextMessage: Graph API did not return a message ID" | |
| ); | |
| } |
| const messageId = response.messages[0].id; | ||
|
|
There was a problem hiding this comment.
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.
| 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" | |
| ); | |
| } |
| return [`*${text.content}*`]; | ||
| case "muted": | ||
| return [`_${text.content}_`]; | ||
| default: | ||
| return [text.content]; |
There was a problem hiding this comment.
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.
| 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)]; |
| * Escape WhatsApp formatting characters. | ||
| */ | ||
| function escapeWhatsApp(text: string): string { | ||
| return text.replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/~/g, "\\~"); |
There was a problem hiding this comment.
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).
| return text.replace(/\*/g, "\\*").replace(/_/g, "\\_").replace(/~/g, "\\~"); | |
| return text | |
| .replace(/\\/g, "\\\\") | |
| .replace(/\*/g, "\\*") | |
| .replace(/_/g, "\\_") | |
| .replace(/~/g, "\\~") | |
| .replace(/`/g, "\\`"); |
| const raw: WhatsAppRawMessage = { | ||
| message: inbound, | ||
| contact, | ||
| phoneNumberId: this.phoneNumberId, |
There was a problem hiding this comment.
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.
| const raw: WhatsAppRawMessage = { | |
| message: inbound, | |
| contact, | |
| phoneNumberId: this.phoneNumberId, | |
| const phoneNumberIdFromThreadId = threadId.split(":")[0]; | |
| const raw: WhatsAppRawMessage = { | |
| message: inbound, | |
| contact, | |
| phoneNumberId: phoneNumberIdFromThreadId, |
|
|
||
| return new Message<WhatsAppRawMessage>({ | ||
| id: raw.message.id, | ||
| threadId: "", |
There was a problem hiding this comment.
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.
| 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, |
| 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], | ||
| }; |
There was a problem hiding this comment.
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.
- 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>
Summary
@chat-adapter/whatsapppackage implementing the WhatsApp Business Cloud API adapter using the Meta Graph API (v21.0)downloadMedia()with lazyfetchData()on attachmentsisMention: true) for correct SDK routingDetails
Capabilities
Media support
Inbound media messages (images, documents, audio, video, stickers) are exposed as
Attachmentobjects on theMessagewith:type—"image","file","audio","video"mimeType— from WhatsApp webhook payloadfetchData()— 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 tokenWHATSAPP_APP_SECRET— App secret for webhook signature verificationWHATSAPP_PHONE_NUMBER_ID— Phone number ID for sending messagesWHATSAPP_VERIFY_TOKEN— Token for webhook URL verificationTest plan
*bold*/~strike~↔ standard markdown)pnpm typecheckpasses across all 23 packagespnpm check(lint/format) passespnpm knip(unused exports/deps) passespnpm testpasses (58 WhatsApp adapter tests + all existing tests)🤖 Generated with Claude Code