bb80be896f
- 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
225 lines
8.7 KiB
JavaScript
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 };
|