Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-whatsapp-adapter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/whatsapp": minor
---

Add WhatsApp adapter using Meta's WhatsApp Business Cloud API
1 change: 1 addition & 0 deletions examples/nextjs-chat/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@chat-adapter/state-redis": "workspace:*",
"@chat-adapter/telegram": "workspace:*",
"@chat-adapter/teams": "workspace:*",
"@chat-adapter/whatsapp": "workspace:*",
"ai": "^6.0.5",
"chat": "workspace:*",
"next": "^16.1.5",
Expand Down
26 changes: 26 additions & 0 deletions examples/nextjs-chat/src/lib/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
createTelegramAdapter,
type TelegramAdapter,
} from "@chat-adapter/telegram";
import {
createWhatsAppAdapter,
type WhatsAppAdapter,
} from "@chat-adapter/whatsapp";
import { ConsoleLogger } from "chat";
import { recorder, withRecording } from "./recorder";

Expand All @@ -28,6 +32,7 @@ export interface Adapters {
slack?: SlackAdapter;
teams?: TeamsAdapter;
telegram?: TelegramAdapter;
whatsapp?: WhatsAppAdapter;
}

// Methods to record for each adapter (outgoing API calls)
Expand Down Expand Up @@ -96,6 +101,13 @@ const TELEGRAM_METHODS = [
"openDM",
"fetchMessages",
];
const WHATSAPP_METHODS = [
"postMessage",
"addReaction",
"removeReaction",
"openDM",
"fetchMessages",
];

