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>
304 lines
9.9 KiB
TypeScript
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
|
|
}
|