alterações de melhorias
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Serviço de geração e envio de boletos.
|
||||
*
|
||||
* Centraliza a montagem do payload, chamada à API externa (cobpagweb.com.br)
|
||||
* e envio do PDF via Evolution API. Antes duplicado entre triageController
|
||||
* e chatController.
|
||||
*/
|
||||
|
||||
const db = require('../database');
|
||||
const evolution = require('./evolutionService');
|
||||
const https = require('https');
|
||||
|
||||
// ============================================================
|
||||
// Helpers de formatação
|
||||
// ============================================================
|
||||
|
||||
function fmtCpf(cpf) {
|
||||
if (!cpf) return '';
|
||||
const d = String(cpf).replace(/\D/g, '');
|
||||
return d.length === 11 ? d.slice(0, 3) + '.' + d.slice(3, 6) + '.' + d.slice(6, 9) + '-' + d.slice(9) : cpf;
|
||||
}
|
||||
|
||||
function fmtData(dt) {
|
||||
if (!dt) return null;
|
||||
if (typeof dt === 'string') return dt.split('T')[0];
|
||||
if (dt instanceof Date) return dt.toISOString().split('T')[0];
|
||||
return String(dt);
|
||||
}
|
||||
|
||||
function fmtDataBR(dt) {
|
||||
if (!dt) return '-';
|
||||
try {
|
||||
let s = fmtData(dt);
|
||||
if (s) {
|
||||
const partes = s.split('-');
|
||||
if (partes.length === 3) return partes[2] + '/' + partes[1] + '/' + partes[0];
|
||||
}
|
||||
} catch (_) {}
|
||||
return '-';
|
||||
}
|
||||
|
||||
function fmtValor(v) {
|
||||
const n = Number(v);
|
||||
if (isNaN(n)) return '';
|
||||
return 'R$ ' + n.toFixed(2).replace('.', ',');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Montagem do payload para API de boleto
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Monta o payload completo para envio à API de geração de boleto (cobpagweb.com.br).
|
||||
* Busca dados do cliente, carnê e empresa no banco.
|
||||
*
|
||||
* @param {string} alias - Alias do banco
|
||||
* @param {number} clienteId - ID do cliente
|
||||
* @param {number} carneId - ID do carnê (título)
|
||||
* @returns {Promise<{payload: object, matricula: string, vencimento: string}|null>}
|
||||
*/
|
||||
async function montarPayloadBoleto(alias, clienteId, carneId) {
|
||||
// Busca cliente
|
||||
const cli = await db.query(alias, `
|
||||
SELECT CLI_CODIGO_ID, CLI_NOME, CLI_NOME_FANTASIA, CLI_CPF, CLI_ENDERECO,
|
||||
CLI_BAIRRO, CLI_CEP, CLI_CIDADES_ID, CLI_MATRICULA, CLI_EMPRESA_ID
|
||||
FROM CLIENTES WHERE CLI_CODIGO_ID = ?
|
||||
`, [clienteId]);
|
||||
if (cli.length === 0) return null;
|
||||
|
||||
const matricula = (cli[0].CLI_MATRICULA || '').trim() || '000000';
|
||||
|
||||
// Busca carnê
|
||||
const car = await db.query(alias, `
|
||||
SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA
|
||||
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?
|
||||
`, [carneId, clienteId]);
|
||||
if (car.length === 0) return null;
|
||||
|
||||
// Busca detalhes do carnê (código de barras, linha digitável, etc.)
|
||||
const det = await db.query(alias, `
|
||||
SELECT CAR_CODIGO_BARRAS, CAR_AGEN_COD_CEDENTE, CAR_NUM_BANCARIO,
|
||||
CAR_DT_CADASTRO, CAR_DT_PROCESSAMENTO, CAR_NUMERO_DOCUMENTO,
|
||||
CAR_LINHA_DIGITAVEL, CAR_NOSSO_NUMERO, CAR_PIX_QRCODE
|
||||
FROM CARNES WHERE CAR_CODIGO_ID = ?
|
||||
`, [carneId]);
|
||||
|
||||
// Busca cidade
|
||||
let cidadeNome = '', cidadeUf = '';
|
||||
if (cli[0].CLI_CIDADES_ID) {
|
||||
const cid = await db.query(alias,
|
||||
'SELECT CID_NOME, CID_UF FROM CIDADES WHERE CID_CODIGO_ID = ?',
|
||||
[cli[0].CLI_CIDADES_ID]);
|
||||
if (cid.length > 0) {
|
||||
cidadeNome = (cid[0].CID_NOME || '').trim();
|
||||
cidadeUf = (cid[0].CID_UF || '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Busca dados da empresa
|
||||
let empresaBoleto = null;
|
||||
if (cli[0].CLI_EMPRESA_ID) {
|
||||
const empData = await db.query(alias, `
|
||||
SELECT EMP_NOME, EMP_CNPJ, EMP_FOTO
|
||||
FROM EMPRESAS WHERE EMP_CODIGO_ID = ?
|
||||
`, [cli[0].CLI_EMPRESA_ID]);
|
||||
if (empData.length > 0) {
|
||||
let fotoBase64 = null;
|
||||
if (empData[0].EMP_FOTO) {
|
||||
try {
|
||||
const buf = empData[0].EMP_FOTO;
|
||||
if (Buffer.isBuffer(buf)) fotoBase64 = buf.toString('base64');
|
||||
else if (typeof buf === 'string') fotoBase64 = buf;
|
||||
} catch (_) {}
|
||||
}
|
||||
empresaBoleto = {
|
||||
EMP_NOME: (empData[0].EMP_NOME || '').trim(),
|
||||
EMP_CNPJ: (empData[0].EMP_CNPJ || '').trim(),
|
||||
EMP_FOTO: fotoBase64,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
empresa: empresaBoleto,
|
||||
Cliente: {
|
||||
CLI_NOME_FANTASIA: (cli[0].CLI_NOME_FANTASIA || cli[0].CLI_NOME || '').trim(),
|
||||
CLI_CPF_CNPJ: fmtCpf(cli[0].CLI_CPF),
|
||||
CLI_ENDERECO: (cli[0].CLI_ENDERECO || '').trim(),
|
||||
CLI_BAIRRO: (cli[0].CLI_BAIRRO || '').trim(),
|
||||
CLI_CEP: (cli[0].CLI_CEP || '').trim(),
|
||||
CLI_CIDADE: cidadeNome,
|
||||
CLI_UF: cidadeUf,
|
||||
},
|
||||
Carnes: [{
|
||||
CAR_CODIGO_BARRAS: (det[0]?.CAR_CODIGO_BARRAS || '').trim(),
|
||||
CAR_DT_VENCIMENTO: fmtData(car[0].CAR_DT_VENCIMENTO),
|
||||
CAR_AGEN_COD_CEDENTE: (det[0]?.CAR_AGEN_COD_CEDENTE || '').trim(),
|
||||
CAR_NUM_BANCARIO: (det[0]?.CAR_NUM_BANCARIO || '').trim(),
|
||||
CAR_DT_CADASTRO: fmtData(det[0]?.CAR_DT_CADASTRO),
|
||||
CAR_DT_PROCESSAMENTO: fmtData(det[0]?.CAR_DT_PROCESSAMENTO),
|
||||
CAR_NUMERO_DOCUMENTO: (det[0]?.CAR_NUMERO_DOCUMENTO || '').trim(),
|
||||
CAR_VALOR_PARCELA: car[0].CAR_VALOR_PARCELA,
|
||||
CAR_LINHA_DIGITAVEL: (det[0]?.CAR_LINHA_DIGITAVEL || '').trim(),
|
||||
CAR_NOSSO_NUMERO: (det[0]?.CAR_NOSSO_NUMERO || '').trim(),
|
||||
CAR_PIX_QRCODE: (det[0]?.CAR_PIX_QRCODE || '').trim(),
|
||||
}],
|
||||
};
|
||||
|
||||
return {
|
||||
payload,
|
||||
matricula,
|
||||
vencimento: fmtData(car[0].CAR_DT_VENCIMENTO),
|
||||
valor: car[0].CAR_VALOR_PARCELA,
|
||||
linhaDigitavel: (det[0]?.CAR_LINHA_DIGITAVEL || '').trim(),
|
||||
pixQrcode: (det[0]?.CAR_PIX_QRCODE || '').trim(),
|
||||
codigoBarras: (det[0]?.CAR_CODIGO_BARRAS || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Geração do PDF via API externa
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Gera o PDF do boleto via API externa (cobpagweb.com.br).
|
||||
*
|
||||
* @param {object} payload - Payload montado por montarPayloadBoleto()
|
||||
* @returns {Promise<string|null>} PDF em base64 ou null se falhar
|
||||
*/
|
||||
function gerarPDF(payload) {
|
||||
return new Promise((resolve) => {
|
||||
const body = JSON.stringify(payload);
|
||||
const url = new URL('https://cobpagweb.com.br/boleto/cliente/index.php?base64');
|
||||
|
||||
const req = https.request({
|
||||
hostname: url.hostname,
|
||||
port: 443,
|
||||
path: url.pathname + url.search,
|
||||
method: 'POST',
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
}
|
||||
}, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const buf = Buffer.concat(chunks);
|
||||
try {
|
||||
const parsed = JSON.parse(buf.toString());
|
||||
const pdf = parsed.base64 || parsed.pdf || parsed.data;
|
||||
if (pdf && pdf.length > 100) {
|
||||
resolve(pdf);
|
||||
} else {
|
||||
console.log('[Boleto] API retornou resposta sem PDF válido');
|
||||
resolve(null);
|
||||
}
|
||||
} catch (_) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', (err) => {
|
||||
console.error('[Boleto] Erro na API:', err.message);
|
||||
resolve(null);
|
||||
});
|
||||
req.on('timeout', () => { req.destroy(); resolve(null); });
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Envio do boleto (PDF ou texto)
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Gera e envia boleto em PDF para o cliente via Evolution API.
|
||||
*
|
||||
* @param {string} alias - Alias do banco
|
||||
* @param {object} instancia - Objeto da instância Evolution
|
||||
* @param {string} numero - Número do destinatário
|
||||
* @param {number} clienteId - ID do cliente
|
||||
* @param {number} carneId - ID do carnê (título)
|
||||
* @returns {Promise<{sucesso: boolean, pdfEnviado: boolean}>}
|
||||
*/
|
||||
async function gerarEnviarBoletoPDF(alias, instancia, numero, clienteId, carneId) {
|
||||
const dados = await montarPayloadBoleto(alias, clienteId, carneId);
|
||||
if (!dados) return { sucesso: false, pdfEnviado: false };
|
||||
|
||||
const pdfBase64 = await gerarPDF(dados.payload);
|
||||
|
||||
if (pdfBase64) {
|
||||
const fileName = dados.matricula + ' - ' + dados.vencimento + '.pdf';
|
||||
try {
|
||||
await evolution.sendMedia(instancia, numero, 'document', pdfBase64, fileName);
|
||||
return { sucesso: true, pdfEnviado: true };
|
||||
} catch (err) {
|
||||
console.error('[Boleto] Erro ao enviar PDF:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: envia como texto
|
||||
const texto = montarTextoBoleto(dados);
|
||||
if (texto) {
|
||||
await evolution.sendText(instancia, numero, texto);
|
||||
return { sucesso: true, pdfEnviado: false };
|
||||
}
|
||||
|
||||
return { sucesso: false, pdfEnviado: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta o texto do boleto (usado como fallback quando PDF não é gerado).
|
||||
*
|
||||
* @param {object} dados - Dados retornados por montarPayloadBoleto()
|
||||
* @returns {string}
|
||||
*/
|
||||
function montarTextoBoleto(dados) {
|
||||
if (!dados) return '';
|
||||
let texto = '📄 *Boleto - Dados para pagamento*\n\n';
|
||||
texto += '💵 Valor: ' + fmtValor(dados.valor) + '\n';
|
||||
texto += '📅 Vencimento: ' + fmtDataBR(dados.vencimento) + '\n';
|
||||
if (dados.codigoBarras) {
|
||||
texto += '\n🔢 *Código de Barras:*\n' + dados.codigoBarras + '\n';
|
||||
}
|
||||
if (dados.linhaDigitavel) {
|
||||
texto += '\n📝 *Linha Digitável:*\n' + dados.linhaDigitavel + '\n';
|
||||
}
|
||||
if (dados.pixQrcode) {
|
||||
texto += '\n💳 PIX disponível (cópia e cola)';
|
||||
}
|
||||
return texto;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monta texto resumido do boleto para envio manual pelo atendente.
|
||||
* Usa formato mais enxuto (usado em chatController.sendBoleto).
|
||||
*
|
||||
* @param {object} carne - Linha da tabela CARNES (pelo menos: CAR_VALOR_PARCELA, CAR_DT_VENCIMENTO, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS)
|
||||
* @param {string} linha - Linha digitável ou código de barras
|
||||
* @param {string} pix - PIX copia-e-cola
|
||||
* @returns {string}
|
||||
*/
|
||||
function montarTextoBoletoResumido(carne, linha, pix) {
|
||||
let texto = '📄 *Boleto*';
|
||||
if (carne.CAR_NUMERO_PARCELA) {
|
||||
texto += ' - Parcela ' + carne.CAR_NUMERO_PARCELA +
|
||||
(carne.CAR_NUMERO_TOTAL_PARCELAS ? '/' + carne.CAR_NUMERO_TOTAL_PARCELAS : '');
|
||||
}
|
||||
texto += '\n';
|
||||
if (carne.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(carne.CAR_VALOR_PARCELA);
|
||||
if (carne.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDataBR(carne.CAR_DT_VENCIMENTO);
|
||||
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
|
||||
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
|
||||
return texto;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
montarPayloadBoleto,
|
||||
gerarPDF,
|
||||
gerarEnviarBoletoPDF,
|
||||
montarTextoBoleto,
|
||||
montarTextoBoletoResumido,
|
||||
fmtDataBR,
|
||||
fmtValor,
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Cache em memória com TTL.
|
||||
*
|
||||
* Simples, sem dependências externas. Usado para evitar consultas repetidas
|
||||
* ao banco para dados que mudam raramente (ex: configurações da empresa).
|
||||
*
|
||||
* Uso:
|
||||
* const cache = require('../services/cacheService');
|
||||
* let valor = cache.get('chave');
|
||||
* if (!valor) {
|
||||
* valor = await db.query(...);
|
||||
* cache.set('chave', valor, 60000); // TTL 60s
|
||||
* }
|
||||
*/
|
||||
|
||||
const store = new Map();
|
||||
|
||||
/**
|
||||
* Armazena um valor no cache.
|
||||
* @param {string} key
|
||||
* @param {*} value
|
||||
* @param {number} [ttlMs=60000] - Tempo de vida em ms (padrão: 1 minuto)
|
||||
*/
|
||||
function set(key, value, ttlMs = 60000) {
|
||||
store.set(key, {
|
||||
value,
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupera um valor do cache. Retorna undefined se expirado ou inexistente.
|
||||
* @param {string} key
|
||||
* @returns {*|undefined}
|
||||
*/
|
||||
function get(key) {
|
||||
const entry = store.get(key);
|
||||
if (!entry) return undefined;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
store.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida uma chave específica.
|
||||
* @param {string} key
|
||||
*/
|
||||
function del(key) {
|
||||
store.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida todas as chaves que começam com o prefixo.
|
||||
* Útil para invalidar todo o cache de uma empresa (ex: 'config:5:').
|
||||
* @param {string} prefix
|
||||
*/
|
||||
function delByPrefix(prefix) {
|
||||
for (const key of store.keys()) {
|
||||
if (key.startsWith(prefix)) {
|
||||
store.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna estatísticas do cache para debug.
|
||||
*/
|
||||
function stats() {
|
||||
let total = 0, valid = 0;
|
||||
const now = Date.now();
|
||||
for (const [, entry] of store) {
|
||||
total++;
|
||||
if (entry.expiresAt > now) valid++;
|
||||
}
|
||||
return { total, valid, expired: total - valid };
|
||||
}
|
||||
|
||||
// Limpeza periódica de entradas expiradas (evita vazamento de memória)
|
||||
const intervalo = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (entry.expiresAt <= now) store.delete(key);
|
||||
}
|
||||
}, 60000);
|
||||
if (intervalo.unref) intervalo.unref();
|
||||
|
||||
module.exports = { set, get, del, delByPrefix, stats };
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Serviço de configuração da empresa com cache.
|
||||
*
|
||||
* As flags CFE_* (triagem, saudação, CSAT, etc.) são lidas em toda operação
|
||||
* de chat/triagem/webhook. Este serviço armazena em cache (TTL 2min) e
|
||||
* invalida automaticamente quando as configurações são alteradas.
|
||||
*/
|
||||
|
||||
const db = require('../database');
|
||||
const cache = require('./cacheService');
|
||||
|
||||
const TTL = 2 * 60 * 1000; // 2 minutos
|
||||
|
||||
/**
|
||||
* Retorna a configuração completa de uma empresa (cacheada).
|
||||
* Invalidação: chamar invalidarConfig(alias, empresaId) após salvar settings.
|
||||
*
|
||||
* @param {string} alias
|
||||
* @param {number} empresaId
|
||||
* @returns {Promise<object>} Linha da tabela CHATC2_CONFIGURACOES_EMPRESA
|
||||
*/
|
||||
async function getConfig(alias, empresaId) {
|
||||
const key = 'cfg:' + alias + ':' + empresaId;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
// Garante que o registro existe (insere se não existir)
|
||||
await garantirRegistro(alias, empresaId);
|
||||
|
||||
const result = await db.query(alias,
|
||||
'SELECT * FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
|
||||
[empresaId]);
|
||||
|
||||
const config = result[0] || {};
|
||||
cache.set(key, config, TTL);
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalida o cache de configuração de uma empresa.
|
||||
* Deve ser chamado após salvar alterações nas configurações.
|
||||
*
|
||||
* @param {string} alias
|
||||
* @param {number} empresaId
|
||||
*/
|
||||
function invalidarConfig(alias, empresaId) {
|
||||
cache.del('cfg:' + alias + ':' + empresaId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Garante que existe um registro de configuração para a empresa.
|
||||
* Idempotente — se já existe, não faz nada.
|
||||
*/
|
||||
async function garantirRegistro(alias, empresaId) {
|
||||
try {
|
||||
const existe = await db.query(alias,
|
||||
'SELECT COUNT(*) AS CT FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
|
||||
[empresaId]);
|
||||
if (existe[0]?.CT === 0) {
|
||||
await db.execute(alias, `
|
||||
INSERT INTO CHATC2_CONFIGURACOES_EMPRESA (CFE_EMPRESA_ID)
|
||||
VALUES (?)
|
||||
`, [empresaId]);
|
||||
}
|
||||
} catch (e) {
|
||||
// Tabela pode não existir ainda — ignora
|
||||
if (!e.message.includes('unknown') && !e.message.includes('não encontrado')) {
|
||||
console.error('[ConfigService] Erro ao garantir registro:', e.message.substring(0, 100));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getConfig, invalidarConfig };
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* 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: extrai credenciais da instância
|
||||
// ============================================================
|
||||
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 = numero.replace(/\D/g, '');
|
||||
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 };
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Serviço de logging estruturado.
|
||||
*
|
||||
* Substitui console.log por logs com níveis, timestamps ISO e saída
|
||||
* simultânea para stdout (desenvolvimento) e arquivo (produção).
|
||||
*
|
||||
* Uso:
|
||||
* const log = require('../services/logger');
|
||||
* log.info('chat', 'Mensagem enviada', { conversaId: 123 });
|
||||
* log.warn('webhook', 'Falha ao baixar mídia', { erro: err.message });
|
||||
* log.error('evolution', 'Timeout', err);
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOG_DIR = path.join(__dirname, '../../logs');
|
||||
if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
|
||||
const LEVELS = { debug: 0, info: 1, warn: 2, error: 3 };
|
||||
const currentLevel = process.env.LOG_LEVEL ? (LEVELS[process.env.LOG_LEVEL] ?? 1) : 1;
|
||||
|
||||
function formatar(level, modulo, mensagem, extra) {
|
||||
const ts = new Date().toISOString();
|
||||
let linha = `[${ts}] [${level.toUpperCase()}] [${modulo}] ${mensagem}`;
|
||||
if (extra !== undefined) {
|
||||
if (extra instanceof Error) {
|
||||
linha += ' | ' + extra.message;
|
||||
if (extra.stack && level === 'error') linha += '\n' + extra.stack;
|
||||
} else if (typeof extra === 'object') {
|
||||
linha += ' | ' + JSON.stringify(extra);
|
||||
} else {
|
||||
linha += ' | ' + String(extra);
|
||||
}
|
||||
}
|
||||
return linha;
|
||||
}
|
||||
|
||||
function escrever(level, modulo, mensagem, extra) {
|
||||
if (LEVELS[level] < currentLevel) return;
|
||||
const linha = formatar(level, modulo, mensagem, extra);
|
||||
|
||||
// Stdout (colorido se terminal interativo)
|
||||
const cores = { error: '\x1b[31m', warn: '\x1b[33m', info: '\x1b[36m', debug: '\x1b[90m' };
|
||||
const reset = '\x1b[0m';
|
||||
if (process.stdout.isTTY) {
|
||||
console.log(cores[level] + linha + reset);
|
||||
} else {
|
||||
console.log(linha);
|
||||
}
|
||||
|
||||
// Arquivo (sempre, sem cores)
|
||||
try {
|
||||
const hoje = new Date().toISOString().slice(0, 10);
|
||||
fs.appendFileSync(path.join(LOG_DIR, `app_${hoje}.log`), linha + '\n', 'utf8');
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
debug: (mod, msg, extra) => escrever('debug', mod, msg, extra),
|
||||
info: (mod, msg, extra) => escrever('info', mod, msg, extra),
|
||||
warn: (mod, msg, extra) => escrever('warn', mod, msg, extra),
|
||||
error: (mod, msg, extra) => escrever('error', mod, msg, extra),
|
||||
};
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* Serviço de manipulação de mídia.
|
||||
*
|
||||
* Centraliza compressão de vídeo, conversão de áudio, validação de headers
|
||||
* e descriptografia de mídia do WhatsApp. Antes espalhado entre
|
||||
* chatController e evolutionController.
|
||||
*/
|
||||
|
||||
const { execFile } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const UPLOAD_DIR = path.join(__dirname, '../../uploads/audio');
|
||||
|
||||
// Garante que o diretório de uploads existe
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
// ============================================================
|
||||
// Compressão de Vídeo
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Comprime vídeo para tamanho aceitável pelo WhatsApp/Evolution API.
|
||||
* Reduz bitrate e resolução se necessário (alvo: ~10MB).
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Buffer do vídeo original
|
||||
* @returns {Promise<Buffer>} Buffer do vídeo comprimido
|
||||
*/
|
||||
function compressVideo(inputBuffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputPath = path.join(UPLOAD_DIR, 'video_in_' + Date.now() + '.mp4');
|
||||
const outputPath = path.join(UPLOAD_DIR, 'video_out_' + Date.now() + '.mp4');
|
||||
|
||||
fs.writeFileSync(inputPath, inputBuffer);
|
||||
const inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
|
||||
|
||||
const targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
|
||||
console.log('[Video] Comprimindo de ' + inputSizeMB + 'MB, bitrate alvo: ' + targetBitrate);
|
||||
|
||||
execFile(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-c:v', 'libx264',
|
||||
'-b:v', targetBitrate,
|
||||
'-c:a', 'aac',
|
||||
'-b:a', '64k',
|
||||
'-vf', 'scale=720:-2',
|
||||
'-movflags', '+faststart',
|
||||
'-preset', 'fast',
|
||||
'-maxrate', '1M',
|
||||
'-bufsize', '2M',
|
||||
outputPath
|
||||
], { timeout: 120000 }, (err) => {
|
||||
try { fs.unlinkSync(inputPath); } catch (_) {}
|
||||
|
||||
if (err) {
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
console.error('[Video] Erro na compressão:', err.message.substring(0, 100));
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const outputBuffer = fs.readFileSync(outputPath);
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
|
||||
const outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
|
||||
console.log('[Video] Compressão concluída: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
|
||||
resolve(outputBuffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Conversão de Áudio
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg.
|
||||
*
|
||||
* @param {Buffer} inputBuffer - Buffer do áudio WebM
|
||||
* @returns {Promise<Buffer>} Buffer Ogg Opus
|
||||
*/
|
||||
function convertWebmToOgg(inputBuffer) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const inputPath = path.join(UPLOAD_DIR, 'input_' + Date.now() + '.webm');
|
||||
const outputPath = path.join(UPLOAD_DIR, 'output_' + Date.now() + '.ogg');
|
||||
|
||||
fs.writeFileSync(inputPath, inputBuffer);
|
||||
|
||||
execFile(ffmpegPath, [
|
||||
'-y',
|
||||
'-i', inputPath,
|
||||
'-c:a', 'libopus',
|
||||
'-b:a', '16k',
|
||||
'-ar', '16000',
|
||||
'-ac', '1',
|
||||
outputPath
|
||||
], { timeout: 30000 }, (err) => {
|
||||
try { fs.unlinkSync(inputPath); } catch (_) {}
|
||||
|
||||
if (err) {
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const outputBuffer = fs.readFileSync(outputPath);
|
||||
try { fs.unlinkSync(outputPath); } catch (_) {}
|
||||
resolve(outputBuffer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Validação de Headers de Mídia
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Valida se um buffer tem cabeçalho de arquivo de mídia válido.
|
||||
* Defesa: rejeita JSON, HTML ou dados criptografados salvos por engano.
|
||||
*
|
||||
* @param {Buffer} buf
|
||||
* @param {string} [mimeType] - MIME type opcional para validação extra
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function validarHeaderMidia(buf, mimeType) {
|
||||
if (!buf || buf.length < 4) return false;
|
||||
|
||||
const headerHex = buf.slice(0, 4).toString('hex');
|
||||
|
||||
// Headers de áudio conhecidos
|
||||
const audioHeaders = ['4f676753', // OggS (ogg/opus)
|
||||
'52494646', // RIFF (wav)
|
||||
'66747970', // ftyp (mp4/m4a)
|
||||
'494433', // ID3 (mp3)
|
||||
'1a45dfa3', // WebM/Matroska EBML
|
||||
'fffb', 'fff3', 'fffa', 'fff2']; // MP3 frames
|
||||
for (const vh of audioHeaders) {
|
||||
if (headerHex.startsWith(vh)) return true;
|
||||
}
|
||||
|
||||
// Imagens
|
||||
if (mimeType && mimeType.startsWith('image/')) {
|
||||
if (headerHex.startsWith('ffd8ff') || // JPEG
|
||||
headerHex.startsWith('89504e47') || // PNG
|
||||
headerHex.startsWith('47494638')) // GIF
|
||||
return true;
|
||||
}
|
||||
|
||||
// Documentos (ZIP/Office)
|
||||
if (mimeType && mimeType.startsWith('application/')) {
|
||||
if (headerHex.startsWith('504b')) return true; // PK (ZIP)
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Descriptografia de Mídia WhatsApp
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* Descriptografa mídia do WhatsApp usando a mediaKey do webhook.
|
||||
* Algoritmo: AES-256-CBC + HMAC-SHA256 (padrão Signal/WhatsApp).
|
||||
*
|
||||
* @param {Buffer} encryptedBuffer - Dados criptografados (inclui 10 bytes HMAC no final)
|
||||
* @param {string} mediaKeyBase64 - Chave em base64 (do webhook)
|
||||
* @param {string} mediaType - 'audio', 'image', 'video', 'document'
|
||||
* @returns {Buffer|null} Dados descriptografados ou null se falhar
|
||||
*/
|
||||
function decryptWhatsAppMedia(encryptedBuffer, mediaKeyBase64, mediaType) {
|
||||
try {
|
||||
const mediaKey = Buffer.from(mediaKeyBase64, 'base64');
|
||||
if (mediaKey.length !== 32) {
|
||||
console.log('[Decrypt] mediaKey tamanho inválido:', mediaKey.length);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Tipo -> info string para HKDF
|
||||
const infoMap = {
|
||||
'image': 'WhatsApp Image Keys',
|
||||
'video': 'WhatsApp Video Keys',
|
||||
'audio': 'WhatsApp Audio Keys',
|
||||
'document': 'WhatsApp Document Keys'
|
||||
};
|
||||
const info = Buffer.from(infoMap[mediaType] || 'WhatsApp Audio Keys');
|
||||
|
||||
// Passo 1: HKDF Extract
|
||||
const salt = Buffer.alloc(32, 0);
|
||||
const prk = crypto.createHmac('sha256', salt).update(mediaKey).digest();
|
||||
|
||||
// Passo 2: HKDF Expand para 112 bytes
|
||||
const hashLen = 32;
|
||||
const numBlocks = Math.ceil(112 / hashLen);
|
||||
const blocks = [];
|
||||
let prev = Buffer.alloc(0);
|
||||
for (let i = 0; i < numBlocks; i++) {
|
||||
const hmac = crypto.createHmac('sha256', prk);
|
||||
hmac.update(prev);
|
||||
hmac.update(info);
|
||||
hmac.update(Buffer.from([i + 1]));
|
||||
prev = hmac.digest();
|
||||
blocks.push(prev);
|
||||
}
|
||||
const expanded = Buffer.concat(blocks).slice(0, 112);
|
||||
|
||||
if (!expanded || expanded.length < 80) {
|
||||
console.log('[Decrypt] HKDF produziu buffer muito curto:', expanded ? expanded.length : 0);
|
||||
return null;
|
||||
}
|
||||
|
||||
const cipherKey = expanded.slice(0, 32);
|
||||
const macKey = expanded.slice(32, 64);
|
||||
const iv = expanded.slice(64, 80);
|
||||
|
||||
// Passo 3: Arquivo = dados criptografados (n-10 bytes) + HMAC (últimos 10 bytes)
|
||||
if (encryptedBuffer.length < 10) {
|
||||
console.log('[Decrypt] Buffer muito curto:', encryptedBuffer.length);
|
||||
return null;
|
||||
}
|
||||
const fileData = encryptedBuffer.slice(0, -10);
|
||||
const expectedMac = encryptedBuffer.slice(-10);
|
||||
|
||||
// Passo 4: Verifica HMAC
|
||||
const hmacVerify = crypto.createHmac('sha256', macKey);
|
||||
hmacVerify.update(fileData);
|
||||
const computedMac = hmacVerify.digest().slice(0, 10);
|
||||
|
||||
if (Buffer.compare(computedMac, expectedMac) !== 0) {
|
||||
console.log('[Decrypt] HMAC inválido - dados corrompidos, chave errada, ou mídia expirada');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Passo 5: Descriptografa AES-256-CBC
|
||||
const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
|
||||
decipher.setAutoPadding(true);
|
||||
const decrypted = Buffer.concat([decipher.update(fileData), decipher.final()]);
|
||||
|
||||
console.log('[Decrypt] Sucesso!', encryptedBuffer.length, 'bytes ->', decrypted.length, 'bytes');
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
console.log('[Decrypt] Erro:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compressVideo,
|
||||
convertWebmToOgg,
|
||||
validarHeaderMidia,
|
||||
decryptWhatsAppMedia,
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Coletor de métricas em memória.
|
||||
*
|
||||
* Contadores simples para monitorar saúde da aplicação.
|
||||
* Exposto via GET /api/:alias/metrics (apenas gerentes).
|
||||
*
|
||||
* Uso:
|
||||
* const metrics = require('../services/metrics');
|
||||
* metrics.incr('webhook.mensagens_recebidas');
|
||||
* metrics.incr('chat.mensagens_enviadas');
|
||||
*/
|
||||
|
||||
const counters = new Map();
|
||||
|
||||
function incr(name, delta = 1) {
|
||||
const current = counters.get(name) || 0;
|
||||
counters.set(name, current + delta);
|
||||
}
|
||||
|
||||
function get(name) {
|
||||
return counters.get(name) || 0;
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
const result = {};
|
||||
for (const [k, v] of counters) result[k] = v;
|
||||
return result;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
counters.clear();
|
||||
}
|
||||
|
||||
// Limpa contadores de taxa a cada minuto (opcional)
|
||||
setInterval(() => {
|
||||
for (const [k] of counters) {
|
||||
if (k.startsWith('rate.')) counters.delete(k);
|
||||
}
|
||||
}, 60000).unref();
|
||||
|
||||
module.exports = { incr, get, getAll, reset };
|
||||
Reference in New Issue
Block a user