/** * 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-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 = { '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, ): Promise { 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(', ')) }