217 lines
6.7 KiB
TypeScript
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);
|
|
}
|