Files
accounted/extensions/general/whatsapp-inbox/lib/webhook-parse.ts
T
629069e281 feat(whatsapp-inbox): conversation layer with clarifying questions (#1340)
PR4 of the WhatsApp intake track: turns the per-message PR3 pipeline into a
conversation. Media replies are burst-debounced into ONE combined ack (M4
single / M5 numbered list) sent by the single winner of the atomic
pending_ack claim; losers stay silent. Multi-company senders get the company
question (reply buttons <=3, list 4-10, numbered text >10) with an 8h
sliding pin ('byt' clears it); their receipts park as staged message rows
until the answer and then run through the normal intake path.

Clarifying questions are evaluated per receipt after extraction, max one per
receipt, priority unreadable > representation > partial, keyed on the
Phase-0 classification (legibility/documentKind/merchantCategory) with
heuristic fallbacks (compressed-chat-photo signal, extended meal regex).
Budgets: <=2 content questions per burst, <=6 per sender per Stockholm day;
over budget acks only and flags the item moved_to_app. Questions expire
after 48h (sweep, silent hand-off) and are asked exactly once.

Free-text answers route through the ONE new LLM call
(lib/interpret-answer.ts): Sonnet via Bedrock, max_tokens 600, no thinking,
forced tool call validated by Zod with hard caps, gated by
checkAgentRateLimit, reply framed as untrusted data. Any failure degrades to
storing the raw text as a note; exact 'nej' short-circuits without the LLM.
Answers land in invoice_inbox_items.channel_context
(representation/user_note/quality) with ChannelQuestionAsked/Answered
processing-history events. Late answers match by quoted wamid or the most
recent open question within 7 days.

New per-minute sweep cron (registry-gated physical route, 503
EXTENSION_DISABLED when off) re-claims stuck rows (max 3 attempts), rescues
crashed burst acks, expires questions and pins. One new migration
(20260802210000) adds whatsapp_messages.acked_at, the relational burst-
membership marker, with pg-real coverage for the single-winner claim.

Verified: full vitest suite (12270), pg-real against a migrated
supabase/postgres 15 (977), lint 0 errors, tsc at the 405 baseline,
check:guards green, crontabs regenerated. Mutation-checked the debounce
claim and the daily budget gate.

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

235 lines
6.4 KiB
TypeScript

/**
* Zod parser for the Meta Cloud API webhook envelope (already signature
* verified). Extracts messages[] and statuses[] from
* entry[].changes[].value and flattens them for the dispatcher.
*
* Contract: NEVER throws. Anything that does not match the envelope parses to
* an empty result; any single message that does not match a known shape
* becomes type 'unknown' (a safe skip) rather than failing the batch. Meta
* adds message types over time and a new type must not take down intake for
* the whole webhook delivery.
*/
import { z } from 'zod'
const MediaSchema = z.object({
id: z.string().min(1),
mime_type: z.string().max(120).optional(),
sha256: z.string().max(200).optional(),
filename: z.string().max(500).optional(),
caption: z.string().max(4096).optional(),
voice: z.boolean().optional(),
})
const InteractiveReplySchema = z.object({
id: z.string().min(1).max(256),
title: z.string().max(1024).optional(),
})
// Permissive per-message schema: `type` is an open string and per-type payloads
// are all optional, so an unexpected combination degrades instead of failing.
const MessageSchema = z.object({
from: z.string().min(1).max(30),
id: z.string().min(1).max(200),
timestamp: z.string().max(30).optional(),
type: z.string().max(40),
text: z.object({ body: z.string().max(65536) }).optional(),
image: MediaSchema.optional(),
document: MediaSchema.optional(),
audio: MediaSchema.optional(),
video: MediaSchema.optional(),
sticker: MediaSchema.optional(),
interactive: z
.object({
type: z.string().max(40).optional(),
button_reply: InteractiveReplySchema.optional(),
list_reply: InteractiveReplySchema.optional(),
})
.optional(),
context: z.object({ id: z.string().max(200).optional() }).optional(),
})
const StatusSchema = z.object({
id: z.string().min(1).max(200),
status: z.string().max(40),
})
const ContactSchema = z.object({
wa_id: z.string().max(30).optional(),
profile: z.object({ name: z.string().max(200).optional() }).optional(),
})
const EnvelopeSchema = z.object({
object: z.string().optional(),
entry: z
.array(
z.object({
changes: z
.array(
z.object({
field: z.string().optional(),
value: z
.object({
messages: z.array(z.unknown()).optional(),
statuses: z.array(z.unknown()).optional(),
contacts: z.array(z.unknown()).optional(),
})
.optional(),
}),
)
.optional(),
}),
)
.optional(),
})
export type ParsedMessageType =
| 'text'
| 'image'
| 'document'
| 'audio'
| 'video'
| 'sticker'
| 'location'
| 'contacts'
| 'interactive'
| 'unknown'
export interface ParsedMedia {
id: string
mime: string | null
sha256: string | null
filename: string | null
voice: boolean
}
export interface ParsedInboundMessage {
wamid: string
from: string
timestamp: string | null
type: ParsedMessageType
/** Body for text messages. */
text: string | null
/** Caption for media messages. */
caption: string | null
media: ParsedMedia | null
/** Interactive answer payload: the tapped button's/row's id (company_id). */
interactiveReplyId: string | null
/** Quoted-reply target (context.id), when present. */
contextWamid: string | null
profileName: string | null
/** The raw value.messages[i] object, persisted verbatim for known senders. */
raw: unknown
}
export interface ParsedStatus {
wamid: string
status: string
}
export interface ParsedWebhook {
messages: ParsedInboundMessage[]
statuses: ParsedStatus[]
}
const KNOWN_TYPES: ReadonlySet<string> = new Set([
'text',
'image',
'document',
'audio',
'video',
'sticker',
'location',
'contacts',
'interactive',
])
function toParsedMessage(
raw: unknown,
profileNames: Map<string, string>,
): ParsedInboundMessage | null {
const parsed = MessageSchema.safeParse(raw)
if (!parsed.success) return null
const msg = parsed.data
const type: ParsedMessageType = KNOWN_TYPES.has(msg.type)
? (msg.type as ParsedMessageType)
: 'unknown'
const mediaSource =
type === 'image'
? msg.image
: type === 'document'
? msg.document
: type === 'audio'
? msg.audio
: type === 'video'
? msg.video
: type === 'sticker'
? msg.sticker
: undefined
return {
wamid: msg.id,
from: msg.from,
timestamp: msg.timestamp ?? null,
type,
text: type === 'text' ? (msg.text?.body ?? null) : null,
caption: mediaSource?.caption ?? null,
media: mediaSource
? {
id: mediaSource.id,
mime: mediaSource.mime_type ?? null,
sha256: mediaSource.sha256 ?? null,
filename: mediaSource.filename ?? null,
voice: mediaSource.voice === true,
}
: null,
interactiveReplyId:
type === 'interactive'
? (msg.interactive?.button_reply?.id ?? msg.interactive?.list_reply?.id ?? null)
: null,
contextWamid: msg.context?.id ?? null,
profileName: profileNames.get(msg.from) ?? null,
raw,
}
}
/**
* Parse a verified webhook body. Returns flattened messages and statuses in
* arrival order. Never throws; unparseable input yields empty arrays.
*/
export function parseWebhookEnvelope(body: unknown): ParsedWebhook {
const result: ParsedWebhook = { messages: [], statuses: [] }
const envelope = EnvelopeSchema.safeParse(body)
if (!envelope.success) return result
for (const entry of envelope.data.entry ?? []) {
for (const change of entry.changes ?? []) {
const value = change.value
if (!value) continue
const profileNames = new Map<string, string>()
for (const rawContact of value.contacts ?? []) {
const contact = ContactSchema.safeParse(rawContact)
if (contact.success && contact.data.wa_id && contact.data.profile?.name) {
profileNames.set(contact.data.wa_id, contact.data.profile.name)
}
}
for (const rawMessage of value.messages ?? []) {
const parsed = toParsedMessage(rawMessage, profileNames)
if (parsed) result.messages.push(parsed)
}
for (const rawStatus of value.statuses ?? []) {
const parsed = StatusSchema.safeParse(rawStatus)
if (parsed.success) result.statuses.push({ wamid: parsed.data.id, status: parsed.data.status })
}
}
}
return result
}