/**
* Build type-safe adapters based on available environment variables.
Expand Down Expand Up @@ -215,5 +227,19 @@ export function buildAdapters(): Adapters {
);
}

// WhatsApp adapter (optional) - env vars: WHATSAPP_ACCESS_TOKEN, WHATSAPP_PHONE_NUMBER_ID
if (
process.env.WHATSAPP_ACCESS_TOKEN &&
process.env.WHATSAPP_PHONE_NUMBER_ID
) {
adapters.whatsapp = withRecording(
createWhatsAppAdapter({
logger: logger.child("whatsapp"),
}),
"whatsapp",
WHATSAPP_METHODS
);
}

return adapters;
}
94 changes: 94 additions & 0 deletions packages/adapter-whatsapp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# @chat-adapter/whatsapp

[![npm version](https://img.shields.io/npm/v/@chat-adapter/whatsapp)](https://www.npmjs.com/package/@chat-adapter/whatsapp)
[![npm downloads](https://img.shields.io/npm/dm/@chat-adapter/whatsapp)](https://www.npmjs.com/package/@chat-adapter/whatsapp)

WhatsApp adapter for [Chat SDK](https://chat-sdk.dev/docs), using the [WhatsApp Business Cloud API](https://developers.facebook.com/docs/whatsapp/cloud-api).

## Installation

```bash
npm install chat @chat-adapter/whatsapp
```

## Usage

```typescript
import { Chat } from "chat";
import { createWhatsAppAdapter } from "@chat-adapter/whatsapp";

const bot = new Chat({
userName: "mybot",
adapters: {
whatsapp: createWhatsAppAdapter({
accessToken: process.env.WHATSAPP_ACCESS_TOKEN!,
phoneNumberId: process.env.WHATSAPP_PHONE_NUMBER_ID!,
verifyToken: process.env.WHATSAPP_VERIFY_TOKEN,
appSecret: process.env.WHATSAPP_APP_SECRET,
}),
},
});
```

Features include reactions, interactive messages (buttons and lists), media attachments, and webhook signature verification.

## Environment variables

| Variable | Required | Description |
|---|---|---|
| `WHATSAPP_ACCESS_TOKEN` | Yes | Meta access token (permanent or system user token) |
| `WHATSAPP_PHONE_NUMBER_ID` | Yes | Bot's phone number ID from Meta dashboard |
| `WHATSAPP_VERIFY_TOKEN` | No | User-defined secret for webhook verification handshake |
| `WHATSAPP_APP_SECRET` | No | App secret for X-Hub-Signature-256 verification |

When using the factory function `createWhatsAppAdapter()` without arguments, these environment variables are auto-detected.

## Webhook setup

WhatsApp uses two webhook mechanisms:

1. **Verification handshake** (GET) — Meta sends a `hub.verify_token` challenge that must match your `WHATSAPP_VERIFY_TOKEN`.
2. **Event delivery** (POST) — incoming messages, reactions, and interactive responses. Optionally verified via `X-Hub-Signature-256` when `WHATSAPP_APP_SECRET` is set.

```typescript
// Next.js App Router example
import { bot } from "@/lib/bot";

export async function GET(request: Request) {
return bot.adapters.whatsapp.handleWebhook(request);
}

export async function POST(request: Request) {
return bot.adapters.whatsapp.handleWebhook(request);
}
```

## Interactive messages

Card elements are automatically converted to WhatsApp interactive messages:

- **3 or fewer buttons** — rendered as WhatsApp reply buttons
- **More than 3 buttons** — rendered as a WhatsApp list message

## Limitations

- **No message editing** — `editMessage()` throws `NotImplementedError`
- **No message deletion** — `deleteMessage()` throws `NotImplementedError`
- **No typing indicator** — `startTyping()` is a no-op
- **No message history API** — `fetchMessages()` returns cached messages only

## Thread ID format

```
whatsapp:{phoneNumberId}:{userPhoneNumber}
```

Example: `whatsapp:1234567890:15551234567`

## Documentation

Full setup instructions, configuration reference, and features at [chat-sdk.dev/docs/adapters/whatsapp](https://chat-sdk.dev/docs/adapters/whatsapp).

## License

MIT
55 changes: 55 additions & 0 deletions packages/adapter-whatsapp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"name": "@chat-adapter/whatsapp",
"version": "4.16.0",
"description": "WhatsApp adapter for chat",
"type": "module",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"test": "vitest run --coverage",
"test:watch": "vitest",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@chat-adapter/shared": "workspace:*",
"chat": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.3.2",
"tsup": "^8.3.5",
"typescript": "^5.7.2",
"vitest": "^4.0.18"
},
"repository": {
"type": "git",
"url": "git+https://github.com/vercel/chat.git",
"directory": "packages/adapter-whatsapp"
},
"homepage": "https://github.com/vercel/chat#readme",
"bugs": {
"url": "https://github.com/vercel/chat/issues"
},
"publishConfig": {
"access": "public"
},
"keywords": [
"chat",
"whatsapp",
"bot",
"adapter"
],
"license": "MIT"
}
177 changes: 177 additions & 0 deletions packages/adapter-whatsapp/src/cards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { cardToFallbackText } from "@chat-adapter/shared";
import type {
ActionsElement,
ButtonElement,
CardChild,
CardElement,
} from "chat";
import { convertEmojiPlaceholders } from "chat";
import type {
WhatsAppInteractiveButton,
WhatsAppInteractiveListRow,
WhatsAppInteractiveListSection,
WhatsAppInteractiveMessage,
} from "./types";

const WHATSAPP_BUTTON_TITLE_LIMIT = 20;
const WHATSAPP_LIST_TITLE_LIMIT = 24;
const WHATSAPP_LIST_DESCRIPTION_LIMIT = 72;
const WHATSAPP_MAX_BUTTONS = 3;

const CALLBACK_DATA_PREFIX = "chat:";

interface WhatsAppCardActionPayload {
a: string;
v?: string;
}

function convertLabel(label: string): string {
return convertEmojiPlaceholders(label, "gchat");
}

function truncate(text: string, limit: number): string {
if (text.length <= limit) {
return text;
}
return `${text.slice(0, limit - 1)}\u2026`;
}

interface CollectedAction {
id: string;
label: string;
type: "button";
value?: string;
}

function collectActions(children: CardChild[]): CollectedAction[] {
const actions: CollectedAction[] = [];

for (const child of children) {
if (child.type === "actions") {
for (const action of (child as ActionsElement).children) {
if (action.type === "button") {
const button = action as ButtonElement;
actions.push({
type: "button",
id: button.id,
label: convertLabel(button.label),
value: button.value,
});
}
// link-buttons are not supported in WhatsApp interactive messages
}
continue;
}

if (child.type === "section") {
actions.push(...collectActions(child.children));
}
}

return actions;
}

export function encodeWhatsAppCallbackData(
actionId: string,
value?: string
): string {
const payload: WhatsAppCardActionPayload = { a: actionId };
if (typeof value === "string") {
payload.v = value;
}
return `${CALLBACK_DATA_PREFIX}${JSON.stringify(payload)}`;
}

export function decodeWhatsAppCallbackData(data?: string): {
actionId: string;
value: string | undefined;
} {
if (!data) {
return { actionId: "whatsapp_callback", value: undefined };
}

if (!data.startsWith(CALLBACK_DATA_PREFIX)) {
return { actionId: data, value: data };
}

try {
const decoded = JSON.parse(
data.slice(CALLBACK_DATA_PREFIX.length)
) as WhatsAppCardActionPayload;

if (typeof decoded.a === "string" && decoded.a) {
return {
actionId: decoded.a,
value: typeof decoded.v === "string" ? decoded.v : undefined,
};
}
} catch {
// Fall back to passthrough behavior below.
}

return { actionId: data, value: data };
}

export function cardToWhatsAppInteractive(
card: CardElement,
to: string
): WhatsAppInteractiveMessage | undefined {
const actions = collectActions(card.children);
if (actions.length === 0) {
return undefined;
}

const bodyText = cardToFallbackText(card) || card.title || "Select an option";
const header = card.title
? { type: "text" as const, text: card.title }
: undefined;

if (actions.length <= WHATSAPP_MAX_BUTTONS) {
const buttons: WhatsAppInteractiveButton[] = actions.map((action) => ({
type: "reply" as const,
reply: {
id: encodeWhatsAppCallbackData(action.id, action.value),
title: truncate(action.label, WHATSAPP_BUTTON_TITLE_LIMIT),
},
}));

return {
messaging_product: "whatsapp",
recipient_type: "individual",
to,
type: "interactive",
interactive: {
type: "button",
header,
body: { text: bodyText },
action: { buttons },
},
};
}

const rows: WhatsAppInteractiveListRow[] = actions.map((action) => ({
id: encodeWhatsAppCallbackData(action.id, action.value),
title: truncate(action.label, WHATSAPP_LIST_TITLE_LIMIT),
description: action.value
? truncate(action.value, WHATSAPP_LIST_DESCRIPTION_LIMIT)
: undefined,
}));

const sections: WhatsAppInteractiveListSection[] = [{ rows }];

return {
messaging_product: "whatsapp",
recipient_type: "individual",
to,
type: "interactive",
interactive: {
type: "list",
header,
body: { text: bodyText },
action: {
button: "Options",
sections,
},
},
};
}
Loading