import { timingSafeEqual } from "crypto"; import { buildWhatsAppTransportMetadata } from "../../channels/transport-hints.js"; import type { GatewayConfig } from "../../config.js"; import type { ConfigFileCache } from "../../config-file-cache.js"; import type { CredentialCache } from "../../credential-cache.js"; import { credentialKey } from "../../credential-key.js"; import { resolveCredentialWithRefresh, verifySecretWithRefresh, } from "../../credential-refresh.js"; import { StringDedupCache } from "../../dedup-cache.js"; import { handleInbound } from "../../handlers/handle-inbound.js"; import { getLogger } from "../../logger.js"; import { readLimitedBody } from "../read-limited-body.js"; import { RejectionRateLimiter } from "../../rejection-rate-limiter.js"; import { resolveAssistant, isRejection, } from "../../routing/resolve-assistant.js"; import { AttachmentValidationError, uploadAttachment, } from "../../runtime/client.js"; import { ContentMismatchError } from "../../download-validation.js"; import { handleCircuitBreakerError, handleNewCommand, isNewCommand, processInboundResult, } from "../../webhook-pipeline.js"; import { ROUTING_REJECTION_NOTICE } from "../../webhook-copy.js"; import { downloadWhatsAppFile } from "../../whatsapp/download.js"; import { markWhatsAppMessageRead, WhatsAppNonRetryableError, type WhatsAppApiCaches, } from "../../whatsapp/api.js"; import { normalizeWhatsAppWebhook } from "../../whatsapp/normalize.js"; import { sendWhatsAppReply } from "../../whatsapp/send.js"; import { verifyWhatsAppWebhookSignature } from "../../whatsapp/verify.js"; const log = getLogger("whatsapp-webhook"); const rejectionLimiter = new RejectionRateLimiter(); export function createWhatsAppWebhookHandler( config: GatewayConfig, caches?: { credentials?: CredentialCache; configFile?: ConfigFileCache }, ) { // 24-hour TTL — WhatsApp message IDs are globally unique and never reused const dedupCache = new StringDedupCache(24 * 60 * 60_000); // Build API caches from the credential and config caches for outbound WhatsApp calls const apiCaches: WhatsAppApiCaches | undefined = caches?.credentials ? { credentials: caches.credentials, configFile: caches.configFile } : undefined; const handler = async (req: Request): Promise => { const traceId = req.headers.get("x-trace-id") ?? undefined; const tlog = traceId ? log.child({ traceId }) : log; // Meta sends a GET request to verify the webhook subscription if (req.method === "GET") { const url = new URL(req.url); const mode = url.searchParams.get("hub.mode"); const token = url.searchParams.get("hub.verify_token"); const challenge = url.searchParams.get("hub.challenge"); // Resolve the verify token from cache const verifyToken = caches?.credentials ? await caches.credentials.get( credentialKey("whatsapp", "webhook_verify_token"), ) : undefined; if (mode === "subscribe" && token && verifyToken) { const a = Buffer.from(token); const b = Buffer.from(verifyToken); const match = a.length === b.length && timingSafeEqual(a, b); if (match) { tlog.info("WhatsApp webhook verify token validated"); // Return the challenge as plain text to complete the handshake return new Response(challenge ?? "", { status: 200 }); } tlog.warn("WhatsApp webhook verify token mismatch"); return Response.json({ error: "Forbidden" }, { status: 403 }); } return Response.json({ error: "Bad Request" }, { status: 400 }); } if (req.method !== "POST") { return Response.json({ error: "Method not allowed" }, { status: 405 }); } // Cap body buffering before the (unauthenticated) signature check; a // header-only guard is bypassable via chunked / absent Content-Length. const bodyResult = await readLimitedBody( req, config.maxWebhookPayloadBytes, ); if (bodyResult.status === "too_large") { tlog.warn("WhatsApp webhook payload too large"); return Response.json({ error: "Payload too large" }, { status: 413 }); } if (bodyResult.status === "unreadable") { return Response.json({ error: "Failed to read body" }, { status: 400 }); } const rawBody = bodyResult.text; // Signature validation is required — reject requests when the app secret is not configured // rather than silently accepting unauthenticated payloads (fail-closed). const appSecret = await resolveCredentialWithRefresh( caches?.credentials, credentialKey("whatsapp", "app_secret"), ); if (!appSecret) { tlog.warn("WhatsApp app secret is not configured — rejecting request"); return Response.json( { error: "Webhook signature validation not configured" }, { status: 500 }, ); } const signatureValid = await verifySecretWithRefresh({ credentials: caches?.credentials, key: credentialKey("whatsapp", "app_secret"), verify: (secret) => verifyWhatsAppWebhookSignature(req.headers, rawBody, secret), log: tlog, label: "WhatsApp webhook signature", }); if (!signatureValid) { tlog.warn("WhatsApp webhook signature verification failed"); return Response.json({ error: "Forbidden" }, { status: 403 }); } let payload: Record; try { payload = JSON.parse(rawBody) as Record; } catch { return Response.json({ error: "Invalid JSON" }, { status: 400 }); } const normalizedMessages = normalizeWhatsAppWebhook(payload); if (normalizedMessages.length === 0) { // Delivery receipts, status updates, etc. — acknowledge silently return Response.json({ ok: true }); } // Track whether any message failed so we can signal Meta to retry the batch. // Returning 500 causes Meta to resend the webhook; the dedup cache ensures // already-processed messages are skipped while failed ones are retried. let hasFailure = false; for (const normalized of normalizedMessages) { const { event, whatsappMessageId, mediaType } = normalized; const from = event.message.conversationExternalId; // Dedup by WhatsApp message ID — atomically reserve so concurrent retries // are blocked while the first request is still processing. if (!dedupCache.reserve(whatsappMessageId)) { tlog.info( { whatsappMessageId }, "Duplicate WhatsApp message ID, ignoring", ); continue; } tlog.info( { source: "whatsapp", messageId: whatsappMessageId, from, ...(mediaType ? { mediaType } : {}), }, "WhatsApp webhook received", ); // Mark message as read (best-effort, do not await) markWhatsAppMessageRead(whatsappMessageId, apiCaches).catch((err) => { tlog.debug( { err, messageId: whatsappMessageId }, "Failed to mark WhatsApp message as read", ); }); // Resolve routing once so we can gate further operations on it const routing = resolveAssistant(config, from, from); // Handle /new command — reset conversation before it reaches the runtime if (isNewCommand(event.message.content)) { if (isRejection(routing)) { tlog.warn( { from, reason: routing.reason }, "Routing rejected /new command", ); sendWhatsAppReply( config, from, ROUTING_REJECTION_NOTICE, undefined, apiCaches, ).catch((err) => { tlog.error( { err, to: from }, "Failed to send /new routing rejection notice", ); }); } else { await handleNewCommand( config, event.sourceChannel, event.message.conversationExternalId, async (text) => { await sendWhatsAppReply(config, from, text, undefined, apiCaches); }, tlog, ); } dedupCache.mark(whatsappMessageId); continue; } if (isRejection(routing)) { tlog.warn( { from, reason: routing.reason }, "Routing rejected inbound WhatsApp message", ); if (rejectionLimiter.shouldSend(from)) { sendWhatsAppReply( config, from, ROUTING_REJECTION_NOTICE, undefined, apiCaches, ).catch((err) => { tlog.error( { err, to: from }, "Failed to send routing rejection notice", ); }); } dedupCache.mark(whatsappMessageId); continue; } // Download and upload attachments if present let attachmentIds: string[] | undefined; const failedAttachmentNames: string[] = []; const eventAttachments = event.message.attachments; if (eventAttachments && eventAttachments.length > 0) { try { attachmentIds = []; // Filter oversized attachments const eligible = eventAttachments.filter((att) => { if ( att.fileSize !== undefined && att.fileSize > (config.maxAttachmentBytes.whatsapp ?? config.maxAttachmentBytes.default) ) { tlog.warn( { fileId: att.fileId, fileSize: att.fileSize, limit: config.maxAttachmentBytes.whatsapp ?? config.maxAttachmentBytes.default, }, "Skipping oversized WhatsApp attachment", ); return false; } return true; }); // Process with bounded concurrency. Validation errors (unsupported // MIME type, dangerous extension) are skipped so that a bad attachment // doesn't drop the user's message. Transient errors (download timeout, // upload 5xx, network failures) are propagated so that Meta retries // the webhook delivery. for ( let i = 0; i < eligible.length; i += config.maxAttachmentConcurrency ) { const batch = eligible.slice( i, i + config.maxAttachmentConcurrency, ); const results = await Promise.allSettled( batch.map(async (att) => { const downloaded = await downloadWhatsAppFile( config, att.fileId, { fileName: att.fileName, mimeType: att.mimeType, }, apiCaches, ); return uploadAttachment(config, downloaded); }), ); for (let j = 0; j < results.length; j++) { const result = results[j]; if (result.status === "fulfilled") { attachmentIds.push(result.value.id); } else if (result.reason instanceof AttachmentValidationError) { tlog.warn( { err: result.reason }, "Skipping WhatsApp attachment with validation error", ); failedAttachmentNames.push( batch[j].fileName || batch[j].fileId, ); } else if (result.reason instanceof ContentMismatchError) { tlog.warn( { err: result.reason }, "Skipping WhatsApp attachment with content mismatch", ); failedAttachmentNames.push( batch[j].fileName || batch[j].fileId, ); } else if (result.reason instanceof WhatsAppNonRetryableError) { tlog.warn( { err: result.reason }, "Skipping WhatsApp attachment with non-retryable error", ); failedAttachmentNames.push( batch[j].fileName || batch[j].fileId, ); } else { // Transient failure — propagate so the webhook returns 500 and // Meta retries the update delivery. throw result.reason; } } } } catch (err) { // Transient attachment failure — return 500 so Meta retries. tlog.error( { err }, "WhatsApp attachment processing failed with transient error", ); dedupCache.unreserve(whatsappMessageId); hasFailure = true; continue; } } // Inject context about failed attachments into the message if (failedAttachmentNames.length > 0) { const failureNotice = `[The user attached file(s) that could not be retrieved: ${failedAttachmentNames.map((n) => `"${n}"`).join(", ")}. Ask them to re-send if the content is important.]`; if (event.message.content.length > 0) { event.message.content += `\n\n${failureNotice}`; } else { event.message.content = failureNotice; } } // Media-only messages with no successfully uploaded attachments have // nothing to forward — skip silently. if ( event.message.content.length === 0 && (!attachmentIds || attachmentIds.length === 0) ) { dedupCache.mark(whatsappMessageId); continue; } try { const result = await handleInbound(config, event, { attachmentIds, transportMetadata: buildWhatsAppTransportMetadata(), replyCallbackUrl: `${config.gatewayInternalBaseUrl}/deliver/whatsapp`, traceId, routingOverride: routing, }); const processed = processInboundResult( result, dedupCache, whatsappMessageId, () => { if (rejectionLimiter.shouldSend(from)) { sendWhatsAppReply( config, from, ROUTING_REJECTION_NOTICE, undefined, apiCaches, ).catch((err) => { tlog.error( { err, to: from }, "Failed to send routing rejection notice", ); }); } }, tlog, ); if (!processed.ok) { hasFailure = true; continue; } // Rejected messages are processed successfully (ok: true) but should // not be logged as "forwarded" — the rejection callback already handled them. if (result.rejected) { dedupCache.mark(whatsappMessageId); continue; } dedupCache.mark(whatsappMessageId); if (!processed.rejected) { tlog.info( { status: "forwarded", whatsappMessageId }, "WhatsApp message forwarded to runtime", ); } } catch (err) { const cbResponse = handleCircuitBreakerError( err, dedupCache, whatsappMessageId, tlog, ); if (cbResponse) return cbResponse; tlog.error( { err, whatsappMessageId }, "Failed to process inbound WhatsApp message", ); dedupCache.unreserve(whatsappMessageId); hasFailure = true; } } if (hasFailure) { return Response.json({ error: "Internal error" }, { status: 500 }); } return Response.json({ ok: true }); }; return { handler, dedupCache }; }