/** * WhatsApp Coexistence webhooks (Embedded Signup v3+). * * When a business runs the WhatsApp Business app on a phone AND the Cloud * API on the same number, Meta delivers three extra webhook fields * alongside the ordinary `messages` field: * * - `smb_message_echoes` — messages the human sent from the phone. * - `history` — thread history synced during onboarding. * - `smb_app_state_sync` — contact / app-state changes from the phone. * * `extractInbound` doesn't model these (they carry no `contacts` + * `messages` pair, so it returns `{ kind: 'unknown' }`) and `Agent.enqueue` * ignores them. This module is the missing piece: split coexistence changes * out of an envelope, and parse the echo payload into a normalized shape. * * The load-bearing use case is **human handoff**: an echo means a human just * answered from the phone, so the agent should stay quiet for a while * instead of double-replying. Pair with `HumanHandoffWindow` * (`src/handoff/human_handoff.ts`) and wire via `mountWebhook`'s / * `mountMultiTenantWebhook`'s `onCoexistence` option. * * The `history` and `smb_app_state_sync` payloads are passed through raw: * their shapes are large, versioned by Meta, and app-specific in what's * worth persisting. Echoes get a typed shape because every consumer needs * the same three fields out of them. */ import type { InboundEnvelope } from '../types.js'; /** Webhook `field` values Meta uses for coexistence changes. */ export const COEXISTENCE_FIELDS = ['smb_message_echoes', 'history', 'smb_app_state_sync'] as const; export type CoexistenceField = (typeof COEXISTENCE_FIELDS)[number]; const FIELD_SET: ReadonlySet = new Set(COEXISTENCE_FIELDS); /** True when `field` is one of the three coexistence webhook fields. */ export function isCoexistenceField(field: unknown): field is CoexistenceField { return typeof field === 'string' && FIELD_SET.has(field); } /** One coexistence change lifted out of an envelope, with its origin number. */ export interface CoexistenceChange { field: CoexistenceField; /** `metadata.phone_number_id` — the business number the change belongs to. */ phoneNumberId: string | null; /** Raw `change.value`, untouched. */ value: unknown; } /** * A message the business sent from the WhatsApp Business app (not via the * Cloud API). `to` is the customer — that's the thread the agent should * back off from. */ export interface MessageEcho { wamid: string; phoneNumberId: string | null; /** Customer the human messaged. */ to: string | null; /** Business number the echo came from. */ from: string | null; type: string; /** Body for `text` echoes, caption for media echoes, else null. */ text: string | null; timestampMs: number | null; /** Raw echo object — media ids, context, and anything Meta adds later. */ raw: unknown; } export interface PartitionedWebhook { /** True when at least one change carried a coexistence field. */ hasCoexistence: boolean; /** Every coexistence change, flattened across entries. */ coexistence: CoexistenceChange[]; /** * The same envelope with coexistence changes stripped, ready for * `Agent.enqueue`. `null` when nothing ordinary is left — don't enqueue. */ ordinary: InboundEnvelope | null; } interface RawChange { field?: unknown; value?: unknown; } interface RawEntry { changes?: RawChange[]; [k: string]: unknown; } interface RawBody { entry?: RawEntry[]; [k: string]: unknown; } /** * Split an envelope into its coexistence changes and the ordinary remainder. * * const { hasCoexistence, coexistence, ordinary } = partitionCoexistenceWebhook(body); * if (hasCoexistence) await handleCoexistence(coexistence); * if (ordinary) await agent.enqueue(ordinary); * * Entries whose changes were entirely coexistence are dropped from * `ordinary`; every other key on the envelope and its entries is preserved. * Non-object input yields an empty partition rather than throwing. */ export function partitionCoexistenceWebhook(body: unknown): PartitionedWebhook { const coexistence: CoexistenceChange[] = []; if (typeof body !== 'object' || body === null) { return { hasCoexistence: false, coexistence, ordinary: null }; } const source = body as RawBody; const entries: RawEntry[] = []; for (const entry of source.entry ?? []) { if (typeof entry !== 'object' || entry === null) continue; const ordinaryChanges: RawChange[] = []; for (const change of entry.changes ?? []) { if (typeof change !== 'object' || change === null) continue; if (isCoexistenceField(change.field)) { coexistence.push({ field: change.field, phoneNumberId: readPhoneNumberId(change.value), value: change.value, }); } else { ordinaryChanges.push(change); } } if (ordinaryChanges.length > 0) entries.push({ ...entry, changes: ordinaryChanges }); } return { hasCoexistence: coexistence.length > 0, coexistence, ordinary: entries.length > 0 ? ({ ...source, entry: entries } as InboundEnvelope) : null, }; } /** True when the envelope carries at least one coexistence change. */ export function isCoexistenceWebhook(body: unknown): boolean { return partitionCoexistenceWebhook(body).hasCoexistence; } /** Every coexistence change in the envelope, flattened. */ export function extractCoexistenceChanges(body: unknown): CoexistenceChange[] { return partitionCoexistenceWebhook(body).coexistence; } /** * Normalized echoes from an envelope's `smb_message_echoes` changes. * Returns `[]` when there are none — safe to call on any envelope. * * Accepts either a full envelope or the pre-partitioned change list, so * `onCoexistence` handlers can pass whichever they already hold. */ export function extractEchoes(body: unknown | CoexistenceChange[]): MessageEcho[] { const changes = Array.isArray(body) ? body : extractCoexistenceChanges(body); const out: MessageEcho[] = []; for (const change of changes) { if (change.field !== 'smb_message_echoes') continue; const value = change.value as { message_echoes?: unknown[] } | undefined; for (const raw of value?.message_echoes ?? []) { if (typeof raw !== 'object' || raw === null) continue; const echo = raw as Record; out.push({ wamid: typeof echo.id === 'string' ? echo.id : '', phoneNumberId: change.phoneNumberId, to: typeof echo.to === 'string' ? echo.to : null, from: typeof echo.from === 'string' ? echo.from : null, type: typeof echo.type === 'string' ? echo.type : 'unknown', text: readEchoText(echo), timestampMs: readTimestampMs(echo.timestamp), raw, }); } } return out; } /** Arguments handed to `mountWebhook`'s / `mountMultiTenantWebhook`'s `onCoexistence`. */ export interface CoexistenceHandlerArgs { /** Worker env, straight off the Hono context. */ env: unknown; /** Resolved tenant, or null on the single-tenant mount. */ tenantId: string | null; /** Every coexistence change in this webhook. */ changes: CoexistenceChange[]; /** Echoes already parsed out of `changes` — the common case. */ echoes: MessageEcho[]; /** The full raw envelope, for anything the parsed shapes don't cover. */ envelope: unknown; } export type CoexistenceHandler = (args: CoexistenceHandlerArgs) => void | Promise; function readPhoneNumberId(value: unknown): string | null { if (typeof value !== 'object' || value === null) return null; const metadata = (value as { metadata?: { phone_number_id?: unknown } }).metadata; const id = metadata?.phone_number_id; return typeof id === 'string' ? id : null; } function readEchoText(echo: Record): string | null { const text = (echo.text as { body?: unknown } | undefined)?.body; if (typeof text === 'string') return text; for (const key of ['image', 'video', 'document', 'audio'] as const) { const caption = (echo[key] as { caption?: unknown } | undefined)?.caption; if (typeof caption === 'string') return caption; } return null; } function readTimestampMs(raw: unknown): number | null { if (typeof raw === 'number' && Number.isFinite(raw)) return raw * 1000; if (typeof raw === 'string') { const parsed = Number.parseInt(raw, 10); if (Number.isFinite(parsed)) return parsed * 1000; } return null; }