Files
BizOne-portal/backend/src/webhooks/webhooks.utils.ts

217 lines
6.7 KiB
TypeScript

import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import type {
NormalizedWebhookEvent,
} from './webhooks.types';
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
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<string, unknown>, 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<string, unknown>, 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<string, string> = {
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<string, unknown>, 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);
}