feat: initial project structure (Model Project) - Backend + Multi-Frontend + Docker
This commit is contained in:
@@ -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 }
|
||||
@@ -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 10–11 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 ? `há ${ageMin}min` : `há ${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 }
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 (1–5) se houver pesquisa pendente
|
||||
const chatId = data.chatId ?? ''
|
||||
const body = (msg.body ?? '').trim()
|
||||
if (/^[1-5]$/.test(body)) {
|
||||
try {
|
||||
const { queryOne: qOne, execute: dbExec } = require('../../database/postgres')
|
||||
const pending = await qOne(
|
||||
`SELECT id FROM protocol_ratings
|
||||
WHERE chat_id=$1 AND status='pending'
|
||||
ORDER BY sent_at DESC LIMIT 1`,
|
||||
[chatId]
|
||||
)
|
||||
if (pending) {
|
||||
await dbExec(
|
||||
`UPDATE protocol_ratings SET rating=$1, status='rated', rated_at=NOW() WHERE id=$2`,
|
||||
[parseInt(body), pending.id]
|
||||
)
|
||||
console.log(`[nw-webhook] ⭐ Avaliação ${body}/5 registrada para ${chatId}`)
|
||||
return // não processa como auto-reply
|
||||
}
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
|
||||
// 3. Lê config do plugin
|
||||
const cfg = await getPluginConfig()
|
||||
const mode = cfg[AUTO_MODE_KEY] ?? 'off'
|
||||
if (mode === 'off') return
|
||||
|
||||
// 4. Chats de grupos ignorados (@g.us)
|
||||
if (chatId.endsWith('@g.us')) return
|
||||
|
||||
// 5. Tipo de mensagem: TEXT, AUDIO (PTT), IMAGE, DOCUMENT (PDF), VIDEO e
|
||||
// CONTACT/VCARD são processados. Áudio é transcrito por Whisper/Gemini;
|
||||
// imagem é descrita/classificada; PDF tem texto extraído; vídeo é
|
||||
// descrito (cena+áudio) por Gemini nativo; vCard é parseado localmente.
|
||||
// LOCATION (GPS): TODO — resolver coordenadas para endereço via Maps API.
|
||||
// STICKER e outros: ignorados.
|
||||
const msgType = (msg.type ?? '').toUpperCase()
|
||||
let effectiveBody = body
|
||||
|
||||
if (msgType === 'AUDIO' || msgType === 'PTT') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] áudio sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { transcribeAudio } = require('./media-transcribe')
|
||||
const transcript = await transcribeAudio({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
})
|
||||
if (!transcript) {
|
||||
console.log(`[nw-auto] áudio inaudível ou falha de transcrição em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 🎙️ transcrição (${transcript.length} chars): "${transcript.slice(0, 80)}…"`)
|
||||
effectiveBody = transcript
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] transcribeAudio erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'IMAGE') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] imagem sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { describeImage } = require('./media-transcribe')
|
||||
const description = await describeImage({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
captionFromUser: body || null,
|
||||
})
|
||||
if (!description) {
|
||||
console.log(`[nw-auto] imagem sem descrição obtida em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 🖼️ ${description.slice(0, 100)}`)
|
||||
effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] describeImage erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'DOCUMENT') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] documento sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { extractPdfText } = require('./media-transcribe')
|
||||
const pdfText = await extractPdfText({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
captionFromUser: body || null,
|
||||
})
|
||||
if (!pdfText) {
|
||||
console.log(`[nw-auto] documento não processável (tipo não suportado ou muito grande) em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 📄 PDF extraído (${pdfText.length} chars) em ${chatId}`)
|
||||
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
|
||||
|
||||
// Persiste texto extraído no log para que pings de follow-up ("???")
|
||||
// possam recuperar o conteúdo do PDF como "pergunta pendente".
|
||||
if (logId) {
|
||||
execute(
|
||||
`UPDATE nw_event_logs SET payload = $1 WHERE id = $2`,
|
||||
[JSON.stringify({ body: pdfText, hasMedia: true, originalCaption: body || null, author: msg.author, pushName: msg.pushName }), logId],
|
||||
).catch(e => console.warn('[nw-auto] falha ao persistir texto PDF no log:', e.message))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] extractPdfText erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'VIDEO') {
|
||||
if (!msg.id) {
|
||||
console.warn('[nw-auto] vídeo sem msg.id, ignorando')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { processVideo } = require('./media-transcribe')
|
||||
const description = await processVideo({
|
||||
motorUrl: cfg[MOTOR_URL_KEY],
|
||||
integKey: cfg[INTEG_KEY_KEY],
|
||||
messageId: msg.id,
|
||||
captionFromUser: body || null,
|
||||
})
|
||||
if (!description) {
|
||||
console.log(`[nw-auto] vídeo não processável (muito grande ou erro) em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 🎬 ${description.slice(0, 100)}`)
|
||||
effectiveBody = body ? `${description}\nLegenda do cliente: ${body}` : description
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] processVideo erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'CONTACT' || msgType === 'VCARD' || msgType === 'CONTACTS_ARRAY') {
|
||||
try {
|
||||
const { extractVCard } = require('./media-transcribe')
|
||||
const card = extractVCard({ rawVcard: msg.vcard, msgBody: msg.body })
|
||||
if (!card) {
|
||||
console.log(`[nw-auto] vCard sem dados úteis em ${chatId}`)
|
||||
return
|
||||
}
|
||||
console.log(`[nw-auto] 👤 ${card.slice(0, 120)}`)
|
||||
effectiveBody = card
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] extractVCard erro:', e.message)
|
||||
return
|
||||
}
|
||||
} else if (msgType === 'LOCATION') {
|
||||
// TODO: implementar reverse geocoding (Maps API) para resolver
|
||||
// msg.location.latitude/longitude → endereço legível e injetar em effectiveBody.
|
||||
// Útil para confirmar endereço de entrega quando cliente compartilha localização.
|
||||
console.log(`[nw-auto] LOCATION recebida em ${chatId} — handler ainda não implementado`)
|
||||
return
|
||||
} else if (msgType !== 'TEXT' && msgType !== '') {
|
||||
return
|
||||
}
|
||||
if (!effectiveBody) return
|
||||
|
||||
// 5c. Detecção de follow-up/ping ("?????", "verificou??", "alô?", "tá aí?")
|
||||
// Quando o cliente está cobrando resposta:
|
||||
// - Bypassa cooldown (a inação é o problema dele, não excesso de respostas)
|
||||
// - Recupera a última pergunta sem resposta no chat
|
||||
// - Enriquece o body com contexto antes de mandar ao LLM
|
||||
let isFollowUp = false
|
||||
let pendingQuestion = null
|
||||
try {
|
||||
const { isFollowUpPing, findPendingQuestion, buildEnrichedBody } = require('./follow-up-detector')
|
||||
if (isFollowUpPing(effectiveBody)) {
|
||||
isFollowUp = true
|
||||
pendingQuestion = await findPendingQuestion(chatId, 1440)
|
||||
const enriched = buildEnrichedBody(effectiveBody, pendingQuestion)
|
||||
console.log(`[nw-auto] 🔔 ping detectado em ${chatId} — pergunta pendente: ${pendingQuestion ? `"${pendingQuestion.body.slice(0,60)}…" (${pendingQuestion.ageMinutes}min)` : 'nenhuma'}`)
|
||||
effectiveBody = enriched
|
||||
|
||||
// Se handoff humano está ativo, alertar o painel admin via SSE — operador
|
||||
// precisa ver que o cliente está cobrando. IA segue pausada (não responde).
|
||||
try {
|
||||
const handoffRow = await queryOne(
|
||||
`SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`,
|
||||
[chatId]
|
||||
)
|
||||
if (handoffRow?.human_until && new Date(handoffRow.human_until) > new Date()) {
|
||||
const { sseBroadcast } = require('../../services/sse')
|
||||
sseBroadcast('handoff.followup_ping', {
|
||||
chatId,
|
||||
instanceId: data.instanceId ?? '',
|
||||
pingBody: body,
|
||||
pendingQuestion: pendingQuestion?.body ?? null,
|
||||
ageMinutes: pendingQuestion?.ageMinutes ?? null,
|
||||
at: new Date().toISOString(),
|
||||
})
|
||||
console.log(`[nw-auto] 📣 handoff ativo + ping → SSE handoff.followup_ping emitido`)
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] handoff/SSE check falhou:', e.message) }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[nw-auto] follow-up-detector erro:', e.message)
|
||||
}
|
||||
|
||||
// 6. Verifica role do número — notificacoes nunca auto-responde (com cache 5min)
|
||||
try {
|
||||
const numInfo = await getNumberRole(cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY], data.instanceId ?? '')
|
||||
if (numInfo?.role === 'notificacoes') return
|
||||
if (numInfo && numInfo.active === false) return
|
||||
} catch { /* se falhar a leitura, deixa passar — melhor responder do que parar */ }
|
||||
|
||||
// 7. Cooldown: evita responder o mesmo chat muito rápido
|
||||
// EXCEÇÕES:
|
||||
// - Pings de follow-up bypassam — cliente cobrando precisa de feedback.
|
||||
// - Última resposta foi do smart-router (status='sent_local') — sem custo
|
||||
// de LLM, então não há motivo de cooldown. Cliente fazendo follow-up
|
||||
// imediato a uma resposta determinística não pode ser bloqueado.
|
||||
// Limite anti-loop (step 8) ainda se aplica em todos os casos.
|
||||
const lastReply = await queryOne(
|
||||
`SELECT created_at, status FROM nw_auto_replies
|
||||
WHERE chat_id = $1
|
||||
ORDER BY created_at DESC LIMIT 1`,
|
||||
[chatId],
|
||||
)
|
||||
if (lastReply && !isFollowUp && lastReply.status === 'sent') {
|
||||
const elapsedMs = Date.now() - new Date(lastReply.created_at).getTime()
|
||||
if (elapsedMs < COOLDOWN_MS) {
|
||||
console.log(`[nw-auto] cooldown ativo para ${chatId} (${Math.round(elapsedMs/1000)}s < ${COOLDOWN_MS/1000}s)`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Anti-loop: máx MAX_REPLIES_1H por chat por hora
|
||||
const countRow = await queryOne(
|
||||
`SELECT COUNT(*) AS cnt FROM nw_auto_replies
|
||||
WHERE chat_id = $1 AND created_at > NOW() - INTERVAL '1 hour'`,
|
||||
[chatId],
|
||||
)
|
||||
if (parseInt(countRow?.cnt ?? 0, 10) >= MAX_REPLIES_1H) {
|
||||
console.log(`[nw-auto] limite de ${MAX_REPLIES_1H} respostas/hora atingido para ${chatId}`)
|
||||
return
|
||||
}
|
||||
|
||||
// 9a. Smart Router — tenta responder localmente sem LLM (cotação, FAQ, horário etc)
|
||||
// Usa effectiveBody (texto original ou transcrição/descrição de mídia)
|
||||
// Em caso de follow-up/ping, pula direto ao LLM — o body foi enriquecido com
|
||||
// contexto da pergunta original e o smart-router não tem como atender.
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
if (isFollowUp) {
|
||||
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
|
||||
return
|
||||
}
|
||||
const { tryLocalReply, sendLocalReply } = require('./smart-router')
|
||||
const local = await tryLocalReply(effectiveBody, data.from, 1)
|
||||
if (local) {
|
||||
const ok = await sendLocalReply(
|
||||
cfg[MOTOR_URL_KEY], cfg[INTEG_KEY_KEY],
|
||||
data.instanceId, chatId, local.reply, local.intent, effectiveBody
|
||||
)
|
||||
if (ok) return // Respondido localmente, não chama LLM
|
||||
}
|
||||
// Se smart-router não acertou ou envio falhou, segue ao LLM
|
||||
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
|
||||
} catch (e) {
|
||||
console.warn('[smart-router] erro, caindo no LLM:', e.message)
|
||||
autoReply(cfg, data.instanceId, chatId, effectiveBody, msg.pushName, data.from)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── Auto-reply: chama brain e envia resposta ──────────────────────────────────
|
||||
|
||||
async function autoReply(cfg, instanceId, chatId, userMsg, contactName, fromPhone) {
|
||||
const motorUrl = cfg[MOTOR_URL_KEY]
|
||||
const integKey = cfg[INTEG_KEY_KEY]
|
||||
|
||||
if (!motorUrl || !integKey) {
|
||||
console.warn('[nw-auto] motor URL ou integration_key não configurados')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Verifica handoff local — se humano está respondendo, não chamar IA
|
||||
try {
|
||||
const { queryOne: qOne } = require('../../database/postgres')
|
||||
const hRow = await qOne(
|
||||
`SELECT human_until FROM nw_handoff WHERE chat_id=$1 ORDER BY updated_at DESC LIMIT 1`,
|
||||
[chatId]
|
||||
)
|
||||
if (hRow?.human_until && new Date(hRow.human_until) > new Date()) {
|
||||
console.log(`[nw-auto] handoff=humano (local) para ${chatId} — IA pausada`)
|
||||
return
|
||||
}
|
||||
} catch { /* falha silenciosa */ }
|
||||
|
||||
// 2. Monta contexto local do projeto
|
||||
let context = undefined
|
||||
let systemExtra = undefined
|
||||
let intentTags = []
|
||||
try {
|
||||
const { buildContext } = require('./context-builder')
|
||||
const phone = (fromPhone ?? '').replace(/\D/g, '').replace(/^55/, '')
|
||||
const ctx = await buildContext(phone, 1, userMsg, chatId)
|
||||
if (ctx) {
|
||||
context = ctx
|
||||
systemExtra = ctx._systemExtra
|
||||
intentTags = ctx.intencoes_detectadas ?? []
|
||||
delete context._systemExtra
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] context-builder falhou:', e.message) }
|
||||
|
||||
// 2a-bis. Intent automático por tipo de mídia — adiciona tags ao brain
|
||||
// (mais barato e preciso do que esperar o LLM detectar sozinho).
|
||||
try {
|
||||
const { detectMediaIntent } = require('./media-transcribe')
|
||||
const mediaTags = detectMediaIntent(userMsg)
|
||||
if (mediaTags.length > 0) {
|
||||
intentTags = [...new Set([...intentTags, ...mediaTags])]
|
||||
console.log(`[nw-auto] 🏷️ intent por mídia: [${mediaTags.join(',')}]`)
|
||||
}
|
||||
} catch { /* silencioso */ }
|
||||
|
||||
// 2a. Persona — define tom de voz/escopo de atendimento por tenant.
|
||||
// Vai ANTES da BASE DE CONHECIMENTO para o LLM ler primeiro o "como falar".
|
||||
try {
|
||||
const { resolvePersona } = require('./personas')
|
||||
const persona = await resolvePersona(1)
|
||||
if (persona) {
|
||||
const personaBlock = `=== PERSONA DE ATENDIMENTO (${persona.label}) ===\n${persona.prompt}`
|
||||
systemExtra = [personaBlock, systemExtra].filter(Boolean).join('\n\n')
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] persona injection falhou:', e.message) }
|
||||
|
||||
// 2b. Injeta brain — filtrado por intent (core sempre + tags relevantes)
|
||||
try {
|
||||
const { query: dbq, queryOne: qOne } = require('../../database/postgres')
|
||||
const chatSector = await qOne(
|
||||
`SELECT sector_id FROM nw_chat_sectors WHERE chat_id=$1 LIMIT 1`, [chatId]
|
||||
)
|
||||
const sectorClause = chatSector?.sector_id
|
||||
? `AND sbn.sector_id = ${Number(chatSector.sector_id)}`
|
||||
: ''
|
||||
const tagsToLoad = ['core', ...intentTags]
|
||||
const brainRows = await dbq(
|
||||
`SELECT sbn.type, sbn.content, s.name AS sector_name, sbn.sort_order
|
||||
FROM sector_brain_nodes sbn
|
||||
JOIN sectors s ON s.id = sbn.sector_id
|
||||
WHERE sbn.active = TRUE ${sectorClause}
|
||||
AND sbn.tags && $1::text[]
|
||||
ORDER BY s.sort_order, sbn.sort_order LIMIT 20`,
|
||||
[tagsToLoad]
|
||||
)
|
||||
if (brainRows.length > 0) {
|
||||
const brainText = brainRows.map(r =>
|
||||
`[${r.type.toUpperCase()}${chatSector?.sector_id ? '' : ` — ${r.sector_name}`}]\n${r.content}`
|
||||
).join('\n\n')
|
||||
const brainBlock = `=== BASE DE CONHECIMENTO ===\n${brainText}`
|
||||
systemExtra = [systemExtra, brainBlock].filter(Boolean).join('\n\n')
|
||||
console.log(`[nw-auto] brain: ${brainRows.length} nós (~${brainText.length} chars) tags=[${tagsToLoad.join(',')}]`)
|
||||
}
|
||||
} catch (e) { console.warn('[nw-auto] brain injection falhou:', e.message) }
|
||||
|
||||
// 3. Chama /api/ext/v1/secretaria/ask (cria/retoma conversa + brain.chat())
|
||||
const askRes = await fetch(`${motorUrl}/api/ext/v1/secretaria/ask`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-nw-key': integKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
chatId,
|
||||
message: userMsg,
|
||||
contactName: contactName ?? chatId,
|
||||
context,
|
||||
systemExtra,
|
||||
tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!askRes.ok) {
|
||||
const txt = await askRes.text()
|
||||
console.error(`[nw-auto] brain ask failed ${askRes.status}:`, txt)
|
||||
await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
const askJson = await askRes.json()
|
||||
const { reply, conversationId, humanApiRequest, sectorId: detectedSectorId } = askJson
|
||||
|
||||
// 3a. Sincroniza setor identificado pela IA → banco local
|
||||
if (detectedSectorId) {
|
||||
try {
|
||||
const { execute: dbExec } = require('../../database/postgres')
|
||||
await dbExec(
|
||||
`INSERT INTO nw_chat_sectors (chat_id, tenant_id, sector_id, updated_at)
|
||||
VALUES ($1,1,$2,NOW())
|
||||
ON CONFLICT (chat_id, tenant_id) DO UPDATE SET sector_id=$2, updated_at=NOW()`,
|
||||
[chatId, detectedSectorId]
|
||||
)
|
||||
} catch { /* silencioso */ }
|
||||
}
|
||||
|
||||
// 3b. Human API — IA precisa de resposta de especialista humano
|
||||
if (humanApiRequest) {
|
||||
try {
|
||||
const localUrl = `http://127.0.0.1:${process.env.PORT || 4001}`
|
||||
await fetch(`${localUrl}/api/human-api/request`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-internal-secret': process.env.INTERNAL_WEBHOOK_SECRET || '' },
|
||||
body: JSON.stringify({
|
||||
chatId, conversationId, instanceId,
|
||||
question: humanApiRequest.question || humanApiRequest,
|
||||
sectorName: humanApiRequest.sectorName || null,
|
||||
}),
|
||||
})
|
||||
} catch (e) { console.warn('[nw-auto] human_api forward falhou:', e.message) }
|
||||
}
|
||||
|
||||
if (!reply?.trim()) return
|
||||
|
||||
// 2. Envia resposta via motor
|
||||
const sendRes = await fetch(`${motorUrl}/api/ext/v1/inbox/${encodeURIComponent(chatId)}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-nw-key': integKey,
|
||||
},
|
||||
body: JSON.stringify({ text: reply }),
|
||||
})
|
||||
|
||||
if (!sendRes.ok) {
|
||||
const txt = await sendRes.text()
|
||||
console.error(`[nw-auto] send failed ${sendRes.status}:`, txt)
|
||||
await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'failed')
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Loga sucesso + telemetria
|
||||
await logAutoReply(instanceId, chatId, conversationId, userMsg, reply, 'sent')
|
||||
const ctxChars = (systemExtra?.length || 0) + (context ? JSON.stringify(context).length : 0)
|
||||
const saved = Math.max(0, 5000 - ctxChars) // baseline de 5000 chars
|
||||
await execute(
|
||||
`INSERT INTO nw_router_metrics (chat_id, handler, intent, user_msg, reply_chars, ctx_chars, saved_chars)
|
||||
VALUES ($1, 'llm', $2, $3, $4, $5, $6)`,
|
||||
[chatId, intentTags.join(',') || null, userMsg.slice(0, 200), reply.length, ctxChars, saved]
|
||||
).catch(() => {})
|
||||
console.log(`[nw-auto] ✅ ${chatId} → "${reply.slice(0, 60)}…" (ctx=${ctxChars}c, saved=${saved}c)`)
|
||||
|
||||
} catch (err) {
|
||||
console.error('[nw-auto] erro inesperado:', err.message)
|
||||
await logAutoReply(instanceId, chatId, null, userMsg, null, 'failed').catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async function logAutoReply(instanceId, chatId, convId, userMsg, reply, status) {
|
||||
await execute(
|
||||
`INSERT INTO nw_auto_replies
|
||||
(instance_id, chat_id, conv_id, user_msg, reply, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[instanceId, chatId, convId ?? null, userMsg, reply ?? null, status],
|
||||
)
|
||||
}
|
||||
|
||||
// ── Handler: session.status ───────────────────────────────────────────────────
|
||||
|
||||
async function handleSessionStatus(data) {
|
||||
await execute(
|
||||
`INSERT INTO nw_event_logs
|
||||
(instance_id, event, direction, payload)
|
||||
VALUES ($1, $2, 'in', $3)`,
|
||||
[
|
||||
data.instanceId ?? '',
|
||||
'session.status',
|
||||
JSON.stringify({ status: data.status }),
|
||||
],
|
||||
)
|
||||
console.log(`[nw-webhook] session.status → ${data.instanceId}: ${data.status}`)
|
||||
}
|
||||
|
||||
module.exports = { createWebhookReceiver }
|
||||
Reference in New Issue
Block a user