diff --git a/README.md b/README.md index 9527a91..3c2f2f5 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,10 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId); | Feature | Supported | Notes | |---------|-----------|-------| | Send messages | Yes | Text messages across all platforms | -| Rich messages (cards) | Yes | Buttons and templates on FB, IG, Telegram, WhatsApp | +| Rich messages (cards) | Yes | Buttons + templates on FB, IG, Telegram, WhatsApp; card `Select`/`RadioSelect` → WhatsApp interactive **list** | +| WhatsApp rich messages | Yes | Interactive lists, cta_url buttons, flows, location-request + voice-call buttons, location pins, contact cards, approved templates, quoted replies — via `ZernioApiClient` ([see below](#whatsapp-rich-messages)) | +| Inbound interactive replies | Yes | Button taps, list selections, and flow responses on `message.raw.metadata` ([see below](#inbound-interactive-replies)) | +| Open conversation by recipient | Yes | `openDM` / `openConversation` — cold-start a chat from a phone number ([see below](#opening-conversations)) | | Edit messages | Partial | Telegram only | | Delete messages | Partial | Telegram, X (full delete); Bluesky, Reddit (self-only) | | Send reactions | Partial | Telegram and WhatsApp (add/remove emoji) | @@ -149,6 +152,9 @@ const { accountId, conversationId } = adapter.decodeThreadId(threadId); |---------|----|----|----------|----------|---|---------|--------| | Send text | Y | Y | Y | Y | Y | Y | Y | | Buttons | Y | Y | Y | Y | - | - | - | +| Lists | - | - | - | Y | - | - | - | +| Location / Contacts | - | - | - | Y | - | - | - | +| Templates / Flows | - | - | - | Y | - | - | - | | Typing | Y | - | Y | Y | - | - | - | | Delete | - | - | Y | - | Y | Self | Self | | Reactions | - | - | Y | Y | - | - | - | @@ -180,6 +186,108 @@ await thread.post( // Falls back to text on X/Bluesky/Reddit ``` +A card `Select` or `RadioSelect` is mapped to a WhatsApp **interactive list** (it can't coexist with reply buttons, so the list takes precedence): + +```typescript +import { Card, Actions, Select, SelectOption } from "chat"; + +await thread.post( + Card({ + title: "Pick a plan", + children: [ + Actions([ + Select({ + id: "plan", + placeholder: "Choose plan", // becomes the list's open button (max 20 chars) + options: [ + SelectOption({ label: "Basic", value: "basic", description: "$10/mo" }), + SelectOption({ label: "Pro", value: "pro" }), + ], + }), + ]), + ], + }) +); +``` + +## WhatsApp Rich Messages + +WhatsApp-only message types that don't map to a Chat SDK card are sent through the exported [`ZernioApiClient`](#api-client), used alongside the adapter. Decode a thread id to get the `accountId` + `conversationId`: + +```typescript +import { ZernioApiClient } from "@zernio/chat-sdk-adapter"; + +const client = new ZernioApiClient(process.env.ZERNIO_API_KEY!, "https://zernio.com/api"); +const { accountId, conversationId } = adapter.decodeThreadId(threadId); + +// Reply buttons / list / cta_url / flow / location-request / voice-call button +await client.sendInteractive(conversationId, accountId, { + type: "cta_url", + body: { text: "View your order" }, + action: { name: "cta_url", parameters: { display_text: "Open", url: "https://example.com/o/123" } }, +}); + +// Location pin +await client.sendLocation(conversationId, accountId, { + latitude: 41.3874, longitude: 2.1686, name: "HQ", address: "Barcelona", +}); + +// Contact cards (vCard) +await client.sendContacts(conversationId, accountId, [ + { name: { formatted_name: "Ana Ruiz" }, phones: [{ phone: "+34600000000", type: "WORK" }] }, +]); + +// Approved template (re-opens the 24h window) +await client.sendTemplate(conversationId, accountId, { name: "order_update", language: "en_US" }); + +// Quote / reply to a specific message +await client.reply(conversationId, accountId, "wamid.HBg...", "Thanks, on it!"); +``` + +`sendInteractive` accepts the full WhatsApp interactive union: `button`, `list`, `cta_url`, `flow`, `location_request_message`, and `voice_call`. Pass `{ replyTo }` as the 4th arg to quote a message. + +## Inbound Interactive Replies + +When a user taps a reply button, picks a list row, or submits a WhatsApp Flow, it arrives as a normal `onNewMessage` whose interactive context is on `message.raw.metadata`: + +```typescript +bot.onNewMessage(/.*/, async (thread, message) => { + const meta = (message.raw as any).metadata; + if (meta?.interactiveType === "button_reply" || meta?.interactiveType === "list_reply") { + // The id you set when sending the button/row + await thread.post(`You picked: ${meta.interactiveId}`); + } + if (meta?.interactiveType === "nfm_reply") { + const form = meta.flowResponseData; // parsed Flow response + } + // meta.referral -> Click-to-WhatsApp ad attribution (when the chat started from an ad) + // meta.quotedMessageId -> the message this one replies to +}); +``` + +## Opening Conversations + +Start a chat with someone who hasn't messaged you yet. + +`openDM(userId)` is the standard Chat SDK method. Because one Zernio account = one channel, namespace the recipient as `"{accountId}:{recipient}"` (a phone/E.164 for WhatsApp). It's resolution-only — no network call — and the first `post()` opens the thread: + +```typescript +const thread = await bot.openDM("507f1f77bcf86cd799439011:16505551234"); +await thread.post("Hi!"); // WhatsApp: the first message must be a template (see below) +``` + +For WhatsApp you must open with an approved template (the 24h-window rule). `openConversation` sends it and returns the thread id in one step: + +```typescript +const threadId = await adapter.openConversation({ + accountId: "507f1f77bcf86cd799439011", + to: "16505551234", + template: { name: "welcome", language: "en_US", params: ["Ana"] }, +}); +// Non-WhatsApp platforms can open with a plain message instead: +// await adapter.openConversation({ accountId, to, message: "Hi!" }); +``` + ## AI Streaming Stream AI responses with the post+edit pattern (works best on Telegram). `thread.post()` accepts an `AsyncIterable`, so you can pass the `textStream` from `streamText` directly: @@ -272,6 +380,17 @@ await client.addReaction(conversationId, messageId, accountId, "👍"); // Upload media const { url } = await client.uploadMedia(fileBuffer, "image/jpeg"); + +// Cold-start a conversation from a recipient (WhatsApp needs a template) +const convo = await client.createConversation({ + accountId, + participantId: "16505551234", + templateName: "welcome", + templateLanguage: "en_US", +}); + +// WhatsApp rich sends: sendInteractive, sendLocation, sendContacts, +// sendTemplate, reply — see "WhatsApp Rich Messages" above. ``` ## Webhook Verification diff --git a/src/adapter.test.ts b/src/adapter.test.ts index 3482045..069f6d1 100644 --- a/src/adapter.test.ts +++ b/src/adapter.test.ts @@ -143,6 +143,53 @@ describe("Thread ID encode/decode", () => { }); }); +// ─── openDM / openConversation ────────────────────────────────────────────── + +describe("openDM", () => { + const adapter = new ZernioAdapter(TEST_CONFIG); + + it("resolves an account-namespaced recipient to a thread id (no network)", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const threadId = await adapter.openDM("acc-123:16505551234"); + expect(threadId).toBe("zernio:acc-123:16505551234"); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("throws when the recipient is not account-namespaced", async () => { + await expect(adapter.openDM("16505551234")).rejects.toThrow(ValidationError); + await expect(adapter.openDM("acc-123:")).rejects.toThrow(ValidationError); + }); +}); + +describe("openConversation", () => { + const adapter = new ZernioAdapter(TEST_CONFIG); + afterEach(() => vi.restoreAllMocks()); + + it("cold-starts a WhatsApp conversation with a template and returns the thread id", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ success: true, data: { conversationId: "16505551234", messageId: "wamid.1", participantId: "16505551234", participantName: "16505551234" } }), + { status: 201 }, + ), + ); + const threadId = await adapter.openConversation({ + accountId: "acc-9", + to: "16505551234", + template: { name: "welcome", language: "en_US", params: ["Ana"] }, + }); + expect(threadId).toBe("zernio:acc-9:16505551234"); + const body = JSON.parse((fetch as any).mock.calls[0][1].body); + expect(body).toMatchObject({ + accountId: "acc-9", + participantId: "16505551234", + templateName: "welcome", + templateLanguage: "en_US", + templateParams: ["Ana"], + }); + }); +}); + // ─── parseMessage Tests ───────────────────────────────────────────────────── describe("parseMessage", () => { @@ -289,6 +336,46 @@ describe("handleWebhook", () => { expect(typeof factoryArg).toBe("function"); }); + it("surfaces WhatsApp interactive-reply metadata on message.raw.metadata", async () => { + const payload = makeWebhookPayload({ + metadata: { interactiveType: "list_reply", interactiveId: "row-pro" }, + } as any); + const body = JSON.stringify(payload); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "X-Zernio-Signature": signPayload(body), + "X-Zernio-Event": "message.received", + "Content-Type": "application/json", + }, + body, + }); + + await adapter.handleWebhook(request); + const [, , factoryArg] = mockChat.processMessage.mock.calls[0]; + const message = await factoryArg(); + expect(message.raw.metadata).toEqual({ interactiveType: "list_reply", interactiveId: "row-pro" }); + }); + + it("skips call.* and message-status events (not Chat SDK concepts)", async () => { + for (const event of ["call.received", "call.ended", "message.delivered", "message.read"]) { + const body = JSON.stringify({ id: "e", event, timestamp: "t" }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { + "X-Zernio-Signature": signPayload(body), + "X-Zernio-Event": event, + "Content-Type": "application/json", + }, + body, + }); + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + } + expect(mockChat.processMessage).not.toHaveBeenCalled(); + expect(mockChat.processReaction).not.toHaveBeenCalled(); + }); + it("skips outgoing messages (prevents echo loop)", async () => { const payload = makeWebhookPayload({ message: makeRawMessage({ direction: "outgoing" }), diff --git a/src/adapter.ts b/src/adapter.ts index 56eb9ed..7d4265a 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -162,7 +162,9 @@ export class ZernioAdapter implements Adapter return this.handleReactionReceived(payload as ZernioReactionWebhookPayload, options); } - // Unhandled event type, acknowledge receipt + // Unhandled event type, acknowledge receipt. (Call lifecycle + message + // delivery-status events are intentionally NOT handled here: they aren't Chat + // SDK concepts. Subscribe to them with your own Zernio webhook handler.) return new Response("OK", { status: 200 }); } @@ -184,8 +186,14 @@ export class ZernioAdapter implements Adapter conversationId: payload.message.conversationId, }); + // Copy the envelope metadata (interactive reply, ad referral, quoted-message + // context) onto the raw message so handlers can read it off message.raw.metadata. + const rawMessage: ZernioRawMessage = payload.metadata + ? { ...payload.message, metadata: payload.metadata } + : payload.message; + const factory = async (): Promise> => { - return this.parseMessage(payload.message); + return this.parseMessage(rawMessage); }; this.chat!.processMessage(this, threadId, factory, options); @@ -342,8 +350,14 @@ export class ZernioAdapter implements Adapter dateSent: new Date(raw.sentAt), edited: false, }, + // chat-sdk Attachment.type is fixed to image/file/video/audio. Media types + // pass through; everything else (location, contact, sticker, share) maps to + // "file" — the true Zernio type + payload stay on `raw.attachments`. attachments: raw.attachments.map((att) => ({ - type: att.type as "image" | "video" | "audio" | "file", + type: + att.type === "image" || att.type === "video" || att.type === "audio" + ? att.type + : ("file" as const), url: att.url, })), }); @@ -374,11 +388,13 @@ export class ZernioAdapter implements Adapter const body: Record = { accountId }; if (card) { - // Map card to native Zernio rich message format (buttons, templates) + // Map card to native Zernio rich message format (buttons, templates, or a + // WhatsApp interactive list when the card carries a Select/RadioSelect). const mapped = mapCardToZernioMessage(card as any); body.message = mapped.message || undefined; if (mapped.buttons) body.buttons = mapped.buttons; if (mapped.template) body.template = mapped.template; + if (mapped.interactive) body.interactive = mapped.interactive; } else { // Plain text / markdown / AST body.message = this.converter.renderPostable(message) || undefined; @@ -497,6 +513,64 @@ export class ZernioAdapter implements Adapter await this.api.deleteMessage(conversationId, messageId, accountId); } + // ─── Opening conversations ──────────────────────────────────────────────── + + /** + * Open a direct-message thread with a recipient (Chat SDK `chat.openDM`). + * + * Because one Zernio account = one channel, the recipient must be namespaced + * with the account: pass `userId` as `"{accountId}:{recipient}"` (the + * recipient is a phone/E.164 for WhatsApp, or the platform user id otherwise). + * + * This is resolution-only and makes NO network call: the Zernio inbox send + * endpoint accepts the recipient handle directly as the conversation id, so we + * return the thread id deterministically and the FIRST `post()` opens the + * conversation. For WhatsApp that first message must be an approved template + * (24h-window rule) — use `openConversation()` to send it in one step. + */ + async openDM(userId: string): Promise { + const sep = userId.indexOf(":"); + if (sep <= 0 || sep === userId.length - 1) { + throw new ValidationError( + "zernio", + `openDM expects "{accountId}:{recipient}" (one account = one channel). Got "${userId}".`, + ); + } + const accountId = userId.slice(0, sep); + const recipient = userId.slice(sep + 1); + return this.encodeThreadId({ accountId, conversationId: recipient }); + } + + /** + * Cold-start a conversation by sending its opening message, and return the + * thread id. Unlike `openDM`, this actually creates the conversation via the + * Zernio API, so it works for WhatsApp: pass a `template` (the only way to + * open outside the 24h window). Other platforms can open with `message`. + */ + async openConversation(params: { + accountId: string; + /** Recipient: phone/E.164 for WhatsApp, platform user id otherwise. */ + to: string; + /** Opening text (non-WhatsApp, or WhatsApp within the 24h window). */ + message?: string; + /** WhatsApp approved template — required to open a WhatsApp conversation cold. */ + template?: { name: string; language: string; params?: string[] }; + }): Promise { + const data = await this.api.createConversation({ + accountId: params.accountId, + participantId: params.to, + ...(params.message ? { message: params.message } : {}), + ...(params.template + ? { + templateName: params.template.name, + templateLanguage: params.template.language, + ...(params.template.params ? { templateParams: params.template.params } : {}), + } + : {}), + }); + return this.encodeThreadId({ accountId: params.accountId, conversationId: data.conversationId }); + } + // ─── Reactions ──────────────────────────────────────────────────────────── /** diff --git a/src/api-client.test.ts b/src/api-client.test.ts index 6676b0f..4e3e942 100644 --- a/src/api-client.test.ts +++ b/src/api-client.test.ts @@ -227,4 +227,75 @@ describe("ZernioApiClient", () => { expect(calledUrl).not.toContain("//v1"); }); }); + + // ─── WhatsApp rich-message helpers ──────────────────────────────────────── + + describe("WhatsApp helpers", () => { + function mockOk() { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response(JSON.stringify({ success: true, data: { messageId: "m1" } }), { status: 200 }), + ); + } + function sentBody(): Record { + return JSON.parse((fetch as any).mock.calls[0][1].body); + } + + it("sendInteractive posts the interactive payload (and replyTo)", async () => { + mockOk(); + await client.sendInteractive( + "conv-1", + "acc-1", + { type: "button", body: { text: "Pick" }, action: { buttons: [{ type: "reply", reply: { id: "y", title: "Yes" } }] } }, + { replyTo: "wamid.X" }, + ); + const body = sentBody(); + expect((body.interactive as any).type).toBe("button"); + expect(body.replyTo).toBe("wamid.X"); + expect(body.accountId).toBe("acc-1"); + }); + + it("sendLocation posts a location pin", async () => { + mockOk(); + await client.sendLocation("conv-1", "acc-1", { latitude: 41.3, longitude: 2.1, name: "HQ" }); + expect((sentBody().location as any).latitude).toBe(41.3); + }); + + it("sendContacts posts contact cards", async () => { + mockOk(); + await client.sendContacts("conv-1", "acc-1", [{ name: { formatted_name: "Ana" } }]); + expect((sentBody().contacts as any)[0].name.formatted_name).toBe("Ana"); + }); + + it("sendTemplate wraps the template element", async () => { + mockOk(); + await client.sendTemplate("conv-1", "acc-1", { name: "hello", language: "en_US" }); + expect((sentBody().template as any).elements[0]).toEqual({ name: "hello", language: "en_US" }); + }); + + it("reply quotes a message with replyTo", async () => { + mockOk(); + await client.reply("conv-1", "acc-1", "wamid.Q", "thanks"); + const body = sentBody(); + expect(body.replyTo).toBe("wamid.Q"); + expect(body.message).toBe("thanks"); + }); + + it("createConversation posts to the conversations collection and returns data", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValueOnce( + new Response( + JSON.stringify({ success: true, data: { conversationId: "16505551234", messageId: "wamid.1", participantId: "16505551234", participantName: "16505551234" } }), + { status: 201 }, + ), + ); + const data = await client.createConversation({ + accountId: "acc-1", + participantId: "16505551234", + templateName: "hello", + templateLanguage: "en_US", + }); + expect(data.conversationId).toBe("16505551234"); + expect((fetch as any).mock.calls[0][0]).toBe(`${baseUrl}/v1/inbox/conversations`); + expect(sentBody().templateName).toBe("hello"); + }); + }); }); diff --git a/src/api-client.ts b/src/api-client.ts index 8f21fbb..45d556f 100644 --- a/src/api-client.ts +++ b/src/api-client.ts @@ -17,6 +17,12 @@ import type { ZernioConversationListResponse, ZernioMessageListResponse, ZernioSendMessageBody, + ZernioCreateConversationBody, + ZernioCreateConversationData, + WhatsAppInteractive, + WhatsAppLocation, + WhatsAppContact, + WhatsAppTemplate, } from "./types.js"; /** Adapter name used in error constructors. */ @@ -49,6 +55,69 @@ export class ZernioApiClient { return result.data ?? {}; } + // ─── WhatsApp rich messages ─────────────────────────────────────────────── + // Typed convenience wrappers over sendMessage for WhatsApp content the + // chat-sdk Card abstraction can't express (location, contacts, the full + // interactive set, approved templates, quoted replies). Each just shapes the + // send body; the routing, auth, and error handling are shared with sendMessage. + + /** + * Send a WhatsApp interactive message: reply buttons, list, cta_url button, + * flow, location-request, or voice-call button. `extra` carries an optional + * `replyTo` (quote) — pass the platform message id to reply to. + */ + async sendInteractive( + conversationId: string, + accountId: string, + interactive: WhatsAppInteractive, + extra?: { replyTo?: string }, + ): Promise> { + return this.sendMessage(conversationId, { accountId, interactive, ...extra }); + } + + /** Send a WhatsApp location pin. */ + async sendLocation( + conversationId: string, + accountId: string, + location: WhatsAppLocation, + ): Promise> { + return this.sendMessage(conversationId, { accountId, location }); + } + + /** Send one or more WhatsApp contact cards (vCard). */ + async sendContacts( + conversationId: string, + accountId: string, + contacts: WhatsAppContact[], + ): Promise> { + return this.sendMessage(conversationId, { accountId, contacts }); + } + + /** + * Send an approved WhatsApp template message (re-opens the 24h window). + * `components` are the WhatsApp template component parameters. + */ + async sendTemplate( + conversationId: string, + accountId: string, + template: WhatsAppTemplate, + ): Promise> { + return this.sendMessage(conversationId, { accountId, template: { elements: [template] } }); + } + + /** + * Reply to (quote) a specific message with text. `replyTo` is the platform + * message id being quoted (WhatsApp `context.message_id`). + */ + async reply( + conversationId: string, + accountId: string, + replyTo: string, + message: string, + ): Promise> { + return this.sendMessage(conversationId, { accountId, message, replyTo }); + } + /** * Edit an existing message in a conversation. * PATCH /v1/inbox/conversations/{conversationId}/messages/{messageId} @@ -93,6 +162,24 @@ export class ZernioApiClient { ); } + /** + * Cold-start a conversation from a recipient (no prior inbound needed). + * POST /v1/inbox/conversations + * + * WhatsApp requires an approved template; other platforms can open with a + * plain `message`. Returns the created/existing conversation id. + */ + async createConversation( + body: ZernioCreateConversationBody, + ): Promise { + const result = await this.request<{ success: boolean; data: ZernioCreateConversationData }>( + "POST", + `/v1/inbox/conversations`, + body, + ); + return result.data; + } + /** * Fetch a single conversation's details. * GET /v1/inbox/conversations/{conversationId}?accountId=... diff --git a/src/card-mapper.test.ts b/src/card-mapper.test.ts index 0fe01f1..5bdba77 100644 --- a/src/card-mapper.test.ts +++ b/src/card-mapper.test.ts @@ -212,4 +212,86 @@ describe("mapCardToZernioMessage", () => { expect(result.buttons![0].type).toBe("postback"); expect(result.buttons![1].type).toBe("url"); }); + + // ─── WhatsApp interactive list (Select / RadioSelect) ────────────────────── + + it("maps a Select into a WhatsApp interactive list", () => { + const result = mapCardToZernioMessage({ + type: "card", + title: "Pick a plan", + children: [ + { + type: "actions", + children: [ + { + type: "select", + id: "plan", + placeholder: "Choose plan", + options: [ + { label: "Basic", value: "basic", description: "$10/mo" }, + { label: "Pro", value: "pro" }, + ], + } as any, + ], + }, + ], + }); + + expect(result.interactive).toBeDefined(); + expect(result.interactive!.type).toBe("list"); + const list = result.interactive as Extract; + expect(list.body.text).toBe("Pick a plan"); + expect(list.action.button).toBe("Choose plan"); + expect(list.action.sections[0].rows).toEqual([ + { id: "basic", title: "Basic", description: "$10/mo" }, + { id: "pro", title: "Pro" }, + ]); + // Buttons are dropped: a list can't coexist with reply buttons on WhatsApp. + expect(result.buttons).toBeUndefined(); + }); + + it("maps a RadioSelect (radio_select) into a list and prefers it over buttons", () => { + const result = mapCardToZernioMessage({ + type: "card", + children: [ + { + type: "actions", + children: [ + { type: "button", id: "ignored", label: "Ignored" } as any, + { + type: "radio_select", + id: "size", + label: "Size", + options: [{ label: "Small", value: "s" }], + } as any, + ], + }, + ], + }); + + expect(result.interactive?.type).toBe("list"); + expect(result.buttons).toBeUndefined(); + }); + + it("truncates list button/row labels to WhatsApp limits", () => { + const result = mapCardToZernioMessage({ + type: "card", + children: [ + { + type: "actions", + children: [ + { + type: "select", + id: "x", + placeholder: "This button label is definitely way too long", + options: [{ label: "A very long row title that exceeds the limit here", value: "v" }], + } as any, + ], + }, + ], + }); + const list = result.interactive as Extract; + expect(list.action.button.length).toBeLessThanOrEqual(20); + expect(list.action.sections[0].rows[0].title.length).toBeLessThanOrEqual(24); + }); }); diff --git a/src/card-mapper.ts b/src/card-mapper.ts index e29cdff..fbfb10f 100644 --- a/src/card-mapper.ts +++ b/src/card-mapper.ts @@ -10,7 +10,7 @@ * fallback text via the format converter's cardToFallbackText(). */ -import type { ZernioSendMessageBody } from "./types.js"; +import type { ZernioSendMessageBody, WhatsAppInteractive } from "./types.js"; // ─── Card Element Types (mirrored from chat-sdk) ──────────────────────────── // We use structural typing rather than importing the chat-sdk types directly, @@ -37,8 +37,17 @@ type CardChildLike = type ActionChildLike = | { type: "button"; id: string; label: string; style?: string; value?: string; disabled?: boolean } | { type: "link-button"; label: string; url: string; style?: string } - | { type: "select"; id: string; placeholder?: string; children?: unknown[] } - | { type: "radio-select"; id: string; children?: unknown[] }; + // chat-sdk Select / RadioSelect. NB: the element uses `options` (not children) + // and the radio type is "radio_select" (underscore). These map to a WhatsApp + // interactive list message. + | { type: "select"; id: string; label?: string; placeholder?: string; options?: SelectOptionLike[] } + | { type: "radio_select"; id: string; label?: string; placeholder?: string; options?: SelectOptionLike[] }; + +interface SelectOptionLike { + label: string; + value: string; + description?: string; +} interface FieldLike { type: "field"; @@ -59,6 +68,42 @@ export interface CardMappingResult { buttons?: ZernioSendMessageBody["buttons"]; /** Template with elements (for cards with image + title + buttons). */ template?: ZernioSendMessageBody["template"]; + /** + * WhatsApp interactive list, mapped from a Select / RadioSelect in the card. + * A list can't coexist with reply buttons in one WhatsApp message, so when a + * select is present it takes precedence and `buttons` is omitted. + */ + interactive?: WhatsAppInteractive; +} + +/** WhatsApp list limits (Cloud API): keep the mapping within them. */ +const LIST_BUTTON_MAX = 20; +const LIST_ROW_TITLE_MAX = 24; +const LIST_ROW_DESC_MAX = 72; +const LIST_ROWS_MAX = 10; + +function truncate(value: string, max: number): string { + return value.length > max ? value.slice(0, max) : value; +} + +/** Map a chat-sdk Select / RadioSelect into a WhatsApp interactive list. */ +function selectToList( + select: { id: string; label?: string; placeholder?: string; options?: SelectOptionLike[] }, + bodyText: string, +): WhatsAppInteractive { + const rows = (select.options ?? []).slice(0, LIST_ROWS_MAX).map((opt) => ({ + id: opt.value, + title: truncate(opt.label, LIST_ROW_TITLE_MAX), + ...(opt.description ? { description: truncate(opt.description, LIST_ROW_DESC_MAX) } : {}), + })); + return { + type: "list", + body: { text: bodyText || select.label || "Select an option" }, + action: { + button: truncate(select.placeholder || select.label || "Select", LIST_BUTTON_MAX), + sections: [{ rows }], + }, + }; } /** @@ -76,6 +121,8 @@ export interface CardMappingResult { export function mapCardToZernioMessage(card: CardLike): CardMappingResult { const textParts: string[] = []; const buttons: NonNullable = []; + // First select/radio-select found — mapped to a WhatsApp list below. + let select: { id: string; label?: string; placeholder?: string; options?: SelectOptionLike[] } | undefined; // Extract title and subtitle if (card.title) textParts.push(card.title); @@ -102,8 +149,10 @@ export function mapCardToZernioMessage(card: CardLike): CardMappingResult { title: action.label, url: action.url, }); + } else if ((action.type === "select" || action.type === "radio_select") && !select) { + // Select / RadioSelect -> WhatsApp interactive list (first one wins). + select = action; } - // Select/RadioSelect are not mappable to Zernio's button format } break; @@ -143,6 +192,16 @@ export function mapCardToZernioMessage(card: CardLike): CardMappingResult { const message = textParts.join("\n"); + // A Select / RadioSelect becomes a WhatsApp interactive list. It can't coexist + // with reply buttons in one WhatsApp message, so the list takes precedence and + // buttons are dropped (the select is the explicit choice affordance). + if (select && (select.options?.length ?? 0) > 0) { + return { + message, + interactive: selectToList(select, message), + }; + } + // If the card has an image + title + buttons, use a generic template // (renders as a carousel card on Facebook/Instagram) if (card.imageUrl && card.title && buttons.length > 0) { diff --git a/src/index.ts b/src/index.ts index df886cb..8a3a13e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,15 +41,24 @@ export type { ZernioWebhookConversation, ZernioWebhookAccount, ZernioWebhookMetadata, + ZernioReferral, ZernioAttachment, ZernioSender, ZernioSendMessageBody, ZernioConversation, ZernioMessageListResponse, ZernioConversationListResponse, + ZernioCreateConversationBody, + ZernioCreateConversationData, ZernioCommentWebhookPayload, ZernioCommentAuthor, ZernioWebhookComment, + // WhatsApp message content (sent via the exported ZernioApiClient, alongside the adapter) + WhatsAppInteractive, + WhatsAppInteractiveHeader, + WhatsAppLocation, + WhatsAppContact, + WhatsAppTemplate, } from "./types.js"; export type { CardMappingResult } from "./card-mapper.js"; diff --git a/src/types.ts b/src/types.ts index 2d0d5a7..620cc2f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,7 +41,7 @@ export interface ZernioThreadId { /** Attachment included in a message. */ export interface ZernioAttachment { - type: "image" | "video" | "audio" | "file" | "sticker" | "share"; + type: "image" | "video" | "audio" | "file" | "sticker" | "share" | "location" | "contact"; url: string; payload?: Record; } @@ -73,6 +73,13 @@ export interface ZernioRawMessage { sender: ZernioSender; sentAt: string; isRead: boolean; + /** + * Webhook envelope metadata, copied onto the raw message by the adapter so + * handlers can read interactive replies (button/list/flow), ad referral, and + * quoted-message context off `message.raw.metadata` (the chat-sdk + * MessageMetadata type is fixed and has no room for these). + */ + metadata?: ZernioWebhookMetadata; } /** Conversation context from the webhook payload. */ @@ -100,6 +107,51 @@ export interface ZernioWebhookMetadata { postbackPayload?: string; postbackTitle?: string; callbackData?: string; + + // ─── WhatsApp interactive replies ────────────────────────────────────────── + // When a recipient taps a reply button, picks a list row, or submits a Flow, + // WhatsApp delivers it as a normal message.received whose interactive context + // lands here. `interactiveId` carries the button/row id you set when sending. + /** Kind of interactive reply the inbound message represents. */ + interactiveType?: "button_reply" | "list_reply" | "nfm_reply"; + /** The id of the tapped reply button or selected list row. */ + interactiveId?: string; + /** Payload for a tapped template button (quick_reply/url template buttons). */ + buttonPayload?: string; + /** Raw JSON string returned by a WhatsApp Flow (`nfm_reply`). */ + flowResponseJson?: string; + /** Parsed Flow response, when `flowResponseJson` was valid JSON. */ + flowResponseData?: Record; + /** Platform message id this message quotes/replies to, when present. */ + quotedMessageId?: string; + /** Click-to-WhatsApp / Click-to-Messenger ad attribution, when the conversation started from an ad. */ + referral?: ZernioReferral; +} + +/** + * Ad-referral attribution attached to an inbound message when the conversation + * was started from a Click-to-WhatsApp (CTWA), Click-to-Messenger (CTM), or + * Click-to-Instagram-Direct (CTD) ad. Fields are platform-dependent, so all are + * optional — read what's present. + */ +export interface ZernioReferral { + // Click-to-WhatsApp + ctwa_clid?: string; + source_id?: string; + source_type?: string; + source_url?: string; + headline?: string; + body?: string; + media_type?: string; + image_url?: string; + video_url?: string; + thumbnail_url?: string; + // Facebook Messenger CTM / Instagram CTD + ad_id?: string; + ref?: string; + source?: string; + type?: string; + ads_context_data?: Record; } /** Full message.received webhook payload envelope. */ @@ -177,7 +229,7 @@ export interface ZernioSendMessageBody { accountId: string; message?: string; attachmentUrl?: string; - attachmentType?: "image" | "video" | "audio" | "file"; + attachmentType?: "image" | "video" | "audio" | "file" | "sticker"; quickReplies?: Array<{ type: string; payload: string; title?: string }>; buttons?: Array<{ type: string; @@ -186,21 +238,134 @@ export interface ZernioSendMessageBody { url?: string; phone?: string; }>; - template?: { - type: "generic"; - elements: Array<{ - title: string; - subtitle?: string; - imageUrl?: string; - buttons?: Array<{ type: string; title: string; url?: string; payload?: string }>; - }>; - }; + template?: + | { + type: "generic"; + elements: Array<{ + title: string; + subtitle?: string; + imageUrl?: string; + buttons?: Array<{ type: string; title: string; url?: string; payload?: string }>; + }>; + } + | { + // WhatsApp approved template message (different from the FB/IG generic + // carousel above). The API discriminates on the elements[0] shape. + elements: [WhatsAppTemplate]; + }; + /** WhatsApp interactive message (buttons, list, cta_url, flow, location request, voice call). */ + interactive?: WhatsAppInteractive; + /** WhatsApp location pin. */ + location?: WhatsAppLocation; + /** WhatsApp contact cards (vCard). */ + contacts?: WhatsAppContact[]; + /** Send the attached audio as a WhatsApp voice note (PTT) rather than a file. */ + isVoiceNote?: boolean; replyMarkup?: unknown; messagingType?: string; messageTag?: string; + /** Platform message id to quote/reply to (WhatsApp `context.message_id`). */ replyTo?: string; } +// ─── WhatsApp message content types ─────────────────────────────────────────── + +/** A WhatsApp location pin. */ +export interface WhatsAppLocation { + latitude: number; + longitude: number; + name?: string; + address?: string; +} + +/** A WhatsApp contact card (vCard). */ +export interface WhatsAppContact { + name: { formatted_name: string; first_name?: string; last_name?: string }; + phones?: Array<{ phone: string; type?: string }>; + emails?: Array<{ email: string; type?: string }>; +} + +/** An approved WhatsApp template message element. */ +export interface WhatsAppTemplate { + name: string; + language: string; + components?: Array>; +} + +/** Header for an interactive message (text or media). */ +export interface WhatsAppInteractiveHeader { + type: "text" | "image" | "video" | "document"; + text?: string; + image?: { link: string }; + video?: { link: string }; + document?: { link: string; filename?: string }; +} + +/** + * The `interactive` payload sent to the Zernio messages endpoint. Mirrors the + * WhatsApp Cloud API interactive object: reply buttons, list, cta_url, flow, + * location request, and voice-call button. + */ +export type WhatsAppInteractive = + | { + type: "button"; + header?: WhatsAppInteractiveHeader; + body: { text: string }; + footer?: { text: string }; + action: { buttons: Array<{ type: "reply"; reply: { id: string; title: string } }> }; + } + | { + type: "list"; + header?: WhatsAppInteractiveHeader; + body: { text: string }; + footer?: { text: string }; + action: { + button: string; + sections: Array<{ + title?: string; + rows: Array<{ id: string; title: string; description?: string }>; + }>; + }; + } + | { + type: "cta_url"; + header?: WhatsAppInteractiveHeader; + body: { text: string }; + footer?: { text: string }; + action: { name: "cta_url"; parameters: { display_text: string; url: string } }; + } + | { + type: "flow"; + header?: WhatsAppInteractiveHeader; + body: { text: string }; + footer?: { text: string }; + action: { + name: "flow"; + parameters: { + flow_message_version?: "3"; + flow_token: string; + flow_id: string; + flow_cta: string; + flow_action: "navigate" | "data_exchange"; + flow_action_payload?: { screen: string; data?: Record }; + mode?: "draft"; + }; + }; + } + | { + type: "location_request_message"; + body: { text: string }; + action?: { name: "send_location" }; + } + | { + type: "voice_call"; + body: { text: string }; + action: { + name: "voice_call"; + parameters?: { display_text?: string; ttl_minutes?: number; payload?: string }; + }; + }; + /** Conversation data returned from the Zernio API. */ export interface ZernioConversation { id: string; @@ -270,3 +435,31 @@ export interface ZernioConversationListResponse { nextCursor: string | null; }; } + +/** + * Body for POST /v1/inbox/conversations — cold-start a conversation from a + * recipient. WhatsApp requires an approved template (`templateName` + + * `templateLanguage`) since you can't open outside the 24h window without one; + * other platforms can open with a plain `message`. + */ +export interface ZernioCreateConversationBody { + accountId: string; + /** Recipient handle: phone/E.164 for WhatsApp, platform user id otherwise. */ + participantId?: string; + participantUsername?: string; + message?: string; + /** WhatsApp approved template name (required for WhatsApp cold-start). */ + templateName?: string; + /** WhatsApp template language code (e.g. "en_US"). */ + templateLanguage?: string; + /** Ordered template body variable values. */ + templateParams?: string[]; +} + +/** Response data from POST /v1/inbox/conversations. */ +export interface ZernioCreateConversationData { + messageId: string; + conversationId: string; + participantId: string; + participantName: string; +}