alterações de melhorias

This commit is contained in:
2026-06-24 14:13:09 +00:00
parent d942694ec0
commit 29190cd71c
34 changed files with 2570 additions and 950 deletions
+308
View File
@@ -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,
};
+89
View File
@@ -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 };
+73
View File
@@ -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 };
+206
View File
@@ -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 };
+64
View File
@@ -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),
};
+252
View File
@@ -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,
};
+41
View File
@@ -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 };