Files
accounted/extensions/general/whatsapp-inbox/lib/graph-api.ts
T
629069e281 feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.

Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.

Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.

New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.

Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.

Co-authored-by: Jakob Wennberg <jakob.wennberg@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 15:27:00 +02:00

455 lines
14 KiB
TypeScript

/**
* 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<string, unknown>,
template: TemplateId,
): Promise<SendTextResult> {
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<void> {
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<SendTextResult> {
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<SendTextResult> {
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<SendTextResult> {
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<void> {
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<DownloadedMedia> {
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<string | null> {
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
}