feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker

This commit is contained in:
VPS 4 Builder
2026-05-15 10:39:35 +02:00
commit 3beac0e0f2
288 changed files with 78761 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
'use strict'
const { timingSafeEqual } = require('crypto')
const { queryOne } = require('../../database/postgres')
/**
* Middleware de autenticação das rotas /nw/*.
* Valida a integration_key enviada pelo motor NewWhats em cada requisição.
* Aceita via header: x-nw-key ou Authorization: Bearer <key>
*/
async function nwAuth(req, res, next) {
const key = req.headers['x-nw-key']
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '').trim()
if (!key) {
return res.status(401).json({ error: 'Chave de integração ausente. Use o header x-nw-key.' })
}
try {
const stored = await queryOne(
"SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'integration_key'"
)
if (!stored?.value) {
return res.status(503).json({ error: 'Plugin NewWhats não configurado. Defina a integration_key no admin.' })
}
const storedBuf = Buffer.from(stored.value)
const keyBuf = Buffer.from(key)
const valid = storedBuf.length === keyBuf.length && timingSafeEqual(storedBuf, keyBuf)
if (!valid) {
return res.status(403).json({ error: 'Chave de integração inválida.' })
}
next()
} catch (err) {
console.error('[nwAuth] Erro ao verificar integration_key:', err.message)
return res.status(500).json({ error: 'Erro interno ao validar autenticação.' })
}
}
module.exports = { nwAuth }
+842
View File
@@ -0,0 +1,842 @@
'use strict'
/**
* context-builder.js
* Constrói o contexto local do projeto Alemão Conveniências para enriquecer
* cada chamada à Secretária IA no motor NewWhats.
*
* O objeto retornado é injetado no system prompt do ProtocolEngine como
* "=== CONTEXTO DO CLIENTE (dados reais do projeto) ===".
*
* Se nada for encontrado, retorna null (sem overhead no motor).
*/
const { queryOne, query } = require('../../database/postgres')
/**
* Normaliza um número de telefone para 1011 dígitos (sem código de país).
* Entrada: JID whatsapp (556799138794@s.whatsapp.net) ou número bruto.
*/
function normalizePhone(raw) {
if (!raw) return ''
// Remove tudo que não for dígito e código de país BR
const digits = raw.replace(/\D/g, '')
if (digits.startsWith('55') && digits.length >= 12) return digits.slice(2)
return digits
}
/**
* Tenta localizar um usuário pelo telefone normalizado.
* Retorna o registro ou null se não encontrado.
*/
const escapeLike = s => s.replace(/[%_\\]/g, c => '\\' + c)
async function findUserByPhone(phone) {
if (!phone || phone.length < 8) return null
try {
// Tenta match exato e com variações de comprimento (com/sem 9 extra)
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3)) // remove 9 extra
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2)) // adiciona 9 extra
for (const v of variants) {
const user = await queryOne(
`SELECT id, nome, cpf, email, telefone, created_at
FROM users
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1
LIMIT 1`,
[`%${escapeLike(v)}`],
)
if (user) return user
}
} catch (e) {
// Tabela pode ter nome diferente — tenta customers
try {
const user = await queryOne(
`SELECT id, nome, cpf, email, telefone, created_at FROM customers WHERE telefone LIKE $1 LIMIT 1`,
[`%${escapeLike(phone)}`],
)
if (user) return user
} catch { /* sem tabela customers */ }
}
return null
}
/**
* Busca o clube e plano ativo do usuário.
*/
async function findActivePlan(userCpf) {
if (!userCpf) return null
try {
return await queryOne(
`SELECT cm.status, cm.started_at AS member_since,
bc.name AS club_name,
cp.name AS plan_name, cp.price_monthly AS price
FROM club_members cm
JOIN benefit_clubs bc ON bc.id = cm.club_id
JOIN club_plans cp ON cp.id = cm.plan_id
WHERE cm.user_cpf = $1 AND cm.status IN ('active','pending_payment')
ORDER BY cm.started_at DESC
LIMIT 1`,
[userCpf.replace(/\D/g, '')],
)
} catch { return null }
}
/**
* Busca os últimos pedidos do usuário (tabela pedidos, por user_id).
*/
async function findRecentOrders(userId, limit = 3) {
if (!userId) return []
try {
return await query(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, total, financeiro_status, created_at
FROM pedidos
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[userId, limit],
)
} catch { return [] }
}
/**
* Busca promoções ativas do dia (schema correto do projeto).
*/
async function findActivePromotions(tenantId = 1) {
try {
return await query(
`SELECT nome, descricao, preco_original, preco_promocional
FROM promotions
WHERE ativo = TRUE AND tenant_id = $1
ORDER BY data_criacao DESC
LIMIT 5`,
[tenantId],
)
} catch { return [] }
}
/**
* Localiza um funcionário/colaborador pelo telefone na tabela accounts.
* Retorna o registro ou null. Usado para detectar quando o remetente é
* um agente interno — evita que a IA trate o funcionário como cliente externo.
*/
async function findAccountByPhone(phone) {
if (!phone || phone.length < 8) return null
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
try {
for (const v of variants) {
const row = await queryOne(
`SELECT id, name, role, availability
FROM accounts
WHERE REGEXP_REPLACE(phone, '[^0-9]', '', 'g') LIKE $1
AND active = true
LIMIT 1`,
[`%${escapeLike(v)}`],
)
if (row) return row
}
} catch { /* tabela accounts pode não existir em todos os projetos */ }
return null
}
/**
* WPP-02 — Localiza cliente PF/PJ na tabela clientes pelo telefone.
*/
async function findClienteByPhone(phone, tenantId = 1) {
if (!phone || phone.length < 8) return null
try {
const variants = [phone]
if (phone.length === 11) variants.push(phone.slice(0, 2) + phone.slice(3))
if (phone.length === 10) variants.push(phone.slice(0, 2) + '9' + phone.slice(2))
for (const v of variants) {
const row = await queryOne(
`SELECT c.id, c.nome, c.tipo, c.cpf_cnpj, c.email, c.status
FROM clientes c
JOIN cliente_telefones ct ON ct.cliente_id = c.id
WHERE c.tenant_id = $1
AND REGEXP_REPLACE(ct.numero, '[^0-9]', '', 'g') LIKE $2
LIMIT 1`,
[tenantId, `%${escapeLike(v)}`],
)
if (row) return row
}
} catch { /* tabela pode não existir ainda */ }
return null
}
// Mapeamento de status para descrição amigável
const STATUS_COTACAO = {
rascunho: 'rascunho (carrinho ainda não confirmado)',
salva: 'salva (aguardando confirmação do cliente)',
enviada: 'enviada (aguardando análise)',
processando: 'em processamento',
concluida: 'concluída',
finalizada: 'finalizada (pedido gerado)',
cancelada: 'cancelada',
}
const STATUS_PEDIDO = {
aguardando: 'aguardando (recebido, aguardando separação)',
novo: 'novo (recebido, aguardando separação)',
separacao: 'em separação (itens sendo separados no estoque)',
embalagem: 'em embalagem (sendo embalado)',
expedicao: 'saiu para entrega / expedição',
entregue: 'entregue',
cancelado: 'cancelado',
auditoria: 'em auditoria interna',
fiscal: 'em processamento fiscal',
}
const FINANCEIRO_STATUS = {
pendente: 'pagamento pendente',
aprovado: 'pagamento aprovado',
recusado: 'pagamento recusado',
reembolsado: 'reembolsado',
}
function descStatus(val, map) {
if (!val) return null
return map[val] ?? val
}
/**
* Extrai menções de cotação ou pedido ID/protocolo da mensagem do usuário.
* Ex: "cotação #1", "cotação 1", "pedido #3", "PED-20260426-3"
*/
function extractMentions(msg) {
if (!msg) return []
const results = []
// Protocolo completo: PED-XXXXXXXX-N
const protos = msg.match(/PED-\d{8}-\d+/gi) ?? []
protos.forEach(p => results.push({ type: 'protocolo', value: p.toUpperCase() }))
// cotação #N ou cotacao N
const cots = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/gi) ?? []
cots.forEach(m => {
const id = m.match(/\d+/)?.[0]
if (id) results.push({ type: 'cotacao_id', value: id })
})
// pedido #N
const peds = msg.match(/pedido\s*[#nº]?\s*(\d+)/gi) ?? []
peds.forEach(m => {
const id = m.match(/\d+/)?.[0]
if (id) results.push({ type: 'pedido_id', value: id })
})
return results
}
/**
* Busca cotação + pedido vinculado diretamente por ID (qualquer usuário).
*/
async function findCotacaoById(id, tenantId = 1) {
try {
const c = await queryOne(
`SELECT c.id, c.status AS cotacao_status, c.total,
p.id AS pedido_id, p.protocolo, p.status AS pedido_status,
p.forma_pagto, p.tipo_entrega, p.financeiro_status,
p.created_at AS pedido_criado_em
FROM cotacoes c
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
WHERE c.id = $1 AND c.tenant_id = $2
ORDER BY p.id DESC
LIMIT 1`,
[id, tenantId],
)
return c
} catch { return null }
}
/**
* Busca pedido por protocolo (qualquer usuário).
*/
async function findPedidoByProtocolo(protocolo, tenantId = 1) {
try {
return await queryOne(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega,
financeiro_status, total, created_at
FROM pedidos
WHERE protocolo ILIKE $1 AND tenant_id = $2
LIMIT 1`,
[protocolo, tenantId],
)
} catch { return null }
}
/**
* WPP-04 — Busca cotação aberta do cliente.
*/
async function findCotacaoAberta(clienteId) {
if (!clienteId) return null
try {
return await queryOne(
`SELECT c.id, c.status, c.total,
COUNT(ci.id) AS total_itens
FROM cotacoes c
LEFT JOIN cotacao_itens ci ON ci.cotacao_id = c.id
WHERE c.cliente_id = $1 AND c.status IN ('rascunho','salva')
GROUP BY c.id
ORDER BY c.updated_at DESC
LIMIT 1`,
[clienteId],
)
} catch { return null }
}
/**
* WPP-04 — Busca últimos pedidos do cliente no módulo de cotação.
*/
async function findPedidosCliente(clienteId, limit = 3) {
if (!clienteId) return []
try {
return await query(
`SELECT id, status, total, tipo_entrega, created_at
FROM pedidos
WHERE cliente_id = $1
ORDER BY created_at DESC
LIMIT $2`,
[clienteId, limit],
)
} catch { return [] }
}
/**
* WPP-03 — WPP-04 — Produtos em destaque por categoria para sugestão rápida.
*/
async function findProdutosDestaque(tenantId = 1) {
try {
return await query(
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
FROM produtos p
WHERE tenant_id = $1 AND ativo = TRUE AND estoque > 0
ORDER BY categoria, nome
LIMIT 12`,
[tenantId],
)
} catch { return [] }
}
// ── Saudação contextual por horário (America/Sao_Paulo) ─────────────────────
function saudacaoPorHorario(date = new Date()) {
// Ajusta para fuso de SP (-03:00) sem dependência externa
const utc = date.getUTCHours() * 60 + date.getUTCMinutes()
const minSP = (utc - 180 + 1440) % 1440
const h = Math.floor(minSP / 60)
const dow = (date.getUTCDay() + (utc - 180 < 0 ? -1 : 0) + 7) % 7
const periodo = h < 5 ? 'madrugada' : h < 12 ? 'manhã' : h < 18 ? 'tarde' : 'noite'
const saudacoes = {
madrugada: 'Boa madrugada',
'manhã': 'Bom dia',
tarde: 'Boa tarde',
noite: 'Boa noite',
}
// Verifica se a loja está aberta conforme brain (Seg-Qui 8-23, Sex-Sab 8-2, Dom 10-22)
let aberto
if (dow === 0) aberto = h >= 10 && h < 22 // domingo
else if (dow >= 1 && dow <= 4) aberto = h >= 8 && h < 23 // seg-qui
else if (dow === 5) aberto = h >= 8 || h < 2 // sexta (atravessa madrugada)
else aberto = h >= 8 || h < 2 // sábado
return {
saudacao: saudacoes[periodo],
periodo,
hora_local:`${String(h).padStart(2, '0')}h`,
loja_aberta: aberto,
dia_semana: ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow],
}
}
// ── Detecção de intenções na mensagem ───────────────────────────────────────
const STOPWORDS = new Set([
'o','a','os','as','um','uma','de','da','do','das','dos','em','no','na','nos','nas',
'e','ou','que','para','pra','por','com','sem','meu','minha','seu','sua','tem','ter',
'tinha','vou','quero','queria','gostaria','poderia','pode','podem','é','são','foi',
'voce','você','vc','ai','aí','la','lá','aqui','tudo','bem','oi','ola','olá','bom',
'boa','dia','tarde','noite','obrigado','obrigada','valeu','flw','ok','sim','não','nao',
])
function tokenize(msg) {
return (msg || '')
.toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9\s]/g, ' ')
.split(/\s+/)
.filter(t => t.length >= 3 && !STOPWORDS.has(t))
}
function detectIntents(msg) {
const m = (msg || '').toLowerCase()
return {
pergunta_horario: /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion|que\s+horas)\b/i.test(m),
pergunta_endereco: /\b(endere[çc]o|onde\s+(fica|esta|est[áa])|localiza|como\s+chego)\b/i.test(m),
pergunta_entrega: /\b(entrega|entregar|delivery|tele|leva\s+at[ée]|taxa\s+de\s+entrega|frete|prazo)\b/i.test(m),
pergunta_pagamento: /\b(pagamento|pagar|pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto|forma\s+de\s+pag)\b/i.test(m),
listar_meus_pedidos: /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(m),
pergunta_sorteio: /\b(sorteio|rifa|ticket|sorteios|premia[çc][aã]o|ganhei)\b/i.test(m),
pergunta_clube: /\b(clube|fideli|assinatura|plano|membro|benef[íi]cio)\b/i.test(m),
urgencia: /\b(urgente|urg[ée]ncia|r[áa]pido|imediato|j[áa]\s+(j[áa]|ja)|tem\s+pressa|t[ôo]\s+com\s+pressa|estou\s+com\s+pressa|emerg[êe]ncia)\b/i.test(m),
frustracao: /\b(absurdo|p[ée]ssimo|horr[ií]vel|nunca\s+mais|cad[êe]\s+meu|reclama[çc][aã]o|cancela|inaceit[áa]vel|demor[oa]u\s+demais|n[ãa]o\s+aguento|t[ôo]\s+puto)\b/i.test(m),
despedida: /^(obrigad[oa]|valeu|flw|tchau|at[ée]|brigad[oa]|t[áa]\s+bom|ok\s+obrigad|nada\s+mais)[\s!.\,]*$/i.test(m.trim()),
saudacao_simples: /^(oi|ol[áa]|opa|eai|e\s+a[íi]|bom\s+dia|boa\s+(tarde|noite|madrugada)|alguem\s+a[íi])[\s!.\,?]*$/i.test(m.trim()),
msg_curta_ambigua: m.trim().length > 0 && m.trim().length < 4, // "?", "ok", "..."
}
}
// ── Busca de produtos por palavras-chave da mensagem ────────────────────────
async function findProdutosByKeywords(tokens, tenantId = 1, limit = 8) {
if (!tokens?.length) return []
try {
// Cria pattern ILIKE para cada token e busca em nome/categoria/descrição
const conditions = tokens.map((_, i) => `(
p.nome ILIKE $${i + 2}
OR p.categoria ILIKE $${i + 2}
OR COALESCE(p.descricao, '') ILIKE $${i + 2}
)`).join(' OR ')
const params = [tenantId, ...tokens.map(t => `%${escapeLike(t)}%`)]
return await query(
`SELECT nome, sku, preco, preco_promocional AS preco_promo, unidade, estoque, categoria
FROM produtos p
WHERE tenant_id = $1 AND ativo = TRUE AND (${conditions})
ORDER BY (estoque > 0) DESC, preco ASC
LIMIT ${Number(limit)}`,
params,
)
} catch { return [] }
}
// ── Mídias recentes processadas (PDF / imagem) ───────────────────────────────
/**
* Busca as últimas mídias processadas neste chat (PDFs e imagens) nas últimas
* `lookbackMinutes` minutos. O texto extraído fica em nw_auto_replies.user_msg
* com prefixo "[documento PDF]" ou "[imagem]".
*
* Útil para que o LLM saiba que o cliente enviou um comprovante/cupom mesmo
* que a mensagem atual seja apenas texto ou um ping de follow-up.
*/
async function findRecentMedia(chatId, lookbackMinutes = 120) {
if (!chatId) return []
try {
const rows = await query(
`SELECT user_msg, created_at
FROM nw_auto_replies
WHERE chat_id = $1
AND created_at > NOW() - ($2 || ' minutes')::interval
AND status IN ('sent','sent_local')
AND (user_msg ILIKE '[documento PDF]%' OR user_msg ILIKE '[imagem]%')
ORDER BY created_at DESC
LIMIT 3`,
[chatId, String(lookbackMinutes)],
)
return rows.map(r => {
const ageMs = Date.now() - new Date(r.created_at).getTime()
const ageMin = Math.round(ageMs / 60_000)
const age = ageMin < 60 ? `${ageMin}min` : `${Math.round(ageMin / 60)}h`
const tipo = r.user_msg.startsWith('[documento PDF]') ? 'PDF' : 'imagem'
return { tipo, conteudo: r.user_msg, enviado: age }
})
} catch { return [] }
}
// ── Sorteios ativos ─────────────────────────────────────────────────────────
async function findSorteiosAtivos(tenantId = 1) {
try {
return await query(
`SELECT id, titulo, descricao, data_sorteio, premio
FROM sorteios
WHERE tenant_id = $1 AND status = 'ativo' AND data_sorteio > NOW()
ORDER BY data_sorteio ASC
LIMIT 5`,
[tenantId],
)
} catch { return [] }
}
/**
* Ponto de entrada principal.
* @param {string} rawPhone — número do remetente (com ou sem código de país)
* @param {number} tenantId
* @param {string} userMsg — mensagem do usuário (para detectar menções de cotação/pedido)
* @param {string|null} chatId — chat_id do WhatsApp (ex: 5567…@s.whatsapp.net); habilita histórico de mídias
* @returns {object|null} contexto para injeção no motor, ou null se nada encontrado
*/
async function buildContext(rawPhone, tenantId = 1, userMsg = '', chatId = null) {
const phone = normalizePhone(rawPhone)
const result = {}
let hasData = false
// ── Saudação contextual e estado da loja ─────────────────────────────────
result.contexto_temporal = saudacaoPorHorario()
hasData = true // sempre injeta, custo zero
// ── Detecção de intenções da mensagem ──────────────────────────────────
const intents = detectIntents(userMsg)
const intentsAtivas = Object.entries(intents).filter(([, v]) => v).map(([k]) => k)
if (intentsAtivas.length > 0) result.intencoes_detectadas = intentsAtivas
// ── Detecção de funcionário/colaborador interno ───────────────────────────
// Se o remetente for um agente cadastrado em accounts, injeta aviso para a IA
// não tratar como cliente externo — evita paradoxos como "como falo com [eu mesmo]?"
const account = await findAccountByPhone(phone)
if (account) {
hasData = true
result.eh_funcionario = true
result.funcionario = {
nome: account.name,
cargo: account.role,
disponivel: account.availability === 'online',
}
}
// ── WPP-02: Cliente do módulo Cotação (PF/PJ) ─────────────────────────────
const cliente = await findClienteByPhone(phone, tenantId)
if (cliente) {
hasData = true
result.cliente_cadastro = {
nome: cliente.nome,
tipo: cliente.tipo, // PF ou PJ
cpf_cnpj: cliente.cpf_cnpj,
email: cliente.email ?? null,
status: cliente.status,
}
// WPP-04: cotação aberta do cliente
const cotacao = await findCotacaoAberta(cliente.id)
if (cotacao) {
result.cotacao_aberta = {
id: cotacao.id,
status: cotacao.status,
total: cotacao.total ? `R$ ${parseFloat(cotacao.total).toFixed(2)}` : 'R$ 0,00',
total_itens: cotacao.total_itens,
}
}
// WPP-04: pedidos recentes do cliente (módulo cotação)
const pedidos = await findPedidosCliente(cliente.id)
if (pedidos.length > 0) {
result.pedidos_cotacao = pedidos.map(p => ({
id: p.id,
status: descStatus(p.status, STATUS_PEDIDO),
valor: p.total ? `R$ ${parseFloat(p.total).toFixed(2)}` : null,
tipo_entrega: p.tipo_entrega,
data: p.created_at ? new Date(p.created_at).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Cliente legacy (tabela users / hotspot) ───────────────────────────────
const user = await findUserByPhone(phone)
if (user) {
hasData = true
if (!result.cliente_cadastro) {
result.cliente = {
nome: user.nome ?? 'Não identificado',
email: user.email ?? null,
telefone: user.telefone ?? phone,
cliente_desde: user.created_at ? new Date(user.created_at).toLocaleDateString('pt-BR') : null,
}
}
// Plano/Clube
const plan = await findActivePlan(user.cpf)
if (plan) {
result.plano = {
clube: plan.club_name,
plano: plan.plan_name ?? 'Sem plano específico',
status: plan.status,
membro_desde: plan.member_since ? new Date(plan.member_since).toLocaleDateString('pt-BR') : null,
preco: plan.price ? `R$ ${parseFloat(plan.price).toFixed(2)}` : null,
}
}
// Pedidos do cliente (app de cotação, via user_id)
const orders = await findRecentOrders(user.id)
if (orders.length > 0) {
result.pedidos_recentes = orders.map(o => ({
protocolo: o.protocolo || `#${o.id}`,
status: o.status,
forma_pagto: o.forma_pagto || null,
tipo_entrega: o.tipo_entrega || null,
valor: o.total ? `R$ ${parseFloat(o.total).toFixed(2)}` : null,
financeiro: o.financeiro_status || null,
data: o.created_at ? new Date(o.created_at).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Promoções/produtos: gate por intenção (economia de tokens) ──────────
// Carrega promoções e destaques só se o cliente está em modo "explorar":
// - saudação simples (mostrar o que tem de novo)
// - intent de promoção/clube/produto
// - mensagem com tokens significativos (busca de produto)
const querUmCatalogo = intents.saudacao_simples ||
intents.pergunta_clube ||
/\b(promo[çc][aã]o|oferta|desconto|tem\s+(o\s+que|algo)|cat[áa]logo|destaque)\b/i.test(userMsg)
if (querUmCatalogo) {
const promos = await findActivePromotions(tenantId)
if (promos.length > 0) {
hasData = true
result.promocoes_ativas = promos.slice(0, 3).map(p => ({
produto: p.nome,
preco_promocional: `R$ ${parseFloat(p.preco_promocional).toFixed(2)}`,
desconto_pct: p.preco_original
? `${Math.round((1 - p.preco_promocional / p.preco_original) * 100)}%`
: null,
}))
}
}
// ── Busca de produtos por palavras-chave da mensagem ────────────────────
// Só dispara se a mensagem tiver tokens significativos (não só saudação)
if (!intents.saudacao_simples && !intents.despedida && !intents.msg_curta_ambigua) {
const tokens = tokenize(userMsg)
if (tokens.length > 0) {
const buscados = await findProdutosByKeywords(tokens, tenantId, 8)
if (buscados.length > 0) {
hasData = true
result.produtos_buscados = buscados.map(p => ({
nome: p.nome,
sku: p.sku,
preco: p.preco_promo
? `R$ ${parseFloat(p.preco_promo).toFixed(2)} (promo, era R$ ${parseFloat(p.preco).toFixed(2)})`
: `R$ ${parseFloat(p.preco).toFixed(2)}`,
unidade: p.unidade,
estoque: Number(p.estoque) > 0 ? 'disponível' : 'indisponível',
categoria: p.categoria,
}))
}
}
}
// ── Sorteios ativos (somente se houver intenção/menção) ────────────────
if (intents.pergunta_sorteio) {
const sorteios = await findSorteiosAtivos(tenantId)
if (sorteios.length > 0) {
hasData = true
result.sorteios_ativos = sorteios.map(s => ({
id: s.id,
titulo: s.titulo,
premio: s.premio,
data_sorteio: s.data_sorteio ? new Date(s.data_sorteio).toLocaleDateString('pt-BR') : null,
}))
}
}
// ── Mídias recentes (PDF / imagem processados neste chat) ─────────────────
// Carrega sempre que houver chatId e a mensagem atual não for uma saudação
// simples sem contexto — evita ruído em "oi" de primeiro contato.
if (chatId && !intents.saudacao_simples) {
const midias = await findRecentMedia(chatId)
if (midias.length > 0) {
result.midias_recentes = midias
hasData = true
}
}
// ── Carrinho abandonado (cotação salva sem finalizar há > 4h) ──────────
// Só checa se temos cliente ou user identificado
if (cliente || user) {
try {
const ownerCol = cliente ? 'cliente_id' : 'user_id'
const ownerVal = cliente ? cliente.id : user.id
const carrinho = await queryOne(
`SELECT id, total, updated_at,
(SELECT COUNT(*) FROM cotacao_itens ci WHERE ci.cotacao_id = c.id) AS total_itens
FROM cotacoes c
WHERE ${ownerCol} = $1 AND tenant_id = $2
AND status IN ('rascunho','salva')
AND updated_at < NOW() - INTERVAL '4 hours'
AND updated_at > NOW() - INTERVAL '7 days'
ORDER BY updated_at DESC LIMIT 1`,
[ownerVal, tenantId],
)
if (carrinho && Number(carrinho.total_itens) > 0) {
result.carrinho_abandonado = {
id: carrinho.id,
total_itens: Number(carrinho.total_itens),
valor: carrinho.total ? `R$ ${parseFloat(carrinho.total).toFixed(2)}` : null,
desde: carrinho.updated_at ? new Date(carrinho.updated_at).toLocaleDateString('pt-BR') : null,
}
}
} catch { /* tabelas podem variar */ }
}
// ── Lookup direto por menção na mensagem (cotação #N, pedido #N, PED-…) ──
const mentions = extractMentions(userMsg)
// Busca itens de um pedido para incluir no contexto da IA
async function itensResumidosPedido(pedidoId) {
try {
const rows = await query(
`SELECT nome_produto, quantidade, observacao FROM pedido_itens WHERE pedido_id=$1 ORDER BY id LIMIT 20`,
[pedidoId]
)
return rows.map(i => {
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
})
} catch { return [] }
}
async function itensResumidosCotacao(cotacaoId) {
try {
const rows = await query(
`SELECT nome_produto, quantidade, observacao FROM cotacao_itens WHERE cotacao_id=$1 ORDER BY id LIMIT 20`,
[cotacaoId]
)
return rows.map(i => {
const obs = i.observacao ? ` (obs: ${i.observacao})` : ''
return `${Number(i.quantidade)}x ${i.nome_produto}${obs}`
})
} catch { return [] }
}
if (mentions.length > 0) {
const consultados = []
for (const m of mentions) {
if (m.type === 'cotacao_id') {
const row = await findCotacaoById(m.value, tenantId)
if (row) {
const jaEPedido = row.cotacao_status === 'finalizada' && row.pedido_id
if (jaEPedido) {
// Cotação virou pedido — a IA deve falar do pedido, não da cotação
const itens = await itensResumidosPedido(row.pedido_id)
consultados.push({
nota_ia: `O cliente se referiu a "cotação #${m.value}" mas esta já foi confirmada e gerou um PEDIDO. Responda sempre sobre o PEDIDO abaixo, nunca diga "cotação finalizada".`,
pedido_protocolo: row.protocolo ?? `#${row.pedido_id}`,
pedido_status: descStatus(row.pedido_status, STATUS_PEDIDO),
forma_pagto: row.forma_pagto ?? null,
tipo_entrega: row.tipo_entrega ?? null,
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
criado_em: row.pedido_criado_em ? new Date(row.pedido_criado_em).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
} else {
// Ainda é cotação (rascunho ou salva, sem pedido gerado)
const itens = await itensResumidosCotacao(row.id)
consultados.push({
cotacao_id: row.id,
cotacao_status: descStatus(row.cotacao_status, STATUS_COTACAO),
total: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
itens: itens.length > 0 ? itens : undefined,
})
}
hasData = true
}
} else if (m.type === 'protocolo') {
const row = await findPedidoByProtocolo(m.value, tenantId)
if (row) {
const itens = await itensResumidosPedido(row.id)
consultados.push({
pedido_protocolo: row.protocolo,
pedido_status: descStatus(row.status, STATUS_PEDIDO),
forma_pagto: row.forma_pagto ?? null,
tipo_entrega: row.tipo_entrega ?? null,
financeiro: descStatus(row.financeiro_status, FINANCEIRO_STATUS),
valor: row.total ? `R$ ${parseFloat(row.total).toFixed(2)}` : null,
criado_em: row.created_at ? new Date(row.created_at).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
hasData = true
}
} else if (m.type === 'pedido_id') {
// 1) tenta protocolo terminando com o número (ex: PED-…-1)
let pedRow = await findPedidoByProtocolo(`%${m.value}`, tenantId).catch(() => null)
// 2) tenta pedido por id numérico direto
if (!pedRow) {
pedRow = await queryOne(
`SELECT id, protocolo, status, forma_pagto, tipo_entrega, financeiro_status, total, created_at
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
[m.value, tenantId]
).catch(() => null)
}
if (pedRow) {
const itens = await itensResumidosPedido(pedRow.id)
consultados.push({
pedido_protocolo: pedRow.protocolo ?? `#${pedRow.id}`,
pedido_status: descStatus(pedRow.status, STATUS_PEDIDO),
forma_pagto: pedRow.forma_pagto ?? null,
tipo_entrega: pedRow.tipo_entrega ?? null,
financeiro: descStatus(pedRow.financeiro_status, FINANCEIRO_STATUS),
valor: pedRow.total ? `R$ ${parseFloat(pedRow.total).toFixed(2)}` : null,
criado_em: pedRow.created_at ? new Date(pedRow.created_at).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
hasData = true
} else {
// 3) último fallback: o cliente pode estar referindo-se ao número da cotação
// (ex: "pedido #1" → cotação #1 → PED-20260426-3)
const cot = await findCotacaoById(m.value, tenantId)
if (cot) {
const jaEPedido = cot.cotacao_status === 'finalizada' && cot.pedido_id
if (jaEPedido) {
const itens = await itensResumidosPedido(cot.pedido_id)
consultados.push({
nota_ia: `O cliente disse "pedido #${m.value}" mas este número é o da cotação original. O pedido real é ${cot.protocolo ?? '#' + cot.pedido_id}. Informe o protocolo correto ao cliente.`,
pedido_protocolo: cot.protocolo ?? `#${cot.pedido_id}`,
pedido_status: descStatus(cot.pedido_status, STATUS_PEDIDO),
forma_pagto: cot.forma_pagto ?? null,
tipo_entrega: cot.tipo_entrega ?? null,
financeiro: descStatus(cot.financeiro_status, FINANCEIRO_STATUS),
valor: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
criado_em: cot.pedido_criado_em ? new Date(cot.pedido_criado_em).toLocaleDateString('pt-BR') : null,
itens: itens.length > 0 ? itens : undefined,
})
} else {
const itens = await itensResumidosCotacao(cot.id)
consultados.push({
cotacao_id: cot.id,
cotacao_status: descStatus(cot.cotacao_status, STATUS_COTACAO),
total: cot.total ? `R$ ${parseFloat(cot.total).toFixed(2)}` : null,
itens: itens.length > 0 ? itens : undefined,
})
}
hasData = true
}
}
}
}
if (consultados.length > 0) result.consulta_mencionada = consultados
}
if (!hasData) return null
// ── SystemExtra: header de grounding + sinais em tempo real ──────────────
// O brain (sector_brain_nodes) já carrega persona, regras, conhecimento.
// Aqui adicionamos só o que muda por mensagem: rotular DADOS_REAIS + flags.
const sinais = []
// Grounding: nomeia o JSON do contexto como DADOS_REAIS (alinhado com brain)
sinais.push(
'[DADOS_REAIS DESTA CONVERSA]\n' +
'O JSON de "context" abaixo é a única fonte da verdade para fatos do cliente, pedidos e produtos. ' +
'NUNCA afirme valores, status ou prazos que não estejam nele. ' +
'Se o dado não está em DADOS_REAIS: pergunte ao cliente OU chame escalar_humano.'
)
// Sinais em tempo real (não estão em CONHECIMENTO estático)
const flags = []
if (!result.contexto_temporal.loja_aberta) {
flags.push(`LOJA_FECHADA (${result.contexto_temporal.dia_semana} ${result.contexto_temporal.hora_local})`)
}
if (result.consulta_mencionada?.length > 0) flags.push('PERGUNTA_SOBRE_PEDIDO_ESPECIFICO')
if (result.carrinho_abandonado) flags.push(`CARRINHO_ABANDONADO_${result.carrinho_abandonado.total_itens}_itens_desde_${result.carrinho_abandonado.desde}`)
if (intents.urgencia) flags.push('URGENCIA')
if (intents.frustracao) flags.push('FRUSTRACAO')
if (intents.despedida) flags.push('DESPEDIDA')
if (intents.msg_curta_ambigua) flags.push('MSG_AMBIGUA')
if (!user && !cliente) flags.push('CLIENTE_NAO_IDENTIFICADO')
if (cliente?.tipo === 'PJ') flags.push('CLIENTE_PJ')
if (result.midias_recentes?.length > 0) {
const tipos = result.midias_recentes.map(m => `${m.tipo}(${m.enviado})`).join(',')
flags.push(`MIDIA_RECENTE[${tipos}]`)
}
if (flags.length > 0) sinais.push('[SINAIS]\n' + flags.join(' | '))
result._systemExtra = sinais.join('\n\n')
return result
}
module.exports = { buildContext, extractMentions, findCotacaoById, findClienteByPhone, normalizePhone }
@@ -0,0 +1,176 @@
'use strict'
/**
* follow-up-detector.js — detecta quando o cliente está cobrando uma resposta
* que ele acha que ficou sem retorno.
*
* Padrões típicos no WhatsApp:
* - "?????" / "???" / "??" — apenas pontuação
* - "verificou?" "verificou??"
* - "viu?" "olá??"
* - "alô??" "ei??"
* - "tá aí?" "alguém aí?"
* - "e aí?" "e então?"
* - "?" (interrogação isolada)
* - mensagens curtas (<= 2 palavras) terminando com ?
*
* Quando detecta, recupera a última pergunta do cliente que ficou sem resposta
* e devolve um payload enriquecido para a IA: "[Cliente está cobrando a resposta
* de uma pergunta anterior — pergunta original: 'X']".
*
* Bypassa cooldown — cliente impaciente precisa de feedback rápido (mesmo que
* seja só uma mensagem-segura "estamos verificando, um momento").
*/
const { query, queryOne } = require('../../database/postgres')
// ── Detecção de padrão ────────────────────────────────────────────────────────
const PING_PATTERNS = [
/^\s*\?{1,}\s*$/, // só "?", "??", "?????"
/^\s*\.{2,}\s*$/, // "..", "..." (cobrança silenciosa)
/^\s*(verificou|viu|olha|al[ôo]|ei|oi|ol[áa])\s*\?+\s*$/i,
/^\s*(t[áa]|est[áa])\s+a[íi]\s*\?+\s*$/i,
/^\s*(algu[ée]m|tem\s+algu[ée]m)\s+a[íi]\s*\?+\s*$/i,
/^\s*e?\s*(a[íi]|ent[ãa]o|n[ãa]o)\s*\?+\s*$/i,
/^\s*(j[áa]\s+)?(viu|verificou|conferiu|olhou)\s*\?+\s*$/i,
/^\s*(consegue|conseguiu|deu|teve)\s+(ver|olhar|verificar)\s*\?+\s*$/i,
/^\s*(pra|para)?\s*hoje\s*\?+\s*$/i,
/^\s*responde\s+a[íi]\s*\??\s*$/i,
/^\s*me\s+responde\s*\??\s*$/i,
]
/**
* Retorna true se a mensagem parece uma cobrança/ping de follow-up.
* @param {string} body — texto da mensagem
*/
function isFollowUpPing(body) {
if (!body) return false
const t = body.trim()
// Mensagens longas (>40 chars) raramente são pings
if (t.length > 40) return false
for (const re of PING_PATTERNS) {
if (re.test(t)) return true
}
return false
}
// ── Lookup da pergunta anterior sem resposta ──────────────────────────────────
/**
* Busca a última pergunta do cliente neste chat que ainda não foi respondida.
* "Não respondida" = não há mensagem fromMe=true ou auto_reply com status='sent*'
* APÓS aquela mensagem do cliente.
*
* @param {string} chatId
* @param {number} lookbackMinutes — janela máxima para buscar (default 24h)
* @returns {Promise<{ body: string, ageMinutes: number } | null>}
*/
async function findPendingQuestion(chatId, lookbackMinutes = 1440) {
if (!chatId) return null
// Pega últimos eventos do chat na janela
const events = await query(
`SELECT direction, from_me, msg_type, payload, created_at
FROM nw_event_logs
WHERE chat_id = $1
AND event = 'message.new'
AND created_at > NOW() - ($2 || ' minutes')::interval
ORDER BY created_at DESC
LIMIT 50`,
[chatId, String(lookbackMinutes)],
).catch(() => [])
if (!events.length) return null
// events[0] é o ping ATUAL (mais recente). É o limite superior do intervalo
// de busca: queremos saber se a pergunta original foi respondida ENTRE
// o momento em que ela foi feita E o momento do ping atual.
const currentPingTime = events[0].created_at
// Procura, do mais recente para o mais antigo (pulando o próprio ping):
// - Última msg do cliente (from_me=false) que NÃO seja outro ping
// - Se houver fromMe=true entre ela e o ping atual → já foi respondida
let foundClientMsg = null
let hasOperatorBetween = false
for (let i = 1; i < events.length; i++) { // i=1 pula o ping atual
const ev = events[i]
const body = ev.payload?.body ?? ''
if (ev.from_me) {
// Resposta do operador/bot (entre alguma msg antiga do cliente e o ping atual)
hasOperatorBetween = true
continue
}
// Mensagem do cliente — pula pings sucessivos ("?", "??", "alô?")
if (!body || !body.trim() || isFollowUpPing(body)) continue
// Achou pergunta substantiva. Se houve operador entre ela e o ping → respondida.
if (hasOperatorBetween) return null
foundClientMsg = ev
break
}
if (!foundClientMsg) return null
// Confirma checando nw_auto_replies — bot pode ter respondido sem aparecer
// como event from_me=true (depende do echo da motor). Janela: estritamente
// entre o momento da pergunta e o momento do ping atual (exclusivo).
//
// EXCLUI auto_replies cujo `user_msg` foi um ping ou um body enriquecido
// de ping anterior — essas respostas NÃO contam como ter "respondido a
// pergunta original", já que foram só reações a cobranças genéricas.
const repliesBetween = await query(
`SELECT user_msg FROM nw_auto_replies
WHERE chat_id = $1
AND created_at > $2
AND created_at < $3
AND status IN ('sent','sent_local')
ORDER BY created_at`,
[chatId, foundClientMsg.created_at, currentPingTime],
).catch(() => [])
const isPingReply = (userMsg) => {
if (!userMsg) return true
const t = userMsg.trim()
// Body enriquecido por buildEnrichedBody começa com '[O cliente enviou apenas' ou '[Atenção: o cliente está cobrando'
if (/^\[(O cliente enviou apenas|Atenção: o cliente está cobrando)/i.test(t)) return true
return isFollowUpPing(t)
}
const hasRealReply = repliesBetween.some(r => !isPingReply(r.user_msg))
if (hasRealReply) return null // já foi respondida com conteúdo real
const ageMs = Date.now() - new Date(foundClientMsg.created_at).getTime()
return {
body: (foundClientMsg.payload?.body ?? '').trim(),
ageMinutes: Math.round(ageMs / 60000),
}
}
/**
* Compõe a mensagem que será enviada ao LLM, dando contexto ao ping.
* Se há pergunta pendente: instrui a IA a responder ela.
* Se não há (ping aleatório, sem histórico): saudação simples.
*/
function buildEnrichedBody(pingBody, pending) {
if (pending) {
return (
`[Atenção: o cliente está cobrando uma resposta. Há ${pending.ageMinutes}min ele perguntou:\n` +
`"${pending.body}"\n` +
`Agora ele reenviou apenas "${pingBody}" para chamar atenção. ` +
`Responda à pergunta original o mais diretamente possível. ` +
`Se realmente não souber a resposta ou se for assunto que precisa de operador humano, ` +
`peça desculpa pela demora e diga que vai chamar um atendente.]`
)
}
// Sem pergunta pendente — saudação leve
return (
`[O cliente enviou apenas "${pingBody}" sem contexto anterior nas últimas 2h. ` +
`Responda com uma saudação curta perguntando como pode ajudar.]`
)
}
module.exports = { isFollowUpPing, findPendingQuestion, buildEnrichedBody }
+67
View File
@@ -0,0 +1,67 @@
'use strict'
const { createRoutes } = require('./routes')
const { createRestProxy, attachWsProxy } = require('./proxy')
const { createWebhookReceiver } = require('./webhook-receiver')
const plugin = {
async activate(ctx) {
// ── Rotas /nw/* (secretária IA consulta o satélite) ───────────────────
const router = createRoutes()
ctx.app.use('/nw', router)
ctx.logger.info('[newwhats] Rotas registradas em /nw')
// ── Receptor de webhooks (motor → satélite, sem autenticação de sessão)
ctx.app.use(createWebhookReceiver())
ctx.logger.info('[newwhats] Receptor de webhook ativo em POST /api/webhook/newwhats')
// ── Rotas especiais: sec_numbers (rota interna do motor, não ext-api) ────
// /api/nw/v1/secretaria/numbers → motor /api/secretaria/numbers (sem auth)
const { queryOne: cfgQuery } = require('../../database/postgres')
async function getMotorUrl() {
const row = await cfgQuery("SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'newwhats_url'")
return row?.value?.replace(/\/$/, '') || null
}
async function numbersProxy(req, res) {
const motorUrl = await getMotorUrl()
if (!motorUrl) return res.status(503).json({ error: 'Motor não configurado' })
const qs = req.originalUrl.split('?')[1]
const path = `/api/secretaria/numbers${req.params[0] ? '/' + req.params[0] : ''}${qs ? '?' + qs : ''}`
try {
const opts = { method: req.method, headers: { 'Content-Type': 'application/json' } }
if (['POST','PUT','PATCH'].includes(req.method)) opts.body = JSON.stringify(req.body)
const r = await fetch(`${motorUrl}${path}`, opts)
const data = await r.json()
res.status(r.status).json(data)
} catch (e) { res.status(502).json({ error: `Motor indisponível: ${e.message}` }) }
}
ctx.app.get('/api/nw/v1/secretaria/numbers', numbersProxy)
ctx.app.post('/api/nw/v1/secretaria/numbers', numbersProxy)
ctx.app.put('/api/nw/v1/secretaria/numbers/:id', (req, res) => {
req.params[0] = req.params.id; numbersProxy(req, res)
})
ctx.app.delete('/api/nw/v1/secretaria/numbers/:id', (req, res) => {
req.params[0] = req.params.id; numbersProxy(req, res)
})
ctx.logger.info('[newwhats] Rotas sec_numbers ativas em /api/nw/v1/secretaria/numbers')
// ── Proxy REST /api/nw/v1/* → motor /api/ext/v1/* ─────────────────────
const restProxy = createRestProxy()
ctx.app.all('/api/nw/v1/*', restProxy)
ctx.logger.info('[newwhats] Proxy REST ativo em /api/nw/v1/*')
// ── Proxy WS /api/nw/v1/stream → motor /api/ext/v1/stream ─────────────
if (ctx.httpServer) {
attachWsProxy(ctx.httpServer)
ctx.logger.info('[newwhats] Proxy WS ativo em /api/nw/v1/stream')
} else {
ctx.logger.warn('[newwhats] httpServer não disponível no ctx — proxy WS desativado')
}
},
async deactivate(ctx) {
ctx.logger.warn('[newwhats] Desativado. Reinicie o servidor para remover as rotas.')
},
}
module.exports = plugin
+44
View File
@@ -0,0 +1,44 @@
{
"id": "newwhats",
"name": "NewWhats — WhatsApp Inbox",
"version": "2.1.0",
"description": "Integração completa com o motor NewWhats: Inbox WhatsApp em tempo real (sessões, chats, mensagens via WebSocket), proxy autenticado REST + WS, e endpoints /nw/* para a Secretária IA consultar dados deste sistema.",
"active_by_default": false,
"ui_path": "/wa-inbox",
"features": [
"Inbox WhatsApp (sessões, chats, mensagens)",
"WebSocket em tempo real (QR, status, novas mensagens)",
"Proxy REST + WS autenticado para o motor",
"Endpoints /nw/* para Secretária IA"
],
"config_schema": [
{
"key": "newwhats_url",
"label": "URL do Servidor NewWhats",
"type": "text",
"required": true,
"help": "Endereço do motor. Ex: http://newwhats.local:8008"
},
{
"key": "integration_key",
"label": "Chave de Integração (x-nw-key)",
"type": "password",
"required": true,
"help": "Chave compartilhada entre o satélite e o motor. Usada nas rotas /nw/* e no proxy REST/WS."
},
{
"key": "api_token",
"label": "Token da API do Motor (Bearer)",
"type": "password",
"required": true,
"help": "Token Bearer enviado pelo worker ao enviar mensagens. Gerado em Admin → API Keys no motor."
},
{
"key": "webhook_secret",
"label": "Segredo do Webhook (HMAC)",
"type": "password",
"required": false,
"help": "Segredo para validar a assinatura x-nw-signature nos webhooks recebidos do motor. Recomendado em produção."
}
]
}
@@ -0,0 +1,616 @@
'use strict'
/**
* media-transcribe.js — transcreve áudio (PTT) e descreve imagens recebidas
* via WhatsApp usando Gemini 1.5 Flash (multimodal nativo).
*
* Fluxo:
* 1. webhook-receiver detecta msg.type === 'AUDIO' ou 'IMAGE'
* 2. Baixa o blob via /api/ext/v1/media/:messageId no motor (com retry,
* pois o motor baixa a mídia de forma assíncrona após emitir o webhook).
* 3. Envia base64 + mime para Gemini Flash com prompt curto.
* 4. Retorna texto pronto para entrar no fluxo de auto-reply normal.
*
* Custo: Gemini 1.5 Flash é ~10× mais barato que GPT-4 e processa áudio/imagem
* nativamente sem pipeline de Whisper + OCR. Fallback para OpenAI Whisper só
* se a chamada Gemini falhar (rede, quota, etc).
*/
const GEMINI_MODEL = 'gemini-2.0-flash'
const GEMINI_API = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`
const OPENAI_WHISPER_MODEL = 'whisper-1'
const OPENAI_API_BASE = 'https://api.openai.com/v1'
const DOWNLOAD_RETRIES = 4 // motor baixa mídia async — espera até ~6s
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000]
const MAX_AUDIO_SECONDS = 300 // 5min — áudios maiores rejeita
const MAX_IMAGE_BYTES = 8 * 1024 * 1024 // 8MB
const MAX_PDF_BYTES = 20 * 1024 * 1024 // 20MB — limite inline do Gemini
const MAX_VIDEO_BYTES = 18 * 1024 * 1024 // 18MB — Gemini inline limit prático
// ── Cache Redis para extrações ────────────────────────────────────────────────
// Evita reprocessar a mesma mídia (msg.id) se motor re-entregar o webhook.
// Chave: nw:media:<msg.id>; TTL: 24h. Falha silenciosa se Redis indisponível.
const CACHE_TTL_SECONDS = 86_400
let _redisClient = null
function getRedis() {
if (_redisClient) return _redisClient
try {
const Redis = require('ioredis')
_redisClient = new Redis({
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT || '6379', 10),
maxRetriesPerRequest: 1,
enableOfflineQueue: false,
lazyConnect: false,
})
_redisClient.on('error', () => { /* silencia spam — operação cai sem cache */ })
} catch { _redisClient = null }
return _redisClient
}
async function cacheGet(key) {
try {
const r = getRedis()
if (!r) return null
const v = await r.get(`nw:media:${key}`)
return v || null
} catch { return null }
}
async function cacheSet(key, value) {
try {
const r = getRedis()
if (!r || !value) return
await r.set(`nw:media:${key}`, value, 'EX', CACHE_TTL_SECONDS)
} catch { /* silencioso */ }
}
// ── Download da mídia do motor ────────────────────────────────────────────────
async function fetchMediaBuffer(motorUrl, integKey, messageId) {
for (let i = 0; i < DOWNLOAD_RETRIES; i++) {
try {
const res = await fetch(`${motorUrl}/api/ext/v1/media/${encodeURIComponent(messageId)}`, {
headers: { 'x-nw-key': integKey },
redirect: 'follow',
})
if (res.status === 404 && i < DOWNLOAD_RETRIES - 1) {
// motor ainda não baixou — backoff e tenta de novo
await new Promise(r => setTimeout(r, DOWNLOAD_BACKOFF[i]))
continue
}
if (!res.ok) throw new Error(`motor /media respondeu ${res.status}`)
const mime = res.headers.get('content-type') || 'application/octet-stream'
const buf = Buffer.from(await res.arrayBuffer())
return { buffer: buf, mime }
} catch (err) {
if (i === DOWNLOAD_RETRIES - 1) throw err
await new Promise(r => setTimeout(r, DOWNLOAD_BACKOFF[i]))
}
}
throw new Error('Mídia indisponível após retries')
}
// ── Chamada Gemini ────────────────────────────────────────────────────────────
async function callGemini(prompt, inlineData) {
const apiKey = process.env.GEMINI_API_KEY
if (!apiKey) throw new Error('GEMINI_API_KEY não configurado')
const body = {
contents: [{
role: 'user',
parts: [
{ text: prompt },
{ inline_data: { mime_type: inlineData.mime, data: inlineData.base64 } },
],
}],
generationConfig: { temperature: 0.2, maxOutputTokens: 512 },
}
const res = await fetch(`${GEMINI_API}?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`Gemini ${res.status}: ${txt.slice(0, 200)}`)
}
const data = await res.json()
const text = data?.candidates?.[0]?.content?.parts?.[0]?.text?.trim()
if (!text) throw new Error('Gemini retornou resposta vazia')
return text
}
// ── Transcrição de áudio ──────────────────────────────────────────────────────
// ── Transcrição via OpenAI Whisper (purpose-built, $0.006/min) ────────────────
async function transcribeWithWhisper(buffer, mime) {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
// FormData multipart (multipart/form-data com Buffer Blob)
const ext = mime?.includes('mp3') ? 'mp3' : mime?.includes('wav') ? 'wav' : 'ogg'
const blob = new Blob([buffer], { type: mime || 'audio/ogg' })
const fd = new FormData()
fd.append('file', blob, `audio.${ext}`)
fd.append('model', OPENAI_WHISPER_MODEL)
fd.append('language', 'pt')
fd.append('response_format', 'text')
const res = await fetch(`${OPENAI_API_BASE}/audio/transcriptions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}` },
body: fd,
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`Whisper ${res.status}: ${txt.slice(0, 200)}`)
}
const text = (await res.text()).trim()
if (!text) throw new Error('Whisper devolveu vazio')
return text
}
/**
* Transcreve um áudio (PTT) recebido via WhatsApp.
* Tenta Whisper (mais confiável e barato para áudio) e cai para Gemini se falhar.
* @returns {Promise<string|null>} texto transcrito ou null em caso de falha
*/
async function transcribeAudio({ motorUrl, integKey, messageId }) {
try {
const cached = await cacheGet(`audio:${messageId}`)
if (cached) {
console.log(`[transcribe] ⚡ cache hit (${cached.length} chars)`)
return cached
}
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
if (buffer.length > 16 * 1024 * 1024) {
console.warn('[transcribe] áudio muito grande, ignorando:', buffer.length)
return null
}
// 1ª tentativa: Whisper
try {
const text = await transcribeWithWhisper(buffer, mime)
console.log(`[transcribe] ✅ Whisper transcribed ${buffer.length}B → ${text.length} chars`)
cacheSet(`audio:${messageId}`, text)
return text
} catch (e) {
console.warn('[transcribe] Whisper falhou, tentando Gemini:', e.message)
}
// 2ª tentativa: Gemini Flash (multimodal)
const prompt =
'Transcreva fielmente o áudio em português brasileiro. ' +
'Devolva APENAS o texto transcrito, sem comentários, sem timestamps e sem prefixo. ' +
'Se o áudio estiver vazio, com muito ruído ou inaudível, devolva exatamente: [inaudível]'
const text = await callGemini(prompt, {
mime: mime?.startsWith('audio/') ? mime : 'audio/ogg',
base64: buffer.toString('base64'),
})
if (/^\[inaudível\]/i.test(text)) return null
console.log(`[transcribe] ✅ Gemini transcribed → ${text.length} chars`)
cacheSet(`audio:${messageId}`, text)
return text
} catch (err) {
console.warn('[transcribe-audio]', err.message)
return null
}
}
// ── Descrição de imagem ───────────────────────────────────────────────────────
// ── Vision via OpenAI gpt-4o-mini ($0.15/1M input tokens, suporta imagem) ────
async function describeWithOpenAI(buffer, mime, captionFromUser) {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
const dataUrl = `data:${mime || 'image/jpeg'};base64,${buffer.toString('base64')}`
const userText =
'Analise a imagem enviada por um cliente de uma loja de conveniência via WhatsApp. ' +
'Classifique em UMA destas categorias e descreva em uma frase curta:\n' +
'- COMPROVANTE_PIX: comprovantes de pagamento PIX/boleto/transferência. Extraia valor e destinatário se visíveis.\n' +
'- RECEITA_MEDICA: receitas médicas/odontológicas. Extraia medicamento, médico/CRM se legíveis.\n' +
'- PRODUTO: foto de produto (alimento, bebida, item de mercado). Identifique marca/produto.\n' +
'- DOCUMENTO: RG, CPF, CNH, conta de luz/água, contrato.\n' +
'- OUTRO: qualquer outra coisa.\n\n' +
(captionFromUser ? `Legenda do cliente: "${captionFromUser}"\n\n` : '') +
'Devolva no máximo UMA linha no formato exato: TIPO: descrição. Sem markdown, sem comentários.'
const body = {
model: 'gpt-4o-mini',
messages: [{
role: 'user',
content: [
{ type: 'text', text: userText },
{ type: 'image_url', image_url: { url: dataUrl } },
],
}],
max_tokens: 200,
temperature: 0.2,
}
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`OpenAI vision ${res.status}: ${txt.slice(0, 200)}`)
}
const data = await res.json()
const text = data?.choices?.[0]?.message?.content?.trim()
if (!text) throw new Error('OpenAI vision devolveu vazio')
return text
}
/**
* Analisa uma imagem recebida e retorna uma descrição curta + classificação
* útil para o LLM entender a intenção (comprovante, receita, foto de produto…).
*
* Tenta Gemini Flash (multimodal nativo) primeiro; se falhar (quota/rede),
* cai para OpenAI gpt-4o-mini vision.
*
* @returns {Promise<string|null>} texto pronto para entrar no fluxo
* formato: "[imagem] TIPO: descrição curta"
*/
async function describeImage({ motorUrl, integKey, messageId, captionFromUser }) {
try {
const cached = await cacheGet(`image:${messageId}`)
if (cached) {
console.log(`[describe-image] ⚡ cache hit (${cached.length} chars)`)
return cached
}
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
if (buffer.length > MAX_IMAGE_BYTES) {
console.warn('[describe-image] imagem muito grande:', buffer.length)
return null
}
const prompt =
'Analise a imagem enviada por um cliente de uma loja de conveniência via WhatsApp. ' +
'Classifique em UMA destas categorias e descreva em uma frase curta:\n' +
'- COMPROVANTE_PIX: prints/fotos de comprovante de pagamento PIX/boleto/transferência. Extraia valor e nome do destinatário se visível.\n' +
'- RECEITA_MEDICA: receitas médicas/odontológicas. Extraia nome do medicamento, médico/CRM se legível.\n' +
'- PRODUTO: foto de um produto (alimento, bebida, item de mercado). Identifique marca/produto.\n' +
'- DOCUMENTO: RG, CPF, CNH, conta de luz/água, contrato.\n' +
'- OUTRO: qualquer outra coisa.\n\n' +
(captionFromUser ? `O cliente escreveu junto com a imagem: "${captionFromUser}"\n\n` : '') +
'Devolva NO MÁXIMO uma linha no formato exato: TIPO: descrição curta. ' +
'Não use markdown. Não comente.'
// 1ª tentativa: Gemini (mais barato em alta escala se quota disponível)
try {
const text = await callGemini(prompt, {
mime: mime?.startsWith('image/') ? mime : 'image/jpeg',
base64: buffer.toString('base64'),
})
console.log(`[describe-image] ✅ Gemini → ${text.length} chars`)
const result = `[imagem] ${text}`
cacheSet(`image:${messageId}`, result)
return result
} catch (e) {
console.warn('[describe-image] Gemini falhou, caindo para OpenAI:', e.message)
}
// 2ª tentativa: OpenAI Vision
const text = await describeWithOpenAI(buffer, mime, captionFromUser)
console.log(`[describe-image] ✅ OpenAI → ${text.length} chars`)
const result = `[imagem] ${text}`
cacheSet(`image:${messageId}`, result)
return result
} catch (err) {
console.warn('[describe-image]', err.message)
return null
}
}
// ── Extração de texto de PDF ──────────────────────────────────────────────────
const PDF_PROMPT = (captionFromUser) =>
'Extraia o conteúdo deste documento PDF enviado por um cliente via WhatsApp. ' +
'Identifique o tipo do documento (cupom fiscal, nota fiscal, comprovante de pagamento, ' +
'contrato, boleto, pedido, etc.) e extraia as informações mais relevantes como: ' +
'número do pedido/protocolo, valores, datas, produtos listados, nome do cliente/empresa, ' +
'status do pedido, CNPJ/CPF e qualquer instrução ao cliente. ' +
(captionFromUser ? `O cliente escreveu junto com o arquivo: "${captionFromUser}"\n\n` : '') +
'Devolva em texto corrido objetivo, máximo 3 parágrafos. Sem markdown, sem comentários.'
async function extractPdfWithOpenAI(buffer, captionFromUser) {
const apiKey = process.env.OPENAI_API_KEY
if (!apiKey) throw new Error('OPENAI_API_KEY não configurado')
const body = {
model: 'gpt-4o-mini',
messages: [{
role: 'user',
content: [
{
type: 'file',
file: {
filename: 'document.pdf',
file_data: `data:application/pdf;base64,${buffer.toString('base64')}`,
},
},
{ type: 'text', text: PDF_PROMPT(captionFromUser) },
],
}],
max_tokens: 1024,
temperature: 0.2,
}
const res = await fetch(`${OPENAI_API_BASE}/chat/completions`, {
method: 'POST',
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`OpenAI PDF ${res.status}: ${txt.slice(0, 200)}`)
}
const data = await res.json()
const text = data?.choices?.[0]?.message?.content?.trim()
if (!text) throw new Error('OpenAI devolveu vazio')
return text
}
async function extractPdfWithClaude(buffer, captionFromUser) {
const apiKey = process.env.ANTHROPIC_API_KEY
if (!apiKey) throw new Error('ANTHROPIC_API_KEY não configurado')
const body = {
model: 'claude-haiku-4-5-20251001',
max_tokens: 1024,
messages: [{
role: 'user',
content: [
{
type: 'document',
source: {
type: 'base64',
media_type: 'application/pdf',
data: buffer.toString('base64'),
},
},
{ type: 'text', text: PDF_PROMPT(captionFromUser) },
],
}],
}
const res = await fetch('https://api.anthropic.com/v1/messages', {
method: 'POST',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) {
const txt = await res.text().catch(() => '')
throw new Error(`Anthropic PDF ${res.status}: ${txt.slice(0, 200)}`)
}
const data = await res.json()
const text = data?.content?.[0]?.text?.trim()
if (!text) throw new Error('Anthropic devolveu vazio')
return text
}
/**
* Extrai e resume o conteúdo de um PDF recebido via WhatsApp.
* Cadeia de fallback: Gemini 2.0 Flash → OpenAI gpt-4o-mini → Claude Haiku.
*
* @returns {Promise<string|null>} texto extraído prefixado com "[documento PDF]" ou null
*/
async function extractPdfText({ motorUrl, integKey, messageId, captionFromUser }) {
try {
const cached = await cacheGet(`pdf:${messageId}`)
if (cached) {
console.log(`[extract-pdf] ⚡ cache hit (${cached.length} chars)`)
return cached
}
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
// Aceita por mime OU por magic bytes (%PDF) — motor às vezes serve
// documentos como application/octet-stream sem distinguir o tipo real.
const isPdfMime = mime?.toLowerCase().includes('pdf')
const isPdfMagic = buffer.length >= 4 && buffer.slice(0, 4).toString('ascii') === '%PDF'
if (!isPdfMime && !isPdfMagic) {
console.log('[extract-pdf] não é PDF — mime:', mime, 'magic:', buffer.slice(0, 4).toString('hex'))
return null
}
if (buffer.length > MAX_PDF_BYTES) {
console.warn('[extract-pdf] PDF muito grande para inline:', buffer.length)
return null
}
const base64 = buffer.toString('base64')
// 1ª tentativa: Gemini Flash (mais barato)
try {
const text = await callGemini(PDF_PROMPT(captionFromUser), {
mime: 'application/pdf',
base64,
})
console.log(`[extract-pdf] ✅ Gemini extraiu ${text.length} chars (${buffer.length}B)`)
const result = `[documento PDF] ${text}`
cacheSet(`pdf:${messageId}`, result)
return result
} catch (e) {
console.warn('[extract-pdf] Gemini falhou, tentando OpenAI:', e.message)
}
// 2ª tentativa: OpenAI gpt-4o-mini
try {
const text = await extractPdfWithOpenAI(buffer, captionFromUser)
console.log(`[extract-pdf] ✅ OpenAI extraiu ${text.length} chars`)
const result = `[documento PDF] ${text}`
cacheSet(`pdf:${messageId}`, result)
return result
} catch (e) {
console.warn('[extract-pdf] OpenAI falhou, tentando Anthropic:', e.message)
}
// 3ª tentativa: Claude Haiku
const text = await extractPdfWithClaude(buffer, captionFromUser)
console.log(`[extract-pdf] ✅ Anthropic extraiu ${text.length} chars`)
const result = `[documento PDF] ${text}`
cacheSet(`pdf:${messageId}`, result)
return result
} catch (err) {
console.warn('[extract-pdf]', err.message)
return null
}
}
// ── Vídeo (Gemini nativo, sem ffmpeg) ────────────────────────────────────────
/**
* Processa um vídeo enviado via WhatsApp. Gemini 2.0 Flash aceita video/mp4
* (e variantes) como inline_data nativamente, descrevendo cena + áudio juntos.
* Para vídeos > 18MB retorna null (motor poderia subir via File API; fora do escopo).
*
* @returns {Promise<string|null>} texto prefixado com "[vídeo]" ou null
*/
async function processVideo({ motorUrl, integKey, messageId, captionFromUser }) {
try {
const cached = await cacheGet(`video:${messageId}`)
if (cached) {
console.log(`[process-video] ⚡ cache hit (${cached.length} chars)`)
return cached
}
const { buffer, mime } = await fetchMediaBuffer(motorUrl, integKey, messageId)
if (buffer.length > MAX_VIDEO_BYTES) {
console.warn('[process-video] vídeo muito grande para inline:', buffer.length)
return null
}
const prompt =
'Analise este vídeo enviado por um cliente de loja de conveniência via WhatsApp. ' +
'Descreva em UMA frase curta o que está acontecendo na cena (foco em produto, problema, ' +
'comprovante ou ambiente). Se houver fala/áudio relevante, transcreva o conteúdo principal. ' +
'Classifique em UMA categoria: COMPROVANTE, PRODUTO_DEFEITO, PRODUTO_DUVIDA, RECLAMACAO, OUTRO. ' +
(captionFromUser ? `Legenda do cliente: "${captionFromUser}"\n\n` : '') +
'Devolva no formato exato: TIPO: descrição (+ fala transcrita se houver). Sem markdown.'
const text = await callGemini(prompt, {
mime: mime?.startsWith('video/') ? mime : 'video/mp4',
base64: buffer.toString('base64'),
})
console.log(`[process-video] ✅ Gemini → ${text.length} chars (${buffer.length}B)`)
const result = `[vídeo] ${text}`
cacheSet(`video:${messageId}`, result)
return result
} catch (err) {
console.warn('[process-video]', err.message)
return null
}
}
// ── vCard / Contato (parsing local, sem LLM) ─────────────────────────────────
/**
* Faz parsing de um vCard (cartão de contato) compartilhado via WhatsApp.
* O motor pode entregar o vCard cru no msg.body OU em msg.vcard. Aceita ambos.
*
* @param {{ rawVcard?: string, msgBody?: string }} input
* @returns {string|null} texto formatado prefixado com "[contato]" ou null
*/
function extractVCard({ rawVcard, msgBody } = {}) {
const text = (rawVcard || msgBody || '').trim()
if (!text || !/BEGIN:VCARD/i.test(text)) return null
const pick = (re) => {
const m = text.match(re)
return m ? m[1].trim() : null
}
const fn = pick(/^FN[^:]*:(.+)$/im)
const n = pick(/^N[^:]*:(.+)$/im)
const tels = [...text.matchAll(/^TEL[^:]*:(\+?[\d\s().-]+)$/gim)]
.map(m => m[1].replace(/\D/g, '')).filter(Boolean)
const emails = [...text.matchAll(/^EMAIL[^:]*:(\S+@\S+)$/gim)].map(m => m[1])
const org = pick(/^ORG[^:]*:(.+)$/im)
const nome = fn || (n ? n.split(';').filter(Boolean).reverse().join(' ').trim() : null)
if (!nome && tels.length === 0) return null
const partes = []
if (nome) partes.push(`nome: ${nome}`)
if (org) partes.push(`empresa: ${org}`)
if (tels.length > 0) partes.push(`tel: ${tels.join(', ')}`)
if (emails.length > 0) partes.push(`email: ${emails.join(', ')}`)
return `[contato] cliente compartilhou contato — ${partes.join(' | ')}`
}
// ── Detecção automática de intent a partir do conteúdo de mídia ──────────────
/**
* Mapeia o `effectiveBody` (com prefixos [imagem]/[vídeo]/[documento PDF]/[contato])
* para tags de intent compreendidas pelo brain (sector_brain_nodes.tags).
* Reduz dependência do LLM detectar intenção sozinho — economiza tokens e
* melhora roteamento de setor.
*
* @param {string} effectiveBody
* @returns {string[]} array de tags (vazio se nenhuma reconhecida)
*/
function detectMediaIntent(effectiveBody) {
if (!effectiveBody) return []
const tags = new Set()
const t = effectiveBody.toLowerCase()
// Imagem
if (/\[imagem\]\s*comprovante_pix/i.test(effectiveBody)) tags.add('pagamento')
if (/\[imagem\]\s*receita_medica/i.test(effectiveBody)) tags.add('saude')
if (/\[imagem\]\s*produto\b/i.test(effectiveBody)) tags.add('produto')
if (/\[imagem\]\s*documento\b/i.test(effectiveBody)) tags.add('documento')
// Vídeo
if (/\[v[íi]deo\]\s*comprovante\b/i.test(effectiveBody)) tags.add('pagamento')
if (/\[v[íi]deo\]\s*produto_defeito/i.test(effectiveBody)) { tags.add('reclamacao'); tags.add('produto') }
if (/\[v[íi]deo\]\s*produto_duvida/i.test(effectiveBody)) tags.add('produto')
if (/\[v[íi]deo\]\s*reclamacao/i.test(effectiveBody)) tags.add('reclamacao')
// Contato
if (/^\[contato\]/.test(effectiveBody)) tags.add('contato_compartilhado')
// PDF — heurística por palavras-chave no texto extraído
if (/^\[documento pdf\]/i.test(effectiveBody)) {
if (/\b(boleto|2[º°]\s*via|c[óo]digo\s+de\s+barras|linha\s+digit[áa]vel)\b/.test(t)) tags.add('pagamento')
if (/\b(comprovante|recibo|pix\s+enviado|transfer[êe]ncia)\b/.test(t)) tags.add('pagamento')
if (/\b(cupom\s+fiscal|nota\s+fiscal|nfc-?e|nf-?e|sat\b)\b/.test(t)) tags.add('nota_fiscal')
if (/\b(receita\s+m[ée]dica|prescri[çc][ãa]o|crm\b)\b/.test(t)) tags.add('saude')
if (/\b(contrato|termo\s+de|ades[ãa]o)\b/.test(t)) tags.add('contrato')
if (/\b(pedido|protocolo|ped-\d{8})\b/.test(t)) tags.add('pedido')
}
return [...tags]
}
module.exports = {
transcribeAudio,
describeImage,
extractPdfText,
processVideo,
extractVCard,
detectMediaIntent,
fetchMediaBuffer,
}
+102
View File
@@ -0,0 +1,102 @@
'use strict'
/**
* personas.js — biblioteca de personas pré-configuradas para a secretária virtual.
*
* Cada tenant pode escolher uma persona via plugin_config newwhats.persona_key.
* O texto é injetado no `systemExtra` da chamada /secretaria/ask, antes da
* BASE DE CONHECIMENTO. Resulta em tom de voz consistente sem precisar reescrever
* o agent inteiro no painel do motor.
*
* Inspirado nas personas administrativas humanizadas da Sofia v1.2.0.
*/
const PERSONAS = {
// ── Conveniência / mercado de bairro (default Alemão Conveniências) ─────────
conveniencia_amigavel: {
label: 'Conveniência amigável',
description: 'Tom informal, curto, próximo. Resolve pedidos e tira dúvidas com leveza.',
prompt: `Você é a atendente virtual de uma loja de conveniência de bairro.
- Fale de forma calorosa e direta, sem formalidade excessiva. Pode usar 1-2 emojis no máximo por mensagem.
- Mensagens curtas (até 3 linhas no WhatsApp).
- Foco: ajudar a montar pedido, confirmar status, tirar dúvida sobre horário/entrega/pagamento.
- Quando não souber, ofereça transferir para a equipe humana.
- Nunca invente preços, estoque ou prazo de entrega — use somente dados que vierem na BASE DE CONHECIMENTO.`,
},
// ── Secretária administrativa (clínica, consultório, escritório) ────────────
secretaria_admin: {
label: 'Secretária administrativa',
description: 'Cordial, profissional, formal sem ser fria. Foco em agendamento e confirmação.',
prompt: `Você é a secretária virtual de um consultório/escritório.
- Tom cordial e profissional. Use "senhor"/"senhora" quando souber o nome ou for solicitado pelo cliente.
- Sem emojis. Mensagens objetivas (até 4 linhas).
- Foco: agendamento, reagendamento, confirmação, lembrete, recados ao titular.
- Antes de agendar, confirme: nome completo, telefone, motivo. Use as ferramentas listar_horarios e agendar_horario quando disponíveis.
- Se o assunto for fora do escopo administrativo, ofereça transferir para a equipe responsável.`,
},
// ── Vendedor de promoções / push de oferta ──────────────────────────────────
vendedor_promo: {
label: 'Vendedor de promoções',
description: 'Energético, persuasivo, gera urgência sem ser invasivo.',
prompt: `Você é o consultor de promoções da loja.
- Tom animado, motivador, sem ser exagerado. Pode usar 1-2 emojis estratégicos (🔥 ⚡ 🎯).
- Quando o cliente perguntar por preço/promoção/oferta: destaque a economia em R$ ou %, prazo da oferta e como aproveitar.
- Sempre encerre com call-to-action claro ("quer que eu adicione no seu pedido?", "posso reservar pra você?").
- Nunca prometa desconto que não esteja na BASE DE CONHECIMENTO. Não invente cupom.
- Mensagens até 4 linhas.`,
},
// ── SAC pós-venda (suporte, reclamação, devolução) ──────────────────────────
sac_pos_venda: {
label: 'SAC pós-venda',
description: 'Empático, resolutivo, prioriza desescalada.',
prompt: `Você é o SAC pós-venda da loja.
- Tom empático e calmo, mesmo se o cliente estiver irritado. Nunca conteste — valide o sentimento ("entendo a situação", "lamento o ocorrido").
- Sem emojis em mensagens de reclamação. Permitido 1 emoji discreto em confirmações ("✅").
- Sempre que houver erro confirmado (item faltante, atraso, cobrança indevida): ofereça resolução concreta (estorno, reentrega, brinde) ou escale para a equipe humana usando escalar_humano.
- Peça número de pedido/protocolo logo no início da conversa para localizar o caso.
- Mensagens até 5 linhas.`,
},
// ── Cobrança amigável (financeiro pendente, lembrete de pagamento) ──────────
cobranca_amigavel: {
label: 'Cobrança amigável',
description: 'Firme mas educada. Foca em facilitar o pagamento.',
prompt: `Você é a atendente do setor financeiro responsável por lembretes de pagamento.
- Tom firme mas respeitoso. Nunca constranja, ameace ou use linguagem que possa ser interpretada como pressão indevida.
- Sem emojis.
- Sempre informe: valor, vencimento original, formas de regularizar (PIX, link de pagamento, parcelamento se houver).
- Se o cliente alegar não-recebimento, problema financeiro ou contestação: escalar_humano imediatamente.
- Nunca exponha dívida em conversa de grupo. Se o chat for de grupo, recuse e peça contato privado.
- Mensagens até 4 linhas.`,
},
}
/**
* Resolve a persona configurada para o tenant.
* Lê plugin_configs (newwhats.persona_key). Default: conveniencia_amigavel.
*
* @returns {{ key: string, label: string, prompt: string } | null}
*/
async function resolvePersona(tenantId = 1) {
try {
const { queryOne } = require('../../database/postgres')
const row = await queryOne(
`SELECT value FROM plugin_configs
WHERE plugin_id='newwhats' AND key='persona_key'`,
)
const key = row?.value || 'conveniencia_amigavel'
const persona = PERSONAS[key]
if (!persona) {
console.warn(`[personas] chave "${key}" não existe; usando default`)
return { key: 'conveniencia_amigavel', ...PERSONAS.conveniencia_amigavel }
}
return { key, ...persona }
} catch {
return { key: 'conveniencia_amigavel', ...PERSONAS.conveniencia_amigavel }
}
}
module.exports = { PERSONAS, resolvePersona }
+169
View File
@@ -0,0 +1,169 @@
'use strict'
/**
* Proxy para a External API do motor NewWhats.
*
* Encaminha chamadas do satélite (Alemão) para o motor:
* REST: GET|POST|... /api/nw/v1/* → {motorUrl}/api/ext/v1/*
* WS: UPGRADE /api/nw/v1/stream → {motorUrl}/api/ext/v1/stream
*
* A integration_key é lida da tabela plugin_configs e injectada automaticamente
* como header x-nw-key em todas as chamadas — nunca fica exposta ao browser.
*/
const http = require('http')
const https = require('https')
const net = require('net')
const url = require('url')
const { queryOne } = require('../../database/postgres')
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function getPluginConfig() {
const [keyRow, urlRow] = await Promise.all([
queryOne("SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'integration_key'"),
queryOne("SELECT value FROM plugin_configs WHERE plugin_id = 'newwhats' AND key = 'newwhats_url'"),
])
return {
integrationKey: keyRow?.value || null,
motorUrl: urlRow?.value?.replace(/\/$/, '') || null,
}
}
function notConfigured(res) {
res.status(503).json({ error: 'Plugin NewWhats não configurado. Configure a integration_key e newwhats_url no admin.' })
}
// ─── REST proxy ───────────────────────────────────────────────────────────────
/**
* Express middleware que faz proxy de /api/nw/v1/* → motor /api/ext/v1/*
* Preserva method, path, query string, body e headers (menos host).
*/
function createRestProxy() {
return async function extApiProxy(req, res) {
const { integrationKey, motorUrl } = await getPluginConfig()
if (!integrationKey || !motorUrl) return notConfigured(res)
const target = url.parse(motorUrl)
const isHttps = target.protocol === 'https:'
const lib = isHttps ? https : http
// Remove /api/nw/v1 do prefix e substitui por /api/ext/v1
const suffix = req.path.replace(/^\/api\/nw\/v1/, '')
const qs = req.originalUrl.split('?')[1]
const fwdPath = `/api/ext/v1${suffix}${qs ? '?' + qs : ''}`
const options = {
hostname: target.hostname,
port: target.port || (isHttps ? 443 : 80),
path: fwdPath,
method: req.method,
headers: {
...req.headers,
host: target.hostname,
'x-nw-key': integrationKey,
// Remove headers que causam problemas no proxy
connection: 'close',
},
}
const proxyReq = lib.request(options, (proxyRes) => {
const status = proxyRes.statusCode || 502
// Se o motor retornar um redirect (3xx), ele pode incluir um header
// Location apontando para sua própria porta (ex: :8008/api/auth/login).
// O browser seguiria esse redirect diretamente para o motor — o que
// causa ERR_CONNECTION_REFUSED porque a porta não está exposta.
// Interceptamos aqui e devolvemos 401 para o browser tratar.
if (status >= 300 && status < 400) {
proxyRes.resume() // descarta o body
return res.status(401).json({ error: 'Motor NewWhats requer autenticação. Verifique a integration_key no painel de Plugins.' })
}
res.status(status)
Object.entries(proxyRes.headers).forEach(([k, v]) => {
// Nunca repassa Location ou Set-Cookie do motor — evita vazamento de URL interna
if (k !== 'transfer-encoding' && k !== 'location' && k !== 'set-cookie') {
res.setHeader(k, v)
}
})
proxyRes.pipe(res)
})
proxyReq.on('error', (err) => {
if (!res.headersSent) {
res.status(502).json({ error: `Motor indisponível: ${err.message}` })
}
})
// Forward body para POST/PUT/PATCH
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
const body = JSON.stringify(req.body)
proxyReq.setHeader('content-length', Buffer.byteLength(body))
proxyReq.setHeader('content-type', 'application/json')
proxyReq.write(body)
}
proxyReq.end()
}
}
// ─── WS proxy (TCP tunnel) ────────────────────────────────────────────────────
/**
* Adiciona handler de upgrade ao httpServer para fazer tunnel do WS.
*
* Fluxo:
* Browser → UPGRADE /api/nw/v1/stream
* Satélite → abre TCP para motor + injeta x-nw-key no upgrade
* Tunnel bidirecional socket ↔ socket (zero overhead no runtime)
*/
function attachWsProxy(httpServer) {
httpServer.on('upgrade', async (req, clientSocket, head) => {
if (!req.url.startsWith('/api/nw/v1/stream')) return
const { integrationKey, motorUrl } = await getPluginConfig()
if (!integrationKey || !motorUrl) {
clientSocket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n')
clientSocket.destroy()
return
}
const target = url.parse(motorUrl)
const port = parseInt(String(target.port || 80), 10)
const host = target.hostname
const proxySocket = net.connect(port, host, () => {
// Reescreve o upgrade request com o path correto + x-nw-key
const headers = Object.entries(req.headers)
.filter(([k]) => k !== 'host' && k !== 'x-nw-key')
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n')
proxySocket.write(
`${req.method} /api/ext/v1/stream HTTP/1.1\r\n` +
`Host: ${host}\r\n` +
`x-nw-key: ${integrationKey}\r\n` +
`${headers}\r\n` +
`\r\n`
)
if (head && head.length) proxySocket.write(head)
proxySocket.pipe(clientSocket)
clientSocket.pipe(proxySocket)
})
proxySocket.on('error', (err) => {
console.error('[nw-proxy] WS tunnel error:', err.message)
clientSocket.destroy()
})
clientSocket.on('error', () => proxySocket.destroy())
clientSocket.on('close', () => proxySocket.destroy())
proxySocket.on('close', () => clientSocket.destroy())
})
}
module.exports = { createRestProxy, attachWsProxy }
+345
View File
@@ -0,0 +1,345 @@
'use strict'
const { Router } = require('express')
const { query, queryOne } = require('../../database/postgres')
const { nwAuth } = require('./auth')
function cleanPhone(tel) {
return String(tel || '').replace(/\D/g, '')
}
function cleanCpf(cpf) {
return String(cpf || '').replace(/\D/g, '')
}
function createRoutes() {
const router = Router()
router.use(nwAuth)
// ── GET /nw/ping ──────────────────────────────────────────────────────────
router.get('/ping', (_req, res) => {
res.json({ ok: true, system: 'alemao-conveniencias', ts: new Date().toISOString() })
})
// ── GET /nw/cliente?telefone= ─────────────────────────────────────────────
// Retorna dados do cliente + tickets da rifa ativa + clube ativo + candidaturas
router.get('/cliente', async (req, res) => {
const { telefone } = req.query
if (!telefone) return res.status(400).json({ error: '"telefone" é obrigatório' })
const clean = cleanPhone(telefone)
try {
const user = await queryOne(
`SELECT id, nome, telefone, cpf, email, timestamp AS created_at
FROM users
WHERE REGEXP_REPLACE(telefone, '\\D', '', 'g') = $1`,
[clean]
)
if (!user) return res.json({ found: false })
const cpf = cleanCpf(user.cpf)
// Tickets da rifa ativa
const tickets = await query(
`SELECT t.numero, r.titulo AS sorteio, t.data_compra
FROM tickets t
JOIN raffles r ON r.id = t.raffle_id
WHERE REGEXP_REPLACE(t.usuario_telefone, '\\D', '', 'g') = $1
AND r.ativo = 1
ORDER BY t.numero ASC`,
[clean]
)
// Clube(s) ativo(s)
const clubes = cpf ? await query(
`SELECT cm.id, cm.status, cm.next_billing_at,
cp.name AS plano, cp.price_monthly,
bc.name AS clube, bc.slug AS clube_slug, bc.specialty
FROM club_members cm
JOIN club_plans cp ON cp.id = cm.plan_id
JOIN benefit_clubs bc ON bc.id = cm.club_id
WHERE cm.user_cpf = $1 AND cm.status IN ('active','pending_payment')
ORDER BY cm.created_at DESC`,
[cpf]
) : []
// Candidaturas ativas (últimas 3)
const candidaturas = cpf ? await query(
`SELECT ja.status, ja.applied_at,
j.title AS vaga, j.work_mode, j.job_type, j.location
FROM job_applications ja
JOIN jobs j ON j.id = ja.job_id
WHERE ja.applicant_cpf = $1
ORDER BY ja.applied_at DESC
LIMIT 3`,
[cpf]
) : []
res.json({
found: true,
cliente: {
...user,
tickets,
clubes,
candidaturas,
},
})
} catch (err) {
console.error('[nw/cliente]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/clube?cpf= ────────────────────────────────────────────────────
// Retorna plano ativo do cliente, clube, dependentes e parceiros próximos
router.get('/clube', async (req, res) => {
const { cpf } = req.query
if (!cpf) return res.status(400).json({ error: '"cpf" é obrigatório' })
const cleanedCpf = cleanCpf(cpf)
try {
const memberships = await query(
`SELECT cm.id, cm.status, cm.next_billing_at, cm.started_at,
cp.name AS plano, cp.price_monthly, cp.max_dependents, cp.features,
bc.id AS clube_id, bc.name AS clube, bc.slug AS clube_slug,
bc.specialty, bc.conditions, bc.rules
FROM club_members cm
JOIN club_plans cp ON cp.id = cm.plan_id
JOIN benefit_clubs bc ON bc.id = cm.club_id
WHERE cm.user_cpf = $1
ORDER BY cm.status = 'active' DESC, cm.created_at DESC`,
[cleanedCpf]
)
if (!memberships.length) return res.json({ encontrado: false, memberships: [] })
// Para cada associação ativa, busca dependentes e parceiros
const result = await Promise.all(memberships.map(async (m) => {
const [dependentes, parceiros] = await Promise.all([
query(
`SELECT nome, parentesco, cpf, nascimento
FROM club_dependents
WHERE member_id = $1
ORDER BY nome ASC`,
[m.id]
),
query(
`SELECT name, specialty, phone, address, discount_percent, notes
FROM club_partners
WHERE club_id = $1 AND active = true
ORDER BY name ASC
LIMIT 10`,
[m.clube_id]
),
])
return { ...m, dependentes, parceiros }
}))
res.json({ encontrado: true, memberships: result })
} catch (err) {
console.error('[nw/clube]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/candidaturas?cpf= ─────────────────────────────────────────────
// Retorna todas as candidaturas do candidato com status detalhado
router.get('/candidaturas', async (req, res) => {
const { cpf } = req.query
if (!cpf) return res.status(400).json({ error: '"cpf" é obrigatório' })
const cleanedCpf = cleanCpf(cpf)
try {
const candidaturas = await query(
`SELECT ja.id, ja.status, ja.applied_at, ja.cover_letter,
j.title AS vaga, j.work_mode, j.job_type, j.location, j.category
FROM job_applications ja
JOIN jobs j ON j.id = ja.job_id
WHERE ja.applicant_cpf = $1
ORDER BY ja.applied_at DESC`,
[cleanedCpf]
)
const statusLabels = {
novo: 'Candidatura recebida',
em_analise: 'Em análise pela empresa',
aprovado: 'Aprovado! Aguarde contato',
reprovado: 'Não selecionado desta vez',
contratado: 'Contratado — parabéns!',
}
const enriched = candidaturas.map(c => ({
...c,
status_label: statusLabels[c.status] || c.status,
}))
res.json({ total: enriched.length, candidaturas: enriched })
} catch (err) {
console.error('[nw/candidaturas]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/parceiros?clube=slug ──────────────────────────────────────────
// Lista parceiros de um clube (para indicar onde o cliente pode usar o benefício)
router.get('/parceiros', async (req, res) => {
const { clube } = req.query
if (!clube) return res.status(400).json({ error: '"clube" (slug) é obrigatório' })
try {
const clubeData = await queryOne(
`SELECT id, name, specialty FROM benefit_clubs WHERE slug = $1 AND active = true`,
[clube]
)
if (!clubeData) return res.json({ encontrado: false, parceiros: [] })
const parceiros = await query(
`SELECT name, specialty, phone, address, discount_percent, notes
FROM club_partners
WHERE club_id = $1 AND active = true
ORDER BY name ASC`,
[clubeData.id]
)
res.json({
encontrado: true,
clube: { name: clubeData.name, specialty: clubeData.specialty },
parceiros,
})
} catch (err) {
console.error('[nw/parceiros]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/vagas ─────────────────────────────────────────────────────────
// Lista vagas abertas (para responder perguntas sobre oportunidades de emprego)
router.get('/vagas', async (_req, res) => {
try {
const vagas = await query(
`SELECT title, category, work_mode, job_type, location,
salary_min, salary_max, salary_hide, slots,
published_at, expires_at
FROM jobs
WHERE active = true
AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY published_at DESC
LIMIT 20`
)
const workModeLabels = { presencial: 'Presencial', hibrido: 'Híbrido', remoto: 'Remoto' }
const jobTypeLabels = { clt: 'CLT', pj: 'PJ', freelance: 'Freelance', estagio: 'Estágio', temporario: 'Temporário' }
const enriched = vagas.map(v => ({
...v,
work_mode_label: workModeLabels[v.work_mode] || v.work_mode,
job_type_label: jobTypeLabels[v.job_type] || v.job_type,
salario: v.salary_hide || (!v.salary_min && !v.salary_max)
? 'A combinar'
: v.salary_min && v.salary_max
? `R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })} R$ ${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
: v.salary_min
? `A partir de R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
: `Até R$ ${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`,
}))
res.json({ total: enriched.length, vagas: enriched })
} catch (err) {
console.error('[nw/vagas]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/promocoes ─────────────────────────────────────────────────────
router.get('/promocoes', async (_req, res) => {
try {
const promos = await query(
`SELECT id, nome, descricao, preco_original, preco_promocional
FROM promotions
WHERE ativo = 1
ORDER BY id ASC`
)
res.json(promos)
} catch (err) {
console.error('[nw/promocoes]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/sorteio ───────────────────────────────────────────────────────
router.get('/sorteio', async (_req, res) => {
try {
const sorteio = await queryOne(
"SELECT * FROM raffles WHERE ativo = 1 ORDER BY id DESC LIMIT 1"
)
if (!sorteio) return res.json({ ativo: false })
const countRow = await queryOne(
`SELECT COUNT(*) AS total
FROM tickets
WHERE raffle_id = $1
AND (status = 'sold' OR (status = 'reserved' AND reserved_until > NOW()))`,
[sorteio.id]
)
const total_vendidos = parseInt(countRow?.total ?? 0, 10)
res.json({
ativo: true,
sorteio: {
id: sorteio.id,
titulo: sorteio.titulo,
descricao: sorteio.descricao,
preco_numero: sorteio.preco_numero,
total_numeros: sorteio.total_numeros,
numeros_vendidos: total_vendidos,
numeros_disponiveis: sorteio.total_numeros - total_vendidos,
},
})
} catch (err) {
console.error('[nw/sorteio]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
// ── GET /nw/sorteio/tickets?telefone= ────────────────────────────────────
router.get('/sorteio/tickets', async (req, res) => {
const { telefone } = req.query
if (!telefone) return res.status(400).json({ error: '"telefone" é obrigatório' })
const clean = cleanPhone(telefone)
try {
const sorteio = await queryOne(
"SELECT id, titulo FROM raffles WHERE ativo = 1 ORDER BY id DESC LIMIT 1"
)
if (!sorteio) return res.json({ ativo: false, tickets: [] })
const tickets = await query(
`SELECT t.numero, t.data_compra
FROM tickets t
WHERE t.raffle_id = $1
AND REGEXP_REPLACE(t.usuario_telefone, '\\D', '', 'g') = $2
AND (t.status = 'sold' OR (t.status = 'reserved' AND t.reserved_until > NOW()))
ORDER BY t.numero ASC`,
[sorteio.id, clean]
)
res.json({
sorteio: sorteio.titulo,
telefone,
total: tickets.length,
tickets,
})
} catch (err) {
console.error('[nw/sorteio/tickets]', err.message)
res.status(500).json({ error: 'Erro interno' })
}
})
return router
}
module.exports = { createRoutes }
+339
View File
@@ -0,0 +1,339 @@
'use strict'
/**
* smart-router.js — Roteador determinístico (sem LLM) para mensagens de alta confiança.
*
* Estratégia:
* 1. Classifica a mensagem do usuário com regex/intents.
* 2. Para intents "deterministicos" (cotação por ID, horário, endereço, entrega,
* pagamento, meus pedidos), busca o dado no DB e responde com template.
* 3. Bypassa completamente o LLM: 0 tokens, resposta instantânea, 100% precisa.
* 4. Loga em nw_auto_replies como status='sent_local' para auditoria.
*
* Para intents ambíguas ou conversacionais → retorna null e o fluxo segue ao LLM.
*/
const { queryOne, query, execute } = require('../../database/postgres')
// ── Tabela de horário (mantida em sincronia com brain_node "HORÁRIO") ───────
function lojaAberta(date = new Date()) {
const utc = date.getUTCHours() * 60 + date.getUTCMinutes()
const minSP = (utc - 180 + 1440) % 1440
const h = Math.floor(minSP / 60)
const dow = (date.getUTCDay() + (utc - 180 < 0 ? -1 : 0) + 7) % 7
let aberto, abreAs, fechaAs
if (dow === 0) { aberto = h >= 10 && h < 22; abreAs = '10h'; fechaAs = '22h' }
else if (dow >= 1 && dow <= 4) { aberto = h >= 8 && h < 23; abreAs = '08h'; fechaAs = '23h' }
else { aberto = h >= 8 || h < 2; abreAs = '08h'; fechaAs = '02h da madrugada' }
return { aberto, abreAs, fechaAs, h, dow }
}
// ── Fases do pedido (em linguagem natural) ──────────────────────────────────
const FASE_NATURAL = {
novo: 'recebemos seu pedido e já vamos começar a separar 📦',
separacao: 'estamos separando os itens no estoque agora 📦',
embalagem: 'já separamos tudo, estamos embalando 🎁',
expedicao: 'saiu para entrega! 🚚 Se for retirada, já está pronto pra buscar.',
entregue: 'foi marcado como *entregue* ✅',
cancelado: 'foi cancelado',
auditoria: 'está em conferência interna, libera em alguns minutos',
fiscal: 'está em processamento fiscal, libera em instantes',
}
const STATUS_FINANCEIRO_TXT = {
pendente: 'pagamento pendente',
aprovado: 'pagamento aprovado ✅',
recusado: 'pagamento recusado ❌',
reembolsado: 'reembolsado',
}
// ── Detectores de intents determinísticos ───────────────────────────────────
function intentCotacaoOuPedido(msg) {
// "em que fase está a cotação #1", "status do pedido 3", "PED-20260426-3"
const proto = msg.match(/PED-\d{8}-\d+/i)
if (proto) return { tipo: 'protocolo', valor: proto[0].toUpperCase() }
const cot = msg.match(/cota[çc][aã]o\s*[#nº]?\s*(\d+)/i)
if (cot) return { tipo: 'cotacao_id', valor: cot[1] }
const ped = msg.match(/pedido\s*[#nº]?\s*(\d+)/i)
if (ped) return { tipo: 'pedido_id', valor: ped[1] }
// "qual o status", "fase do meu pedido" sem número
if (/\b(status|fase|andamento)\s+(do|da)\s+(meu|minha|ultimo|último)\s+(pedido|cota[çc][aã]o)/i.test(msg)) {
return { tipo: 'ultimo_do_cliente', valor: null }
}
return null
}
function intentHorario(msg) {
return /\b(abert[oa]s?|fechad[oa]s?|hor[áa]rio|funcion(am|ando|a)|que\s+horas\s+(abre|fecha|fecham))\b/i.test(msg)
&& !/\b(meu|minha)\s+pedido\b/i.test(msg)
}
function intentEndereco(msg) {
return /\b(endere[çc]o|onde\s+(fica|esta|est[áa]o|voc[êe]s)|localiza[çc][aã]o|como\s+chego|qual\s+(o|a)\s+rua)\b/i.test(msg)
}
function intentEntrega(msg) {
return /\b(taxa\s+de\s+entrega|frete|entreg(am|a\s+em)|delivery|tele.?entrega|prazo\s+de\s+entrega|valor\s+da\s+entrega|leva\s+at[ée])\b/i.test(msg)
}
function intentPagamento(msg) {
return /\b(formas?\s+de\s+pagamento|aceitam?\s+(pix|cart[ãa]o|d[ée]bito|cr[ée]dito|dinheiro|boleto)|qual\s+pagamento|posso\s+pagar)\b/i.test(msg)
}
function intentMeusPedidos(msg) {
return /\b(meus\s+pedidos|minhas\s+compras|hist[óo]rico\s+de\s+(pedidos|compras)|o\s+que\s+(j[áa]\s+)?comprei)\b/i.test(msg)
}
function intentMenu(msg) {
return /^(menu|op[çc][õo]es|ajuda|help|comandos?)[\s!.\,?]*$/i.test(msg.trim())
}
// ── Templates de resposta ───────────────────────────────────────────────────
function tplHorario() {
const { aberto, abreAs, fechaAs, dow } = lojaAberta()
const dia = ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado'][dow]
if (aberto) {
return `Sim, estamos *abertos* agora! 🟢\nHoje (${dia}) atendemos até ${fechaAs}.\nPosso te ajudar com algum pedido?`
}
return `No momento estamos *fechados* 🔴\nHoje (${dia}) abrimos às ${abreAs} e fechamos às ${fechaAs}.\nVocê pode mandar seu pedido por aqui que assim que abrirmos a equipe processa! 😊`
}
function tplEndereco() {
return `📍 *Alemão Conveniências*\nVocê pode pedir por aqui no WhatsApp ou pelo nosso app: alemaocoonveniencias.com.br\n\nPosso te ajudar a montar um pedido?`
}
function tplEntrega() {
return `🚚 *Entrega e Retirada*\n\n• *Retirada na loja*: grátis, pronto em até 30 min\n• *Tele-entrega até 3 km*: R$ 5,00\n• *Tele-entrega 3-5 km*: R$ 8,00\n• *Acima de 5 km*: sob consulta\n\nPedido mínimo p/ entrega: R$ 25,00\nPrazo: 30 a 60 min conforme demanda.`
}
function tplPagamento() {
return `💳 *Formas de pagamento*\n\n• *Pix* (recomendado, libera o pedido na hora)\n• *Cartão de crédito* (no app ou maquininha)\n• *Cartão de débito* (maquininha)\n• *Dinheiro* (avise se precisa de troco)\n• *Boleto* só pra PJ acima de R$ 200\n\nQual prefere?`
}
function tplMenu() {
return `Oi! 😊 Posso te ajudar com:\n\n📦 Status do seu pedido (ex: "cotação #1")\n🛒 Buscar produto (ex: "tem cerveja gelada?")\n🚚 Info sobre entrega e prazos\n💳 Formas de pagamento\n🕐 Horário de funcionamento\n\nÉ só me dizer o que precisa!`
}
function tplCotacao(c, itens) {
// c: { cotacao_id, cotacao_status, total, pedido_protocolo, pedido_status, forma_pagto, tipo_entrega, financeiro_status, criado_em }
// itens: [{ nome_produto, quantidade, subtotal }]
const linhas = []
const ref = c.pedido_protocolo ? `*${c.pedido_protocolo}*` : `Cotação *#${c.cotacao_id}*`
linhas.push(`${ref}`)
if (c.pedido_status && FASE_NATURAL[c.pedido_status]) {
linhas.push(FASE_NATURAL[c.pedido_status])
} else if (c.cotacao_status) {
linhas.push(`Status da cotação: *${c.cotacao_status}*`)
}
if (c.tipo_entrega) linhas.push(`Modalidade: ${c.tipo_entrega === 'retirada' ? '🏪 retirada na loja' : '🚚 tele-entrega'}`)
if (c.forma_pagto) {
const pago = c.financeiro_status && STATUS_FINANCEIRO_TXT[c.financeiro_status]
linhas.push(`Pagamento: ${c.forma_pagto.toUpperCase()}${pago ? ` (${pago})` : ''}`)
}
if (c.total) linhas.push(`Total: R$ ${parseFloat(c.total).toFixed(2)}`)
// Lista de produtos (se houver)
if (Array.isArray(itens) && itens.length > 0) {
linhas.push('') // linha em branco antes da lista
linhas.push('🛒 *Itens:*')
itens.forEach(it => {
const qty = parseFloat(it.quantidade)
const qtyStr = Number.isInteger(qty) ? `${qty}x` : `${qty}`
const sub = it.subtotal != null ? ` — R$ ${parseFloat(it.subtotal).toFixed(2)}` : ''
linhas.push(`${qtyStr} ${it.nome_produto}${sub}`)
})
}
// Pergunta de fechamento conforme status
if (c.pedido_status === 'entregue') linhas.push(`\nVocê chegou a receber tudo certinho? 🙏`)
else if (c.pedido_status === 'expedicao') linhas.push(`\nQualquer coisa é só me chamar!`)
return linhas.join('\n')
}
// Busca itens de um pedido ou cotação
async function lookupItens({ pedido_id, cotacao_id }, tenantId = 1) {
if (pedido_id) {
return await query(
`SELECT nome_produto, quantidade, subtotal
FROM pedido_itens pi
JOIN pedidos p ON p.id = pi.pedido_id
WHERE pi.pedido_id = $1 AND p.tenant_id = $2
ORDER BY pi.id`,
[pedido_id, tenantId],
).catch(() => [])
}
if (cotacao_id) {
return await query(
`SELECT nome_produto, quantidade, subtotal
FROM cotacao_itens ci
JOIN cotacoes c ON c.id = ci.cotacao_id
WHERE ci.cotacao_id = $1 AND c.tenant_id = $2
ORDER BY ci.id`,
[cotacao_id, tenantId],
).catch(() => [])
}
return []
}
function tplMeusPedidos(pedidos) {
if (!pedidos || pedidos.length === 0) {
return `Não encontrei pedidos no seu histórico ainda. Quer começar um agora? 🛒`
}
const linhas = ['📋 *Seus últimos pedidos*\n']
pedidos.slice(0, 5).forEach(p => {
const fase = FASE_NATURAL[p.status]?.replace(/[📦🎁🚚✅]/g, '').trim() || p.status
linhas.push(`• *${p.protocolo}* (${p.data}) — ${fase}`)
})
linhas.push('\nQuer detalhes de algum? Me diga o número.')
return linhas.join('\n')
}
// ── Lookups ─────────────────────────────────────────────────────────────────
async function lookupCotacaoOuPedido(intent, tenantId = 1) {
if (intent.tipo === 'cotacao_id') {
return await queryOne(
`SELECT c.id AS cotacao_id, c.status AS cotacao_status, c.total,
p.id AS pedido_id, p.protocolo AS pedido_protocolo, p.status AS pedido_status,
p.forma_pagto, p.tipo_entrega, p.financeiro_status
FROM cotacoes c
LEFT JOIN pedidos p ON p.cotacao_id = c.id AND p.tenant_id = c.tenant_id
WHERE c.id = $1 AND c.tenant_id = $2
ORDER BY p.id DESC LIMIT 1`,
[intent.valor, tenantId],
).catch(() => null)
}
if (intent.tipo === 'protocolo') {
return await queryOne(
`SELECT cotacao_id, NULL AS cotacao_status, total,
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
forma_pagto, tipo_entrega, financeiro_status
FROM pedidos WHERE protocolo ILIKE $1 AND tenant_id = $2 LIMIT 1`,
[intent.valor, tenantId],
).catch(() => null)
}
if (intent.tipo === 'pedido_id') {
return await queryOne(
`SELECT cotacao_id, NULL AS cotacao_status, total,
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
forma_pagto, tipo_entrega, financeiro_status
FROM pedidos WHERE id = $1 AND tenant_id = $2 LIMIT 1`,
[intent.valor, tenantId],
).catch(() => null)
}
return null
}
async function lookupUltimosPedidos(phone, tenantId = 1) {
if (!phone) return []
// Tenta achar user pelo telefone
const user = await queryOne(
`SELECT id FROM users
WHERE REGEXP_REPLACE(telefone, '[^0-9]', '', 'g') LIKE $1 LIMIT 1`,
[`%${phone}`],
).catch(() => null)
if (!user) return []
return await query(
`SELECT protocolo, status, total,
TO_CHAR(created_at, 'DD/MM') AS data
FROM pedidos WHERE user_id = $1 AND tenant_id = $2
ORDER BY created_at DESC LIMIT 5`,
[user.id, tenantId],
).catch(() => [])
}
// ── Roteador principal ──────────────────────────────────────────────────────
/**
* Tenta responder a mensagem localmente sem chamar o LLM.
* @param {string} msg — mensagem do usuário
* @param {string} fromPhone — número do remetente (digits-only ou JID)
* @param {number} tenantId
* @returns {Promise<{reply: string, intent: string} | null>}
*/
async function tryLocalReply(msg, fromPhone, tenantId = 1) {
if (!msg || !msg.trim()) return null
const m = msg.trim()
const phone = (fromPhone || '').replace(/\D/g, '').replace(/^55/, '')
// 1. Cotação/Pedido por ID — alta prioridade
const cot = intentCotacaoOuPedido(m)
if (cot && cot.tipo !== 'ultimo_do_cliente') {
const data = await lookupCotacaoOuPedido(cot, tenantId)
if (data) {
const itens = await lookupItens({ pedido_id: data.pedido_id, cotacao_id: data.cotacao_id }, tenantId)
return { reply: tplCotacao(data, itens), intent: 'cotacao_lookup' }
}
// Não achou — deixa o LLM lidar (pode pedir mais detalhes)
return null
}
if (cot?.tipo === 'ultimo_do_cliente') {
const lista = await lookupUltimosPedidos(phone, tenantId)
if (lista.length > 0) {
const last = await queryOne(
`SELECT cotacao_id, NULL AS cotacao_status, total,
id AS pedido_id, protocolo AS pedido_protocolo, status AS pedido_status,
forma_pagto, tipo_entrega, financeiro_status
FROM pedidos WHERE protocolo = $1 AND tenant_id = $2 LIMIT 1`,
[lista[0].protocolo, tenantId],
).catch(() => null)
if (last) {
const itens = await lookupItens({ pedido_id: last.pedido_id, cotacao_id: last.cotacao_id }, tenantId)
return { reply: tplCotacao(last, itens), intent: 'cotacao_lookup_ultimo' }
}
}
return null
}
// 2. FAQ determinísticos (mensagens curtas e objetivas)
if (intentMenu(m)) return { reply: tplMenu(), intent: 'menu' }
if (intentHorario(m)) return { reply: tplHorario(), intent: 'horario' }
if (intentEndereco(m)) return { reply: tplEndereco(), intent: 'endereco' }
if (intentEntrega(m)) return { reply: tplEntrega(), intent: 'entrega' }
if (intentPagamento(m)) return { reply: tplPagamento(), intent: 'pagamento' }
// 3. Meus pedidos
if (intentMeusPedidos(m)) {
const lista = await lookupUltimosPedidos(phone, tenantId)
return { reply: tplMeusPedidos(lista), intent: 'meus_pedidos' }
}
return null
}
// ── Envio direto via motor (bypassa /secretaria/ask) ────────────────────────
async function sendLocalReply(motorUrl, integKey, instanceId, chatId, reply, intent, userMsg) {
try {
const res = 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 }),
})
const status = res.ok ? 'sent_local' : 'failed_local'
await execute(
`INSERT INTO nw_auto_replies (instance_id, chat_id, conv_id, user_msg, reply, status)
VALUES ($1, $2, NULL, $3, $4, $5)`,
[instanceId || '', chatId, userMsg, reply, status],
)
// Telemetria: baseline ~5000 chars de prompt seriam enviados ao LLM
await execute(
`INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars)
VALUES ($1, 'local', $2, $3, $4, 0, 5000)`,
[chatId, intent, userMsg.slice(0, 200), reply.length],
).catch(() => {})
if (res.ok) {
console.log(`[smart-router] ✅ [${intent}] ${chatId} → "${reply.slice(0, 50)}…" (0 tokens)`)
return true
}
console.warn(`[smart-router] envio falhou: ${res.status}`)
return false
} catch (err) {
console.error('[smart-router] erro ao enviar:', err.message)
return false
}
}
module.exports = { tryLocalReply, sendLocalReply }
+246
View File
@@ -0,0 +1,246 @@
'use strict'
/**
* sync-knowledge.js
* Constrói o nó de conhecimento da Secretária IA com dados reais do banco
* e sincroniza com o motor NewWhats via API REST.
*
* Fluxo:
* 1. Lê dados do DB Alemão (clubes, planos, parceiros, promoções, sorteio, vagas)
* 2. Monta texto estruturado do knowledge node
* 3. Chama o motor NewWhats para atualizar o nó (ou cria se não existir)
* 4. Retorna { ok, preview, agent_id, node_id }
*/
const { query, queryOne } = require('../../database/postgres')
// ── Busca configurações do plugin no banco ────────────────────────────────────
async function getPluginConfig() {
const rows = await query(
`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`
)
const cfg = {}
for (const r of rows) cfg[r.key] = r.value
return cfg
}
// ── Chama a API interna do motor NewWhats (/api/secretaria/*) ─────────────────
// Nota: as rotas /api/secretaria/* são internas do motor (não passam pelo proxy
// /api/ext/v1). São acessadas diretamente pela URL do motor configurada no plugin.
async function motorFetch(motorUrl, _integKey, method, path, body) {
const url = `${motorUrl}/api/secretaria${path}`
const opts = {
method,
headers: { 'Content-Type': 'application/json' },
}
if (body) opts.body = JSON.stringify(body)
const res = await fetch(url, opts)
if (!res.ok) {
const txt = await res.text()
throw new Error(`Motor ${method} /api/secretaria${path}${res.status}: ${txt}`)
}
return res.json()
}
// ── Monta o texto do knowledge node ──────────────────────────────────────────
async function buildKnowledgeText() {
const lines = []
// ── Empresa ──────────────────────────────────────────────────────────────
lines.push(`=== ALEMÃO CONVENIÊNCIAS ===`)
lines.push(`Somos uma empresa de conveniências com foco em benefícios para nossos clientes.`)
lines.push(`Site: alemaoconveniencias.com | Atendimento via WhatsApp`)
lines.push(``)
// ── Clubes de Benefícios ─────────────────────────────────────────────────
const clubes = await query(
`SELECT id, name, slug, specialty, conditions, rules
FROM benefit_clubs
WHERE active = true
ORDER BY id ASC`
)
if (clubes.length) {
lines.push(`=== CLUBES DE BENEFÍCIOS ===`)
for (const clube of clubes) {
lines.push(`\n${clube.name} (${clube.specialty})`)
// Planos do clube
const planos = await query(
`SELECT name, price_monthly, max_dependents, features
FROM club_plans
WHERE club_id = $1 AND active = true
ORDER BY price_monthly ASC`,
[clube.id]
)
if (planos.length) {
lines.push(` Planos disponíveis:`)
for (const p of planos) {
const deps = p.max_dependents > 0 ? ` (até ${p.max_dependents} dependente${p.max_dependents > 1 ? 's' : ''})` : ' (titular)'
lines.push(` - ${p.name}${deps}: R$ ${Number(p.price_monthly).toFixed(2).replace('.', ',')}/mês`)
let features = []
try { features = typeof p.features === 'string' ? JSON.parse(p.features) : (p.features || []) } catch {}
if (features.length) lines.push(` Incluído: ${features.slice(0, 4).join(', ')}`)
}
}
// Parceiros do clube
const parceiros = await query(
`SELECT name, specialty, phone, address, discount_percent
FROM club_partners
WHERE club_id = $1 AND active = true
ORDER BY name ASC
LIMIT 8`,
[clube.id]
)
if (parceiros.length) {
lines.push(` Parceiros credenciados:`)
for (const p of parceiros) {
const desc = p.discount_percent ? `${p.discount_percent}% desconto` : ''
const tel = p.phone ? ` | Tel: ${p.phone}` : ''
lines.push(` - ${p.name} (${p.specialty})${desc}${tel}`)
if (p.address) lines.push(` Endereço: ${p.address}`)
}
}
if (clube.conditions) lines.push(` Condições: ${clube.conditions}`)
if (clube.rules) lines.push(` Regras: ${clube.rules}`)
}
lines.push(``)
}
// ── Sorteio Ativo ─────────────────────────────────────────────────────────
const sorteio = await queryOne(
`SELECT titulo, descricao, preco_numero, total_numeros FROM raffles WHERE ativo = 1 LIMIT 1`
)
if (sorteio) {
const countRow = await queryOne(
`SELECT COUNT(*) AS total FROM tickets t
JOIN raffles r ON r.id = t.raffle_id
WHERE r.ativo = 1 AND (t.status = 'sold' OR (t.status = 'reserved' AND t.reserved_until > NOW()))`
)
const vendidos = parseInt(countRow?.total ?? 0, 10)
const disponiveis = sorteio.total_numeros - vendidos
lines.push(`=== SORTEIO ATIVO ===`)
lines.push(`Nome: ${sorteio.titulo}`)
if (sorteio.descricao) lines.push(`Descrição: ${sorteio.descricao}`)
lines.push(`Preço por número: R$ ${Number(sorteio.preco_numero).toFixed(2).replace('.', ',')}`)
lines.push(`Números disponíveis: ${disponiveis} de ${sorteio.total_numeros}`)
lines.push(`Como comprar: acesse nosso site e escolha seus números favoritos.`)
lines.push(``)
}
// ── Promoções Ativas ──────────────────────────────────────────────────────
const promos = await query(
`SELECT nome, descricao, preco_original, preco_promocional
FROM promotions WHERE ativo = TRUE ORDER BY id ASC`
)
if (promos.length) {
lines.push(`=== PROMOÇÕES ATIVAS ===`)
for (const p of promos) {
const economia = p.preco_original && p.preco_promocional
? ` (de R$ ${Number(p.preco_original).toFixed(2).replace('.', ',')} por R$ ${Number(p.preco_promocional).toFixed(2).replace('.', ',')})`
: ''
lines.push(`${p.nome}${economia}`)
if (p.descricao) lines.push(` ${p.descricao}`)
}
lines.push(``)
}
// ── Vagas de Emprego ──────────────────────────────────────────────────────
const vagas = await query(
`SELECT title, category, work_mode, job_type, location, salary_hide, salary_min, salary_max
FROM jobs
WHERE active = true AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY published_at DESC
LIMIT 10`
)
if (vagas.length) {
const modeMap = { presencial: 'Presencial', hibrido: 'Híbrido', remoto: 'Remoto' }
const typeMap = { clt: 'CLT', pj: 'PJ', freelance: 'Freelance', estagio: 'Estágio', temporario: 'Temporário' }
lines.push(`=== VAGAS DE EMPREGO DISPONÍVEIS ===`)
lines.push(`(Temos ${vagas.length} vaga${vagas.length > 1 ? 's' : ''} abertas no momento)`)
for (const v of vagas) {
const modo = modeMap[v.work_mode] || v.work_mode
const tipo = typeMap[v.job_type] || v.job_type
const sal = v.salary_hide || (!v.salary_min && !v.salary_max)
? 'A combinar'
: v.salary_min && v.salary_max
? `R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
: v.salary_min
? `A partir de R$ ${Number(v.salary_min).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
: `Até R$ ${Number(v.salary_max).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}`
const loc = v.location ? ` | ${v.location}` : ''
lines.push(`${v.title}${modo} | ${tipo}${loc} | ${sal}`)
}
lines.push(`Para se candidatar, acesse nosso site na seção Vagas.`)
lines.push(``)
}
// ── Informações Gerais ────────────────────────────────────────────────────
lines.push(`=== ATENDIMENTO ===`)
lines.push(`Horário: Consulte pelo WhatsApp ou site para horário atualizado.`)
lines.push(`Carteirinha digital: disponível na área do cliente em nosso site.`)
lines.push(`Dúvidas sobre pagamento, cancelamento ou benefícios: informe que vai encaminhar para nossa equipe.`)
return lines.join('\n')
}
// ── Endpoint principal: sincroniza knowledge node no motor ────────────────────
async function syncKnowledge() {
const cfg = await getPluginConfig()
const motorUrl = cfg.newwhats_url
const integKey = cfg.integration_key
if (!motorUrl || !integKey) {
throw new Error('Plugin NewWhats não configurado (newwhats_url ou integration_key ausentes)')
}
// 1. Monta o texto
const knowledgeText = await buildKnowledgeText()
// 2. Busca agentes no motor
const agents = await motorFetch(motorUrl, integKey, 'GET', '/agents')
if (!agents.length) throw new Error('Nenhum agente encontrado no motor. Crie um agente na Secretária IA.')
const agent = agents.find(a => a.active) || agents[0]
// 3. Busca nós do agente
const nodes = await motorFetch(motorUrl, integKey, 'GET', `/agents/${agent.id}/nodes`)
// 4. Encontra o nó de knowledge (ou cria)
const knowledgeNode = nodes.find(n => n.type === 'knowledge')
let nodeId
if (knowledgeNode) {
// Atualiza nó existente
await motorFetch(motorUrl, integKey, 'PUT', `/nodes/${knowledgeNode.id}`, {
title: 'Base de Conhecimento — Alemão Conveniências',
content: knowledgeText,
active: true,
sort_order: knowledgeNode.sort_order ?? 3,
})
nodeId = knowledgeNode.id
} else {
// Cria nó novo
const created = await motorFetch(motorUrl, integKey, 'POST', `/agents/${agent.id}/nodes`, {
type: 'knowledge',
title: 'Base de Conhecimento — Alemão Conveniências',
content: knowledgeText,
sort_order: 3,
})
nodeId = created.id
}
return {
ok: true,
agent_id: agent.id,
agent: agent.name,
node_id: nodeId,
preview: knowledgeText,
chars: knowledgeText.length,
synced_at: new Date().toISOString(),
}
}
module.exports = { syncKnowledge, buildKnowledgeText }
@@ -0,0 +1,662 @@
'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 }