import { createHmac, createHash, timingSafeEqual } from 'node:crypto'; import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import type { NormalizedWebhookEvent, } from './webhooks.types'; function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) { return null; } return value as Record; } function asArray(value: unknown) { return Array.isArray(value) ? value : []; } function readString(value: unknown) { return typeof value === 'string' && value.trim() ? value.trim() : undefined; } function readTimestamp(value: unknown) { if (typeof value === 'number' || (typeof value === 'string' && value.trim())) { const raw = String(value).trim(); const milliseconds = /^\d+$/.test(raw) ? Number(raw) * 1000 : Date.parse(raw); if (!Number.isNaN(milliseconds)) { return new Date(milliseconds); } } return new Date(); } function buildEventId(provider: string, payload: Record, seed?: string) { const hash = createHash('sha256') .update(provider) .update(':') .update(seed || JSON.stringify(payload)) .digest('hex'); return `evt_${hash.slice(0, 32)}`; } function buildMetaEvents(payload: Record, provider: string) { const normalized: NormalizedWebhookEvent[] = []; const entries = asArray(payload.entry); for (const entry of entries) { const entryRecord = asRecord(entry); const changes = asArray(entryRecord?.changes); for (const change of changes) { const changeRecord = asRecord(change); const field = readString(changeRecord?.field); const value = asRecord(changeRecord?.value); if (!value) { continue; } const metadata = asRecord(value.metadata); const recipientPhone = readString(metadata?.display_phone_number) || readString(metadata?.phone_number_id); for (const status of asArray(value.statuses)) { const statusRecord = asRecord(status); if (!statusRecord) { continue; } const rawStatus = readString(statusRecord.status) || 'sent'; const statusMap: Record = { sent: 'message.sent', delivered: 'message.delivered', read: 'message.read', failed: 'message.failed', }; normalized.push({ provider, eventType: statusMap[rawStatus] || 'message.sent', eventId: readString(statusRecord.id) || readString(statusRecord.meta_msg_id) || buildEventId(provider, statusRecord, rawStatus), senderPhone: readString(statusRecord.recipient_id), recipientPhone, externalMessageId: readString(statusRecord.id), eventTimestamp: readTimestamp(statusRecord.timestamp), payload: statusRecord, }); } for (const message of asArray(value.messages)) { const messageRecord = asRecord(message); if (!messageRecord) { continue; } const contacts = asArray(value.contacts); const firstContact = asRecord(contacts[0]); const senderPhone = readString(messageRecord.from) || readString(firstContact?.wa_id) || readString(firstContact?.phone); normalized.push({ provider, eventType: field === 'messages' ? 'message.inbound' : 'account.updated', eventId: readString(messageRecord.id) || buildEventId(provider, messageRecord, readString(messageRecord.from)), senderPhone, recipientPhone, externalMessageId: readString(messageRecord.id), eventTimestamp: readTimestamp(messageRecord.timestamp), payload: messageRecord, }); } if (field === 'message_template_status_update') { normalized.push({ provider, eventType: 'template.updated', eventId: buildEventId(provider, value, 'template'), recipientPhone, eventTimestamp: new Date(), payload: value, }); } } } return normalized; } function buildGenericEvents(payload: Record, provider: string) { const eventType = readString(payload.event_type) || readString(payload.eventType) || readString(payload.type) || 'account.updated'; const senderPhone = readString(payload.sender_phone) || readString(payload.senderPhone) || readString(payload.from); const recipientPhone = readString(payload.recipient_phone) || readString(payload.recipientPhone) || readString(payload.to); const externalMessageId = readString(payload.external_message_id) || readString(payload.externalMessageId) || readString(payload.message_id) || readString(payload.messageId); const seed = readString(payload.event_id) || readString(payload.id) || externalMessageId || JSON.stringify(payload); return [ { provider, eventType, eventId: readString(payload.event_id) || readString(payload.eventId) || readString(payload.id) || buildEventId(provider, payload, seed), senderPhone, recipientPhone, externalMessageId, eventTimestamp: readTimestamp(payload.timestamp || payload.created_at || payload.createdAt), payload, }, ]; } export function verifyMetaSignature(rawBody: Buffer, signatureHeader: string, appSecret: string) { const [scheme, receivedSignature] = signatureHeader.split('='); if (scheme !== 'sha256' || !receivedSignature) { throw new UnauthorizedException('Invalid meta webhook signature format'); } const expectedSignature = createHmac('sha256', appSecret).update(rawBody).digest('hex'); const receivedBuffer = Buffer.from(receivedSignature, 'hex'); const expectedBuffer = Buffer.from(expectedSignature, 'hex'); if ( receivedBuffer.length !== expectedBuffer.length || !timingSafeEqual(receivedBuffer, expectedBuffer) ) { throw new UnauthorizedException('Invalid meta webhook signature'); } } export function normalizeWebhookPayload(provider: string, payload: unknown) { const payloadRecord = asRecord(payload); if (!payloadRecord) { throw new BadRequestException('Webhook payload must be a JSON object'); } const normalizedProvider = provider.toLowerCase(); if ( (normalizedProvider === 'meta' || normalizedProvider === 'default') && readString(payloadRecord.object) === 'whatsapp_business_account' ) { const metaEvents = buildMetaEvents(payloadRecord, normalizedProvider); if (metaEvents.length > 0) { return metaEvents; } } return buildGenericEvents(payloadRecord, normalizedProvider); }