663 lines
26 KiB
JavaScript
663 lines
26 KiB
JavaScript
'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 (1–5) 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 }
|