* 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>
540 lines
17 KiB
TypeScript
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
|
|
}
|