wacrm/src/lib/whatsapp/meta-api.ts
Arnas Donauskas dc245d5914
feat(flows): foundation for interactive WhatsApp messages (PR 1/4)
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md

## What's in this PR

### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template

### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation

### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'

### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words

### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field

### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2

### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders

## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean

## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)

## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:12:27 +03:00

560 lines
17 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 }
}
// ============================================================
// 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 }
}