Files
accounted/extensions/general/whatsapp-inbox/lib/graph-api.ts
T
273af39994 fix(whatsapp): company question falls back to numbered text and ignores archived companies (#1992)
* fix(whatsapp): company question survives a Meta-rejected interactive send (#1589)

The linked multi-company sender in #1589 never heard back because Meta
rejected the reply-button payload synchronously (HTTP 400, #131009
"Duplicate button title"): the sender belongs to two companies with the
same name, one of them archived. askCompanyQuestion rolled the question
back and returned not_asked, the row stayed parked as
staged_awaiting_company, and the channel went silent.

- Exclude archived companies wherever the channel resolves memberships
  (isMember, resolveCompanyTarget, loadCompanyOptions, applyCompanyChoice,
  the M3 greeting count), same inner-join filter as the middleware.
- uniqueTitles: interactive button/row titles are made unique (position
  suffix) so two live same-named companies, or names that truncate to the
  same prefix, no longer trip #131009.
- Numbered-text fallback: when the interactive send is rejected at send
  time, ask the same M6 question as plain numbered text; roll back only
  when that fails too. A typed digit is recorded as via='numbered'.
- Drain: when the sender now resolves as 'single', rows parked behind the
  dead question are re-opened and kicked instead of expiring at Meta.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

* fix(whatsapp): single-company drain also clears the dead company question (#1589)

Re-opening the parked rows left the conversation in state awaiting_company
with company_options and the company pending_question intact, so the sender
stayed behind a zombie question for up to 48h: every typed word became a
company_retry re-offering the archived company, 'byt' was swallowed, and
finalizeBurst could not ask about the drained receipts until the TTL sweep.

- After the drain, when a company question is open in any of its shapes
  (awaiting_company state, kept company_options, company pending_question),
  clear it through the guarded updateConversation: state -> idle, options and
  the company pending_question deleted, other question types untouched.
- Tests: the clear in its awaiting_company and post-TTL (idle + options)
  shapes, and its no-op for a representation question.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 22:26:38 +02:00

540 lines
17 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
/** Why the send failed, for the outbound row (#1552). Null on success. */
errorDetail: 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
let errorDetail: string | null = null
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(() => '')
errorDetail = `Send failed (HTTP ${response.status}): ${detail.slice(0, 250)}`
log.warn('WhatsApp send failed', {
status: response.status,
template,
detail: detail.slice(0, 300),
})
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
errorDetail = `Send errored: ${message.slice(0, 250)}`
log.warn('WhatsApp send errored', { template, error: message })
}
return { ok, wamid, errorDetail }
}
/** 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',
// A failed reply used to be indistinguishable from a delivered one at
// the row level (#1552): keep the Graph error on the record.
error_message: result.ok ? null : (result.errorDetail ?? 'Send 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()}…`
}
/**
* Truncate every title to `max` and make the results unique. Meta rejects an
* interactive payload whose buttons or rows share a title (HTTP 400, error
* #131009 "Duplicate button title"), which happens when a sender belongs to
* two same-named companies or when two long names truncate to the same
* prefix. Colliding entries (compared case-insensitively) get their 1-based
* position appended: the same digit the numbered text variant and the
* typed-digit answer path use, so a "Bolag AB 2" button and a "2" reply mean
* the same option. The result never exceeds `max`.
*/
export function uniqueTitles(titles: string[], max: number): string[] {
const truncated = titles.map((t) => truncateTitle(t, max))
const occurrences = new Map<string, number>()
for (const title of truncated) {
const key = title.toLowerCase()
occurrences.set(key, (occurrences.get(key) ?? 0) + 1)
}
return truncated.map((title, i) => {
if ((occurrences.get(title.toLowerCase()) ?? 0) < 2) return title
const suffix = ` ${i + 1}`
return `${truncateTitle(titles[i], max - suffix.length)}${suffix}`
})
}
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 titles = uniqueTitles(
buttons.map((b) => b.title),
BUTTON_TITLE_MAX,
)
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, i) => ({
type: 'reply',
reply: { id: b.id, title: titles[i] },
})),
},
},
},
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 titles = uniqueTitles(
rows.map((r) => r.title),
LIST_ROW_TITLE_MAX,
)
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, i) => ({
id: r.id,
title: titles[i],
})),
},
],
},
},
},
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),
})
}
}
/** The instant "received" signal on accepted media (U+2705 check mark).
* Built via fromCharCode so no literal emoji byte can be mangled in transit. */
export const RECEIVED_REACTION_EMOJI = String.fromCharCode(0x2705)
/**
* React to an inbound message with an emoji. This is the instant "your
* receipt reached us" signal, sent from the webhook itself: reactions attach
* to the sender's own bubble and add no message of their own, so the
* debounced combined ack (M4/M5) stays the ONE message per burst. Best-effort
* and cosmetic exactly like markReadWithTyping: a failure is logged and
* swallowed, no outbound whatsapp_messages row is persisted (a reaction is
* not a message in the conversation model), and intake is unaffected.
*/
export async function sendReaction(
to: string,
wamid: string,
emoji: string = RECEIVED_REACTION_EMOJI,
): 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',
recipient_type: 'individual',
to,
type: 'reaction',
reaction: { message_id: wamid, emoji },
}),
},
{ timeoutMs: SEND_TIMEOUT_MS, description: 'WhatsApp reaction' },
)
if (!response.ok) {
log.warn('WhatsApp reaction failed', { status: response.status })
}
} catch (err) {
log.warn('WhatsApp reaction 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
}