Files
mercado.clube67.com/backend/plugins/newwhats/webhook-receiver.js
T

663 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict'
/**
* Receptor de Webhooks — motor NewWhats → satélite Alemão
*
* Rota: POST /api/webhook/newwhats
* Origem: motor (newwhats.local:8008) via HTTP direto
*
* Validação:
* Header x-nw-signature: sha256=<hmac>
* HMAC-SHA256(webhook_secret, rawBody)
*
* Eventos tratados:
* message.new — nova mensagem recebida/enviada
* session.status — instância conectou / desconectou
*
* Auto-resposta (Frente 2 v2.2):
* Modo controlado por plugin_config auto_reply_mode:
* off → só loga (comportamento anterior)
* automatico → chama brain do motor e envia reply
*
* Regras de segurança:
* - fromMe=true → nunca responde
* - role=notificacoes → nunca responde
* - cooldown 15s por chat_id → evita flood (era 45s, reduzido pois bloqueava 2ª msg em conversa ativa)
* - max 8 replies/conversa em 1h → anti-loop
* - só responde msg tipo TEXT
*/
const { createHmac, timingSafeEqual } = require('crypto')
const { Router } = require('express')
const { queryOne, query, execute } = require('../../database/postgres')
const SECRET_KEY = 'webhook_secret'
const MOTOR_URL_KEY = 'newwhats_url'
const INTEG_KEY_KEY = 'integration_key'
const AUTO_MODE_KEY = 'auto_reply_mode' // off | automatico
const COOLDOWN_MS = 15_000 // 15s entre replies no mesmo chat
const MAX_REPLIES_1H = 8 // máx respostas automáticas por chat por hora
// ── Config helpers ────────────────────────────────────────────────────────────
// Cache em memória: evita query ao banco em cada mensagem recebida.
// Config muda raramente (admin altera via painel) — TTL de 60s é seguro.
let _cfgCache = null
let _cfgExpiry = 0
async function getPluginConfig() {
if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache
const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
_cfgCache = {}
for (const r of rows) _cfgCache[r.key] = r.value
_cfgExpiry = Date.now() + 60_000
return _cfgCache
}
// Cache de role por instance_id — HTTP ao motor em cada msg custa 200-500ms.
// TTL de 5min: role muda raramente (admin altera via painel da secretária).
const _numberRoleCache = new Map()
const NUMBER_ROLE_TTL = 5 * 60_000
async function getNumberRole(motorUrl, integKey, instanceId) {
const cached = _numberRoleCache.get(instanceId)
if (cached && Date.now() - cached.ts < NUMBER_ROLE_TTL) return cached
try {
const res = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, {
headers: { 'x-nw-key': integKey ?? '' },
})
if (!res.ok) return null
const nums = await res.json()
const info = Array.isArray(nums) ? nums.find(n => n.instance_id === instanceId) : null
const entry = { role: info?.role ?? null, active: info?.active ?? true, ts: Date.now() }
_numberRoleCache.set(instanceId, entry)
return entry
} catch { return null }
}
// ── Assinatura HMAC ───────────────────────────────────────────────────────────
async function getSecret() {
const row = await queryOne(
"SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = $1",
[SECRET_KEY],
)
return row?.value ?? null
}
function verifySignature(secret, rawBody, sigHeader) {
if (!sigHeader?.startsWith('sha256=')) return false
const expected = Buffer.from(
'sha256=' + createHmac('sha256', secret).update(rawBody).digest('hex'),
)
const received = Buffer.from(sigHeader)
if (expected.length !== received.length) return false
return timingSafeEqual(expected, received)
}
// ── Roteador principal ────────────────────────────────────────────────────────
function createWebhookReceiver() {
const router = Router()
router.post('/api/webhook/newwhats', async (req, res) => {
try {
const sig = req.headers['x-nw-signature']
const secret = await getSecret()
if (!secret) {
console.warn('[nw-webhook] webhook_secret não configurado — assinatura ignorada')
} else {
const rawBody = req.rawBody ?? Buffer.from(JSON.stringify(req.body))
if (!verifySignature(secret, rawBody, sig)) {
console.warn('[nw-webhook] Assinatura inválida — rejeitado')
return res.status(401).json({ error: 'Assinatura inválida' })
}
}
const { event, data } = req.body ?? {}
if (!event || !data) return res.status(400).json({ error: 'Payload inválido' })
if (event === 'message.new') {
await handleMessageNew(data)
} else if (event === 'session.status') {
await handleSessionStatus(data)
}
res.json({ ok: true })
} catch (err) {
console.error('[nw-webhook] Erro interno:', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
return router
}
// ── Handler: message.new ──────────────────────────────────────────────────────
async function handleMessageNew(data) {
const msg = data?.message
if (!msg) return
// 1. Loga sempre — captura id para poder atualizar payload após extração de mídia
const logRow = await queryOne(
`INSERT INTO nw_event_logs
(instance_id, event, direction, chat_id, from_me, msg_type, payload)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id`,
[
data.instanceId ?? '',
'message.new',
msg.fromMe ? 'out' : 'in',
data.chatId ?? null,
msg.fromMe ?? false,
msg.type ?? null,
JSON.stringify({ body: msg.body, hasMedia: msg.hasMedia, author: msg.author, pushName: msg.pushName }),
],
)
const logId = logRow?.id ?? null
// 2. Nunca responder mensagem enviada pelo próprio número.
// Se havia handoff ativo, o operador respondeu — libera a IA imediatamente.
if (msg.fromMe) {
try {
const { sseBroadcast } = require('../../services/sse')
const chatId = data.chatId ?? ''
const updated = await execute(
`UPDATE nw_handoff SET human_until=NULL, updated_at=NOW()
WHERE chat_id=$1 AND human_until > NOW()`,
[chatId]
)
if (updated?.rowCount > 0) {
console.log(`[nw-webhook] handoff limpo após resposta do operador — ${chatId}`)
sseBroadcast('handoff.expired', { chatId })
}
} catch { /* silencioso */ }
return
}
// 2b. Captura avaliação de satisfação (15) se houver pesquisa pendente
const chatId = data.chatId ?? ''
const body = (msg.body ?? '').trim()
if (/^[1-5]$/.test(body)) {
try {
const { queryOne: qOne, execute: dbExec } = require('../../database/postgres')
const pending = await qOne(
`SELECT id FROM protocol_ratings
WHERE chat_id=$1 AND status='pending'
ORDER BY sent_at DESC LIMIT 1`,
[chatId]
)
if (pending) {
await dbExec(
`UPDATE protocol_ratings SET rating=$1, status='rated', rated_at=NOW() WHERE id=$2`,
[parseInt(body), pending.id]
)
console.log(`[nw-webhook] ⭐ Avaliação ${body}/5 registrada para ${chatId}`)
return // não processa como auto-reply
}
} catch { /* silencioso */ }
}
// 3. Lê config do plugin
const cfg = await getPluginConfig()
const mode = cfg[AUTO_MODE_KEY] ?? 'off'
if (mode === 'off') return
// 4. Chats de grupos ignorados (@g.us)
if (chatId.endsWith('@g.us')) return
// 5. Tipo de mensagem: TEXT, AUDIO (PTT), IMAGE, DOCUMENT (PDF), VIDEO e
// CONTACT/VCARD são processados. Áudio é transcrito por Whisper/Gemini;
// imagem é descrita/classificada; PDF tem texto extraído; vídeo é
// descrito (cena+áudio) por Gemini nativo; vCard é parseado localmente.
// LOCATION (GPS): TODO — resolver coordenadas para endereço via Maps API.
// STICKER e outros: ignorados.
const msgType = (msg.type ?? '').toUpperCase()
let effectiveBody = body
if (msgType === 'AUDIO' || msgType === 'PTT') {
if (!msg.id) {
console.warn('[nw-auto] áudio sem msg.id, ignorando')
return
}
try {
const { transcribeAudio } = require('./media-transcribe')
const transcript = await transcribeAudio({
motorUrl: cfg[MOTOR_URL_KEY],
integKey: cfg[INTEG_KEY_KEY],
messageId: msg.id,
})
if (!transcript) {
console.log(`[nw-auto] áudio inaudível ou falha de transcrição em ${chatId}`)
return
}
console.log(`[nw-auto] 🎙️ transcrição (${transcript.length} chars): "${transcript.slice(0, 80)}…"`)
effectiveBody = transcript
} catch (e) {
console.warn('[nw-auto] transcribeAudio erro:', e.message)
return
}
} else if (msgType === 'IMAGE') {
if (!msg.id) {
console.warn('[nw-auto] imagem sem msg.id, ignorando')
return
}
try {
const { describeImage } = require('./media-transcribe')
const description = await describeImage({
motorUrl: cfg[MOTOR_URL_KEY],
integKey: cfg[INTEG_KEY_KEY],
messageId: msg.id,
captionFromUser: body || null,
})
if (!description) {
console.log(`[nw-auto] imagem sem descrição obtida em ${chatId}`)
return
}
console.log(`[nw-auto] 🖼️ ${description.slice(0, 100)}`)
effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description
} catch (e) {
console.warn('[nw-auto] describeImage erro:', e.message)
return
}
} else if (msgType === 'DOCUMENT') {
if (!msg.id) {
console.warn('[nw-auto] documento sem msg.id, ignorando')
return
}
try {
const { extractPdfText } = require('./media-transcribe')
const pdfText = await extractPdfText({
motorUrl: cfg[MOTOR_URL_KEY],
integKey: cfg[INTEG_KEY_KEY],
messageId: msg.id,
captionFromUser: body || null,
})
if (!pdfText) {
console.log(`[nw-auto] documento não processável (tipo não suportado ou muito grande) em ${chatId}`)
return
}
console.log(`[nw-auto] 📄 PDF extraído (${pdfText.length} chars) em ${chatId}`)
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
// Persiste texto extraído no log para que pings de follow-up ("???")
// possam recuperar o conteúdo do PDF como "pergunta pendente".
if (logId) {
execute(
`UPDATE nw_event_logs SET payload = $1 WHERE id = $2`,
[JSON.stringify({ body: pdfText, hasMedia: true, originalCaption: body || null, author: msg.author, pushName: msg.pushName }), logId],
).catch(e => console.warn('[nw-auto] falha ao persistir texto PDF no log:', e.message))
}
} catch (e) {
console.warn('[nw-auto] extractPdfText erro:', e.message)
return
}
} else if (msgType === 'VIDEO') {
if (!msg.id) {
console.warn('[nw-auto] vídeo sem msg.id, ignorando')
return
}
try {
const { processVideo } = require('./media-transcribe')
const description = await processVideo({
motorUrl: cfg[MOTOR_URL_KEY],
integKey: cfg[INTEG_KEY_KEY],
messageId: msg.id,
captionFromUser: body || null,
})
if (!description) {
console.log(`[nw-auto] vídeo não processável (muito grande ou erro) em ${chatId}`)
return
}
console.log(`[nw-auto] 🎬 ${description.slice(0, 100)}`)
effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description
} catch (e) {
console.warn('[nw-auto] processVideo erro:', e.message)
return
}
} else if (msgType === 'CONTACT' || msgType === 'VCARD' || msgType === 'CONTACTS_ARRAY') {
try {
const { extractVCard } = require('./media-transcribe')
const card = extractVCard({ rawVcard: msg.vcard, msgBody: msg.body })
if (!card) {
console.log(`[nw-auto] vCard sem dados úteis em ${chatId}`)
return
}
console.log(`[nw-auto] 👤 ${card.slice(0, 120)}`)
effectiveBody = card
} catch (e) {
console.warn('[nw-auto] extractVCard erro:', e.message)
return
}
} else if (msgType === 'LOCATION') {
// TODO: implementar reverse geocoding (Maps API) para resolver
// msg.location.latitude/longitude → endereço legível e injetar em effectiveBody.
// Útil para confirmar endereço de entrega quando cliente compartilha localização.
console.log(`[nw-auto] LOCATION recebida em ${chatId} — handler ainda não implementado`)
return
} else if (msgType !== 'TEXT' && msgType !== '') {
return
}
if (!effectiveBody) return
// 5c. Detecção de follow-up/ping ("?????", "verificou??", "alô?", "tá aí?")
// Quando o cliente está cobrando resposta:
// - Bypassa cooldown (a inação é o problema dele, não excesso de respostas)
// - Recupera a última pergunta sem resposta no chat
// - Enriquece o body com contexto antes de mandar ao LLM
let isFollowUp = false
let pendingQuestion = null
try {
const { isFollowUpPing, findPendingQuestion, buildEnrichedBody } = require('./follow-up-detector')
if (isFollowUpPing(effectiveBody)) {
isFollowUp = true
pendingQuestion = await findPendingQuestion(chatId, 1440)
const enriched = buildEnrichedBody(effectiveBody, pendingQuestion)
console.log(`[nw-auto] 🔔 ping detectado em ${chatId} — pergunta pendente: ${pendingQuestion ? `"${pendingQuestion.body.slice(0,60)}…" (${pendingQuestion.ageMinutes}min)` : 'nenhuma'}`)
effectiveBody = enriched
// Se handoff humano está ativo, alertar o painel admin via SSE — operador
// precisa ver que o cliente está cobrando. IA segue pausada (não responde).
try {
const handoffRow = await queryOne(
`SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`,
[chatId]
)
if (handoffRow?.human_until && new Date(handoffRow.human_until) > new Date()) {
const { sseBroadcast } = require('../../services/sse')
sseBroadcast('handoff.followup_ping', {
chatId,
instanceId: data.instanceId ?? '',
pingBody: body,
pendingQuestion: pendingQuestion?.body ?? null,
ageMinutes: pendingQuestion?.ageMinutes ?? null,
at: new Date().toISOString(),
})
console.log(`[nw-auto] 📣 handoff ativo + ping → SSE handoff.followup_ping emitido`)
}
} catch (e) { console.warn('[nw-auto] handoff/SSE check falhou:', e.message) }
}
} catch (e) {
console.warn('[nw-auto] follow-up-detector erro:', e.message)
}
// 6. Verifica role do número — notificacoes nunca auto-responde (com cache 5min)
try {
const numInfo = await getNumberRole(cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY], data.instanceId ?? '')
if (numInfo?.role === 'notificacoes') return
if (numInfo && numInfo.active === false) return
} catch { /* se falhar a leitura, deixa passar — melhor responder do que parar */ }
// 7. Cooldown: evita responder o mesmo chat muito rápido
// EXCEÇÕES:
// - Pings de follow-up bypassam — cliente cobrando precisa de feedback.
// - Última resposta foi do smart-router (status='sent_local') — sem custo
// de LLM, então não há motivo de cooldown. Cliente fazendo follow-up
// imediato a uma resposta determinística não pode ser bloqueado.
// Limite anti-loop (step 8) ainda se aplica em todos os casos.
const lastReply = await queryOne(
`SELECT created_at, status FROM nw_auto_replies
WHERE chat_id = $1
ORDER BY created_at DESC LIMIT 1`,
[chatId],
)
if (lastReply && !isFollowUp && lastReply.status === 'sent') {
const elapsedMs = Date.now() - new Date(lastReply.created_at).getTime()
if (elapsedMs < COOLDOWN_MS) {
console.log(`[nw-auto] cooldown ativo para ${chatId} (${Math.round(elapsedMs/1000)}s < ${COOLDOWN_MS/1000}s)`)
return
}
}
// 8. Anti-loop: máx MAX_REPLIES_1H por chat por hora
const countRow = await queryOne(
`SELECT COUNT(*) AS cnt FROM nw_auto_replies
WHERE chat_id = $1 AND created_at > NOW() - INTERVAL '1 hour'`,
[chatId],
)
if (parseInt(countRow?.cnt ?? 0, 10) >= MAX_REPLIES_1H) {
console.log(`[nw-auto] limite de ${MAX_REPLIES_1H} respostas/hora atingido para ${chatId}`)
return
}
// 9a. Smart Router — tenta responder localmente sem LLM (cotação, FAQ, horário etc)
// Usa effectiveBody (texto original ou transcrição/descrição de mídia)
// Em caso de follow-up/ping, pula direto ao LLM — o body foi enriquecido com
// contexto da pergunta original e o smart-router não tem como atender.
setImmediate(async () => {
try {
if (isFollowUp) {
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
return
}
const { tryLocalReply, sendLocalReply } = require('./smart-router')
const local = await tryLocalReply(effectiveBody, data.from, 1)
if (local) {
const ok = await sendLocalReply(
cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY],
data.instanceId, chatId, local.reply, local.intent, effectiveBody
)
if (ok) return // Respondido localmente, não chama LLM
}
// Se smart-router não acertou ou envio falhou, segue ao LLM
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
} catch (e) {
console.warn('[smart-router] erro, caindo no LLM:', e.message)
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
}
})
}
// ── Auto-reply: chama brain e envia resposta ──────────────────────────────────
async function autoReply(cfg, instanceId, chatId, userMsg, contactName, fromPhone) {
const motorUrl = cfg[MOTOR_URL_KEY]
const integKey = cfg[INTEG_KEY_KEY]
if (!motorUrl || !integKey) {
console.warn('[nw-auto] motor URL ou integration_key não configurados')
return
}
try {
// 1. Verifica handoff local — se humano está respondendo, não chamar IA
try {
const { queryOne: qOne } = require('../../database/postgres')
const hRow = await qOne(
`SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`,
[chatId]
)
if (hRow?.human_until && new Date(hRow.human_until) > new Date()) {
console.log(`[nw-auto] handoff=humano (local) para ${chatId} — IA pausada`)
return
}
} catch { /* falha silenciosa */ }
// 2. Monta contexto local do projeto
let context = undefined
let systemExtra = undefined
let intentTags = []
try {
const { buildContext } = require('./context-builder')
const phone = (fromPhone ?? '').replace(/\D/g, '').replace(/^55/, '')
const ctx = await buildContext(phone, 1, userMsg, chatId)
if (ctx) {
context = ctx
systemExtra = ctx._systemExtra
intentTags = ctx.intencoes_detectadas ?? []
delete context._systemExtra
}
} catch (e) { console.warn('[nw-auto] context-builder falhou:', e.message) }
// 2a-bis. Intent automático por tipo de mídia — adiciona tags ao brain
// (mais barato e preciso do que esperar o LLM detectar sozinho).
try {
const { detectMediaIntent } = require('./media-transcribe')
const mediaTags = detectMediaIntent(userMsg)
if (mediaTags.length > 0) {
intentTags = [...new Set([...intentTags, ...mediaTags])]
console.log(`[nw-auto] 🏷️ intent por mídia: [${mediaTags.join(',')}]`)
}
} catch { /* silencioso */ }
// 2a. Persona — define tom de voz/escopo de atendimento por tenant.
// Vai ANTES da BASE DE CONHECIMENTO para o LLM ler primeiro o "como falar".
try {
const { resolvePersona } = require('./personas')
const persona = await resolvePersona(1)
if (persona) {
const personaBlock = `=== PERSONA DE ATENDIMENTO (${persona.label}) ===\n${persona.prompt}`
systemExtra = [personaBlock, systemExtra].filter(Boolean).join('\n\n')
}
} catch (e) { console.warn('[nw-auto] persona injection falhou:', e.message) }
// 2b. Injeta brain — filtrado por intent (core sempre + tags relevantes)
try {
const { query: dbq, queryOne: qOne } = require('../../database/postgres')
const chatSector = await qOne(
`SELECT sector_id FROM nw_chat_sectors WHERE chat_id=$1 LIMIT 1`, [chatId]
)
const sectorClause = chatSector?.sector_id
? `AND sbn.sector_id = ${Number(chatSector.sector_id)}`
: ''
const tagsToLoad = ['core', ...intentTags]
const brainRows = await dbq(
`SELECT sbn.type, sbn.content, s.name AS sector_name, sbn.sort_order
FROM sector_brain_nodes sbn
JOIN sectors s ON s.id = sbn.sector_id
WHERE sbn.active = TRUE ${sectorClause}
AND sbn.tags && $1::text[]
ORDER BY s.sort_order, sbn.sort_order LIMIT 20`,
[tagsToLoad]
)
if (brainRows.length > 0) {
const brainText = brainRows.map(r =>
`[${r.type.toUpperCase()}${chatSector?.sector_id ? '' : `${r.sector_name}`}]\n${r.content}`
).join('\n\n')
const brainBlock = `=== BASE DE CONHECIMENTO ===\n${brainText}`
systemExtra = [systemExtra, brainBlock].filter(Boolean).join('\n\n')
console.log(`[nw-auto] brain: ${brainRows.length} nós (~${brainText.length} chars) tags=[${tagsToLoad.join(',')}]`)
}
} catch (e) { console.warn('[nw-auto] brain injection falhou:', e.message) }
// 3. Chama /api/ext/v1/secretaria/ask (cria/retoma conversa + brain.chat())
const askRes = await fetch(`${motorUrl}/api/ext/v1/secretaria/ask`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-nw-key': integKey,
},
body: JSON.stringify({
chatId,
message: userMsg,
contactName: contactName ?? chatId,
context,
systemExtra,
tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],
}),
})
if (!askRes.ok) {
const txt = await askRes.text()
console.error(`[nw-auto] brain ask failed ${askRes.status}:`, txt)
await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed')
return
}
const askJson = await askRes.json()
const { reply, conversationId, humanApiRequest, sectorId: detectedSectorId } = askJson
// 3a. Sincroniza setor identificado pela IA → banco local
if (detectedSectorId) {
try {
const { execute: dbExec } = require('../../database/postgres')
await dbExec(
`INSERT INTO nw_chat_sectors (chat_id, tenant_id, sector_id, updated_at)
VALUES ($1,1,$2,NOW())
ON CONFLICT (chat_id, tenant_id) DO UPDATE SET sector_id=$2, updated_at=NOW()`,
[chatId, detectedSectorId]
)
} catch { /* silencioso */ }
}
// 3b. Human API — IA precisa de resposta de especialista humano
if (humanApiRequest) {
try {
const localUrl = `http://127.0.0.1:${process.env.PORT || 4001}`
await fetch(`${localUrl}/api/human-api/request`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-internal-secret': process.env.INTERNAL_WEBHOOK_SECRET || '' },
body: JSON.stringify({
chatId, conversationId, instanceId,
question: humanApiRequest.question || humanApiRequest,
sectorName: humanApiRequest.sectorName || null,
}),
})
} catch (e) { console.warn('[nw-auto] human_api forward falhou:', e.message) }
}
if (!reply?.trim()) return
// 2. Envia resposta via motor
const sendRes = await fetch(`${motorUrl}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-nw-key': integKey,
},
body: JSON.stringify({ text: reply }),
})
if (!sendRes.ok) {
const txt = await sendRes.text()
console.error(`[nw-auto] send failed ${sendRes.status}:`, txt)
await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'failed')
return
}
// 3. Loga sucesso + telemetria
await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'sent')
const ctxChars = (systemExtra?.length || 0) + (context ? JSON.stringify(context).length : 0)
const saved = Math.max(0, 5000 - ctxChars) // baseline de 5000 chars
await execute(
`INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars)
VALUES ($1, 'llm', $2, $3, $4, $5, $6)`,
[chatId, intentTags.join(',') || null, userMsg.slice(0, 200), reply.length, ctxChars, saved]
).catch(() => {})
console.log(`[nw-auto] ✅ ${chatId} → "${reply.slice(0, 60)}…" (ctx=${ctxChars}c, saved=${saved}c)`)
} catch (err) {
console.error('[nw-auto] erro inesperado:', err.message)
await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed').catch(() => {})
}
}
async function logAutoReply(instanceId, chatId, convId, userMsg, reply, status) {
await execute(
`INSERT INTO nw_auto_replies
(instance_id, chat_id, conv_id, user_msg, reply, status)
VALUES ($1, $2, $3, $4, $5, $6)`,
[instanceId, chatId, convId ?? null, userMsg, reply ?? null, status],
)
}
// ── Handler: session.status ───────────────────────────────────────────────────
async function handleSessionStatus(data) {
await execute(
`INSERT INTO nw_event_logs
(instance_id, event, direction, payload)
VALUES ($1, $2, 'in', $3)`,
[
data.instanceId ?? '',
'session.status',
JSON.stringify({ status: data.status }),
],
)
console.log(`[nw-webhook] session.status → ${data.instanceId}: ${data.status}`)
}
module.exports = { createWebhookReceiver }