/** * Serviço unificado de comunicação com Evolution API. * * Centraliza TODAS as chamadas HTTP para a Evolution API que antes estavam * duplicadas em chatController, triageController e evolutionController. * * Uso: * const evolution = require('../services/evolutionService'); * await evolution.sendText(instancia, numero, texto); * await evolution.sendMedia(instancia, numero, tipo, base64, nome); * const result = await evolution.request(url, apiKey, endpoint, 'POST', body); */ const https = require('https'); const http = require('http'); // ============================================================ // Função interna: requisição HTTP de baixo nível // ============================================================ function _request(baseUrl, apiKey, endpoint, method, body) { const urlClean = (baseUrl || '').replace(/\/+$/, ''); if (!urlClean || !apiKey) return Promise.reject(new Error('URL ou API Key não configurados')); const parsedUrl = new URL(urlClean + endpoint); const lib = parsedUrl.protocol === 'https:' ? https : http; const data = body ? JSON.stringify(body) : ''; return new Promise((resolve, reject) => { const options = { hostname: parsedUrl.hostname, port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80), path: parsedUrl.pathname + parsedUrl.search, method: method || 'GET', timeout: 30000, headers: { 'Content-Type': 'application/json', 'apikey': apiKey }, }; if (data) options.headers['Content-Length'] = Buffer.byteLength(data); const req = lib.request(options, (resHttp) => { let respBody = ''; resHttp.on('data', chunk => respBody += chunk); resHttp.on('end', () => { try { const parsed = JSON.parse(respBody); parsed._httpStatus = resHttp.statusCode; if (resHttp.statusCode >= 400) { reject(Object.assign( new Error(parsed.error || parsed.response?.message?.[0] || 'HTTP ' + resHttp.statusCode), { _httpStatus: resHttp.statusCode, response: parsed } )); } else { resolve(parsed); } } catch (e) { if (resHttp.statusCode >= 400) { reject(Object.assign(new Error('HTTP ' + resHttp.statusCode), { _httpStatus: resHttp.statusCode, raw: respBody })); } else { resolve({ raw: respBody, _httpStatus: resHttp.statusCode }); } } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Timeout')); }); if (data) req.write(data); req.end(); }); } // ============================================================ // Função auxiliar: normaliza número para formato WhatsApp // ============================================================ function _normalizarNumero(numero) { let n = String(numero || '').replace(/\D/g, ''); if (!n) return ''; // Adiciona DDI Brasil (55) se não tiver código de país if (!n.startsWith('55') && !n.startsWith('1') && !n.startsWith('3') && !n.startsWith('4') && !n.startsWith('6') && !n.startsWith('7') && !n.startsWith('8') && !n.startsWith('9')) { n = '55' + n; } // Corrige celular sem o 9º dígito: DDD(2) + 8 dígitos → DDD(2) + 9 + 8 dígitos if (n.startsWith('55')) { const resto = n.slice(2); if (resto.length === 10) { n = '55' + resto.slice(0, 2) + '9' + resto.slice(2); } } return n; } function _extrairCredenciais(instancia) { return { url: ((instancia.INS_URL || '').trim()).replace(/\/+$/, ''), apiKey: (instancia.INS_API_KEY || '').trim(), instanceName: (instancia.INS_INSTANCE_NAME || '').trim(), }; } // ============================================================ // API PÚBLICA // ============================================================ /** * Requisição HTTP genérica para a Evolution API. * Usada para operações avançadas: QR Code, fetchInstances, restart, fetchProfile, etc. * * @param {string} baseUrl - URL base da Evolution (ex: https://evo.empresa.com) * @param {string} apiKey - API Key da Evolution * @param {string} endpoint - Caminho (ex: /instance/fetchInstances) * @param {string} method - GET, POST, PUT, DELETE * @param {object} [body] - Corpo da requisição (opcional) * @returns {Promise} Resposta parseada (JSON) ou { raw, _httpStatus } */ async function request(baseUrl, apiKey, endpoint, method, body) { return _request(baseUrl, apiKey, endpoint, method, body); } /** * Envia mensagem de texto via Evolution API. * * @param {object} instancia - Objeto da instância (linha do banco: INS_URL, INS_API_KEY, INS_INSTANCE_NAME) * @param {string} numero - Número do destinatário (com ou sem formatação) * @param {string} texto - Texto da mensagem * @returns {Promise} */ async function sendText(instancia, numero, texto) { const { url, apiKey, instanceName } = _extrairCredenciais(instancia); if (!url || !apiKey || !instanceName || !numero) return; const numeroLimpo = numero.replace(/\D/g, ''); const endpoint = '/message/sendText/' + encodeURIComponent(instanceName); const payload = { number: numeroLimpo, text: texto || '', delay: 0 }; try { await _request(url, apiKey, endpoint, 'POST', payload); } catch (err) { console.error('[Evolution] Erro ao enviar texto:', err.message?.substring(0, 100)); } } /** * Envia mídia (imagem, áudio, vídeo, documento) via Evolution API. * * @param {object} instancia - Objeto da instância * @param {string} numero - Número do destinatário * @param {string} tipo - 'image', 'audio', 'video', 'document' * @param {string} midiaBase64 - Conteúdo em base64 * @param {string} [nomeArquivo] - Nome do arquivo * @returns {Promise} */ async function sendMedia(instancia, numero, tipo, midiaBase64, nomeArquivo) { const { url, apiKey, instanceName } = _extrairCredenciais(instancia); if (!url || !apiKey || !instanceName || !numero || !midiaBase64) return; const numeroLimpo = _normalizarNumero(numero); const fileName = nomeArquivo || 'arquivo'; let endpoint, payload; if (tipo === 'audio') { // Envia como mensagem de voz (PTT) via sendWhatsAppAudio endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName); payload = { number: numeroLimpo, audio: midiaBase64, encoding: true }; console.log('[Evolution] Enviando áudio PTT, tamanho base64:', midiaBase64.length); } else if (tipo === 'image') { endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName); payload = { number: numeroLimpo, mediatype: 'image', media: midiaBase64, fileName }; } else if (tipo === 'video') { endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName); payload = { number: numeroLimpo, mediatype: 'video', media: midiaBase64, fileName }; } else { // document e outros tipos endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName); payload = { number: numeroLimpo, mediatype: 'document', media: midiaBase64, fileName }; } try { const result = await _request(url, apiKey, endpoint, 'POST', payload); console.log('[Evolution] Mídia enviada, status:', result._httpStatus); return result; } catch (err) { console.error('[Evolution] Erro ao enviar mídia:', err.message?.substring(0, 100)); throw err; } } /** * Baixa mídia da Evolution API via GET /chat/getMedia/. * Usado no webhook para buscar áudio/imagem/vídeo/documento recebidos. * * @param {string} baseUrl - URL base da Evolution * @param {string} apiKey - API Key * @param {string} instanceName - Nome da instância * @param {string} messageKey - ID da mensagem (data.key.id) * @returns {Promise} */ async function downloadMedia(baseUrl, apiKey, instanceName, messageKey) { if (!baseUrl || !apiKey || !instanceName || !messageKey) return null; const urlClean = baseUrl.replace(/\/+$/, ''); const getUrl = urlClean + '/chat/getMedia/' + encodeURIComponent(instanceName) + '/' + encodeURIComponent(messageKey); return new Promise((resolve) => { const u = new URL(getUrl); const lib = u.protocol === 'https:' ? https : http; const req = lib.get(getUrl, { timeout: 20000, headers: { 'apikey': apiKey } }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { const full = Buffer.concat(chunks); if (res.statusCode !== 200) { console.log('[Evolution] downloadMedia HTTP ' + res.statusCode + ': ' + full.toString('utf8').substring(0, 100)); resolve(null); } else { resolve(full); } }); }); req.on('error', () => resolve(null)); req.on('timeout', () => { req.destroy(); resolve(null); }); }); } module.exports = { request, sendText, sendMedia, downloadMedia };