Files
chatc2/src/services/evolutionService.js
T
AyronSantos bb80be896f feat: nova conversa com número avulso + normalização WhatsApp + dark mode no modal
- Botão '+' na sidebar para iniciar conversa com qualquer número
- Busca automática: cliente → dependente → número novo
- Campo de mensagem inicial personalizável no modal
- Normalização de número (DDI 55) ao enviar via Evolution API
- Modal de nova conversa com suporte a dark mode
- Correção: foto do cliente não é mais atualizada a cada mensagem
- Correção: triagem não processa respostas se já tem atendente+equipe
2026-06-24 16:52:48 +00:00

225 lines
8.7 KiB
JavaScript

/**
* 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<object>} 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<void>}
*/
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<void>}
*/
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<Buffer|null>}
*/
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 };