Spaces:
Starting
Starting
| /** | |
| * 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"; | |
| import { Public } from "../auth/public.decorator.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, | |
| }; | |
| } | |
| // --------------------------------------------------------------------------- | |
| () | |
| ("webhooks/whatsapp") | |
| export class WhatsAppWebhookController { | |
| private readonly logger = new Logger(WhatsAppWebhookController.name); | |
| constructor( | |
| (DB) private readonly db: Database, | |
| (ConversationsService) private readonly conversations: ConversationsService, | |
| (WorkflowService) private readonly workflow: WorkflowService, | |
| ) {} | |
| /** Meta webhook verification challenge (GET). */ | |
| () | |
| async verify( | |
| () query: Record<string, string>, | |
| () 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). */ | |
| () | |
| (200) | |
| async handleUpdate( | |
| () req: FastifyRequest & RawBodyRequest<FastifyRequest>, | |
| ("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<string, unknown>, | |
| }) | |
| .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, {}); | |
| // Emit message.received unconditionally — photo-only messages must also trigger intake. | |
| // If workflow emit fails, propagate so provider can retry and we avoid marking | |
| // processedAt for an unprocessed event. | |
| 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, | |
| }); | |
| } | |
| await this.db | |
| .update(webhookEvents) | |
| .set({ processedAt: new Date() }) | |
| .where(eq(webhookEvents.id, inserted.id)); | |
| } | |
| } | |