/** * Meta WhatsApp Cloud API helpers. * * Every function takes a single options object (named parameters) instead * of positional arguments. This was a deliberate choice after the same * swapped-args bug was found four times in a row with the positional form * (e.g. `(accessToken, phoneNumberId)` vs `(phoneNumberId, accessToken)`). * With named params, a typo surfaces immediately as a TypeScript error * instead of a runtime rejection from Meta. */ const META_API_VERSION = 'v21.0' const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}` export interface MetaSendResult { messageId: string } export interface MetaPhoneInfo { id: string display_phone_number: string verified_name?: string quality_rating?: string } interface MetaErrorResponse { error?: { message?: string; code?: number; type?: string } } async function throwMetaError(response: Response, fallback: string): Promise { let message = fallback try { const data = (await response.json()) as MetaErrorResponse if (data.error?.message) message = data.error.message } catch { // response body wasn't JSON — keep the fallback } throw new Error(message) } // ============================================================ // Phone number / account // ============================================================ export interface VerifyPhoneNumberArgs { phoneNumberId: string accessToken: string } /** * Verify a Meta phone number ID by fetching its public metadata * (display_phone_number, verified_name, quality_rating). */ export async function verifyPhoneNumber( args: VerifyPhoneNumberArgs ): Promise { const { phoneNumberId, accessToken } = args const url = `${META_API_BASE}/${phoneNumberId}?fields=id,display_phone_number,verified_name,quality_rating` const response = await fetch(url, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) } return response.json() } // ============================================================ // Sending // ============================================================ export interface SendTextMessageArgs { phoneNumberId: string accessToken: string to: string text: string /** Meta's message_id of the message being replied to. Adds a `context` field * so WhatsApp renders the new message as a reply with a quote preview. */ contextMessageId?: string } /** * Send a free-form WhatsApp text message. * Only works inside the 24-hour customer service window. */ export async function sendTextMessage( args: SendTextMessageArgs ): Promise { const { phoneNumberId, accessToken, to, text, contextMessageId } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const body: Record = { messaging_product: 'whatsapp', recipient_type: 'individual', to, type: 'text', text: { body: text }, } if (contextMessageId) { body.context = { message_id: contextMessageId } } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(body), }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } export interface SendTemplateMessageArgs { phoneNumberId: string accessToken: string to: string templateName: string language?: string params?: string[] /** Meta's message_id of the message being replied to. */ contextMessageId?: string } /** * Send a pre-approved WhatsApp message template. Required outside * the 24-hour window and for any first-touch messaging. */ export async function sendTemplateMessage( args: SendTemplateMessageArgs ): Promise { const { phoneNumberId, accessToken, to, templateName, language = 'en_US', params, contextMessageId, } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const template: Record = { name: templateName, language: { code: language }, } if (params && params.length > 0) { template.components = [ { type: 'body', parameters: params.map((p) => ({ type: 'text', text: String(p) })), }, ] } const body: Record = { messaging_product: 'whatsapp', recipient_type: 'individual', to, type: 'template', template, } if (contextMessageId) { body.context = { message_id: contextMessageId } } const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(body), }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } // ============================================================ // Reactions // ============================================================ export interface SendReactionMessageArgs { phoneNumberId: string accessToken: string to: string /** Meta's message_id of the message being reacted to. */ targetMessageId: string /** Single emoji, or empty string to remove an existing reaction. */ emoji: string } /** * Send a reaction (or removal) to a previously-exchanged message. * Empty `emoji` removes the reaction per Meta's spec. */ export async function sendReactionMessage( args: SendReactionMessageArgs ): Promise { const { phoneNumberId, accessToken, to, targetMessageId, emoji } = args const url = `${META_API_BASE}/${phoneNumberId}/messages` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ messaging_product: 'whatsapp', recipient_type: 'individual', to, type: 'reaction', reaction: { message_id: targetMessageId, emoji }, }), }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } // ============================================================ // Interactive (button replies + list messages) // ============================================================ // // Meta's two flavours of interactive message — used by the Flows // engine to drive scripted chatbot menus. Caller passes plain // JS values; helpers shape the Meta payload and enforce Meta's // limits BEFORE the network call so the failure mode is a // developer-facing error rather than a customer-facing one. /** * Meta limits for interactive messages, hard-coded so violations * fail at build/save time rather than as a 400 from the Meta API * mid-conversation. See: * https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-reply-buttons-messages * https://developers.facebook.com/docs/whatsapp/cloud-api/messages/interactive-list-messages */ export const INTERACTIVE_LIMITS = { maxButtons: 3, buttonTitleMaxLength: 20, maxListSections: 10, maxListRowsTotal: 10, listRowTitleMaxLength: 24, listRowDescriptionMaxLength: 72, bodyMaxLength: 1024, footerMaxLength: 60, headerTextMaxLength: 60, } as const export interface InteractiveButton { /** Stable id sent back in the webhook when tapped (≤ 256 chars). */ id: string /** Visible label (≤ 20 chars per Meta). */ title: string } export interface SendInteractiveButtonsArgs { phoneNumberId: string accessToken: string to: string /** The body text — what the customer reads above the buttons. */ bodyText: string /** Optional plain-text header (≤ 60 chars). */ headerText?: string /** Optional grey footer line under the buttons (≤ 60 chars). */ footerText?: string /** 1–3 buttons. Validated against Meta's limits before sending. */ buttons: InteractiveButton[] /** Meta's message_id of the message being replied to (quote preview). */ contextMessageId?: string } /** * Send an interactive message with up to 3 inline reply buttons. The * customer taps one and Meta delivers a webhook with * `messages[0].interactive.button_reply.id` set to the matching button.id. * * Validation throws BEFORE the network call so misconfigured flows * fail at save time, not during a live conversation. */ export async function sendInteractiveButtons( args: SendInteractiveButtonsArgs ): Promise { const { phoneNumberId, accessToken, to, bodyText, headerText, footerText, buttons, contextMessageId, } = args validateInteractiveBody(bodyText) validateInteractiveHeaderFooter(headerText, footerText) if (buttons.length < 1 || buttons.length > INTERACTIVE_LIMITS.maxButtons) { throw new Error( `Interactive button message requires 1-${INTERACTIVE_LIMITS.maxButtons} buttons (got ${buttons.length}).` ) } for (const btn of buttons) { if (!btn.id) throw new Error('Interactive button missing id.') if (!btn.title) throw new Error(`Interactive button "${btn.id}" missing title.`) if (btn.title.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) { throw new Error( `Interactive button title "${btn.title}" exceeds ${INTERACTIVE_LIMITS.buttonTitleMaxLength} chars.` ) } } const interactive: Record = { type: 'button', body: { text: bodyText }, action: { buttons: buttons.map((b) => ({ type: 'reply', reply: { id: b.id, title: b.title }, })), }, } if (headerText) interactive.header = { type: 'text', text: headerText } if (footerText) interactive.footer = { text: footerText } const body: Record = { messaging_product: 'whatsapp', recipient_type: 'individual', to, type: 'interactive', interactive, } if (contextMessageId) body.context = { message_id: contextMessageId } const url = `${META_API_BASE}/${phoneNumberId}/messages` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(body), }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } export interface InteractiveListRow { /** Stable id sent back in the webhook when tapped (≤ 200 chars). */ id: string /** Visible row title (≤ 24 chars per Meta). */ title: string /** Optional secondary line shown under the title (≤ 72 chars). */ description?: string } export interface InteractiveListSection { /** Optional section header shown above its rows. */ title?: string rows: InteractiveListRow[] } export interface SendInteractiveListArgs { phoneNumberId: string accessToken: string to: string bodyText: string /** Label of the tap-to-expand button on the message bubble. */ buttonLabel: string headerText?: string footerText?: string /** * 1–10 rows TOTAL across all sections. Meta caps the *total*, not * per-section. Validation enforces this before send. */ sections: InteractiveListSection[] contextMessageId?: string } /** * Send an interactive message with a tap-to-expand list of selectable * rows. Use when there are more options than the 3-button limit allows. * Webhook arrives with `messages[0].interactive.list_reply.id` set to * the matching row.id. */ export async function sendInteractiveList( args: SendInteractiveListArgs ): Promise { const { phoneNumberId, accessToken, to, bodyText, buttonLabel, headerText, footerText, sections, contextMessageId, } = args validateInteractiveBody(bodyText) validateInteractiveHeaderFooter(headerText, footerText) if (!buttonLabel) throw new Error('Interactive list requires a buttonLabel.') if (buttonLabel.length > INTERACTIVE_LIMITS.buttonTitleMaxLength) { throw new Error( `Interactive list buttonLabel "${buttonLabel}" exceeds ${INTERACTIVE_LIMITS.buttonTitleMaxLength} chars.` ) } if (sections.length < 1 || sections.length > INTERACTIVE_LIMITS.maxListSections) { throw new Error( `Interactive list requires 1-${INTERACTIVE_LIMITS.maxListSections} sections (got ${sections.length}).` ) } const totalRows = sections.reduce((sum, s) => sum + s.rows.length, 0) if (totalRows < 1 || totalRows > INTERACTIVE_LIMITS.maxListRowsTotal) { throw new Error( `Interactive list requires 1-${INTERACTIVE_LIMITS.maxListRowsTotal} rows total across all sections (got ${totalRows}).` ) } const seenIds = new Set() for (const section of sections) { for (const row of section.rows) { if (!row.id) throw new Error('Interactive list row missing id.') if (seenIds.has(row.id)) { throw new Error(`Interactive list has duplicate row id "${row.id}".`) } seenIds.add(row.id) if (!row.title) throw new Error(`Interactive list row "${row.id}" missing title.`) if (row.title.length > INTERACTIVE_LIMITS.listRowTitleMaxLength) { throw new Error( `Interactive list row title "${row.title}" exceeds ${INTERACTIVE_LIMITS.listRowTitleMaxLength} chars.` ) } if ( row.description && row.description.length > INTERACTIVE_LIMITS.listRowDescriptionMaxLength ) { throw new Error( `Interactive list row description for "${row.id}" exceeds ${INTERACTIVE_LIMITS.listRowDescriptionMaxLength} chars.` ) } } } const interactive: Record = { type: 'list', body: { text: bodyText }, action: { button: buttonLabel, sections: sections.map((s) => ({ ...(s.title ? { title: s.title } : {}), rows: s.rows.map((r) => ({ id: r.id, title: r.title, ...(r.description ? { description: r.description } : {}), })), })), }, } if (headerText) interactive.header = { type: 'text', text: headerText } if (footerText) interactive.footer = { text: footerText } const body: Record = { messaging_product: 'whatsapp', recipient_type: 'individual', to, type: 'interactive', interactive, } if (contextMessageId) body.context = { message_id: contextMessageId } const url = `${META_API_BASE}/${phoneNumberId}/messages` const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify(body), }) if (!response.ok) { await throwMetaError(response, `Meta API error: ${response.status}`) } const data = await response.json() return { messageId: data.messages[0].id } } function validateInteractiveBody(bodyText: string): void { if (!bodyText) throw new Error('Interactive message requires bodyText.') if (bodyText.length > INTERACTIVE_LIMITS.bodyMaxLength) { throw new Error( `Interactive bodyText exceeds ${INTERACTIVE_LIMITS.bodyMaxLength} chars.` ) } } function validateInteractiveHeaderFooter( headerText: string | undefined, footerText: string | undefined, ): void { if (headerText && headerText.length > INTERACTIVE_LIMITS.headerTextMaxLength) { throw new Error( `Interactive headerText exceeds ${INTERACTIVE_LIMITS.headerTextMaxLength} chars.` ) } if (footerText && footerText.length > INTERACTIVE_LIMITS.footerMaxLength) { throw new Error( `Interactive footerText exceeds ${INTERACTIVE_LIMITS.footerMaxLength} chars.` ) } } // ============================================================ // Media // ============================================================ export interface GetMediaUrlArgs { mediaId: string accessToken: string } /** * Resolve a media ID to Meta's (short-lived, authenticated) CDN URL * plus the MIME type. Step one of the media-proxy flow. */ export async function getMediaUrl( args: GetMediaUrlArgs ): Promise<{ url: string; mimeType: string }> { const { mediaId, accessToken } = args const response = await fetch(`${META_API_BASE}/${mediaId}`, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { await throwMetaError(response, `Media fetch failed: ${response.status}`) } const data = await response.json() if (!data.url) throw new Error('Media URL not found in Meta response') return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' } } export interface DownloadMediaArgs { downloadUrl: string accessToken: string } /** * Fetch the binary bytes for a media URL obtained from getMediaUrl. * Step two of the media-proxy flow. */ export async function downloadMedia( args: DownloadMediaArgs ): Promise<{ buffer: Buffer; contentType: string }> { const { downloadUrl, accessToken } = args const response = await fetch(downloadUrl, { headers: { Authorization: `Bearer ${accessToken}` }, }) if (!response.ok) { throw new Error(`Media download failed: ${response.status}`) } const contentType = response.headers.get('content-type') || 'application/octet-stream' const buffer = Buffer.from(await response.arrayBuffer()) return { buffer, contentType } }