/** * Meta Cloud API (Graph v26.0) client for the WhatsApp channel. * * Plain HTTPS via fetchWithTimeout, deliberately no SDK: the surface we need * is four endpoints, and the AGPL dependency budget is audited (CLAUDE.md). * * Every message send persists an outbound whatsapp_messages row through the * caller's service client (wamid from the send response). Sends are * best-effort: a failed send logs + records a failed row but never throws, * because a reply must never take down webhook acking or intake processing. */ import type { SupabaseClient } from '@supabase/supabase-js' import { fetchWithTimeout, TimeoutError } from '@/lib/http/fetch-with-timeout' import { createLogger } from '@/lib/logger' import type { TemplateId } from './messages' const log = createLogger('whatsapp-inbox/graph-api') const GRAPH_BASE = 'https://graph.facebook.com/v26.0' const SEND_TIMEOUT_MS = 10_000 const MEDIA_LOOKUP_TIMEOUT_MS = 10_000 const MEDIA_DOWNLOAD_TIMEOUT_MS = 30_000 /** WhatsApp caps inbound images at 5 MB and documents at 100 MB; our inbox * pipeline caps everything at 10 MB (matches invoice-inbox MAX_FILE_SIZE). */ export const MAX_MEDIA_BYTES = 10 * 1024 * 1024 export class GraphApiError extends Error { readonly name = 'GraphApiError' constructor( message: string, readonly status?: number, ) { super(message) } } function getAccessToken(): string { const token = process.env.WHATSAPP_ACCESS_TOKEN if (!token) throw new GraphApiError('WHATSAPP_ACCESS_TOKEN is not configured') return token } function getPhoneNumberId(): string { const id = process.env.WHATSAPP_PHONE_NUMBER_ID if (!id) throw new GraphApiError('WHATSAPP_PHONE_NUMBER_ID is not configured') return id } export interface SendMessageBase { /** Recipient: E.164 digits, no '+' (Meta's wa_id format). */ to: string /** Template id stamped into raw_payload for throttle checks and audit. */ template: TemplateId senderPhoneHash?: string | null phoneLinkId?: string | null conversationId?: string | null correlationId?: string | null /** Underlag item this outbound message concerns (ack/question sends). * Lets a quoted reply resolve back to its receipt. */ inboxItemId?: string | null } export interface SendTextArgs extends SendMessageBase { body: string } export interface SendTextResult { ok: boolean wamid: string | null } /** POST one message payload to the Graph API. Never throws. */ async function postToGraph( payload: Record, template: TemplateId, ): Promise { let wamid: string | null = null let ok = false try { const response = await fetchWithTimeout( `${GRAPH_BASE}/${getPhoneNumberId()}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${getAccessToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }, { timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp send' }, ) if (response.ok) { const body = (await response.json().catch(() => null)) as { messages?: Array<{ id?: string }> } | null wamid = body?.messages?.[0]?.id ?? null ok = true } else { const detail = await response.text().catch(() => '') log.warn('WhatsApp send failed', { status: response.status, template, detail: detail.slice(0, 300), }) } } catch (err) { log.warn('WhatsApp send errored', { template, error: err instanceof Error ? err.message : String(err), }) } return { ok, wamid } } /** Persist the outbound message row. Never throws. */ async function persistOutbound( supabase: SupabaseClient, args: SendMessageBase & { bodyText: string; messageType: string }, result: SendTextResult, ): Promise { try { await supabase.from('whatsapp_messages').insert({ direction: 'outbound', wamid: result.wamid, sender_phone_hash: args.senderPhoneHash ?? null, phone_link_id: args.phoneLinkId ?? null, conversation_id: args.conversationId ?? null, message_type: args.messageType, body_text: args.bodyText, raw_payload: { template: args.template }, // Outbound rows are not jobs: mark done so the sweep never claims them. processing_status: 'done', delivery_status: result.ok ? 'sent' : 'failed', correlation_id: args.correlationId ?? null, inbox_item_id: args.inboxItemId ?? null, }) } catch (err) { log.error('Failed to persist outbound WhatsApp message row', err) } } /** * Send a plain text message and persist the outbound row. Never throws. */ export async function sendText( supabase: SupabaseClient, args: SendTextArgs, ): Promise { const result = await postToGraph( { messaging_product: 'whatsapp', recipient_type: 'individual', to: args.to, type: 'text', text: { body: args.body }, }, args.template, ) await persistOutbound(supabase, { ...args, bodyText: args.body, messageType: 'text' }, result) return result } // ── Interactive messages (company choice) ──────────────────── export interface ChoiceOption { /** Sent back verbatim in the interactive reply payload (company_id here). */ id: string title: string } /** WhatsApp hard limits: button titles 20 chars, list row titles 24. */ export const BUTTON_TITLE_MAX = 20 export const LIST_ROW_TITLE_MAX = 24 export const MAX_REPLY_BUTTONS = 3 export const MAX_LIST_ROWS = 10 /** * Truncate a display title cleanly: prefer cutting at a word boundary when * one sits in the second half, and mark the cut with a single ellipsis. */ export function truncateTitle(name: string, max: number): string { const trimmed = name.trim() if (trimmed.length <= max) return trimmed const slice = trimmed.slice(0, max - 1) const lastSpace = slice.lastIndexOf(' ') const cut = lastSpace >= Math.floor(max / 2) ? slice.slice(0, lastSpace) : slice return `${cut.trimEnd()}…` } export interface SendReplyButtonsArgs extends SendMessageBase { body: string /** At most MAX_REPLY_BUTTONS options; extras are dropped defensively. */ buttons: ChoiceOption[] } /** * Send an interactive reply-buttons message (max 3). Never throws. */ export async function sendReplyButtons( supabase: SupabaseClient, args: SendReplyButtonsArgs, ): Promise { const buttons = args.buttons.slice(0, MAX_REPLY_BUTTONS) const result = await postToGraph( { messaging_product: 'whatsapp', recipient_type: 'individual', to: args.to, type: 'interactive', interactive: { type: 'button', body: { text: args.body }, action: { buttons: buttons.map((b) => ({ type: 'reply', reply: { id: b.id, title: truncateTitle(b.title, BUTTON_TITLE_MAX) }, })), }, }, }, args.template, ) await persistOutbound( supabase, { ...args, bodyText: `${args.body}\n[${buttons.map((b) => b.title).join(' | ')}]`, messageType: 'interactive', }, result, ) return result } export interface SendListArgs extends SendMessageBase { body: string /** Label on the list-opening button (<= 20 chars after truncation). */ buttonLabel: string /** At most MAX_LIST_ROWS options; extras are dropped defensively. */ rows: ChoiceOption[] } /** * Send an interactive list message (max 10 rows). Never throws. */ export async function sendList( supabase: SupabaseClient, args: SendListArgs, ): Promise { const rows = args.rows.slice(0, MAX_LIST_ROWS) const result = await postToGraph( { messaging_product: 'whatsapp', recipient_type: 'individual', to: args.to, type: 'interactive', interactive: { type: 'list', body: { text: args.body }, action: { button: truncateTitle(args.buttonLabel, BUTTON_TITLE_MAX), sections: [ { rows: rows.map((r) => ({ id: r.id, title: truncateTitle(r.title, LIST_ROW_TITLE_MAX), })), }, ], }, }, }, args.template, ) await persistOutbound( supabase, { ...args, bodyText: `${args.body}\n[${rows.map((r) => r.title).join(' | ')}]`, messageType: 'interactive', }, result, ) return result } /** * Mark an inbound message read and show the typing indicator. Best-effort: * cosmetic, so failures are logged and swallowed. */ export async function markReadWithTyping(wamid: string): Promise { try { const response = await fetchWithTimeout( `${GRAPH_BASE}/${getPhoneNumberId()}/messages`, { method: 'POST', headers: { Authorization: `Bearer ${getAccessToken()}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ messaging_product: 'whatsapp', status: 'read', message_id: wamid, typing_indicator: { type: 'text' }, }), }, { timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp mark-read' }, ) if (!response.ok) { log.warn('WhatsApp mark-read failed', { status: response.status }) } } catch (err) { log.warn('WhatsApp mark-read errored', { error: err instanceof Error ? err.message : String(err), }) } } export interface DownloadedMedia { buffer: ArrayBuffer mime: string | null fileSize: number } /** * Download a media item: resolve the media id to a fresh short-lived URL * (TTL ~5 min, re-resolvable for days, so retries work), then fetch the bytes * with the same Bearer token. Enforces MAX_MEDIA_BYTES twice: via the * content-length header before reading, and while streaming the body (a * missing or lying header must not let an oversized file through). * * Throws GraphApiError / TimeoutError: the caller owns error handling here, * unlike sends, because a failed download IS a failed intake. */ export async function downloadMedia(mediaId: string): Promise { const token = getAccessToken() const lookupResponse = await fetchWithTimeout( `${GRAPH_BASE}/${encodeURIComponent(mediaId)}?phone_number_id=${encodeURIComponent(getPhoneNumberId())}`, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }, { timeoutMs: MEDIA_LOOKUP_TIMEOUT_MS, description: 'WhatsApp media lookup' }, ) if (!lookupResponse.ok) { throw new GraphApiError(`Media lookup failed (${lookupResponse.status})`, lookupResponse.status) } const lookup = (await lookupResponse.json().catch(() => null)) as { url?: string mime_type?: string file_size?: number } | null if (!lookup?.url) { throw new GraphApiError('Media lookup returned no download URL') } if (typeof lookup.file_size === 'number' && lookup.file_size > MAX_MEDIA_BYTES) { throw new GraphApiError('Media exceeds the size limit') } const download = await fetchWithTimeout( lookup.url, { method: 'GET', headers: { Authorization: `Bearer ${token}` } }, { timeoutMs: MEDIA_DOWNLOAD_TIMEOUT_MS, description: 'WhatsApp media download' }, ) if (!download.ok) { throw new GraphApiError(`Media download failed (${download.status})`, download.status) } const declaredLength = Number.parseInt(download.headers.get('content-length') ?? '', 10) if (Number.isFinite(declaredLength) && declaredLength > MAX_MEDIA_BYTES) { throw new GraphApiError('Media exceeds the size limit') } // Stream with a running byte count so a body larger than its content-length // header is rejected without buffering the whole thing first. const chunks: Uint8Array[] = [] let total = 0 if (download.body) { const reader = download.body.getReader() for (;;) { const { done, value } = await reader.read() if (done) break if (value) { total += value.byteLength if (total > MAX_MEDIA_BYTES) { await reader.cancel().catch(() => undefined) throw new GraphApiError('Media exceeds the size limit') } chunks.push(value) } } } const buffer = new ArrayBuffer(total) const view = new Uint8Array(buffer) let offset = 0 for (const chunk of chunks) { view.set(chunk, offset) offset += chunk.byteLength } return { buffer, mime: lookup.mime_type ?? download.headers.get('content-type'), fileSize: total, } } // ── Display number (for the wa.me deep link) ───────────────── // // WHATSAPP_PHONE_NUMBER_ID is a Graph object id, not the phone number, and the // env contract for this extension is fixed at six vars. The wa.me link needs // the real number, so resolve it once from the Graph API and cache in module // scope. On failure the panel simply gets no deep link (code still works). let cachedDisplayNumber: { value: string; fetchedAt: number } | null = null const DISPLAY_NUMBER_TTL_MS = 60 * 60 * 1000 export async function getDisplayPhoneNumber(): Promise { if (cachedDisplayNumber && Date.now() - cachedDisplayNumber.fetchedAt < DISPLAY_NUMBER_TTL_MS) { return cachedDisplayNumber.value } try { const response = await fetchWithTimeout( `${GRAPH_BASE}/${getPhoneNumberId()}?fields=display_phone_number`, { method: 'GET', headers: { Authorization: `Bearer ${getAccessToken()}` } }, { timeoutMs: MEDIA_LOOKUP_TIMEOUT_MS, description: 'WhatsApp number lookup' }, ) if (!response.ok) return null const payload = (await response.json().catch(() => null)) as { display_phone_number?: string } | null const digits = payload?.display_phone_number?.replace(/\D/g, '') ?? '' if (!digits) return null cachedDisplayNumber = { value: digits, fetchedAt: Date.now() } return digits } catch (err) { if (!(err instanceof TimeoutError)) { log.warn('WhatsApp display number lookup errored', { error: err instanceof Error ? err.message : String(err), }) } return null } } /** Test-only: reset the module-level display-number cache. */ export function resetDisplayNumberCacheForTests(): void { cachedDisplayNumber = null }