Files
accounted/extensions/general/whatsapp-inbox/lib/webhook-parse.ts
T
6c64dd2312 fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1563)
* fix(whatsapp): make every silent disposition observable, stop pure silence for linked senders (#1552)

Silence was a legitimate outcome in seven places and none left a trace a
support question could be answered from. Now:

- Unknown-sender declines (over quota, quota RPC failure, greeting
  throttle) persist content-free trace rows: wamid, phone hash, type,
  disposition. No body, media, raw payload, or profile name; capped at
  20 rows per hash and day; deleted by the existing 30-day retention.
  The wamid dedupe also stops redelivered bad-code/greeting messages
  from earning a second reply.
- Linked-sender deliberate silences (muted, stale tap, ignorable type)
  record their reason on the skipped row.
- Non-policy silences reply: a row missing its media reference sends
  M18 through the link's reply address, a link revoked between arrival
  and processing sends the M1 unlinked copy (greeting-throttled).
- Outbound rows keep WHY a send failed (Graph error detail), and Meta
  'failed' delivery statuses store their error code and title.
- The WhatsApp settings panel shows the last inbound event (closed
  enum, server-derived) and warns when the latest reply never left.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(whatsapp): include errorDetail in typed sendText mock results

SendTextResult gained errorDetail; vi.mocked call sites must match the
widened type or they raise fresh tsc errors over the repo baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(whatsapp): review fixes: fail-closed greeting throttle, cap only declined traces

From CodeRabbit's pass on #1563:
- greetingThrottled fails closed when the throttle window cannot be
  read, matching the unknown-sender quota's stance.
- The decline-trace day cap applies only to 'skipped' rows (the one
  unbounded path); 'done' traces always insert so the wamid dedupe
  keeps preventing duplicate M1/M2 replies even past the cap. Their
  volume is already bounded upstream by the greeting throttle and the
  pre-binding quota.
- company-question test mocks match the widened SendTextResult.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

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

256 lines
7.2 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),
// Present on 'failed' statuses: the only record of WHY a message never
// reached the recipient (undeliverable, blocked, re-engagement needed).
errors: z
.array(
z.object({
code: z.union([z.number(), z.string()]).optional(),
title: z.string().max(300).optional(),
message: z.string().max(300).optional(),
}),
)
.optional(),
})
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
/** Compact "code: title" from the first status error, when Meta sent one. */
errorDetail: string | null
}
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) continue
const firstError = parsed.data.errors?.[0]
const errorDetail = firstError
? [firstError.code, firstError.title ?? firstError.message]
.filter((part) => part != null && String(part).length > 0)
.join(': ')
.slice(0, 300) || null
: null
result.statuses.push({ wamid: parsed.data.id, status: parsed.data.status, errorDetail })
}
}
}
return result
}