'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:; 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} 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} 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} 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} 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, }