'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 }