177 lines
6.9 KiB
JavaScript
177 lines
6.9 KiB
JavaScript
'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 }
|