wacrm/src/lib/whatsapp/meta-api.ts
Arnas Donauskas 3113738c7e
feat(templates): submit-to-Meta endpoint + component-based form builder
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.

API surface
-----------

POST /api/whatsapp/templates/submit
  Auth → fetch whatsapp_config → validate payload → build Meta
  components → POST to /{waba_id}/message_templates → persist row
  with meta_template_id + status + sample_values +
  last_submitted_at. Per-field validators throw with specific
  messages before the network call, so misconfigured templates
  fail with "button #3 missing url" instead of a 400 from Meta.
  429s are detected and surfaced as "rate limit hit (100/hr)".

WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.

New modules
-----------

src/lib/whatsapp/template-validators.ts
  Pure functions encoding Meta's caps: 1024-char body, 60-char
  footer/header, 25-char button text, 10 buttons max, 2 URL / 1
  PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
  example required when {{1}} is present, COPY_CODE example
  required, header media URL must be http(s), variables must be
  contiguous starting at {{1}}. 41 unit tests cover each rule.

src/lib/whatsapp/template-components.ts
  Translates our local row shape into Meta's components array
  (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
  array per spec, header_handle preferred over header_url when
  both are present). Snapshot tests assert the wire shape.

src/lib/whatsapp/template-status-normalize.ts
  Single source of truth for upstream→local status mapping.
  Replaces the duplicated normalizer in sync/route.ts; the
  upcoming webhook handler will share it too.

src/lib/whatsapp/meta-api.ts
  New submitMessageTemplate() helper following the existing
  named-args + throwMetaError pattern.

Form rewrite
------------

src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:

  * Header: format select (None / Text / Image / Video / Document)
    + conditional inputs — text + variable-aware sample row for
    TEXT, URL paste for media (file upload slot ready for the
    Resumable Upload follow-up)
  * Body: textarea with live {{N}} extraction → sample-value rows
    auto-appear and shrink as variables are added/removed
  * Footer: 60-char capped input
  * Buttons editor: add up to 10, per-row type picker
    (Quick Reply / URL / Phone / Copy Code), conditional URL +
    example + phone + code fields, X to remove
  * Card display now surfaces rejection_reason / submission_error
    (red banner) and quality_score (GREEN/YELLOW/RED chip)
  * AUTHENTICATION shows a "create in Meta Manager + Sync" banner
    and disables Submit — that category has a different shape
    (fixed body, mandatory OTP button) and lands in a follow-up

Side effects
------------

  * Broadcast Step 1 picker (step1-choose-template.tsx) now
    filters to status='APPROVED' to match the inbox picker —
    selecting a non-approved template would 400 on send.
  * .env.local.example documents META_APP_ID (Resumable Upload
    follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.

Out of scope (subsequent PRs):
  * Edit / resubmit / delete endpoints (PR 4)
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)
  * Resumable Upload + AUTHENTICATION builder (follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:34:58 +02:00

618 lines
19 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()
}
// ============================================================
// 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 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<MetaSendResult> {
const {
phoneNumberId,
accessToken,
to,
templateName,
language = 'en_US',
params,
contextMessageId,
} = args
const url = `${META_API_BASE}/${phoneNumberId}/messages`
const template: Record<string, unknown> = {
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<string, unknown> = {
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 }
}
// ============================================================
// 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,
}
}
// ============================================================
// 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 }
}