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

215 lines
5.8 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(),
})
// 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(),
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'
| '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
/** 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',
])
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,
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
}