Files
mercado.clube67.com/backend/plugins/newwhats/follow-up-detector.js
T

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 }