Files
accounted/extensions/general/whatsapp-inbox/lib/process-inbound.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

359 lines
13 KiB
TypeScript

/**
* Deferred intake worker: one inbound media message -> one Underlag item.
*
* The webhook persists the message row and 200s fast; this worker runs via
* the after() idiom (lib/webhooks/dispatch-kick.ts) on the same instance.
* The whatsapp_messages row IS the durable job record: the atomic claim
* (UPDATE ... WHERE processing_status='received' RETURNING) makes redelivered
* webhooks and the PR4 sweep cron safe to race.
*
* Failure policy: NOTHING here returns a retryable status to Meta. Rejected
* and rate-limited content acks in chat and lands as 'skipped'; real failures
* land as 'error' + error_message with a single M18 to the user.
*/
import { after } from 'next/server'
import type { SupabaseClient } from '@supabase/supabase-js'
import { createServiceClientNoCookies } from '@/lib/auth/api-keys'
import { checkInboxUploadRateLimit } from '@/lib/rate-limits/inbox'
import { appendProcessingHistory } from '@/lib/processing-history/append'
import { computeSHA256 } from '@/lib/core/documents/document-service'
import { createLogger } from '@/lib/logger'
import { uploadAndExtract } from '@/extensions/general/invoice-inbox/lib/upload-and-extract'
import type { InvoiceExtractionResult, WhatsAppMessage, WhatsAppPhoneLink } from '@/types'
import { sendText, markReadWithTyping, downloadMedia, GraphApiError } from './graph-api'
import { botCopy, TEMPLATE } from './messages'
const log = createLogger('whatsapp-inbox/process-inbound')
/** Chat intake accepts what phones actually produce. Narrower than the upload
* allowlist on purpose: WhatsApp transcodes photos to JPEG, so HEIC never
* arrives, and everything else gets the M15 nudge. */
export const CHAT_ALLOWED_MIME_TYPES: ReadonlySet<string> = new Set([
'image/jpeg',
'image/png',
'image/webp',
'application/pdf',
])
/** M17 is sent at most once per this window per sender, not once per file. */
const RATE_LIMIT_NOTICE_WINDOW_MS = 10 * 60 * 1000
const EXTENSION_FOR_MIME: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'application/pdf': 'pdf',
}
function fallbackFilename(mime: string): string {
const ext = EXTENSION_FOR_MIME[mime] ?? 'bin'
return `whatsapp-${new Date().toISOString().slice(0, 10)}.${ext}`
}
function formatSek(amount: number): string {
return `${new Intl.NumberFormat('sv-SE', {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
}).format(amount)} kr`
}
async function loadRow(
supabase: SupabaseClient,
messageId: string,
): Promise<WhatsAppMessage | null> {
const { data } = await supabase
.from('whatsapp_messages')
.select('*')
.eq('id', messageId)
.maybeSingle()
return (data as WhatsAppMessage | null) ?? null
}
async function loadLink(
supabase: SupabaseClient,
phoneLinkId: string,
): Promise<WhatsAppPhoneLink | null> {
const { data } = await supabase
.from('whatsapp_phone_links')
.select('*')
.eq('id', phoneLinkId)
.maybeSingle()
return (data as WhatsAppPhoneLink | null) ?? null
}
/**
* Resolve which company the receipt lands in (PR3 ladder: default company if
* still a member -> sole membership -> null). The PR4 conversation pin slots
* in above default when it exists.
*/
async function resolveCompanyId(
supabase: SupabaseClient,
link: WhatsAppPhoneLink,
): Promise<string | null> {
if (link.default_company_id) {
const { data: membership } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
.eq('company_id', link.default_company_id)
.maybeSingle()
if (membership) return link.default_company_id
}
const { data: memberships } = await supabase
.from('company_members')
.select('company_id')
.eq('user_id', link.user_id)
const companyIds = [...new Set((memberships ?? []).map((m) => m.company_id as string))]
if (companyIds.length === 1) return companyIds[0]
return null
}
/** True when an M17 notice already went to this sender inside the window. */
async function rateLimitNoticeAlreadySent(
supabase: SupabaseClient,
senderPhoneHash: string,
): Promise<boolean> {
const since = new Date(Date.now() - RATE_LIMIT_NOTICE_WINDOW_MS).toISOString()
const { data } = await supabase
.from('whatsapp_messages')
.select('id')
.eq('direction', 'outbound')
.eq('sender_phone_hash', senderPhoneHash)
.eq('raw_payload->>template', TEMPLATE.m17RateLimited)
.gte('created_at', since)
.limit(1)
.maybeSingle()
return data != null
}
async function markStatus(
supabase: SupabaseClient,
messageId: string,
status: 'skipped' | 'error' | 'done',
extra: { errorMessage?: string | null; inboxItemId?: string | null } = {},
): Promise<void> {
// Literal payload (no spread) so the phantom-column scanner can verify it.
// Writing null where the caller passed nothing matches the columns' actual
// state on every path that reaches here.
await supabase
.from('whatsapp_messages')
.update({
processing_status: status,
error_message: extra.errorMessage ?? null,
inbox_item_id: extra.inboxItemId ?? null,
})
.eq('id', messageId)
}
/**
* Process one inbound media message end to end. Never throws.
*/
export async function processInboundMessage(
supabase: SupabaseClient,
messageId: string,
): Promise<void> {
const row = await loadRow(supabase, messageId)
if (!row) return
// Atomic claim: only a row still in 'received' can be taken, so a webhook
// redelivery racing this worker (or the PR4 sweep) claims exactly once.
const { data: claimed } = await supabase
.from('whatsapp_messages')
.update({ processing_status: 'processing', attempts: row.attempts + 1 })
.eq('id', messageId)
.eq('processing_status', 'received')
.select('id')
.maybeSingle()
if (!claimed) return
const attempt = row.attempts + 1
const copy = botCopy('sv')
const to = extractRecipient(row)
const replyBase = {
senderPhoneHash: row.sender_phone_hash,
phoneLinkId: row.phone_link_id,
conversationId: row.conversation_id,
correlationId: row.correlation_id,
}
try {
if (!row.phone_link_id || !row.media_id || !to) {
await markStatus(supabase, messageId, 'error', {
errorMessage: 'Message row is missing link or media reference',
})
return
}
if (row.wamid) await markReadWithTyping(row.wamid)
const link = await loadLink(supabase, row.phone_link_id)
if (!link || link.revoked_at) {
await markStatus(supabase, messageId, 'skipped', {
errorMessage: 'Phone link revoked before processing',
})
return
}
// ── Company resolution ─────────────────────────────────
const companyId = await resolveCompanyId(supabase, link)
if (!companyId) {
await sendText(supabase, { to, body: copy.m6NoDefaultCompany(), template: TEMPLATE.m6NoDefaultCompany, ...replyBase })
await markStatus(supabase, messageId, 'skipped', {
errorMessage: 'No default company set for multi-company sender',
})
return
}
// ── Per-company intake quota (ack-and-drop, never retryable) ──
const limit = await checkInboxUploadRateLimit(supabase, companyId)
if (!limit.ok) {
try {
await appendProcessingHistory({
companyId,
correlationId: row.correlation_id ?? messageId,
aggregateType: 'System',
aggregateId: messageId,
eventType: 'RateLimitedDropped',
payload: {
channel: 'whatsapp',
scope: limit.scope,
retry_after_sec: limit.retryAfterSec,
whatsapp_message_id: messageId,
},
actor: { type: 'system', id: 'whatsapp-inbound' },
occurredAt: new Date(),
})
} catch (err) {
log.error('RateLimitedDropped append failed', err)
}
if (row.sender_phone_hash && !(await rateLimitNoticeAlreadySent(supabase, row.sender_phone_hash))) {
await sendText(supabase, { to, body: copy.m17RateLimited({}), template: TEMPLATE.m17RateLimited, ...replyBase })
}
await markStatus(supabase, messageId, 'skipped', { errorMessage: 'Rate limited' })
return
}
// ── MIME allowlist ─────────────────────────────────────
const mime = (row.media_mime ?? '').split(';')[0].trim().toLowerCase()
if (!CHAT_ALLOWED_MIME_TYPES.has(mime)) {
await sendText(supabase, { to, body: copy.m15Unsupported(), template: TEMPLATE.m15Unsupported, ...replyBase })
await markStatus(supabase, messageId, 'skipped', {
errorMessage: `Unsupported media type: ${mime || 'unknown'}`,
})
return
}
// ── Download (fresh URL per media id; 10 MB stream-checked cap) ──
const media = await downloadMedia(row.media_id)
// ── Exact duplicate check within the company ───────────
const sha256 = await computeSHA256(media.buffer)
const { data: duplicate } = await supabase
.from('document_attachments')
.select('id')
.eq('company_id', companyId)
.eq('sha256_hash', sha256)
.limit(1)
.maybeSingle()
if (duplicate) {
await sendText(supabase, { to, body: copy.m4Duplicate(), template: TEMPLATE.m4Duplicate, ...replyBase })
await markStatus(supabase, messageId, 'skipped', { errorMessage: 'Duplicate document (sha256)' })
return
}
// ── Upload + extract (the shared invoice-inbox funnel) ──
const result = await uploadAndExtract(
supabase,
link.user_id,
companyId,
{ name: row.media_filename || fallbackFilename(mime), buffer: media.buffer, type: mime },
'whatsapp',
undefined,
undefined,
{
channelMeta: { whatsappMessageId: messageId, caption: row.body_text ?? null },
actorId: 'whatsapp-inbound',
},
)
await markStatus(supabase, messageId, 'done', {
inboxItemId: result.inbox_item_id,
})
// ── Receipt ack ────────────────────────────────────────
const extracted = result.extracted_data as InvoiceExtractionResult | undefined
const total = extracted?.totals?.total ?? null
if (total != null) {
await sendText(supabase, {
to,
body: copy.m4Ack({
merchant: extracted?.supplier?.name ?? null,
amount: formatSek(total),
date: extracted?.invoice?.invoiceDate ?? null,
}),
template: TEMPLATE.m4Ack,
...replyBase,
})
} else {
await sendText(supabase, { to, body: copy.m4AckEmpty(), template: TEMPLATE.m4AckEmpty, ...replyBase })
}
} catch (err) {
const message =
err instanceof GraphApiError || err instanceof Error ? err.message : String(err)
log.error('WhatsApp intake processing failed', err, { messageId })
try {
await markStatus(supabase, messageId, 'error', { errorMessage: message.slice(0, 500) })
// M18 once per message: only on the first attempt, so a PR4 sweep
// re-claim of the same row never spams the sender.
if (to && attempt <= 1) {
await sendText(supabase, { to, body: botCopy('sv').m18Error(), template: TEMPLATE.m18Error, ...replyBase })
}
} catch (innerErr) {
log.error('Failed to record WhatsApp processing error', innerErr, { messageId })
}
}
}
/**
* The recipient phone (E.164 digits) for replies. The raw inbound payload is
* persisted verbatim for linked senders, so `from` is read back from it: the
* row itself never stores the raw number outside raw_payload/phone_enc.
*/
function extractRecipient(row: WhatsAppMessage): string | null {
const raw = row.raw_payload as { from?: unknown } | null
return raw && typeof raw.from === 'string' && raw.from.length > 0 ? raw.from : null
}
/**
* Schedule processing of freshly persisted message rows after the webhook
* response is sent. Exact dispatch-kick idiom: never awaited by the caller,
* never throws, falls back to a microtask outside a request scope (tests).
*/
export function kickInboundProcessing(messageIds: string[]): void {
if (messageIds.length === 0) return
const run = async (): Promise<void> => {
try {
const supabase = createServiceClientNoCookies()
for (const id of messageIds) {
await processInboundMessage(supabase, id)
}
} catch (err) {
// The PR4 sweep cron re-claims 'received' rows: a failed kick is a
// latency regression, never a lost message.
log.warn('deferred WhatsApp processing failed; sweep will retry', {
error: err instanceof Error ? err.message : String(err),
})
}
}
try {
after(() => run())
} catch {
queueMicrotask(() => void run())
}
}