Initial BizOne portal setup
This commit is contained in:
216
backend/src/webhooks/webhooks.utils.ts
Normal file
216
backend/src/webhooks/webhooks.utils.ts
Normal file
@ -0,0 +1,216 @@
|
||||
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' &&
|
||||
readString(payloadRecord.object) === 'whatsapp_business_account'
|
||||
) {
|
||||
const metaEvents = buildMetaEvents(payloadRecord, normalizedProvider);
|
||||
if (metaEvents.length > 0) {
|
||||
return metaEvents;
|
||||
}
|
||||
}
|
||||
|
||||
return buildGenericEvents(payloadRecord, normalizedProvider);
|
||||
}
|
||||
Reference in New Issue
Block a user