/** * WhatsApp webhook controller. * * GET /api/webhooks/whatsapp — hub.challenge verification (required by Meta). * POST /api/webhooks/whatsapp — inbound message processing. * * Raw body note: Fastify does NOT attach req.rawBody by default. We read the * body from req.body (already parsed JSON) — safe because the HMAC is over the * raw bytes before JSON parse. For full signature verification in production, * configure `addContentTypeParser` or use the rawBody plugin. In dev, when * WHATSAPP_APP_SECRET is unset we skip verification entirely (logged warning). * * Day 2 is INBOUND ONLY — no messages are sent back (CLAUDE.md #2, #3). */ import { Controller, Get, Headers, HttpCode, HttpException, HttpStatus, Inject, Logger, Post, Query, RawBodyRequest, Req, Res, } from "@nestjs/common"; import type { FastifyReply, FastifyRequest } from "fastify"; import { eq } from "drizzle-orm"; import { type Database, webhookEvents } from "@courtmitra/db"; import type { NormalizedInboundMessage } from "@courtmitra/contracts"; import { verifyWebhookChallenge, verifyWhatsAppSignature } from "@courtmitra/adapters"; import { DB } from "../db/db.module.js"; import { ConversationsService } from "../conversations/conversations.service.js"; import { WorkflowService } from "../workflow/inngest.module.js"; // ---- WhatsApp Cloud API payload types (minimal) --------------------------- interface WaTextMessage { body: string } interface WaImage { id: string; mime_type?: string } interface WaDocument { id: string; mime_type?: string; filename?: string } interface WaAudio { id: string; mime_type?: string } interface WaVideo { id: string; mime_type?: string } interface WaMessage { id: string; from: string; type: "text" | "image" | "document" | "audio" | "video" | "interactive" | string; text?: WaTextMessage; image?: WaImage; document?: WaDocument; audio?: WaAudio; video?: WaVideo; } interface WaEntry { id: string; changes: Array<{ value: { messaging_product: string; metadata: { phone_number_id: string; display_phone_number: string }; contacts?: Array<{ wa_id: string; profile?: { name?: string } }>; messages?: WaMessage[]; }; }>; } interface WaWebhookPayload { object: string; entry: WaEntry[]; } function normalizeWhatsAppMessage( msg: WaMessage, entry: WaEntry, ): NormalizedInboundMessage | null { const phoneNumberId = entry.changes[0]?.value.metadata.phone_number_id ?? "unknown"; const media: NormalizedInboundMessage["media"] = []; if (msg.image) { media.push({ kind: "photo", providerFileId: msg.image.id, mimeType: msg.image.mime_type ?? "image/jpeg", filename: null }); } if (msg.document) { media.push({ kind: "document", providerFileId: msg.document.id, mimeType: msg.document.mime_type ?? null, filename: msg.document.filename ?? null, }); } if (msg.audio) { media.push({ kind: "audio", providerFileId: msg.audio.id, mimeType: msg.audio.mime_type ?? "audio/ogg", filename: null }); } if (msg.video) { media.push({ kind: "video", providerFileId: msg.video.id, mimeType: msg.video.mime_type ?? "video/mp4", filename: null }); } return { channel: "whatsapp", // Thread = the WA number pair (sender + our phone number ID) externalThreadId: `${msg.from}@${phoneNumberId}`, providerMessageId: msg.id, fromSubject: msg.from, text: msg.text?.body ?? null, media, rawPayloadRef: msg, }; } // --------------------------------------------------------------------------- @Controller("webhooks/whatsapp") export class WhatsAppWebhookController { private readonly logger = new Logger(WhatsAppWebhookController.name); constructor( @Inject(DB) private readonly db: Database, @Inject(ConversationsService) private readonly conversations: ConversationsService, @Inject(WorkflowService) private readonly workflow: WorkflowService, ) {} /** Meta webhook verification challenge (GET). */ @Get() async verify( @Query() query: Record, @Res() res: FastifyReply, ) { const challenge = verifyWebhookChallenge(query); if (!challenge) { return res.status(HttpStatus.FORBIDDEN).send("Verification failed"); } return res.status(HttpStatus.OK).send(challenge); } /** Inbound message processing (POST). */ @Post() @HttpCode(200) async handleUpdate( @Req() req: FastifyRequest & RawBodyRequest, @Headers("x-hub-signature-256") signatureHeader?: string, ) { const appSecret = process.env["WHATSAPP_APP_SECRET"]; // Fix 13: in production, reject requests if the secret is unset. if (!appSecret && process.env["NODE_ENV"] === "production") { throw new HttpException("Forbidden: WHATSAPP_APP_SECRET not configured", HttpStatus.FORBIDDEN); } if (appSecret) { // Verify HMAC signature. // Note: Fastify parses the body before we see it here, so we use // the re-serialized body for HMAC. In production, configure the // rawBody plugin for exact byte-level verification. const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body), "utf8"); const sig = signatureHeader ?? ""; if (!verifyWhatsAppSignature(rawBody, sig)) { throw new HttpException("Forbidden: invalid signature", HttpStatus.FORBIDDEN); } } else { // Dev bypass: skip signature verification when secret is unset (CLAUDE.md convention) this.logger.warn("WHATSAPP_APP_SECRET not set — skipping signature verification (dev mode)"); } const payload = req.body as WaWebhookPayload; if (!payload || payload.object !== "whatsapp_business_account") { return { ok: true, skipped: "not a whatsapp_business_account event" }; } // Process all messages across entries (usually one entry in prod) for (const entry of payload.entry ?? []) { for (const change of entry.changes ?? []) { const msgs = change.value.messages ?? []; for (const msg of msgs) { await this.processWhatsAppMessage(msg, entry, payload); } } } return { ok: true }; } private async processWhatsAppMessage( msg: WaMessage, entry: WaEntry, rawPayload: WaWebhookPayload, ) { const providerEventId = `whatsapp:${msg.id}`; // Dedupe const [inserted] = await this.db .insert(webhookEvents) .values({ provider: "whatsapp", providerEventId, payload: rawPayload as unknown as Record, }) .onConflictDoNothing() .returning(); if (!inserted) return; // already processed const normalized = normalizeWhatsAppMessage(msg, entry); if (!normalized) { await this.db .update(webhookEvents) .set({ processedAt: new Date() }) .where(eq(webhookEvents.id, inserted.id)); return; } const result = await this.conversations.ingestInbound(normalized, {}); try { // Emit message.received unconditionally — photo-only messages must also trigger intake. await this.workflow.send("message.received", { missionId: result.missionId, conversationId: result.conversationId, messageId: result.messageId, }); for (const evidenceItemId of result.evidenceItemIds) { await this.workflow.send("evidence.uploaded", { missionId: result.missionId, evidenceItemId, }); } } catch (err) { this.logger.warn(`Inngest send failed (non-fatal): ${String(err)}`); } await this.db .update(webhookEvents) .set({ processedAt: new Date() }) .where(eq(webhookEvents.id, inserted.id)); } }