111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
/**
|
|
* Webhook Dispatcher — envia eventos para URLs registradas na tabela ext_webhooks.
|
|
*
|
|
* Fluxo:
|
|
* hookBus emite ext:message.new ou ext:session.status
|
|
* → dispatcher consulta ext_webhooks do tenant
|
|
* → para cada webhook ativo que subscreve o evento, faz POST com envelope + assinatura HMAC
|
|
*
|
|
* Envelope enviado ao receptor:
|
|
* { event, data, timestamp }
|
|
*
|
|
* Header de assinatura:
|
|
* x-nw-signature: sha256=<hmac-hex>
|
|
* HMAC-SHA256(secret, JSON.stringify(body))
|
|
*
|
|
* Retry: sem retry automático nesta fase (Fase 5 scope mínimo).
|
|
* Timeout: 10s por requisição para não bloquear o event loop.
|
|
*/
|
|
import { createHmac } from 'crypto'
|
|
import type { PrismaClient } from '@prisma/client'
|
|
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
|
let rootLogger: any
|
|
if (process.env.NODE_ENV === 'production') {
|
|
rootLogger = require('../../../dist/config/logger').logger
|
|
} else {
|
|
rootLogger = require('../../../backend/src/config/logger').logger
|
|
}
|
|
|
|
const logger = rootLogger.child({ module: 'webhook-dispatcher' })
|
|
|
|
const DISPATCH_TIMEOUT_MS = 10_000
|
|
|
|
// Eventos hookBus → nome de evento no payload enviado ao receptor
|
|
const HOOK_TO_EVENT: Record<string, string> = {
|
|
'ext:message.new': 'message.new',
|
|
'ext:session.status': 'session.status',
|
|
}
|
|
|
|
function sign(secret: string, body: string): string {
|
|
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
|
|
}
|
|
|
|
async function dispatch(
|
|
url: string,
|
|
secret: string,
|
|
event: string,
|
|
data: Record<string, unknown>,
|
|
): Promise<void> {
|
|
const body = JSON.stringify({ event, data, timestamp: Date.now() })
|
|
const sig = sign(secret, body)
|
|
|
|
const ctrl = new AbortController()
|
|
const timer = setTimeout(() => ctrl.abort(), DISPATCH_TIMEOUT_MS)
|
|
|
|
try {
|
|
const res = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
'x-nw-signature': sig,
|
|
},
|
|
body,
|
|
signal: ctrl.signal,
|
|
})
|
|
if (!res.ok) {
|
|
logger.warn({ url, event, status: res.status }, '[webhook] Receptor retornou erro')
|
|
}
|
|
} catch (err: any) {
|
|
logger.warn({ url, event, err: err.message }, '[webhook] Falha ao entregar')
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
}
|
|
|
|
export function buildWebhookDispatcher(prisma: PrismaClient, hooks: HookBus): void {
|
|
for (const [hookEvent, wsEvent] of Object.entries(HOOK_TO_EVENT)) {
|
|
hooks.register(hookEvent, async (raw: any) => {
|
|
const tenantId: string | undefined = raw.tenantId
|
|
if (!tenantId) return
|
|
|
|
// Carregar apenas webhooks ativos que subscrevem este evento
|
|
let webhooks: Array<{ url: string; secret: string }>
|
|
try {
|
|
webhooks = await prisma.extWebhook.findMany({
|
|
where: {
|
|
tenantId,
|
|
active: true,
|
|
events: { has: wsEvent },
|
|
},
|
|
select: { url: true, secret: true },
|
|
})
|
|
} catch (err: any) {
|
|
logger.error({ err, tenantId, event: wsEvent }, '[webhook] Erro ao buscar webhooks')
|
|
return
|
|
}
|
|
|
|
if (webhooks.length === 0) return
|
|
|
|
// Retira tenantId do payload antes de enviar ao receptor
|
|
const { tenantId: _tid, ...data } = raw
|
|
|
|
// Dispara em paralelo sem bloquear o hookBus
|
|
Promise.allSettled(
|
|
webhooks.map(wh => dispatch(wh.url, wh.secret, wsEvent, data))
|
|
).catch(() => {})
|
|
})
|
|
}
|
|
|
|
logger.info('[webhook] Dispatcher ativo para eventos: ' + Object.values(HOOK_TO_EVENT).join(', '))
|
|
}
|