wacrm/src/lib/whatsapp/meta-api.ts
Arnas Donauskas 312683ca05
feat(inbox): send photo, video, document & voice in chat (#213)
Agents can now attach a photo/video/document or record a voice note in
the inbox composer and send it live in a 1:1 thread — previously media
could only be received from customers or sent via the Flows builder.

- 023_chat_media.sql: new account-scoped `chat-media` Storage bucket
  (mirrors flow-media RLS from 020) plus Meta-accepted audio MIME types.
- meta-api: sendMediaMessage gains an `audio` kind (link-only — Meta
  rejects caption/filename on audio; WhatsApp renders OGG/Opus as a
  voice note).
- /api/whatsapp/send: media branch reusing the phone-variant retry loop.
- /api/whatsapp/transcode-audio: Node-runtime route remuxing the
  WebM/Opus that Chromium's MediaRecorder produces to OGG/Opus via
  ffmpeg-static (Firefox/Safari record accepted formats and skip it).
- composer: attach menu, file pickers, voice recorder + auto-transcode,
  preview with caption; thread: optimistic handleSendMedia.
- shared uploadAccountMedia helper (flows form refactored onto it).
- next.config: allow microphone=(self) (was fully blocked) + CSP
  media-src; document optional FFMPEG_PATH override.

Tests: meta-api audio payload shape, buildMediaPath, audio-transcode
helpers (14 new). typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:16 +03:00

960 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<never> {
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<MetaPhoneInfo> {
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()
}
// ============================================================
// Cloud API registration (subscription for inbound webhooks)
// ============================================================
//
// Saving a phone_number_id + access_token to whatsapp_config is NOT
// enough to receive inbound events from Meta. Two extra calls are
// required:
//
// POST /{phone_number_id}/register
// Subscribes the number for THIS app's webhook. Requires a
// 6-digit 2FA PIN the user previously set in Meta WhatsApp
// Manager → Two-step verification. Without /register, inbound
// events are routed to whichever app last claimed the number
// (often the one that did Embedded Signup) — so a second user
// adding a second number under the same WABA silently loses
// every inbound message.
//
// POST /{waba_id}/subscribed_apps
// Subscribes the WABA itself to this app. Required exactly
// once per WABA, but idempotent so calling on every save is
// safe and cheap.
//
// Both calls are no-ops when already done — Meta returns success +
// the helpers below treat that as success.
export interface RegisterPhoneNumberArgs {
phoneNumberId: string
accessToken: string
/**
* 6-digit PIN the user set in Meta WhatsApp Manager →
* Two-step verification. If 2FA is not enabled on the number,
* Meta rejects /register with a clear error and the user is
* pointed at the right setting in the UI.
*/
pin: string
}
export interface RegisterPhoneNumberResult {
success: boolean
/**
* True when Meta indicated the number was already registered to
* THIS app — same outcome as a fresh registration from the
* caller's POV, surfaced separately for logging clarity.
*/
alreadyRegistered: boolean
}
/**
* Register a phone number for inbound webhook events.
*
* Errors that should be surfaced verbatim to the user:
* * Missing / wrong PIN → "Two-step verification PIN required..."
* * No 2FA enabled → "Two-factor authentication is not on..."
* * Number on other app → "Number is registered to another app..."
*/
export async function registerPhoneNumber(
args: RegisterPhoneNumberArgs
): Promise<RegisterPhoneNumberResult> {
const { phoneNumberId, accessToken, pin } = args
const url = `${META_API_BASE}/${phoneNumberId}/register`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ messaging_product: 'whatsapp', pin }),
})
if (response.ok) {
return { success: true, alreadyRegistered: false }
}
// Meta returns an error envelope with a code. Code 133005 + the
// text "already registered" appears when the number is already
// subscribed to this app — that's success from the caller's
// perspective, surface it as such.
let data: { error?: { message?: string; code?: number; error_subcode?: number } } = {}
try {
data = await response.json()
} catch {
/* keep empty */
}
const message = data.error?.message ?? `Meta API error: ${response.status}`
if (/already.*registered/i.test(message)) {
return { success: true, alreadyRegistered: true }
}
throw new Error(message)
}
export interface SubscribeWabaToAppArgs {
wabaId: string
accessToken: string
}
/**
* Subscribe the WABA to this Meta app's webhook. Idempotent — Meta
* returns success even when the subscription already exists.
*/
export async function subscribeWabaToApp(
args: SubscribeWabaToAppArgs
): Promise<void> {
const { wabaId, accessToken } = args
const url = `${META_API_BASE}/${wabaId}/subscribed_apps`
const response = await fetch(url, {
method: 'POST',
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
}
export interface GetSubscribedAppsArgs {
wabaId: string
accessToken: string
}
export interface SubscribedApp {
whatsapp_business_api_data?: {
id?: string
name?: string
link?: string
}
}
/**
* Diagnostic — fetch the list of apps currently subscribed to this
* WABA. The UI uses this to confirm OUR app is in the list when
* the user clicks Verify Registration.
*/
export async function getSubscribedApps(
args: GetSubscribedAppsArgs
): Promise<SubscribedApp[]> {
const { wabaId, accessToken } = args
const url = `${META_API_BASE}/${wabaId}/subscribed_apps`
const response = await fetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
})
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
const data = (await response.json()) as { data?: SubscribedApp[] }
return data.data ?? []
}
// ============================================================
// 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<MetaSendResult> {
const { phoneNumberId, accessToken, to, text, contextMessageId } = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const body: Record<string, unknown> = {
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 type MediaKind = 'image' | 'video' | 'document' | 'audio'
export interface SendMediaMessageArgs {
phoneNumberId: string
accessToken: string
to: string
kind: MediaKind
/** Public URL Meta fetches at send time. */
link: string
/** Optional caption — Meta caps at 1024 chars. Documents + images + videos accept it; audio does NOT. */
caption?: string
/** Document-only. Shown in the recipient's chat as the file name. Ignored for image/video/audio. */
filename?: string
contextMessageId?: string
}
/**
* Send an image, video, document, or audio (voice note) via a public URL.
*
* Used by the Flows engine's `send_media` node and the inbox composer's
* agent-initiated media sends. Mirrors `sendTextMessage` — single fetch,
* throws on non-2xx, returns Meta's message id.
*
* Audio is special-cased: Meta rejects `caption` and `filename` on audio
* messages, so we send `{ link }` only. WhatsApp auto-renders an
* OGG/Opus file as a playable voice note (waveform) rather than a file
* attachment.
*/
export async function sendMediaMessage(
args: SendMediaMessageArgs,
): Promise<MetaSendResult> {
const { phoneNumberId, accessToken, to, kind, link, caption, filename, contextMessageId } = args
if (!link) throw new Error('sendMediaMessage requires a link.')
const url = `${META_API_BASE}/${phoneNumberId}/messages`
// Audio accepts neither caption nor filename per Meta's spec — adding
// either yields a 400. image/video/document accept a caption; only
// document accepts a filename.
const media: Record<string, unknown> = { link }
if (caption && kind !== 'audio') media.caption = caption
if (kind === 'document' && filename) media.filename = filename
const body: Record<string, unknown> = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: kind,
[kind]: media,
}
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 }
}
import type { MessageTemplate } from '@/types'
import {
buildSendComponents,
type SendTimeParams,
} from './template-send-builder'
export interface SendTemplateMessageArgs {
phoneNumberId: string
accessToken: string
to: string
templateName: string
language?: string
/**
* Legacy body-only params. Kept for backward compat with callers
* that haven't migrated to the structured `template` + `messageParams`
* pair below. New callers should pass `template` so media headers
* and URL buttons land on the send.
*/
params?: string[]
/**
* The template row from message_templates. When provided, the helper
* builds the full components array (header + body + buttons) via
* buildSendComponents — that's the only way image/video/document
* headers and URL-with-variable buttons actually reach the recipient.
*/
template?: MessageTemplate
/**
* Structured per-send values. Body variables go in `body`; header
* text variables in `headerText`; media overrides in
* `headerMediaUrl` / `headerMediaId`; URL/COPY_CODE button values
* in `buttonParams` keyed by index.
*/
messageParams?: SendTimeParams
/** 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.
*
* Caller paths:
* - Legacy: pass `params: string[]` (body only). Same behaviour as
* before this helper learned about media + buttons.
* - Structured: pass `template` (and optionally `messageParams`).
* The full components array is built from the row so media
* headers + URL buttons land correctly.
*/
export async function sendTemplateMessage(
args: SendTemplateMessageArgs
): Promise<MetaSendResult> {
const {
phoneNumberId,
accessToken,
to,
templateName,
language = 'en_US',
params,
template,
messageParams,
contextMessageId,
} = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const templatePayload: Record<string, unknown> = {
name: templateName,
language: { code: language },
}
if (template) {
const components = buildSendComponents(template, {
// Legacy callers pass body values in `params`; fold them into
// `messageParams.body` so the new path covers them too.
body: messageParams?.body ?? params,
headerText: messageParams?.headerText,
headerMediaUrl: messageParams?.headerMediaUrl,
headerMediaId: messageParams?.headerMediaId,
buttonParams: messageParams?.buttonParams,
})
if (components.length > 0) {
templatePayload.components = components
}
} else if (params && params.length > 0) {
// Legacy body-only path — no template row available.
templatePayload.components = [
{
type: 'body',
parameters: params.map((p) => ({ type: 'text', text: String(p) })),
},
]
}
const body: Record<string, unknown> = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type: 'template',
template: templatePayload,
}
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 }
}
// ============================================================
// Template submission (Business Management API)
// ============================================================
import type { MetaTemplateSubmitPayload } from './template-components'
export interface SubmitMessageTemplateArgs {
wabaId: string
accessToken: string
payload: MetaTemplateSubmitPayload
}
export interface SubmitMessageTemplateResult {
id: string
status: string
category?: string
}
/**
* Submit a message template to Meta for approval.
*
* Returns Meta's assigned template id + initial status (typically
* PENDING). Caller persists `id` as `meta_template_id` so the
* upcoming edit/delete flows can scope to this exact template (and
* language variant) via `hsm_id`, rather than nuking every variant
* with the same name.
*
* 429s from Meta (rate limit: 100 creates/hour/WABA) surface as a
* regular `Error('Meta API error: 429')`. The route handler
* distinguishes 429 and shows a more actionable toast.
*/
export async function submitMessageTemplate(
args: SubmitMessageTemplateArgs
): Promise<SubmitMessageTemplateResult> {
const { wabaId, accessToken, payload } = args
const url = `${META_API_BASE}/${wabaId}/message_templates`
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(payload),
})
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
const data = await response.json()
if (!data?.id) {
throw new Error('Meta accepted the template but returned no id.')
}
return {
id: String(data.id),
status: typeof data.status === 'string' ? data.status : 'PENDING',
category: typeof data.category === 'string' ? data.category : undefined,
}
}
export interface EditMessageTemplateArgs {
/** Meta's template id (stored locally as `meta_template_id`). */
metaTemplateId: string
accessToken: string
/** Send the full components array — Meta replaces, not patches. */
components: MetaTemplateSubmitPayload['components']
/** Optional — only certain category transitions are allowed by Meta. */
category?: MetaTemplateSubmitPayload['category']
}
export interface EditMessageTemplateResult {
success: boolean
}
/**
* Edit an existing (APPROVED or REJECTED) message template.
*
* Meta caps edits at 10 per 30 days (and 1 per 24h for APPROVED
* templates). Every edit re-triggers review, so the status flips
* back to PENDING until Meta approves the new components.
*
* Note: PENDING / DISABLED / IN_APPEAL templates cannot be edited
* — the route handler enforces that before calling here.
*/
export async function editMessageTemplate(
args: EditMessageTemplateArgs
): Promise<EditMessageTemplateResult> {
const { metaTemplateId, accessToken, components, category } = args
const body: Record<string, unknown> = { components }
if (category) body.category = category
const response = await fetch(`${META_API_BASE}/${metaTemplateId}`, {
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().catch(() => ({}))
return { success: data?.success !== false }
}
export interface DeleteMessageTemplateArgs {
wabaId: string
accessToken: string
name: string
/**
* Without `hsm_id`, Meta deletes EVERY language variant of the
* template with this `name`. Pass the row's `meta_template_id`
* to scope to a single variant.
*/
metaTemplateId?: string
}
/**
* Delete a message template on Meta. Pass `metaTemplateId` to scope
* to a single language variant — otherwise Meta nukes every variant
* sharing the same `name`.
*/
export async function deleteMessageTemplate(
args: DeleteMessageTemplateArgs
): Promise<void> {
const { wabaId, accessToken, name, metaTemplateId } = args
const params = new URLSearchParams({ name })
if (metaTemplateId) params.set('hsm_id', metaTemplateId)
const url = `${META_API_BASE}/${wabaId}/message_templates?${params.toString()}`
const response = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${accessToken}` },
})
// Treat a 404 as a no-op — the template is already gone on Meta's
// side, and we still want the local row removed.
if (response.status === 404) return
if (!response.ok) {
await throwMetaError(response, `Meta API error: ${response.status}`)
}
}
// ============================================================
// 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<MetaSendResult> {
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<MetaSendResult> {
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<string, unknown> = {
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<string, unknown> = {
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<MetaSendResult> {
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<string>()
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<string, unknown> = {
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<string, unknown> = {
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 }
}