diff --git a/.changeset/add-whatsapp-adapter.md b/.changeset/add-whatsapp-adapter.md new file mode 100644 index 00000000..58b6950c --- /dev/null +++ b/.changeset/add-whatsapp-adapter.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/whatsapp": minor +--- + +Add WhatsApp adapter using Meta's WhatsApp Business Cloud API diff --git a/examples/nextjs-chat/package.json b/examples/nextjs-chat/package.json index dcd0531e..7a83fb72 100644 --- a/examples/nextjs-chat/package.json +++ b/examples/nextjs-chat/package.json @@ -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", diff --git a/examples/nextjs-chat/src/lib/adapters.ts b/examples/nextjs-chat/src/lib/adapters.ts index 11c362b8..c455eac3 100644 --- a/examples/nextjs-chat/src/lib/adapters.ts +++ b/examples/nextjs-chat/src/lib/adapters.ts @@ -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"; @@ -28,6 +32,7 @@ export interface Adapters { slack?: SlackAdapter; teams?: TeamsAdapter; telegram?: TelegramAdapter; + whatsapp?: WhatsAppAdapter; } // Methods to record for each adapter (outgoing API calls) @@ -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. @@ -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; } diff --git a/packages/adapter-whatsapp/README.md b/packages/adapter-whatsapp/README.md new file mode 100644 index 00000000..bdaf3983 --- /dev/null +++ b/packages/adapter-whatsapp/README.md @@ -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 diff --git a/packages/adapter-whatsapp/package.json b/packages/adapter-whatsapp/package.json new file mode 100644 index 00000000..5485a72f --- /dev/null +++ b/packages/adapter-whatsapp/package.json @@ -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" +} diff --git a/packages/adapter-whatsapp/src/cards.ts b/packages/adapter-whatsapp/src/cards.ts new file mode 100644 index 00000000..1ffb3e76 --- /dev/null +++ b/packages/adapter-whatsapp/src/cards.ts @@ -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, + }, + }, + }; +} diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts new file mode 100644 index 00000000..23c9d50e --- /dev/null +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -0,0 +1,607 @@ +import { + AdapterRateLimitError, + AuthenticationError, + NetworkError, + PermissionError, + ValidationError, +} from "@chat-adapter/shared"; +import type { ChatInstance, Logger } from "chat"; +import { NotImplementedError } from "chat"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { encodeWhatsAppCallbackData } from "./cards"; +import { + createWhatsAppAdapter, + WhatsAppAdapter, + type WhatsAppIncomingMessage, +} from "./index"; + +const mockLogger: Logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + child: vi.fn().mockReturnThis(), +}; + +const mockFetch = vi.fn(); + +beforeEach(() => { + mockFetch.mockReset(); + vi.stubGlobal("fetch", mockFetch); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function whatsappOk(result: unknown = {}): Response { + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function whatsappError( + status: number, + errorCode: number, + message: string +): Response { + return new Response( + JSON.stringify({ + error: { code: errorCode, message }, + }), + { + status, + headers: { "content-type": "application/json" }, + } + ); +} + +function createMockChat(): ChatInstance { + return { + getLogger: vi.fn().mockReturnValue(mockLogger), + getState: vi.fn(), + getUserName: vi.fn().mockReturnValue("bot"), + handleIncomingMessage: vi.fn().mockResolvedValue(undefined), + processMessage: vi.fn(), + processReaction: vi.fn(), + processAction: vi.fn(), + processModalClose: vi.fn(), + processModalSubmit: vi.fn().mockResolvedValue(undefined), + processSlashCommand: vi.fn(), + processAssistantThreadStarted: vi.fn(), + processAssistantContextChanged: vi.fn(), + processAppHomeOpened: vi.fn(), + } as unknown as ChatInstance; +} + +function sampleIncomingMessage( + overrides?: Partial +): WhatsAppIncomingMessage { + return { + id: "wamid.123", + from: "15551234567", + timestamp: "1735689600", + type: "text", + text: { body: "hello" }, + ...overrides, + }; +} + +function webhookPayload( + messages: WhatsAppIncomingMessage[], + contacts?: Array<{ wa_id?: string; profile?: { name?: string } }> +) { + return { + object: "whatsapp_business_account", + entry: [ + { + id: "WHATSAPP_BUSINESS_ACCOUNT_ID", + changes: [ + { + field: "messages", + value: { + messaging_product: "whatsapp", + metadata: { + display_phone_number: "15559876543", + phone_number_id: "PHONE_ID", + }, + contacts: contacts ?? [ + { + profile: { name: "Test User" }, + wa_id: "15551234567", + }, + ], + messages, + }, + }, + ], + }, + ], + }; +} + +function createAdapter( + overrides?: Partial<{ + accessToken: string; + phoneNumberId: string; + verifyToken: string; + appSecret: string; + }> +): WhatsAppAdapter { + return new WhatsAppAdapter({ + accessToken: overrides?.accessToken ?? "test-token", + phoneNumberId: overrides?.phoneNumberId ?? "PHONE_ID", + verifyToken: overrides?.verifyToken ?? "my-verify-token", + appSecret: overrides?.appSecret, + logger: mockLogger, + }); +} + +describe("createWhatsAppAdapter", () => { + it("throws when accessToken is missing", () => { + expect(() => createWhatsAppAdapter({})).toThrow(ValidationError); + }); + + it("throws when phoneNumberId is missing", () => { + expect(() => createWhatsAppAdapter({ accessToken: "token" })).toThrow( + ValidationError + ); + }); + + it("creates adapter from env vars", () => { + const originalToken = process.env.WHATSAPP_ACCESS_TOKEN; + const originalPhone = process.env.WHATSAPP_PHONE_NUMBER_ID; + + process.env.WHATSAPP_ACCESS_TOKEN = "env-token"; + process.env.WHATSAPP_PHONE_NUMBER_ID = "env-phone"; + + try { + const adapter = createWhatsAppAdapter(); + expect(adapter).toBeInstanceOf(WhatsAppAdapter); + expect(adapter.name).toBe("whatsapp"); + } finally { + if (originalToken) { + process.env.WHATSAPP_ACCESS_TOKEN = originalToken; + } else { + Reflect.deleteProperty(process.env, "WHATSAPP_ACCESS_TOKEN"); + } + if (originalPhone) { + process.env.WHATSAPP_PHONE_NUMBER_ID = originalPhone; + } else { + Reflect.deleteProperty(process.env, "WHATSAPP_PHONE_NUMBER_ID"); + } + } + }); +}); + +describe("WhatsAppAdapter", () => { + it("encodes and decodes thread IDs", () => { + const adapter = createAdapter(); + const threadId = adapter.encodeThreadId({ + phoneNumberId: "PHONE_ID", + userPhoneNumber: "15551234567", + }); + + expect(threadId).toBe("whatsapp:PHONE_ID:15551234567"); + + const decoded = adapter.decodeThreadId(threadId); + expect(decoded.phoneNumberId).toBe("PHONE_ID"); + expect(decoded.userPhoneNumber).toBe("15551234567"); + }); + + it("throws on invalid thread IDs", () => { + const adapter = createAdapter(); + + expect(() => adapter.decodeThreadId("invalid")).toThrow(ValidationError); + expect(() => adapter.decodeThreadId("whatsapp:only")).toThrow( + ValidationError + ); + expect(() => adapter.decodeThreadId("whatsapp:a:b:c")).toThrow( + ValidationError + ); + }); + + it("handles webhook verification GET request", async () => { + const adapter = createAdapter({ verifyToken: "my-secret" }); + await adapter.initialize(createMockChat()); + + const url = + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=my-secret&hub.challenge=challenge_123"; + const request = new Request(url, { method: "GET" }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const body = await response.text(); + expect(body).toBe("challenge_123"); + }); + + it("rejects webhook verification with wrong token", async () => { + const adapter = createAdapter({ verifyToken: "my-secret" }); + await adapter.initialize(createMockChat()); + + const url = + "https://example.com/webhook?hub.mode=subscribe&hub.verify_token=wrong&hub.challenge=challenge_123"; + const request = new Request(url, { method: "GET" }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(403); + }); + + it("processes incoming text messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = webhookPayload([sampleIncomingMessage()]); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const processMessage = chat.processMessage as ReturnType; + expect(processMessage).toHaveBeenCalledTimes(1); + + const [, threadId, message] = processMessage.mock.calls[0] as [ + unknown, + string, + { text: string }, + ]; + expect(threadId).toBe("whatsapp:PHONE_ID:15551234567"); + expect(message.text).toBe("hello"); + }); + + it("processes incoming image messages with caption", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = webhookPayload([ + sampleIncomingMessage({ + type: "image", + text: undefined, + image: { + id: "img_123", + mime_type: "image/jpeg", + caption: "A photo", + }, + }), + ]); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const processMessage = chat.processMessage as ReturnType; + expect(processMessage).toHaveBeenCalledTimes(1); + + const [, , message] = processMessage.mock.calls[0] as [ + unknown, + string, + { text: string; attachments: Array<{ type: string }> }, + ]; + expect(message.text).toBe("A photo"); + expect(message.attachments).toHaveLength(1); + expect(message.attachments[0]?.type).toBe("image"); + }); + + it("processes incoming reaction messages", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const payload = webhookPayload([ + sampleIncomingMessage({ + type: "reaction", + text: undefined, + reaction: { + message_id: "wamid.original", + emoji: "\ud83d\udc4d", + }, + }), + ]); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const processReaction = chat.processReaction as ReturnType; + expect(processReaction).toHaveBeenCalledTimes(1); + + const [event] = processReaction.mock.calls[0] as [ + { added: boolean; rawEmoji: string }, + ]; + expect(event.added).toBe(true); + expect(event.rawEmoji).toBe("\ud83d\udc4d"); + }); + + it("processes interactive button reply as action", async () => { + const adapter = createAdapter(); + const chat = createMockChat(); + await adapter.initialize(chat); + + const callbackData = encodeWhatsAppCallbackData("approve", "request-123"); + const payload = webhookPayload([ + sampleIncomingMessage({ + type: "interactive", + text: undefined, + interactive: { + type: "button_reply", + button_reply: { + id: callbackData, + title: "Approve", + }, + }, + }), + ]); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + + const processAction = chat.processAction as ReturnType; + expect(processAction).toHaveBeenCalledTimes(1); + + const [event] = processAction.mock.calls[0] as [ + { actionId: string; value?: string }, + ]; + expect(event.actionId).toBe("approve"); + expect(event.value).toBe("request-123"); + }); + + it("posts text messages", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + whatsappOk({ messages: [{ id: "wamid.sent_1" }] }) + ); + + const result = await adapter.postMessage("whatsapp:PHONE_ID:15551234567", { + markdown: "hello", + }); + + expect(result.id).toBe("wamid.sent_1"); + expect(result.threadId).toBe("whatsapp:PHONE_ID:15551234567"); + + const sentBody = JSON.parse( + String((mockFetch.mock.calls[0]?.[1] as RequestInit).body) + ) as { to: string; type: string; text: { body: string } }; + + expect(sentBody.to).toBe("15551234567"); + expect(sentBody.type).toBe("text"); + expect(sentBody.text.body).toBe("hello"); + }); + + it("posts card messages as interactive buttons", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + whatsappOk({ messages: [{ id: "wamid.sent_2" }] }) + ); + + await adapter.postMessage("whatsapp:PHONE_ID:15551234567", { + type: "card", + title: "Approval needed", + children: [ + { + type: "actions", + children: [ + { + type: "button", + id: "approve", + label: "Approve", + value: "req-1", + }, + { + type: "button", + id: "reject", + label: "Reject", + value: "req-1", + }, + ], + }, + ], + }); + + const sentBody = JSON.parse( + String((mockFetch.mock.calls[0]?.[1] as RequestInit).body) + ) as { type: string; interactive: { type: string } }; + + expect(sentBody.type).toBe("interactive"); + expect(sentBody.interactive.type).toBe("button"); + }); + + it("adds and removes reactions", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + mockFetch + .mockResolvedValueOnce( + whatsappOk({ messages: [{ id: "wamid.reaction_1" }] }) + ) + .mockResolvedValueOnce( + whatsappOk({ messages: [{ id: "wamid.reaction_2" }] }) + ); + + await adapter.addReaction( + "whatsapp:PHONE_ID:15551234567", + "wamid.original", + "\ud83d\udc4d" + ); + await adapter.removeReaction( + "whatsapp:PHONE_ID:15551234567", + "wamid.original", + "\ud83d\udc4d" + ); + + const addBody = JSON.parse( + String((mockFetch.mock.calls[0]?.[1] as RequestInit).body) + ) as { type: string; reaction: { emoji: string } }; + const removeBody = JSON.parse( + String((mockFetch.mock.calls[1]?.[1] as RequestInit).body) + ) as { type: string; reaction: { emoji: string } }; + + expect(addBody.type).toBe("reaction"); + expect(addBody.reaction.emoji).toBe("\ud83d\udc4d"); + expect(removeBody.type).toBe("reaction"); + expect(removeBody.reaction.emoji).toBe(""); + }); + + it("throws NotImplementedError for editMessage", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + await expect( + adapter.editMessage("whatsapp:PHONE_ID:15551234567", "msg1", "updated") + ).rejects.toBeInstanceOf(NotImplementedError); + }); + + it("throws NotImplementedError for deleteMessage", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + await expect( + adapter.deleteMessage("whatsapp:PHONE_ID:15551234567", "msg1") + ).rejects.toBeInstanceOf(NotImplementedError); + }); + + it("startTyping is a no-op", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + // Should not throw or make any fetch calls + await adapter.startTyping("whatsapp:PHONE_ID:15551234567"); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("isDM always returns true", () => { + const adapter = createAdapter(); + expect(adapter.isDM("whatsapp:PHONE_ID:15551234567")).toBe(true); + }); + + it("openDM constructs thread ID from phone number", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + const threadId = await adapter.openDM("15559876543"); + expect(threadId).toBe("whatsapp:PHONE_ID:15559876543"); + }); + + it("paginates cached messages", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + adapter.parseMessage(sampleIncomingMessage({ id: "m1", timestamp: "1" })); + adapter.parseMessage(sampleIncomingMessage({ id: "m2", timestamp: "2" })); + adapter.parseMessage(sampleIncomingMessage({ id: "m3", timestamp: "3" })); + + const backward = await adapter.fetchMessages( + "whatsapp:PHONE_ID:15551234567", + { limit: 2, direction: "backward" } + ); + + expect(backward.messages.map((m) => m.text)).toEqual(["hello", "hello"]); + expect(backward.messages).toHaveLength(2); + expect(backward.nextCursor).toBe("m2"); + + const forward = await adapter.fetchMessages( + "whatsapp:PHONE_ID:15551234567", + { limit: 2, direction: "forward" } + ); + + expect(forward.messages).toHaveLength(2); + expect(forward.nextCursor).toBe("m2"); + }); + + it("maps WhatsApp API errors to adapter-specific error types", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + whatsappError(401, 190, "Invalid OAuth access token") + ); + await expect( + adapter.postMessage("whatsapp:PHONE_ID:15551234567", "test") + ).rejects.toBeInstanceOf(AuthenticationError); + + mockFetch.mockResolvedValueOnce(whatsappError(429, 80007, "Rate limited")); + await expect( + adapter.postMessage("whatsapp:PHONE_ID:15551234567", "test") + ).rejects.toBeInstanceOf(AdapterRateLimitError); + + mockFetch.mockResolvedValueOnce( + whatsappError(403, 10, "Permission denied") + ); + await expect( + adapter.postMessage("whatsapp:PHONE_ID:15551234567", "test") + ).rejects.toBeInstanceOf(PermissionError); + + mockFetch.mockResolvedValueOnce(whatsappError(400, 400, "Bad request")); + await expect( + adapter.postMessage("whatsapp:PHONE_ID:15551234567", "test") + ).rejects.toBeInstanceOf(ValidationError); + }); + + it("throws NetworkError when API returns non-JSON response", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + mockFetch.mockResolvedValueOnce( + new Response("oops", { + status: 500, + headers: { "content-type": "text/html" }, + }) + ); + + await expect( + adapter.postMessage("whatsapp:PHONE_ID:15551234567", "test") + ).rejects.toBeInstanceOf(NetworkError); + }); + + it("fetches thread metadata", async () => { + const adapter = createAdapter(); + await adapter.initialize(createMockChat()); + + const thread = await adapter.fetchThread("whatsapp:PHONE_ID:15551234567"); + expect(thread.channelId).toBe("PHONE_ID"); + expect(thread.channelName).toBe("15551234567"); + expect(thread.isDM).toBe(true); + }); + + it("rejects webhook with missing signature when appSecret is set", async () => { + const adapter = createAdapter({ appSecret: "test-secret" }); + await adapter.initialize(createMockChat()); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(webhookPayload([sampleIncomingMessage()])), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); +}); diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts new file mode 100644 index 00000000..da3f0864 --- /dev/null +++ b/packages/adapter-whatsapp/src/index.ts @@ -0,0 +1,1071 @@ +import { + AdapterRateLimitError, + AuthenticationError, + cardToFallbackText, + extractCard, + NetworkError, + PermissionError, + ValidationError, +} from "@chat-adapter/shared"; +import type { + Adapter, + AdapterPostableMessage, + Attachment, + ChatInstance, + EmojiValue, + FetchOptions, + FetchResult, + FormattedContent, + Logger, + RawMessage, + ThreadInfo, + WebhookOptions, +} from "chat"; +import { + ConsoleLogger, + convertEmojiPlaceholders, + defaultEmojiResolver, + Message, + NotImplementedError, +} from "chat"; +import { cardToWhatsAppInteractive, decodeWhatsAppCallbackData } from "./cards"; +import { WhatsAppFormatConverter } from "./markdown"; +import type { + WhatsAppAdapterConfig, + WhatsAppApiResponse, + WhatsAppContact, + WhatsAppIncomingMessage, + WhatsAppInteractiveMessage, + WhatsAppRawMessage, + WhatsAppReactionMessage, + WhatsAppTextMessage, + WhatsAppThreadId, + WhatsAppWebhookPayload, +} from "./types"; + +const WHATSAPP_API_BASE = "https://graph.facebook.com"; +const WHATSAPP_API_VERSION = "v21.0"; +const WHATSAPP_MESSAGE_LIMIT = 4096; +const TRAILING_SLASHES_REGEX = /\/+$/; +const MESSAGE_SEQUENCE_PATTERN = /:(\d+)$/; + +interface WhatsAppMessageAuthor { + fullName: string; + isBot: boolean | "unknown"; + isMe: boolean; + userId: string; + userName: string; +} + +export class WhatsAppAdapter + implements Adapter +{ + readonly name = "whatsapp"; + + private readonly accessToken: string; + private readonly phoneNumberId: string; + private readonly verifyToken?: string; + private readonly appSecret?: string; + private readonly apiBaseUrl: string; + private readonly apiVersion: string; + private readonly logger: Logger; + private readonly formatConverter = new WhatsAppFormatConverter(); + private readonly messageCache = new Map< + string, + Message[] + >(); + private readonly contactNames = new Map(); + + private chat: ChatInstance | null = null; + private _botUserId?: string; + + get botUserId(): string | undefined { + return this._botUserId; + } + + get userName(): string { + return this.phoneNumberId; + } + + constructor(config: WhatsAppAdapterConfig & { logger: Logger }) { + this.accessToken = config.accessToken; + this.phoneNumberId = config.phoneNumberId; + this.verifyToken = config.verifyToken; + this.appSecret = config.appSecret; + this.apiBaseUrl = (config.apiBaseUrl ?? WHATSAPP_API_BASE).replace( + TRAILING_SLASHES_REGEX, + "" + ); + this.apiVersion = config.apiVersion ?? WHATSAPP_API_VERSION; + this.logger = config.logger; + } + + async initialize(chat: ChatInstance): Promise { + this.chat = chat; + this._botUserId = this.phoneNumberId; + + this.logger.info("WhatsApp adapter initialized", { + botUserId: this._botUserId, + phoneNumberId: this.phoneNumberId, + }); + } + + async handleWebhook( + request: Request, + options?: WebhookOptions + ): Promise { + // GET requests are webhook verification handshakes + if (request.method === "GET") { + return this.handleVerification(request); + } + + // Optionally verify X-Hub-Signature-256 + if (this.appSecret) { + const signature = request.headers.get("x-hub-signature-256"); + if (!signature) { + this.logger.warn( + "WhatsApp webhook rejected: missing X-Hub-Signature-256" + ); + return new Response("Missing signature", { status: 401 }); + } + + const body = await request.clone().text(); + const isValid = await this.verifySignature(body, signature); + if (!isValid) { + this.logger.warn( + "WhatsApp webhook rejected: invalid X-Hub-Signature-256" + ); + return new Response("Invalid signature", { status: 401 }); + } + } + + let payload: WhatsAppWebhookPayload; + try { + payload = (await request.json()) as WhatsAppWebhookPayload; + } catch { + return new Response("Invalid JSON", { status: 400 }); + } + + if (!this.chat) { + this.logger.warn( + "Chat instance not initialized, ignoring WhatsApp webhook" + ); + return new Response("OK", { status: 200 }); + } + + try { + this.processPayload(payload, options); + } catch (error) { + this.logger.warn("Failed to process WhatsApp webhook payload", { + error: String(error), + }); + } + + return new Response("OK", { status: 200 }); + } + + encodeThreadId(platformData: WhatsAppThreadId): string { + return `whatsapp:${platformData.phoneNumberId}:${platformData.userPhoneNumber}`; + } + + decodeThreadId(threadId: string): WhatsAppThreadId { + const parts = threadId.split(":"); + if (parts[0] !== "whatsapp" || parts.length !== 3) { + throw new ValidationError( + "whatsapp", + `Invalid WhatsApp thread ID: ${threadId}` + ); + } + + const phoneNumberId = parts[1]; + const userPhoneNumber = parts[2]; + + if (!(phoneNumberId && userPhoneNumber)) { + throw new ValidationError( + "whatsapp", + `Invalid WhatsApp thread ID: ${threadId}` + ); + } + + return { phoneNumberId, userPhoneNumber }; + } + + async postMessage( + threadId: string, + message: AdapterPostableMessage + ): Promise> { + const parsed = this.resolveThreadId(threadId); + + const card = extractCard(message); + if (card) { + const interactive = cardToWhatsAppInteractive( + card, + parsed.userPhoneNumber + ); + if (interactive) { + return this.sendInteractiveMessage(interactive, parsed, threadId); + } + } + + const text = this.truncateMessage( + convertEmojiPlaceholders( + card + ? cardToFallbackText(card) + : this.formatConverter.renderPostable(message), + "gchat" + ) + ); + + if (!text.trim()) { + throw new ValidationError("whatsapp", "Message text cannot be empty"); + } + + const payload: WhatsAppTextMessage = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsed.userPhoneNumber, + type: "text", + text: { body: text }, + }; + + const response = await this.whatsappFetch( + `${this.phoneNumberId}/messages`, + payload + ); + + const waMessageId = response.messages?.[0]?.id ?? `sent_${Date.now()}`; + + const rawMessage: WhatsAppRawMessage = { + id: waMessageId, + from: this.phoneNumberId, + timestamp: String(Math.floor(Date.now() / 1000)), + type: "text", + text: { body: text }, + }; + + const parsedMessage = this.parseWhatsAppMessage( + rawMessage, + threadId, + parsed.userPhoneNumber + ); + this.cacheMessage(parsedMessage); + + return { + id: parsedMessage.id, + threadId: parsedMessage.threadId, + raw: rawMessage, + }; + } + + async editMessage( + _threadId: string, + _messageId: string, + _message: AdapterPostableMessage + ): Promise> { + throw new NotImplementedError( + "WhatsApp Cloud API does not support editing messages", + "editMessage" + ); + } + + async deleteMessage(_threadId: string, _messageId: string): Promise { + throw new NotImplementedError( + "WhatsApp Cloud API does not support deleting messages", + "deleteMessage" + ); + } + + async addReaction( + threadId: string, + messageId: string, + emoji: EmojiValue | string + ): Promise { + const parsed = this.resolveThreadId(threadId); + const resolvedEmoji = this.resolveEmoji(emoji); + + const payload: WhatsAppReactionMessage = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsed.userPhoneNumber, + type: "reaction", + reaction: { + message_id: messageId, + emoji: resolvedEmoji, + }, + }; + + await this.whatsappFetch( + `${this.phoneNumberId}/messages`, + payload + ); + } + + async removeReaction( + threadId: string, + messageId: string, + _emoji: EmojiValue | string + ): Promise { + const parsed = this.resolveThreadId(threadId); + + // WhatsApp removes a reaction by sending an empty emoji + const payload: WhatsAppReactionMessage = { + messaging_product: "whatsapp", + recipient_type: "individual", + to: parsed.userPhoneNumber, + type: "reaction", + reaction: { + message_id: messageId, + emoji: "", + }, + }; + + await this.whatsappFetch( + `${this.phoneNumberId}/messages`, + payload + ); + } + + async startTyping(_threadId: string): Promise { + // WhatsApp Cloud API does not expose typing indicators. + // No-op for compatibility. + } + + async fetchMessages( + threadId: string, + options: FetchOptions = {} + ): Promise> { + const messages = [...(this.messageCache.get(threadId) ?? [])].sort((a, b) => + this.compareMessages(a, b) + ); + + return this.paginateMessages(messages, options); + } + + async fetchMessage( + _threadId: string, + messageId: string + ): Promise | null> { + return this.findCachedMessage(messageId) ?? null; + } + + async fetchThread(threadId: string): Promise { + const parsed = this.resolveThreadId(threadId); + + return { + id: this.encodeThreadId(parsed), + channelId: parsed.phoneNumberId, + channelName: parsed.userPhoneNumber, + isDM: true, + metadata: { + phoneNumberId: parsed.phoneNumberId, + userPhoneNumber: parsed.userPhoneNumber, + }, + }; + } + + channelIdFromThreadId(threadId: string): string { + return this.resolveThreadId(threadId).phoneNumberId; + } + + async openDM(userId: string): Promise { + return this.encodeThreadId({ + phoneNumberId: this.phoneNumberId, + userPhoneNumber: userId, + }); + } + + isDM(_threadId: string): boolean { + // WhatsApp conversations are inherently DMs. + return true; + } + + parseMessage(raw: WhatsAppRawMessage): Message { + const threadId = this.encodeThreadId({ + phoneNumberId: this.phoneNumberId, + userPhoneNumber: raw.from ?? "unknown", + }); + + const message = this.parseWhatsAppMessage(raw, threadId, raw.from); + this.cacheMessage(message); + return message; + } + + renderFormatted(content: FormattedContent): string { + return this.formatConverter.fromAst(content); + } + + // --------------------------------------------------------------------------- + // Private helpers + // --------------------------------------------------------------------------- + + private handleVerification(request: Request): Response { + const url = new URL(request.url); + const mode = url.searchParams.get("hub.mode"); + const token = url.searchParams.get("hub.verify_token"); + const challenge = url.searchParams.get("hub.challenge"); + + if (mode === "subscribe" && token === this.verifyToken) { + this.logger.info("WhatsApp webhook verified"); + return new Response(challenge ?? "", { status: 200 }); + } + + this.logger.warn("WhatsApp webhook verification failed", { + mode, + tokenMatch: token === this.verifyToken, + }); + return new Response("Forbidden", { status: 403 }); + } + + private async verifySignature( + body: string, + signature: string + ): Promise { + if (!this.appSecret) { + return false; + } + + const expectedPrefix = "sha256="; + if (!signature.startsWith(expectedPrefix)) { + return false; + } + + const signatureHex = signature.slice(expectedPrefix.length); + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + encoder.encode(this.appSecret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const signatureBuffer = await crypto.subtle.sign( + "HMAC", + key, + encoder.encode(body) + ); + const computedHex = Array.from(new Uint8Array(signatureBuffer)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + return computedHex === signatureHex; + } + + private processPayload( + payload: WhatsAppWebhookPayload, + options?: WebhookOptions + ): void { + if (!payload.entry) { + return; + } + + for (const entry of payload.entry) { + if (!entry.changes) { + continue; + } + + for (const change of entry.changes) { + if (change.field !== "messages" || !change.value) { + continue; + } + + // Cache contact names + if (change.value.contacts) { + for (const contact of change.value.contacts) { + if (contact.wa_id && contact.profile?.name) { + this.contactNames.set(contact.wa_id, contact.profile.name); + } + } + } + + if (change.value.messages) { + for (const message of change.value.messages) { + this.handleIncomingMessage(message, change.value.contacts, options); + } + } + } + } + } + + private handleIncomingMessage( + waMessage: WhatsAppIncomingMessage, + contacts?: WhatsAppContact[], + options?: WebhookOptions + ): void { + if (!this.chat) { + return; + } + + const from = waMessage.from ?? "unknown"; + + // Handle reactions separately + if (waMessage.type === "reaction" && waMessage.reaction) { + this.handleReaction(waMessage, from, options); + return; + } + + // Handle interactive responses (button/list replies) as actions + if (waMessage.type === "interactive" && waMessage.interactive) { + this.handleInteractiveResponse(waMessage, from, contacts, options); + return; + } + + // Handle button responses as actions + if (waMessage.type === "button" && waMessage.button) { + this.handleButtonResponse(waMessage, from, contacts, options); + return; + } + + const threadId = this.encodeThreadId({ + phoneNumberId: this.phoneNumberId, + userPhoneNumber: from, + }); + + const parsedMessage = this.parseWhatsAppMessage(waMessage, threadId, from); + this.cacheMessage(parsedMessage); + + this.chat.processMessage(this, threadId, parsedMessage, options); + } + + private handleReaction( + waMessage: WhatsAppIncomingMessage, + from: string, + options?: WebhookOptions + ): void { + if (!(this.chat && waMessage.reaction)) { + return; + } + + const threadId = this.encodeThreadId({ + phoneNumberId: this.phoneNumberId, + userPhoneNumber: from, + }); + + const emoji = waMessage.reaction.emoji ?? ""; + const messageId = waMessage.reaction.message_id ?? ""; + const added = emoji !== ""; + + const contactName = this.contactNames.get(from); + const author: WhatsAppMessageAuthor = { + userId: from, + userName: contactName ?? from, + fullName: contactName ?? from, + isBot: false, + isMe: false, + }; + + const emojiValue = emoji + ? defaultEmojiResolver.fromGChat(emoji) + : defaultEmojiResolver.fromGChat(""); + + this.chat.processReaction( + { + adapter: this, + threadId, + messageId, + emoji: emojiValue, + rawEmoji: emoji, + added, + user: author, + raw: waMessage, + }, + options + ); + } + + private handleInteractiveResponse( + waMessage: WhatsAppIncomingMessage, + from: string, + contacts?: WhatsAppContact[], + options?: WebhookOptions + ): void { + if (!(this.chat && waMessage.interactive)) { + return; + } + + const threadId = this.encodeThreadId({ + phoneNumberId: this.phoneNumberId, + userPhoneNumber: from, + }); + + const reply = + waMessage.interactive.button_reply ?? waMessage.interactive.list_reply; + if (!reply?.id) { + return; + } + + const { actionId, value } = decodeWhatsAppCallbackData(reply.id); + const contactName = this.resolveContactName(from, contacts); + + this.chat.processAction( + { + adapter: this, + actionId, + value, + messageId: waMessage.id ?? "", + threadId, + user: { + userId: from, + userName: contactName, + fullName: contactName, + isBot: false, + isMe: false, + }, + raw: waMessage, + }, + options + ); + } + + private handleButtonResponse( + waMessage: WhatsAppIncomingMessage, + from: string, + contacts?: WhatsAppContact[], + options?: WebhookOptions + ): void { + if (!(this.chat && waMessage.button)) { + return; + } + + const threadId = this.encodeThreadId({ + phoneNumberId: this.phoneNumberId, + userPhoneNumber: from, + }); + + const payload = waMessage.button.payload ?? waMessage.button.text ?? ""; + const { actionId, value } = decodeWhatsAppCallbackData(payload); + const contactName = this.resolveContactName(from, contacts); + + this.chat.processAction( + { + adapter: this, + actionId, + value, + messageId: waMessage.id ?? "", + threadId, + user: { + userId: from, + userName: contactName, + fullName: contactName, + isBot: false, + isMe: false, + }, + raw: waMessage, + }, + options + ); + } + + private parseWhatsAppMessage( + raw: WhatsAppIncomingMessage, + threadId: string, + from?: string + ): Message { + const text = this.extractMessageText(raw); + const contactName = from ? this.contactNames.get(from) : undefined; + const displayName = contactName ?? from ?? "unknown"; + + const author: WhatsAppMessageAuthor = { + userId: from ?? "unknown", + userName: displayName, + fullName: displayName, + isBot: false, + isMe: from === this.phoneNumberId, + }; + + const timestamp = raw.timestamp + ? new Date(Number.parseInt(raw.timestamp, 10) * 1000) + : new Date(); + + return new Message({ + id: raw.id ?? `msg_${Date.now()}`, + threadId, + text, + formatted: this.formatConverter.toAst(text), + raw, + author, + metadata: { + dateSent: timestamp, + edited: false, + }, + attachments: this.extractAttachments(raw), + isMention: true, // WhatsApp messages to a business are always directed + }); + } + + private extractMessageText(raw: WhatsAppIncomingMessage): string { + if (raw.text?.body) { + return raw.text.body; + } + if (raw.image?.caption) { + return raw.image.caption; + } + if (raw.video?.caption) { + return raw.video.caption; + } + if (raw.document?.caption) { + return raw.document.caption; + } + if (raw.interactive?.button_reply?.title) { + return raw.interactive.button_reply.title; + } + if (raw.interactive?.list_reply?.title) { + return raw.interactive.list_reply.title; + } + if (raw.button?.text) { + return raw.button.text; + } + return ""; + } + + private extractAttachments(raw: WhatsAppIncomingMessage): Attachment[] { + const attachments: Attachment[] = []; + + if (raw.image?.id) { + attachments.push(this.createMediaAttachment("image", raw.image)); + } + if (raw.video?.id) { + attachments.push(this.createMediaAttachment("video", raw.video)); + } + if (raw.audio?.id) { + attachments.push(this.createMediaAttachment("audio", raw.audio)); + } + if (raw.voice?.id) { + attachments.push(this.createMediaAttachment("audio", raw.voice)); + } + if (raw.document?.id) { + attachments.push(this.createMediaAttachment("file", raw.document)); + } + if (raw.sticker?.id) { + attachments.push(this.createMediaAttachment("image", raw.sticker)); + } + + return attachments; + } + + private createMediaAttachment( + type: Attachment["type"], + media: { id?: string; mime_type?: string; filename?: string } + ): Attachment { + const mediaId = media.id ?? ""; + return { + type, + name: media.filename, + mimeType: media.mime_type, + fetchData: async () => this.downloadMedia(mediaId), + }; + } + + private async downloadMedia(mediaId: string): Promise { + // First get the media URL + const mediaInfo = await this.whatsappFetch<{ url?: string }>(mediaId); + if (!mediaInfo.url) { + throw new NetworkError( + "whatsapp", + `Failed to get download URL for media ${mediaId}` + ); + } + + // Then download the actual file + let response: Response; + try { + response = await fetch(mediaInfo.url, { + headers: { Authorization: `Bearer ${this.accessToken}` }, + }); + } catch (error) { + throw new NetworkError( + "whatsapp", + `Failed to download WhatsApp media ${mediaId}`, + error instanceof Error ? error : undefined + ); + } + + if (!response.ok) { + throw new NetworkError( + "whatsapp", + `Failed to download WhatsApp media ${mediaId}: ${response.status}` + ); + } + + return Buffer.from(await response.arrayBuffer()); + } + + private async sendInteractiveMessage( + interactive: WhatsAppInteractiveMessage, + parsed: WhatsAppThreadId, + threadId: string + ): Promise> { + const response = await this.whatsappFetch( + `${this.phoneNumberId}/messages`, + interactive + ); + + const waMessageId = response.messages?.[0]?.id ?? `sent_${Date.now()}`; + + const rawMessage: WhatsAppRawMessage = { + id: waMessageId, + from: this.phoneNumberId, + timestamp: String(Math.floor(Date.now() / 1000)), + type: "interactive", + }; + + const parsedMessage = this.parseWhatsAppMessage( + rawMessage, + threadId, + parsed.userPhoneNumber + ); + this.cacheMessage(parsedMessage); + + return { + id: parsedMessage.id, + threadId: parsedMessage.threadId, + raw: rawMessage, + }; + } + + private resolveEmoji(emoji: EmojiValue | string): string { + if (typeof emoji !== "string") { + return defaultEmojiResolver.toGChat(emoji.name); + } + return emoji; + } + + private resolveContactName( + from: string, + contacts?: WhatsAppContact[] + ): string { + if (contacts) { + const contact = contacts.find((c) => c.wa_id === from); + if (contact?.profile?.name) { + return contact.profile.name; + } + } + return this.contactNames.get(from) ?? from; + } + + private resolveThreadId(value: string): WhatsAppThreadId { + if (value.startsWith("whatsapp:")) { + return this.decodeThreadId(value); + } + + return { + phoneNumberId: this.phoneNumberId, + userPhoneNumber: value, + }; + } + + private truncateMessage(text: string): string { + if (text.length <= WHATSAPP_MESSAGE_LIMIT) { + return text; + } + return `${text.slice(0, WHATSAPP_MESSAGE_LIMIT - 3)}...`; + } + + private paginateMessages( + messages: Message[], + options: FetchOptions + ): FetchResult { + const limit = Math.max(1, Math.min(options.limit ?? 50, 100)); + const direction = options.direction ?? "backward"; + + if (messages.length === 0) { + return { messages: [] }; + } + + const messageIndexById = new Map( + messages.map((message, index) => [message.id, index]) + ); + + if (direction === "backward") { + const end = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? messages.length) + : messages.length; + const start = Math.max(0, end - limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: start > 0 ? page[0]?.id : undefined, + }; + } + + const start = + options.cursor && messageIndexById.has(options.cursor) + ? (messageIndexById.get(options.cursor) ?? -1) + 1 + : 0; + const end = Math.min(messages.length, start + limit); + const page = messages.slice(start, end); + + return { + messages: page, + nextCursor: end < messages.length ? page.at(-1)?.id : undefined, + }; + } + + private cacheMessage(message: Message): void { + const existing = this.messageCache.get(message.threadId) ?? []; + const index = existing.findIndex((item) => item.id === message.id); + + if (index >= 0) { + existing[index] = message; + } else { + existing.push(message); + } + + existing.sort((a, b) => this.compareMessages(a, b)); + this.messageCache.set(message.threadId, existing); + } + + private findCachedMessage( + messageId: string + ): Message | undefined { + for (const messages of this.messageCache.values()) { + const found = messages.find((message) => message.id === messageId); + if (found) { + return found; + } + } + return undefined; + } + + private compareMessages( + a: Message, + b: Message + ): number { + const timeDiff = + a.metadata.dateSent.getTime() - b.metadata.dateSent.getTime(); + if (timeDiff !== 0) { + return timeDiff; + } + return this.messageSequence(a.id) - this.messageSequence(b.id); + } + + private messageSequence(messageId: string): number { + const match = messageId.match(MESSAGE_SEQUENCE_PATTERN); + return match ? Number.parseInt(match[1], 10) : 0; + } + + private async whatsappFetch( + endpoint: string, + payload?: object + ): Promise { + const url = `${this.apiBaseUrl}/${this.apiVersion}/${endpoint}`; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bearer ${this.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(payload ?? {}), + }); + } catch (error) { + throw new NetworkError( + "whatsapp", + `Network error calling WhatsApp API ${endpoint}`, + error instanceof Error ? error : undefined + ); + } + + let data: TResult & { error?: { code?: number; message?: string } }; + try { + data = (await response.json()) as TResult & { + error?: { code?: number; message?: string }; + }; + } catch { + throw new NetworkError( + "whatsapp", + `Failed to parse WhatsApp API response for ${endpoint}` + ); + } + + if (!response.ok || data.error) { + this.throwWhatsAppApiError(endpoint, response.status, data.error); + } + + return data; + } + + private throwWhatsAppApiError( + endpoint: string, + status: number, + error?: { code?: number; message?: string } + ): never { + const errorCode = error?.code ?? status; + const description = error?.message ?? `WhatsApp API ${endpoint} failed`; + + if (status === 429 || errorCode === 80007) { + throw new AdapterRateLimitError("whatsapp"); + } + + if (status === 401 || errorCode === 190) { + throw new AuthenticationError("whatsapp", description); + } + + if (status === 403 || errorCode === 10) { + throw new PermissionError("whatsapp", endpoint); + } + + if (errorCode >= 400 && errorCode < 500) { + throw new ValidationError("whatsapp", description); + } + + throw new NetworkError( + "whatsapp", + `${description} (status ${status}, error ${errorCode})` + ); + } +} + +export function createWhatsAppAdapter( + config?: Partial +): WhatsAppAdapter { + const accessToken = config?.accessToken ?? process.env.WHATSAPP_ACCESS_TOKEN; + if (!accessToken) { + throw new ValidationError( + "whatsapp", + "accessToken is required. Set WHATSAPP_ACCESS_TOKEN or provide it in config." + ); + } + + const phoneNumberId = + config?.phoneNumberId ?? process.env.WHATSAPP_PHONE_NUMBER_ID; + if (!phoneNumberId) { + throw new ValidationError( + "whatsapp", + "phoneNumberId is required. Set WHATSAPP_PHONE_NUMBER_ID or provide it in config." + ); + } + + const verifyToken = config?.verifyToken ?? process.env.WHATSAPP_VERIFY_TOKEN; + const appSecret = config?.appSecret ?? process.env.WHATSAPP_APP_SECRET; + const apiBaseUrl = config?.apiBaseUrl ?? WHATSAPP_API_BASE; + const apiVersion = config?.apiVersion ?? WHATSAPP_API_VERSION; + + return new WhatsAppAdapter({ + accessToken, + phoneNumberId, + verifyToken, + appSecret, + apiBaseUrl, + apiVersion, + logger: config?.logger ?? new ConsoleLogger("info").child("whatsapp"), + }); +} + +export { + cardToWhatsAppInteractive, + decodeWhatsAppCallbackData, + encodeWhatsAppCallbackData, +} from "./cards"; +export { WhatsAppFormatConverter } from "./markdown"; +export type { + WhatsAppAdapterConfig, + WhatsAppIncomingMessage, + WhatsAppInteractiveMessage, + WhatsAppRawMessage, + WhatsAppThreadId, + WhatsAppWebhookPayload, +} from "./types"; diff --git a/packages/adapter-whatsapp/src/markdown.ts b/packages/adapter-whatsapp/src/markdown.ts new file mode 100644 index 00000000..62faffb9 --- /dev/null +++ b/packages/adapter-whatsapp/src/markdown.ts @@ -0,0 +1,63 @@ +/** + * WhatsApp format conversion. + * + * WhatsApp supports basic markdown (bold, italic, strikethrough, monospace) + * but not full markdown syntax. This adapter emits normalized markdown text. + */ + +import { + type AdapterPostableMessage, + BaseFormatConverter, + type Content, + isTableNode, + parseMarkdown, + type Root, + stringifyMarkdown, + tableToAscii, + walkAst, +} from "chat"; + +const WHATSAPP_MESSAGE_LIMIT = 4096; + +export class WhatsAppFormatConverter extends BaseFormatConverter { + fromAst(ast: Root): string { + // Replace table nodes with ASCII code blocks since WhatsApp + // does not support pipe-delimited table syntax. + const transformed = walkAst(structuredClone(ast), (node: Content) => { + if (isTableNode(node)) { + return { + type: "code" as const, + value: tableToAscii(node), + lang: undefined, + } as Content; + } + return node; + }); + + const result = stringifyMarkdown(transformed).trim(); + if (result.length <= WHATSAPP_MESSAGE_LIMIT) { + return result; + } + return `${result.slice(0, WHATSAPP_MESSAGE_LIMIT - 3)}...`; + } + + toAst(text: string): Root { + return parseMarkdown(text); + } + + override renderPostable(message: AdapterPostableMessage): string { + if (typeof message === "string") { + return message; + } + if ("raw" in message) { + return message.raw; + } + if ("markdown" in message) { + return this.fromMarkdown(message.markdown); + } + if ("ast" in message) { + return this.fromAst(message.ast); + } + return super.renderPostable(message); + } +} diff --git a/packages/adapter-whatsapp/src/types.ts b/packages/adapter-whatsapp/src/types.ts new file mode 100644 index 00000000..5b4c49b2 --- /dev/null +++ b/packages/adapter-whatsapp/src/types.ts @@ -0,0 +1,230 @@ +/** + * WhatsApp Cloud API adapter types. + * @see https://developers.facebook.com/docs/whatsapp/cloud-api + */ + +/** + * WhatsApp adapter configuration. + */ +export interface WhatsAppAdapterConfig { + /** Meta access token (permanent or system user token). */ + accessToken: string; + /** Optional custom API base URL (defaults to https://graph.facebook.com). */ + apiBaseUrl?: string; + /** Cloud API version (defaults to v21.0). */ + apiVersion?: string; + /** Optional app secret for X-Hub-Signature-256 webhook verification. */ + appSecret?: string; + /** Bot's phone number ID from Meta dashboard. */ + phoneNumberId: string; + /** Optional user-defined secret for webhook verification handshake. */ + verifyToken?: string; +} + +/** + * WhatsApp thread ID components. + */ +export interface WhatsAppThreadId { + /** Bot's phone number ID. */ + phoneNumberId: string; + /** User's phone number (recipient). */ + userPhoneNumber: string; +} + +/** + * WhatsApp webhook payload envelope. + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/components + */ +export interface WhatsAppWebhookPayload { + entry?: WhatsAppWebhookEntry[]; + object?: string; +} + +/** + * WhatsApp webhook entry. + */ +export interface WhatsAppWebhookEntry { + changes?: WhatsAppWebhookChange[]; + id?: string; +} + +/** + * WhatsApp webhook change. + */ +export interface WhatsAppWebhookChange { + field?: string; + value?: WhatsAppWebhookValue; +} + +/** + * WhatsApp webhook value (messages field). + */ +export interface WhatsAppWebhookValue { + contacts?: WhatsAppContact[]; + messages?: WhatsAppIncomingMessage[]; + metadata?: WhatsAppWebhookMetadata; + statuses?: WhatsAppStatus[]; +} + +/** + * WhatsApp webhook metadata. + */ +export interface WhatsAppWebhookMetadata { + display_phone_number?: string; + phone_number_id?: string; +} + +/** + * WhatsApp contact information from webhook. + */ +export interface WhatsAppContact { + profile?: { name?: string }; + wa_id?: string; +} + +/** + * WhatsApp incoming message. + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/webhooks/payload-examples + */ +export interface WhatsAppIncomingMessage { + audio?: WhatsAppMedia; + button?: { payload?: string; text?: string }; + context?: { from?: string; id?: string }; + document?: WhatsAppMedia; + from?: string; + id?: string; + image?: WhatsAppMedia; + interactive?: WhatsAppInteractiveResponse; + reaction?: { emoji?: string; message_id?: string }; + sticker?: WhatsAppMedia; + text?: { body?: string }; + timestamp?: string; + type?: string; + video?: WhatsAppMedia; + voice?: WhatsAppMedia; +} + +/** + * WhatsApp media object. + */ +export interface WhatsAppMedia { + caption?: string; + filename?: string; + id?: string; + mime_type?: string; + sha256?: string; +} + +/** + * WhatsApp interactive response (button reply or list reply). + */ +export interface WhatsAppInteractiveResponse { + button_reply?: { id?: string; title?: string }; + list_reply?: { description?: string; id?: string; title?: string }; + type?: string; +} + +/** + * WhatsApp message status update. + */ +export interface WhatsAppStatus { + id?: string; + recipient_id?: string; + status?: string; + timestamp?: string; +} + +/** + * WhatsApp Cloud API response envelope. + */ +export interface WhatsAppApiResponse { + error?: WhatsAppApiError; + messages?: Array<{ id?: string }>; +} + +/** + * WhatsApp Cloud API error. + */ +export interface WhatsAppApiError { + code?: number; + error_subcode?: number; + fbtrace_id?: string; + message?: string; + type?: string; +} + +/** + * WhatsApp outgoing text message payload. + */ +export interface WhatsAppTextMessage { + messaging_product: "whatsapp"; + preview_url?: boolean; + recipient_type: "individual"; + text: { body: string }; + to: string; + type: "text"; +} + +/** + * WhatsApp outgoing reaction message payload. + */ +export interface WhatsAppReactionMessage { + messaging_product: "whatsapp"; + reaction: { emoji: string; message_id: string }; + recipient_type: "individual"; + to: string; + type: "reaction"; +} + +/** + * WhatsApp interactive message button. + */ +export interface WhatsAppInteractiveButton { + reply: { id: string; title: string }; + type: "reply"; +} + +/** + * WhatsApp interactive list row. + */ +export interface WhatsAppInteractiveListRow { + description?: string; + id: string; + title: string; +} + +/** + * WhatsApp interactive list section. + */ +export interface WhatsAppInteractiveListSection { + rows: WhatsAppInteractiveListRow[]; + title?: string; +} + +/** + * WhatsApp outgoing interactive message payload (buttons or list). + */ +export interface WhatsAppInteractiveMessage { + interactive: + | { + action: { buttons: WhatsAppInteractiveButton[] }; + body: { text: string }; + header?: { text: string; type: "text" }; + type: "button"; + } + | { + action: { + button: string; + sections: WhatsAppInteractiveListSection[]; + }; + body: { text: string }; + header?: { text: string; type: "text" }; + type: "list"; + }; + messaging_product: "whatsapp"; + recipient_type: "individual"; + to: string; + type: "interactive"; +} + +export type WhatsAppRawMessage = WhatsAppIncomingMessage; diff --git a/packages/adapter-whatsapp/tsconfig.json b/packages/adapter-whatsapp/tsconfig.json new file mode 100644 index 00000000..8768f5bd --- /dev/null +++ b/packages/adapter-whatsapp/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "strictNullChecks": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/adapter-whatsapp/tsup.config.ts b/packages/adapter-whatsapp/tsup.config.ts new file mode 100644 index 00000000..faf3167a --- /dev/null +++ b/packages/adapter-whatsapp/tsup.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["esm"], + dts: true, + clean: true, + sourcemap: true, +}); diff --git a/packages/adapter-whatsapp/vitest.config.ts b/packages/adapter-whatsapp/vitest.config.ts new file mode 100644 index 00000000..edc2d946 --- /dev/null +++ b/packages/adapter-whatsapp/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + globals: true, + environment: "node", + coverage: { + provider: "v8", + reporter: ["text", "json-summary"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6d0b825..640e8463 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@chat-adapter/telegram': specifier: workspace:* version: link:../../packages/adapter-telegram + '@chat-adapter/whatsapp': + specifier: workspace:* + version: link:../../packages/adapter-whatsapp ai: specifier: ^6.0.5 version: 6.0.6(zod@4.3.3) @@ -445,6 +448,28 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/adapter-whatsapp: + dependencies: + '@chat-adapter/shared': + specifier: workspace:* + version: link:../adapter-shared + chat: + specifier: workspace:* + version: link:../chat + devDependencies: + '@types/node': + specifier: ^25.3.2 + version: 25.3.2 + tsup: + specifier: ^8.3.5 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.7.2 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0) + packages/chat: dependencies: '@workflow/serde': diff --git a/turbo.json b/turbo.json index 98bf25f6..10437122 100644 --- a/turbo.json +++ b/turbo.json @@ -11,7 +11,11 @@ "GOOGLE_CHAT_PUBSUB_TOPIC", "GOOGLE_CHAT_IMPERSONATE_USER", "BOT_USERNAME", - "REDIS_URL" + "REDIS_URL", + "WHATSAPP_ACCESS_TOKEN", + "WHATSAPP_PHONE_NUMBER_ID", + "WHATSAPP_VERIFY_TOKEN", + "WHATSAPP_APP_SECRET" ], "tasks": { "build": {