Files
accounted/extensions/general/whatsapp-inbox/lib/graph-api.ts
T
398c734b93 feat(whatsapp-inbox): intake extension with webhook, phone linking and receipt ack (#1338)
Webhook lifecycle: GET hub.challenge handshake (constant-time verify-token
compare); POST verifies X-Hub-Signature-256 over the RAW body before any
parse, Zod-parses the envelope, persists inbound rows (partial-unique wamid
= dedupe against Meta's up-to-7-day redelivery), acks 200 fast and defers
media processing via the after() idiom. Rejected and rate-limited content
always acks 200 and lands as skipped/error rows, never a retryable status.

Linking: the settings panel (Installningar -> WhatsApp) mints AC- one-time
codes (sha256 stored, 10 min TTL, single use, ambiguity-free alphabet); the
webhook consumes the code, binds phone to user (HMAC-peppered hash + AES-256-
GCM at rest) and confirms with M3. Keyword commands stopp/start/hjalp;
unknown senders get one throttled M1 greeting (1/h, 3/day) behind the
sender-quota RPC, with no media download and no content persistence.

Intake worker: atomic claim on the message row (the durable job record),
company resolution (default -> sole membership -> M6 fallback, no item),
per-company inbox quota (ack-and-drop, M17 once per 10 min per sender),
MIME allowlist, 10 MB stream-checked media download, exact sha256 duplicate
check, then the shared uploadAndExtract funnel (source 'whatsapp',
channel_context caption, whatsapp_message_id) and the M4 ack with extracted
merchant/total/date. Failures wrap to 'error' + error_message + one M18.

uploadAndExtract widened: source 'whatsapp', optional channelMeta + actorId;
email/upload paths behaviorally unchanged.

Deferred to PR4: burst debounce + combined ack (M5), in-chat company choice
(M6 buttons + 8h pin), clarifying questions M7-M10, interpret-answer LLM
call, sweep cron, retention cron.

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

304 lines
9.9 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 SendTextArgs {
/** Recipient: E.164 digits, no '+' (Meta's wa_id format). */
to: string
body: 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
}
export interface SendTextResult {
ok: boolean
wamid: string | null
}
/**
* Send a plain text message and persist the outbound row. Never throws.
*/
export async function sendText(
supabase: SupabaseClient,
args: SendTextArgs,
): 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({
messaging_product: 'whatsapp',
recipient_type: 'individual',
to: args.to,
type: 'text',
text: { body: args.body },
}),
},
{ timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp send' },
)
if (response.ok) {
const payload = (await response.json().catch(() => null)) as {
messages?: Array<{ id?: string }>
} | null
wamid = payload?.messages?.[0]?.id ?? null
ok = true
} else {
const detail = await response.text().catch(() => '')
log.warn('WhatsApp send failed', {
status: response.status,
template: args.template,
detail: detail.slice(0, 300),
})
}
} catch (err) {
log.warn('WhatsApp send errored', {
template: args.template,
error: err instanceof Error ? err.message : String(err),
})
}
try {
await supabase.from('whatsapp_messages').insert({
direction: 'outbound',
wamid,
sender_phone_hash: args.senderPhoneHash ?? null,
phone_link_id: args.phoneLinkId ?? null,
conversation_id: args.conversationId ?? null,
message_type: 'text',
body_text: args.body,
raw_payload: { template: args.template },
// Outbound rows are not jobs: mark done so the PR4 sweep never claims them.
processing_status: 'done',
delivery_status: ok ? 'sent' : 'failed',
correlation_id: args.correlationId ?? null,
})
} catch (err) {
log.error('Failed to persist outbound WhatsApp message row', err)
}
return { ok, wamid }
}
/**
* 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
}