/** * ChannelWebhookHost — the HTTP mount for webhook-driven channels (CC0). * * Self-driving channels (telegram/slack/discord) run in ChannelRunner; the * webhook channels (whatsapp/sms) need a public HTTP surface a provider can * POST to. This host reads the SAME channel store, builds one shuttle per * enabled webhook channel, and mounts each at a per-channel path: * * GET /webhooks/whatsapp/ Meta verification handshake * POST /webhooks/whatsapp/ Meta Cloud API events * POST /webhooks/sms/ Twilio inbound form * GET /healthz liveness (for tunnels/proxies) * * Discipline (learned from production BSPs — see the channel-connect board): * - verify the signature FIRST, on the raw body * - respond 200 fast, process async — never make the provider wait * - a bad Meta signature gets 200 + drop (a 4xx makes Meta retry-storm a * stale app config forever); a bad Twilio signature gets 403 * - dedup by provider message id (Meta re-delivers the same event) * - a payload for the wrong phone_number_id is dropped, not misrouted * * Binds to loopback by default: exposing the port is an explicit act (a * tunnel or reverse proxy in front, or OWNWARE_WEBHOOK_HOST=0.0.0.0). * The listener starts only when at least one enabled webhook channel exists. */ import { type GatewayClient } from '../gateway-client.js'; import type { PairingStore } from '../pairing.js'; import { WhatsAppShuttle } from '../whatsapp/shuttle.js'; import { SmsShuttle } from '../sms/shuttle.js'; import { type WhatsAppWebhookBody } from '../whatsapp/message.js'; import { type WhatsAppDeliveryStore } from '../whatsapp/delivery-store.js'; import type { ChannelConfig } from './config.js'; import type { ChannelStore } from './store.js'; export type WebhookInstance = { readonly kind: 'whatsapp'; readonly config: ChannelConfig; readonly shuttle: WhatsAppShuttle; } | { readonly kind: 'sms'; readonly config: ChannelConfig; readonly shuttle: SmsShuttle; }; export type WebhookShuttleFactory = (config: ChannelConfig, gateway: GatewayClient, deps: { readonly pairing?: PairingStore; readonly fetch?: typeof fetch; readonly whatsappDelivery?: WhatsAppDeliveryStore; }) => WebhookInstance | null; /** Default factory: the webhook-driven channels. Self-driving kinds return null. */ export declare function defaultWebhookFactory(config: ChannelConfig, gateway: GatewayClient, deps?: { readonly pairing?: PairingStore; readonly fetch?: typeof fetch; readonly whatsappDelivery?: WhatsAppDeliveryStore; }): WebhookInstance | null; export interface WhatsAppFilterResult { readonly body: WhatsAppWebhookBody; /** WAMIDs of the messages kept (mark them seen before async dispatch). */ readonly ids: string[]; readonly droppedSeen: number; readonly droppedMismatch: number; } /** * Keep only messages this channel should process: drop changes addressed to a * different phone_number_id (one Meta app can carry many numbers — never * misroute), and drop already-seen WAMIDs (Meta re-delivers). Messages * without an id are kept — they can't be deduped. */ export declare function filterWhatsAppInbound(body: WhatsAppWebhookBody, phoneNumberId: string, isSeen: (id: string) => boolean): WhatsAppFilterResult; export interface WebhookHostOptions { readonly gateway?: GatewayClient; readonly gatewayUrl?: string; readonly gatewayToken?: string; /** Pairing store handed to personal-line channels. */ readonly pairing?: PairingStore; /** Public base URL (e.g. the tunnel URL). Enables Twilio signature validation. */ readonly publicBaseUrl?: string; /** Injectable outbound fetch for provider APIs (tests). */ readonly fetch?: typeof fetch; readonly factory?: WebhookShuttleFactory; /** Durable WhatsApp WAMID/delivery/handoff owner. File-backed in real CLIs. */ readonly whatsappDelivery?: WhatsAppDeliveryStore; /** Diagnostics sink. Default: console.error. Never receives secrets. */ readonly log?: (line: string) => void; } export interface WebhookHostStartOptions { /** Listen port. Default 3012 (`OWNWARE_WEBHOOK_PORT` in the CLIs). 0 = ephemeral. */ readonly port?: number; /** Bind host. Default 127.0.0.1 — exposing is an explicit act. */ readonly host?: string; } export interface WebhookHostStatus { /** Actual listen port, or null while no enabled webhook channel exists. */ readonly port: number | null; /** Mounted webhook paths, one per channel. */ readonly paths: string[]; } export declare class ChannelWebhookHost { private readonly store; private readonly opts; private readonly instances; private readonly gateway; private readonly factory; private readonly smsSeen; private readonly whatsappDelivery; private readonly inFlight; /** Per-customer chain: preserves turn order and prevents two first messages creating two threads. */ private readonly whatsappQueues; private readonly log; private server; private listen; private boundPort; private recovered; constructor(store: ChannelStore, opts?: WebhookHostOptions); /** Build instances from the store and listen if any webhook channel exists. */ start(opts?: WebhookHostStartOptions): Promise; /** Diff the store vs running instances; start/stop the listener as needed. */ reload(): Promise; status(): WebhookHostStatus; get activeIds(): string[]; /** Await all in-flight async dispatches (tests / graceful shutdown). */ idle(): Promise; stop(): Promise; private startServer; private stopServer; private track; private scheduleWhatsApp; private route; private whatsappChallenge; private whatsappInbound; private dispatchWhatsApp; private smsInbound; } //# sourceMappingURL=webhook-host.d.ts.map