diff --git a/.changeset/brave-camels-message.md b/.changeset/brave-camels-message.md new file mode 100644 index 00000000..75a1e57a --- /dev/null +++ b/.changeset/brave-camels-message.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/whatsapp": minor +--- + +Add `sendTemplate()` for sending pre-approved template messages, enabling business-initiated conversations outside the 24-hour customer service window diff --git a/apps/docs/content/adapters/official/whatsapp.mdx b/apps/docs/content/adapters/official/whatsapp.mdx index d479352f..514ab91e 100644 --- a/apps/docs/content/adapters/official/whatsapp.mdx +++ b/apps/docs/content/adapters/official/whatsapp.mdx @@ -20,7 +20,7 @@ features: scheduledMessages: no cardFormat: status: partial - label: WhatsApp templates + label: Interactive messages buttons: status: yes label: Interactive replies @@ -29,7 +29,7 @@ features: tables: no fields: status: partial - label: Template variables + label: Formatted text imagesInCards: yes modals: no slashCommands: no @@ -169,6 +169,27 @@ Card elements are automatically converted to WhatsApp interactive messages: - **More than 3 buttons** — falls back to formatted text. - **Max body text** — 1024 characters. +### Template messages + +Outside the 24-hour customer service window, WhatsApp only accepts pre-approved [template messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates). Use `sendTemplate` to start business-initiated conversations: + +```typescript +const threadId = await adapter.openDM("15551234567"); + +await adapter.sendTemplate(threadId, { + name: "appointment_reminder", + language: "en", + components: [ + { + type: "body", + parameters: [{ type: "text", text: "Tomorrow at 2pm" }], + }, + ], +}); +``` + +Templates must be created and approved in [WhatsApp Manager](https://business.facebook.com/wa/manage/message-templates/) before they can be sent. Quick reply button taps on a template arrive as button responses and are dispatched to your `onAction` handlers. + ### Thread ID format ``` diff --git a/packages/adapter-whatsapp/README.md b/packages/adapter-whatsapp/README.md index 9db3cfe4..426f593d 100644 --- a/packages/adapter-whatsapp/README.md +++ b/packages/adapter-whatsapp/README.md @@ -121,6 +121,7 @@ export async function POST(request: Request) { | Streaming | Buffered (accumulates then sends) | | Mark as read | Yes | | Auto-chunking | Yes (splits at 4096 chars) | +| Template messages | Yes (via `sendTemplate`) | ### Rich content @@ -169,6 +170,27 @@ Card elements are automatically converted to WhatsApp interactive messages: - **More than 3 buttons** — falls back to formatted text - **Max body text** — 1024 characters +## Template messages + +Outside the 24-hour customer service window, WhatsApp only accepts pre-approved [template messages](https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates). Use `sendTemplate` to start business-initiated conversations: + +```typescript +const threadId = await adapter.openDM("15551234567"); + +await adapter.sendTemplate(threadId, { + name: "appointment_reminder", + language: "en", + components: [ + { + type: "body", + parameters: [{ type: "text", text: "Tomorrow at 2pm" }], + }, + ], +}); +``` + +Templates must be created and approved in [WhatsApp Manager](https://business.facebook.com/wa/manage/message-templates/) before they can be sent. Quick reply button taps on a template arrive as button responses and are dispatched to your `onAction` handlers. + ## Thread ID format ``` diff --git a/packages/adapter-whatsapp/src/index.test.ts b/packages/adapter-whatsapp/src/index.test.ts index 069f85a0..35f8bed0 100644 --- a/packages/adapter-whatsapp/src/index.test.ts +++ b/packages/adapter-whatsapp/src/index.test.ts @@ -11,6 +11,7 @@ import { import { createWhatsAppAdapter, splitMessage, WhatsAppAdapter } from "./index"; const NOT_SUPPORTED_PATTERN = /not support/i; +const NO_MESSAGE_ID_PATTERN = /did not return a message ID/i; const ACCESS_TOKEN_PATTERN = /accessToken/i; const APP_SECRET_PATTERN = /appSecret/i; @@ -864,6 +865,142 @@ describe("postMessage", () => { }); }); +// --------------------------------------------------------------------------- +// sendTemplate +// --------------------------------------------------------------------------- + +describe("sendTemplate", () => { + let fetchSpy: MockInstance; + + const makeGraphApiResponse = () => + new Response(JSON.stringify({ messages: [{ id: "wamid.template123" }] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + beforeEach(() => { + fetchSpy = vi + .spyOn(global, "fetch") + .mockImplementation(() => Promise.resolve(makeGraphApiResponse())); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("sends a template with name and language", async () => { + const adapter = createTestAdapter(); + const result = await adapter.sendTemplate( + "whatsapp:123456789:15551234567", + { + name: "appointment_reminder", + language: "en", + } + ); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(String(url)).toContain("/123456789/messages"); + const sent = JSON.parse(init?.body as string); + expect(sent.type).toBe("template"); + expect(sent.to).toBe("15551234567"); + expect(sent.template).toEqual({ + name: "appointment_reminder", + language: { code: "en" }, + }); + expect(result.id).toBe("wamid.template123"); + }); + + it("includes components when provided", async () => { + const adapter = createTestAdapter(); + await adapter.sendTemplate("whatsapp:123456789:15551234567", { + name: "order_shipped", + language: "en_US", + components: [ + { + type: "body", + parameters: [ + { type: "text", text: "Ada" }, + { type: "text", text: "#12345" }, + ], + }, + { + type: "button", + sub_type: "url", + index: 0, + parameters: [{ type: "text", text: "12345" }], + }, + ], + }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(sent.template.name).toBe("order_shipped"); + expect(sent.template.language).toEqual({ code: "en_US" }); + expect(sent.template.components).toHaveLength(2); + expect(sent.template.components[0].parameters[0].text).toBe("Ada"); + }); + + it("converts emoji placeholders in component parameters", async () => { + const adapter = createTestAdapter(); + await adapter.sendTemplate("whatsapp:123456789:15551234567", { + name: "order_shipped", + language: "en_US", + components: [ + { + type: "body", + parameters: [{ type: "text", text: "Shipped! {{emoji:thumbs_up}}" }], + }, + ], + }); + + const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(sent.template.components[0].parameters[0].text).toBe("Shipped! 👍"); + }); + + it("omits components when the array is empty", async () => { + const adapter = createTestAdapter(); + await adapter.sendTemplate("whatsapp:123456789:15551234567", { + name: "hello_world", + language: "en_US", + components: [], + }); + + const sent = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string); + expect(sent.template).not.toHaveProperty("components"); + }); + + it("throws when the API returns no message ID", async () => { + fetchSpy.mockImplementation(() => + Promise.resolve( + new Response(JSON.stringify({ messages: [] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + ) + ); + + const adapter = createTestAdapter(); + await expect( + adapter.sendTemplate("whatsapp:123456789:15551234567", { + name: "hello_world", + language: "en_US", + }) + ).rejects.toThrow(NO_MESSAGE_ID_PATTERN); + }); + + it("throws on invalid thread ID", async () => { + const adapter = createTestAdapter(); + await expect( + adapter.sendTemplate("slack:C123:ts123", { + name: "hello_world", + language: "en_US", + }) + ).rejects.toThrow("Invalid WhatsApp thread ID"); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // editMessage // --------------------------------------------------------------------------- diff --git a/packages/adapter-whatsapp/src/index.ts b/packages/adapter-whatsapp/src/index.ts index f769c8da..3b366a11 100644 --- a/packages/adapter-whatsapp/src/index.ts +++ b/packages/adapter-whatsapp/src/index.ts @@ -40,6 +40,8 @@ import type { WhatsAppMediaResponse, WhatsAppRawMessage, WhatsAppSendResponse, + WhatsAppTemplateComponent, + WhatsAppTemplateMessage, WhatsAppThreadId, WhatsAppTypingIndicatorResponse, WhatsAppWebhookPayload, @@ -94,6 +96,10 @@ export type { WhatsAppAdapterConfig, WhatsAppMediaResponse, WhatsAppRawMessage, + WhatsAppTemplateButtonParameter, + WhatsAppTemplateComponent, + WhatsAppTemplateMessage, + WhatsAppTemplateParameter, WhatsAppThreadId, } from "./types"; @@ -888,6 +894,85 @@ export class WhatsAppAdapter }; } + /** + * Send a pre-approved template message via the Cloud API. + * + * Templates are the only message type WhatsApp accepts outside the + * 24-hour customer service window, making them the way to start + * business-initiated conversations. The adapter does not auto-substitute + * templates for outbound text posts — callers must opt in explicitly + * when they detect the window is closed. + * + * @example + * ```typescript + * await adapter.sendTemplate(threadId, { + * name: "appointment_reminder", + * language: "en", + * components: [ + * { + * type: "body", + * parameters: [{ type: "text", text: "Tomorrow at 2pm" }], + * }, + * ], + * }); + * ``` + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates + */ + async sendTemplate( + threadId: string, + template: WhatsAppTemplateMessage + ): Promise> { + const { userWaId } = this.decodeThreadId(threadId); + + // Convert emoji placeholders in component parameters (e.g. body text + // variables), mirroring the interactive message path + const components = template.components?.length + ? (JSON.parse( + convertEmojiPlaceholders( + JSON.stringify(template.components), + "whatsapp" + ) + ) as WhatsAppTemplateComponent[]) + : undefined; + + const response = await this.graphApiRequest( + `/${this.phoneNumberId}/messages`, + { + messaging_product: "whatsapp", + recipient_type: "individual", + to: userWaId, + type: "template", + template: { + name: template.name, + language: { code: template.language }, + ...(components ? { components } : {}), + }, + } + ); + + if (!(response.messages?.length && response.messages[0]?.id)) { + throw new Error( + "WhatsApp API did not return a message ID for template message" + ); + } + const messageId = response.messages[0].id; + + return { + id: messageId, + threadId, + raw: { + message: { + id: messageId, + from: this.phoneNumberId, + timestamp: String(Math.floor(Date.now() / 1000)), + type: "template", + }, + phoneNumberId: this.phoneNumberId, + }, + }; + } + /** * Edit a message. Not supported by WhatsApp Cloud API — throws an error. * @@ -1131,7 +1216,7 @@ export class WhatsAppAdapter * For WhatsApp, this simply constructs the thread ID since all * conversations are inherently DMs. Note: you can only message users * who have messaged you first (within the 24-hour window) or - * via approved template messages. + * via approved template messages (see {@link sendTemplate}). */ async openDM(userId: string): Promise { return this.encodeThreadId({ diff --git a/packages/adapter-whatsapp/src/types.ts b/packages/adapter-whatsapp/src/types.ts index 7a238025..59035638 100644 --- a/packages/adapter-whatsapp/src/types.ts +++ b/packages/adapter-whatsapp/src/types.ts @@ -205,7 +205,8 @@ export interface WhatsAppInboundMessage { | "button" | "reaction" | "order" - | "system"; + | "system" + | "template"; /** Video message content */ video?: { caption?: string; @@ -309,6 +310,77 @@ export interface WhatsAppInteractiveMessage { type: "button" | "list"; } +// ============================================================================= +// Template Messages +// ============================================================================= + +/** + * Parameter for a template header or body component. + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages#parameter-object + */ +export type WhatsAppTemplateParameter = + | { text: string; type: "text" } + | { + currency: { + amount_1000: number; + code: string; + fallback_value: string; + }; + type: "currency"; + } + | { + date_time: { fallback_value: string }; + type: "date_time"; + } + | { image: { id?: string; link?: string }; type: "image" } + | { + document: { filename?: string; id?: string; link?: string }; + type: "document"; + } + | { type: "video"; video: { id?: string; link?: string } }; + +/** + * Parameter for a template button component. + * + * URL buttons take a text parameter substituted into the button's URL; + * quick reply buttons take a payload echoed back in the button response. + */ +export type WhatsAppTemplateButtonParameter = + | { text: string; type: "text" } + | { payload: string; type: "payload" }; + +/** + * A component of a template message carrying variable substitutions. + */ +export type WhatsAppTemplateComponent = + | { parameters: WhatsAppTemplateParameter[]; type: "header" } + | { parameters: WhatsAppTemplateParameter[]; type: "body" } + | { + index: number; + parameters: WhatsAppTemplateButtonParameter[]; + sub_type: "url" | "quick_reply"; + type: "button"; + }; + +/** + * A pre-approved template message. + * + * Templates are the only message type the Cloud API accepts outside the + * 24-hour customer service window, so they are required for + * business-initiated conversations. + * + * @see https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates + */ +export interface WhatsAppTemplateMessage { + /** Variable substitutions for the template's components. Omit for templates without variables. */ + components?: WhatsAppTemplateComponent[]; + /** Template language code (e.g. "en", "en_US") */ + language: string; + /** Name of the approved template */ + name: string; +} + // ============================================================================= // Raw Message Type // =============================================================================