Files
chatc2/src/controllers/chatController.js
T
2026-06-25 12:30:47 +00:00

1707 lines
73 KiB
JavaScript

const db = require('../database');
const { garantirEstrutura } = require('../resolucaoSetup');
const ffmpegPath = require('ffmpeg-static');
const { execFile } = require('child_process');
const path = require('path');
const fs = require('fs');
/**
* 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 video original
* @returns {Promise<Buffer>} Buffer do video comprimido
*/
function compressVideo(inputBuffer) {
return new Promise(function(resolve, reject) {
var tmpDir = path.join(__dirname, '../../uploads/audio');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
var inputPath = path.join(tmpDir, 'video_in_' + Date.now() + '.mp4');
var outputPath = path.join(tmpDir, 'video_out_' + Date.now() + '.mp4');
fs.writeFileSync(inputPath, inputBuffer);
var inputSizeMB = (inputBuffer.length / (1024 * 1024)).toFixed(1);
// Se o video ja tem <= 10MB, nao precisa comprimir muito
var targetBitrate = inputBuffer.length < 10 * 1024 * 1024 ? '1M' : '500k';
console.log('[Video] Comprimindo video 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 }, function(err) {
try { fs.unlinkSync(inputPath); } catch(e) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch(e) {}
console.error('[Video] Erro na compressao:', err.message.substring(0, 100));
return reject(err);
}
var outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch(e) {}
var outputSizeMB = (outputBuffer.length / (1024 * 1024)).toFixed(1);
console.log('[Video] Compressao concluida: ' + inputSizeMB + 'MB -> ' + outputSizeMB + 'MB');
resolve(outputBuffer);
});
});
}
/**
* Converte buffer de áudio WebM para Ogg Opus usando ffmpeg
*/
function convertWebmToOgg(inputBuffer) {
return new Promise(function(resolve, reject) {
var tmpDir = path.join(__dirname, '../../uploads/audio');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
var inputPath = path.join(tmpDir, 'input_' + Date.now() + '.webm');
var outputPath = path.join(tmpDir, '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 }, function(err) {
// Limpa arquivo temporário de entrada
try { fs.unlinkSync(inputPath); } catch(e) {}
if (err) {
try { fs.unlinkSync(outputPath); } catch(e) {}
return reject(err);
}
var outputBuffer = fs.readFileSync(outputPath);
try { fs.unlinkSync(outputPath); } catch(e) {}
resolve(outputBuffer);
});
});
}
/**
* 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
* @returns {boolean}
*/
function validarHeaderMidiaServida(buf, mimeType) {
if (!buf || buf.length < 4) return false;
var headerHex = buf.slice(0, 4).toString('hex');
// Valida headers de audio conhecidos
var validHeaders = ['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 (var vh of validHeaders) {
if (headerHex.startsWith(vh)) return true;
}
// Para imagens: JPEG (FF D8 FF), PNG (89504e47), GIF (47494638), WebP (52494646 ja coberto)
if (mimeType.startsWith('image/')) {
if (headerHex.startsWith('ffd8ff') || headerHex.startsWith('89504e47') ||
headerHex.startsWith('47494638')) return true;
}
// Para documentos: ZIP/Office (504b)
if (mimeType.startsWith('application/')) {
if (headerHex.startsWith('504b')) return true;
}
return false;
}
class ChatController {
/**
* Página do chat
* GET /app/:alias/company/:empresaId/conversation/:conversaId
*/
static chatPage(req, res) {
res.sendFile(path.resolve(__dirname, '../public/chat.html'));
}
/**
* Listar conversas (sidebar esquerda)
* GET /api/:alias/conversations?empresaId=&status=A,E
*/
static async listConversations(req, res) {
try {
const { alias } = req.params;
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const statusFilter = req.query.status || 'A,E';
const filter = req.query.filter || ''; // 'mine', 'unassigned', 'all'
if (!empresaId || !req.user?.empresas?.some(function(e) { return Number(e) === Number(empresaId); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Status via parâmetros (evita SQL injection). Aceita apenas A/E/F.
const statusList = statusFilter.split(',').map(s => s.trim().toUpperCase()).filter(s => /^[AEF]$/.test(s));
if (statusList.length === 0) statusList.push('A', 'E');
const statusPh = statusList.map(() => '?').join(',');
const params = [empresaId];
statusList.forEach(s => params.push(s));
let extraFilter = '';
if (filter === 'mine') {
extraFilter = ' AND c.CON_USUARIO_ID = ?';
params.push(req.user?.id || 0);
} else if (filter === 'unassigned') {
extraFilter = ' AND c.CON_USUARIO_ID IS NULL AND c.CON_EQUIPE_ID IS NULL';
} else if (filter === 'equipe') {
// Busca equipes do usuário logado
const minhasEquipes = await db.query(alias,
'SELECT EQU_EQUIPE_ID FROM CHATC2_USU_EQUIPES WHERE EQU_USUARIO_ID = ?', [req.user?.id]);
if (minhasEquipes.length > 0) {
const eqPh = minhasEquipes.map(() => '?').join(',');
extraFilter = ' AND c.CON_EQUIPE_ID IN (' + eqPh + ')';
minhasEquipes.forEach(e => params.push(e.EQU_EQUIPE_ID));
} else {
extraFilter = ' AND 1=0'; // Nenhuma equipe
}
}
const result = await db.query(alias, `
SELECT c.*,
(SELECT CME_TEXTO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS ULTIMA_MSG,
(SELECT CME_TIPO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS ULTIMA_TIPO,
(SELECT CME_DT_ENVIO FROM CHATC2_CONVERSAS_MENSAGENS WHERE CME_CONVERSA_ID = c.CON_CODIGO_ID ORDER BY CME_DT_ENVIO DESC FETCH FIRST 1 ROWS ONLY) AS DT_ULTIMA_MSG
FROM CHATC2_CONVERSAS c
WHERE c.CON_EMPRESA_ID = ? AND c.CON_STATUS IN (${statusPh}) AND c.CON_SITUACAO = 'A' ${extraFilter}
ORDER BY c.CON_DT_ULTIMA_MSG DESC
`, params);
// --- Busca em lote: etiquetas de todas as conversas (evita N+1 queries) ---
const conversaIds = result.map(r => r.CON_CODIGO_ID);
const labelsMap = {};
for (const cid of conversaIds) labelsMap[cid] = [];
if (conversaIds.length > 0) {
const idsPlaceholders = conversaIds.map(() => '?').join(',');
const todasEtiquetas = await db.query(alias, `
SELECT ce.CET_CONVERSA_ID, e.ETI_CODIGO_ID, e.ETI_NOME, e.ETI_COR
FROM CHATC2_ETIQUETAS e
INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID IN (${idsPlaceholders}) AND e.ETI_SITUACAO = 'A'
`, conversaIds);
for (const et of todasEtiquetas) {
if (!labelsMap[et.CET_CONVERSA_ID]) labelsMap[et.CET_CONVERSA_ID] = [];
labelsMap[et.CET_CONVERSA_ID].push({
id: et.ETI_CODIGO_ID, nome: (et.ETI_NOME || '').trim(), cor: (et.ETI_COR || '#667eea').trim()
});
}
}
// --- Busca em lote: fotos dos clientes (se config ativo) ---
const fotosMap = {};
let fotoCelularAtivo = false;
{
const config = await db.query(alias,
'SELECT CFE_FOTO_CELULAR FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?', [empresaId]);
if (config.length > 0 && config[0].CFE_FOTO_CELULAR === 'S') {
fotoCelularAtivo = true;
const clienteIds = result.filter(r => r.CON_CLIENTE_ID).map(r => r.CON_CLIENTE_ID);
if (clienteIds.length > 0) {
const cliPlaceholders = clienteIds.map(() => '?').join(',');
const fotos = await db.query(alias,
'SELECT CLI_CODIGO_ID, CLI_FOTO, CLI_NOME_FOTO FROM CLIENTES WHERE CLI_CODIGO_ID IN (' + cliPlaceholders + ')',
clienteIds);
for (const f of fotos) {
const rawFoto = f.CLI_FOTO;
if (!rawFoto) continue;
let foto = null;
if (Buffer.isBuffer(rawFoto)) {
const str = rawFoto.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(str.slice(0, 30))) {
foto = str;
} else {
let prefixo = 'data:image/jpeg;base64,';
if (rawFoto[0] === 0x89 && rawFoto[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (rawFoto[0] === 0x47 && rawFoto[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (rawFoto[0] === 0x52 && rawFoto[1] === 0x49) prefixo = 'data:image/webp;base64,';
foto = prefixo + rawFoto.toString('base64');
}
} else {
foto = rawFoto;
}
fotosMap[f.CLI_CODIGO_ID] = foto;
}
}
}
}
// --- Monta resultado final sem novas queries ---
const conversations = result.map((row) => ({
id: row.CON_CODIGO_ID,
empresaId: row.CON_EMPRESA_ID,
instanciaId: row.CON_INSTANCIA_ID,
clienteId: row.CON_CLIENTE_ID,
numero: (row.CON_NUMERO || '').trim(),
nomeContato: (row.CON_NOME_CONTATO || '').trim(),
status: (row.CON_STATUS || '').trim(),
usuarioId: row.CON_USUARIO_ID,
equipeId: row.CON_EQUIPE_ID,
dtInicio: row.CON_DT_INICIO,
dtFinal: row.CON_DT_FINAL,
ultimaMsg: row.ULTIMA_MSG || '',
ultimaTipo: (row.ULTIMA_TIPO || 'text').trim(),
dtUltimaMsg: row.DT_ULTIMA_MSG || row.CON_DT_ULTIMA_MSG,
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
labels: labelsMap[row.CON_CODIGO_ID] || [],
foto: fotosMap[row.CON_CLIENTE_ID] || null,
}));
// Contagens para cada filtro
const userId = req.user?.id || 0;
var countMine = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS WHERE CON_EMPRESA_ID = ? AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' AND CON_USUARIO_ID = ?`,
[empresaId, userId]);
var countUnassigned = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS WHERE CON_EMPRESA_ID = ? AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' AND CON_USUARIO_ID IS NULL AND CON_EQUIPE_ID IS NULL`,
[empresaId]);
var countEquipe = 0;
if (filter === 'equipe' || filter === 'mine' || filter === 'unassigned' || filter === 'all') {
var minhasEquipes = await db.query(alias,
'SELECT EQU_EQUIPE_ID FROM CHATC2_USU_EQUIPES WHERE EQU_USUARIO_ID = ?', [userId]);
if (minhasEquipes.length > 0) {
var eqIds = minhasEquipes.map(function(e) { return "'" + e.EQU_EQUIPE_ID + "'"; }).join(',');
var countEq = await db.query(alias,
`SELECT COUNT(*) AS CT FROM CHATC2_CONVERSAS WHERE CON_EMPRESA_ID = ? AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A' AND CON_EQUIPE_ID IN (${eqIds})`,
[empresaId]);
countEquipe = countEq[0]?.CT || 0;
}
}
res.json({
success: true,
data: conversations,
contagens: {
mine: countMine[0]?.CT || 0,
unassigned: countUnassigned[0]?.CT || 0,
equipe: countEquipe,
},
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Pegar/conversa específica
* GET /api/:alias/conversations/:id
*/
static async getConversation(req, res) {
try {
const { alias, id } = req.params;
const result = await db.query(alias,
'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (result.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
const row = result[0];
const empresaId = row.CON_EMPRESA_ID;
if (empresaId && !req.user?.empresas?.some(function(e) { return String(e) === String(empresaId); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Verifica se o número pertence a um dependente
let dependenteInfo = null;
if (!row.CON_CLIENTE_ID && row.CON_NUMERO) {
const depNumero = row.CON_NUMERO.replace(/\D/g, '');
const depTermo = depNumero.slice(-8);
const dep = await db.query(alias,
`SELECT d.DEPC_NOME, d.DEPC_TELEFONE, d.DEPC_PARENTESCO,
c.CLI_CODIGO_ID AS TITULAR_ID, c.CLI_NOME AS TITULAR_NOME,
c.CLI_MATRICULA AS TITULAR_MATRICULA, c.CLI_PLANOS_ID AS TITULAR_PLANO_ID,
c.CLI_CIDADES_FAT_ID, c.CLI_BAIRROS_FAT_ID
FROM DEPENDENTES_CLI d
LEFT JOIN CLIENTES c ON d.DEPC_CLIENTE_ID = c.CLI_CODIGO_ID
WHERE d.DEPC_EMPRESA_ID = ? AND d.DEPC_SITUACAO = 'A'
AND REPLACE(REPLACE(REPLACE(COALESCE(d.DEPC_TELEFONE,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'`,
[empresaId, depTermo]
);
if (dep.length > 0) {
const d = dep[0];
// Busca dados adicionais do titular
let cidadeNome = null, bairroNome = null, planoNome = null;
let inadimplente = false;
if (d.TITULAR_ID) {
if (d.CLI_CIDADES_FAT_ID) {
const cid = await db.query(alias, 'SELECT CID_NOME FROM CIDADES WHERE CID_CODIGO_ID = ?', [d.CLI_CIDADES_FAT_ID]);
if (cid.length > 0) cidadeNome = cid[0].CID_NOME?.trim();
}
if (d.CLI_BAIRROS_FAT_ID) {
const bai = await db.query(alias, 'SELECT BAR_DESCRICAO FROM BAIRROS WHERE BAR_CODIGO_ID = ?', [d.CLI_BAIRROS_FAT_ID]);
if (bai.length > 0) bairroNome = bai[0].BAR_DESCRICAO?.trim();
}
if (d.TITULAR_PLANO_ID) {
const pla = await db.query(alias, 'SELECT PLA_NOME FROM PLANOS WHERE PLA_CODIGO_ID = ?', [d.TITULAR_PLANO_ID]);
if (pla.length > 0) planoNome = pla[0].PLA_NOME?.trim();
}
const tit = await db.query(alias,
'SELECT COUNT(*) AS T FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE',
[d.TITULAR_ID]);
inadimplente = tit[0]?.T > 0;
}
dependenteInfo = {
nome: (d.DEPC_NOME || '').trim(),
telefone: (d.DEPC_TELEFONE || '').trim(),
parentesco: (d.DEPC_PARENTESCO || '').trim(),
titularId: d.TITULAR_ID || d.CLI_CODIGO_ID || null,
titularNome: (d.TITULAR_NOME || '').trim(),
titularMatricula: (d.TITULAR_MATRICULA || '').trim(),
titularCidade: cidadeNome,
titularBairro: bairroNome,
titularPlano: planoNome,
titularInadimplente: inadimplente,
};
}
}
// Info do cliente se existir
let clienteInfo = null;
if (row.CON_CLIENTE_ID) {
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME, CLI_MATRICULA, CLI_SITUACAO, CLI_EMAIL, CLI_CELULAR,
CLI_FONE1, CLI_ENDERECO_FAT, CLI_NUMERO_FAT, CLI_CIDADES_FAT_ID, CLI_BAIRROS_FAT_ID,
CLI_COBRADOR_ID, CLI_DIA_COBRANCA, CLI_FOTO, CLI_NOME_FOTO, CLI_PLANOS_ID
FROM CLIENTES WHERE CLI_CODIGO_ID = ?
`, [row.CON_CLIENTE_ID]);
if (cli.length > 0) {
const c = cli[0];
// Busca plano
let planoNome = null;
if (c.CLI_PLANOS_ID) {
const plano = await db.query(alias, 'SELECT PLA_NOME FROM PLANOS WHERE PLA_CODIGO_ID = ?', [c.CLI_PLANOS_ID]);
if (plano.length > 0) planoNome = plano[0].PLA_NOME?.trim();
}
// Busca cidade/bairro
let cidadeNome = null, bairroNome = null;
if (c.CLI_CIDADES_FAT_ID) {
const cidade = await db.query(alias, 'SELECT CID_NOME FROM CIDADES WHERE CID_CODIGO_ID = ?', [c.CLI_CIDADES_FAT_ID]);
if (cidade.length > 0) cidadeNome = cidade[0].CID_NOME?.trim();
}
if (c.CLI_BAIRROS_FAT_ID) {
const bairro = await db.query(alias, 'SELECT BAR_DESCRICAO FROM BAIRROS WHERE BAR_CODIGO_ID = ?', [c.CLI_BAIRROS_FAT_ID]);
if (bairro.length > 0) bairroNome = bairro[0].BAR_DESCRICAO?.trim();
}
// Inadimplente? Verifica se tem títulos abertos vencidos
let inadimplente = false;
const titulos = await db.query(alias,
`SELECT COUNT(*) AS T FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE`,
[c.CLI_CODIGO_ID]);
inadimplente = titulos[0].T > 0;
clienteInfo = {
id: c.CLI_CODIGO_ID,
nome: (c.CLI_NOME || '').trim(),
matricula: (c.CLI_MATRICULA || '').trim(),
situacao: (c.CLI_SITUACAO || '').trim(),
email: (c.CLI_EMAIL || '').trim(),
celular: (c.CLI_CELULAR || '').trim(),
telefone: (c.CLI_FONE1 || '').trim(),
endereco: (c.CLI_ENDERECO_FAT || '').trim(),
numero: (c.CLI_NUMERO_FAT || '').trim(),
cidade: cidadeNome,
bairro: bairroNome,
plano: planoNome,
inadimplente,
foto: (function(){
var f = c.CLI_FOTO;
if (!f) return null;
if (Buffer.isBuffer(f)) {
var s = f.toString('utf8');
if (/^[A-Za-z0-9+/]+=*$/.test(s.slice(0, 30))) return s;
var prefixo = 'data:image/jpeg;base64,';
if (f[0] === 0x89 && f[1] === 0x50) prefixo = 'data:image/png;base64,';
else if (f[0] === 0x47 && f[1] === 0x49) prefixo = 'data:image/gif;base64,';
else if (f[0] === 0x52 && f[1] === 0x49) prefixo = 'data:image/webp;base64,';
return prefixo + f.toString('base64');
}
return f;
})(),
};
}
}
// Etiquetas
const labels = await db.query(alias, `
SELECT e.* FROM CHATC2_ETIQUETAS e INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID = ? AND e.ETI_SITUACAO = 'A'
`, [id]);
res.json({
success: true,
data: {
id: row.CON_CODIGO_ID,
empresaId: row.CON_EMPRESA_ID,
instanciaId: row.CON_INSTANCIA_ID,
clienteId: row.CON_CLIENTE_ID,
numero: (row.CON_NUMERO || '').trim(),
nomeContato: (row.CON_NOME_CONTATO || '').trim(),
status: (row.CON_STATUS || '').trim(),
usuarioId: row.CON_USUARIO_ID,
equipeId: row.CON_EQUIPE_ID,
dtInicio: row.CON_DT_INICIO,
dtFinal: row.CON_DT_FINAL,
dtUltimaMsg: row.CON_DT_ULTIMA_MSG,
saudacaoEnviada: (row.CON_SAUDACAO_ENVIADA || 'N').trim(),
csatEnviado: (row.CON_CSAT_ENVIADO || 'N').trim(),
primeiraMsg: row.CON_PRIMEIRA_MSG,
motivoId: row.CON_MOTIVO_ID || null,
resolucao: row.CON_RESOLUCAO || null,
cliente: clienteInfo,
dependente: dependenteInfo,
labels: labels.map(l => ({ id: l.ETI_CODIGO_ID, nome: (l.ETI_NOME || '').trim(), cor: (l.ETI_COR || '#667eea').trim() })),
}
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Pegar mensagens de uma conversa
* GET /api/:alias/conversations/:id/messages?page=&limit=
*/
/**
* Verifica se a conversa existe e pertence a uma empresa do usuário.
* Retorna { status: 404|403 } em caso de bloqueio, ou { ok:true, empresaId }.
*/
static async checarConversaEmpresa(alias, id, req) {
const r = await db.query(alias,
'SELECT CON_EMPRESA_ID FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (r.length === 0) return { status: 404 };
const emp = r[0].CON_EMPRESA_ID;
const minhas = (req.user && req.user.empresas) || [];
if (emp && !minhas.some(function(e) { return String(e) === String(emp); })) return { status: 403 };
return { ok: true, empresaId: emp };
}
static async getMessages(req, res) {
try {
const { alias, id } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const offset = (page - 1) * limit;
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 404 ? 'Conversa não encontrada.' : 'Sem permissão.' });
const msgs = await db.query(alias, `
SELECT *
FROM CHATC2_CONVERSAS_MENSAGENS
WHERE CME_CONVERSA_ID = ? AND CME_SITUACAO = 'A'
ORDER BY CME_DT_ENVIO DESC
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY
`, [id, offset, limit]);
var mapped = msgs.reverse().map(function(m) { return {
id: m.CME_CODIGO_ID,
conversaId: m.CME_CONVERSA_ID,
remetente: (m.CME_REMETENTE || '').trim(),
usuarioId: m.CME_USUARIO_ID,
mensagem: m.CME_TEXTO || m.CME_MENSAGEM || '',
tipo: (m.CME_TIPO || 'text').trim(),
dtEnvio: m.CME_DT_ENVIO,
privada: (m.CME_PRIVADA || 'N').trim(),
lida: (m.CME_LIDA || 'N').trim(),
midiaId: m.CME_MIDIA_ID,
};});
// Busca transcricao para mensagens com midia
for (var i = 0; i < mapped.length; i++) {
if (mapped[i].midiaId && (mapped[i].tipo === 'audio' || mapped[i].tipo === 'ptt')) {
try {
var trans = await db.query(alias,
'SELECT MAT_TRANSCRICAO FROM CHATC2_MENSAGENS_ATENDIMENTOS WHERE MAT_CODIGO_ID = ? AND MAT_TRANSCRICAO IS NOT NULL',
[mapped[i].midiaId]);
if (trans.length > 0 && trans[0].MAT_TRANSCRICAO) {
var txt = trans[0].MAT_TRANSCRICAO;
if (typeof txt === 'object' && txt.toString) txt = txt.toString();
mapped[i].transcricao = txt;
}
} catch(e) {}
}
}
res.json({
success: true,
data: mapped,
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Enviar mensagem
* POST /api/:alias/conversations/:id/messages
*/
static async sendMessage(req, res) {
try {
const { alias, id } = req.params;
const { mensagem, tipo, privada, nomeArquivo } = req.body;
let midiaBase64 = req.body.midiaBase64;
const usuarioId = req.user?.id;
const conv = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
if (conv[0].CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).some(function(e) { return String(e) === String(conv[0].CON_EMPRESA_ID); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// Se a conversa estiver finalizada, reabre
if (conv[0].CON_STATUS === 'F') {
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_STATUS = 'A', CON_DT_FINAL = NULL WHERE CON_CODIGO_ID = ?", [id]);
}
const newId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
let midiaId = null;
// Salva mídia se houver
if (midiaBase64) {
midiaId = await db.nextId(alias, 'GEN_MENSAGENS_ATENDIMENTOS');
// Para áudio: converte WebM → OGG antes de salvar no BD
// Assim o BD já tem o OGG e o envio para Evolution usa a mesma versão
var midiaFinal = midiaBase64;
var nomeFinal = nomeArquivo || 'arquivo';
var mimeType = tipo || 'application/octet-stream';
if (tipo === 'audio') {
mimeType = 'audio/webm';
try {
var inputBuf = Buffer.from(midiaBase64, 'base64');
if (inputBuf.length > 100) {
var oggBuf = await convertWebmToOgg(inputBuf);
if (oggBuf && oggBuf.length > 100) {
midiaFinal = oggBuf.toString('base64');
mimeType = 'audio/ogg';
nomeFinal = 'audio.ogg';
console.log('[SendMsg] Áudio convertido WebM→OGG antes de salvar:', inputBuf.length, '→', oggBuf.length);
}
}
} catch(convErr) {
console.error('[SendMsg] Conversão áudio falhou, usando WebM:', convErr.message.substring(0, 80));
}
} else if (tipo === 'image') {
var ext = (nomeArquivo || '').split('.').pop().toLowerCase();
if (ext === 'png') mimeType = 'image/png';
else if (ext === 'gif') mimeType = 'image/gif';
else if (ext === 'webp') mimeType = 'image/webp';
else mimeType = 'image/jpeg';
}
await db.execute(alias, `
INSERT INTO CHATC2_MENSAGENS_ATENDIMENTOS (MAT_CODIGO_ID, MAT_CONVERSA_ID, MAT_MENSAGEM_ID, MAT_NOME_ARQUIVO, MAT_TIPO_ARQUIVO, MAT_ARQUIVO)
VALUES (?, ?, ?, ?, ?, ?)
`, [midiaId, id, newId, nomeFinal, mimeType, midiaFinal]);
// Usa a versão convertida para envio posterior à Evolution
midiaBase64 = midiaFinal;
}
// Verifica config para enviar nome do usuário
let textoFinal = mensagem || '';
try {
const config = await db.query(alias,
"SELECT CFE_ENVIAR_NOME_USUARIO FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?",
[conv[0].CON_EMPRESA_ID]);
if (config.length > 0 && config[0].CFE_ENVIAR_NOME_USUARIO === 'S') {
const user = await db.query(alias,
'SELECT USU_NOME FROM USUARIOS WHERE USU_CODIGO_ID = ?', [usuarioId]);
if (user.length > 0) {
const nomeUser = (user[0].USU_NOME || '').trim();
if (nomeUser && textoFinal) {
// Padrão WhatsApp: *Nome:* em negrito + quebra de linha + mensagem
textoFinal = '*' + nomeUser + ':*\n' + textoFinal;
}
}
}
} catch(e) {}
// Processa placeholders: [CLIENTE], [CONTATO], [EMPRESAR], [EMPRESAF]
textoFinal = await ChatController.processarPlaceholders(alias, id, textoFinal);
// Insere mensagem no banco
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_SITUACAO, CME_DT_ENVIO, CME_MIDIA_ID)
VALUES (?, ?, 'U', ?, ?, ?, ?, 'A', CURRENT_TIMESTAMP, ?)
`, [newId, id, usuarioId, textoFinal, tipo || 'text', privada === 'S' ? 'S' : 'N', midiaId]);
// Atualiza data da última mensagem
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [id]);
// Se não for privada, envia via Evolution API
if (privada !== 'S' && conv[0].CON_NUMERO) {
try {
const instancia = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [conv[0].CON_INSTANCIA_ID]);
if (instancia.length > 0) {
const ins = instancia[0];
console.log('[SendMsg] Enviando mídia tipo:', tipo, 'tamanho:', (midiaBase64 || '').length, 'nome:', nomeArquivo, 'para:', conv[0].CON_NUMERO);
var evoResult = await sendEvolutionMessage(ins, conv[0].CON_NUMERO, textoFinal, tipo, midiaBase64, nomeArquivo);
console.log('[SendMsg] Evolution resposta:', (evoResult || '').substring(0, 200));
} else {
console.log('[SendMsg] Instância não encontrada para ID:', conv[0].CON_INSTANCIA_ID);
}
} catch (evoErr) {
console.error('[SendMsg] Erro ao enviar via Evolution:', evoErr.message);
}
}
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Finalizar conversa
* POST /api/:alias/conversations/:id/finalize
*/
static async finalizeConversation(req, res) {
try {
const { alias, id } = req.params;
const conv = await db.query(alias,
'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
const c = conv[0];
if (c.CON_EMPRESA_ID && !((req.user && req.user.empresas) || []).some(function(e) { return String(e) === String(c.CON_EMPRESA_ID); }))
return res.status(403).json({ success: false, error: 'Sem permissão.' });
// ===== Fluxo de Resolução: valida motivo/resolução conforme config =====
await garantirEstrutura(alias);
const motivoId = (req.body && req.body.motivoId) ? parseInt(req.body.motivoId, 10) : null;
const resolucao = (req.body && req.body.resolucao != null) ? String(req.body.resolucao).trim() : '';
const flagsRows = await db.query(alias,
'SELECT CFE_MOTIVO_VISUALIZAR, CFE_MOTIVO_OBRIGATORIO, CFE_RESOLUCAO_VISUALIZAR, CFE_RESOLUCAO_OBRIGATORIO FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[c.CON_EMPRESA_ID]);
const fl = flagsRows[0] || {};
const isS = (v) => String(v || 'N').trim() === 'S';
if (isS(fl.CFE_MOTIVO_VISUALIZAR) && isS(fl.CFE_MOTIVO_OBRIGATORIO) && !motivoId) {
return res.status(400).json({ success: false, error: 'Selecione o motivo do atendimento para finalizar.' });
}
if (isS(fl.CFE_RESOLUCAO_VISUALIZAR) && isS(fl.CFE_RESOLUCAO_OBRIGATORIO) && !resolucao) {
return res.status(400).json({ success: false, error: 'Preencha a resolução do atendimento para finalizar.' });
}
// Busca nome do usuário que atendeu
let usuarioNome = null;
if (c.CON_USUARIO_ID) {
const user = await db.query(alias,
'SELECT USU_NOME FROM USUARIOS WHERE USU_CODIGO_ID = ?', [c.CON_USUARIO_ID]);
if (user.length > 0) usuarioNome = (user[0].USU_NOME || '').trim();
}
// Busca nome da equipe
let equipeNome = null;
if (c.CON_EQUIPE_ID) {
const equipe = await db.query(alias,
'SELECT EQU_NOME FROM CHATC2_EQUIPES WHERE EQU_CODIGO_ID = ?', [c.CON_EQUIPE_ID]);
if (equipe.length > 0) equipeNome = (equipe[0].EQU_NOME || '').trim();
}
// Busca etiquetas
const etiquetas = await db.query(alias, `
SELECT ETI_NOME FROM CHATC2_ETIQUETAS e
INNER JOIN CHATC2_CONVERSAS_ETIQUETAS ce ON e.ETI_CODIGO_ID = ce.CET_ETIQUETA_ID
WHERE ce.CET_CONVERSA_ID = ? AND e.ETI_SITUACAO = 'A'
`, [id]);
const etiquetasDesc = etiquetas.map(function(e) { return (e.ETI_NOME || '').trim(); }).filter(Boolean).join(', ');
await db.execute(alias, `
UPDATE CHATC2_CONVERSAS SET
CON_STATUS = 'F',
CON_DT_FINAL = CURRENT_TIMESTAMP,
CON_USUARIO_NOME = ?,
CON_EQUIPE_NOME = ?,
CON_ETIQUETAS_DESC = ?,
CON_MOTIVO_ID = ?,
CON_RESOLUCAO = ?
WHERE CON_CODIGO_ID = ?
`, [usuarioNome, equipeNome, etiquetasDesc, motivoId, resolucao || null, id]);
// CSAT
const config = await db.query(alias,
'SELECT CFE_CSAT_ATIVO, CFE_CSAT_MENSAGEM FROM CHATC2_CONFIGURACOES_EMPRESA WHERE CFE_EMPRESA_ID = ?',
[conv[0].CON_EMPRESA_ID]);
if (config.length > 0 && config[0].CFE_CSAT_ATIVO === 'S') {
var csatEnviadoSucesso = false;
try {
const empresaId = conv[0].CON_EMPRESA_ID;
var baseUrl = process.env.EXTERNAL_URL || process.env.NGROK_URL || ('https://' + (req.headers.host || 'localhost:3000'));
var csatLink = baseUrl.replace(/^https?:\/\//, '') + '/app/' + alias + '/csat?alias=' + alias + '&conversa=' + id + '&empresa=' + empresaId;
const msgCsat = 'Ol\u00e1! Gostar\u00edamos de saber como foi seu atendimento.\n\nAcesse o link abaixo para avaliar:\n' + csatLink;
const inst = await db.query(alias,
"SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ? AND INS_SITUACAO = 'A'",
[conv[0].CON_INSTANCIA_ID]);
if (inst.length > 0) {
var csatResult = await sendEvolutionMessage(inst[0], conv[0].CON_NUMERO, msgCsat, 'text', null);
console.log('[CSAT] Resultado envio:', JSON.stringify(csatResult));
if (csatResult) {
try {
var parsed = typeof csatResult === 'string' ? JSON.parse(csatResult) : csatResult;
if (parsed && parsed.key && parsed.key.remoteJid) csatEnviadoSucesso = true;
} catch(e) {
if (csatResult.status || (typeof csatResult === 'string' && csatResult.length > 20)) csatEnviadoSucesso = true;
}
}
} else {
console.log('[CSAT] Instancia nao encontrada ou inativa para ID:', conv[0].CON_INSTANCIA_ID);
}
} catch(e) { console.error('[CSAT] Erro ao enviar:', e.message); }
// So marca como enviado se realmente conseguiu enviar
if (csatEnviadoSucesso) {
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_CSAT_ENVIADO = 'S' WHERE CON_CODIGO_ID = ?", [id]);
console.log('[CSAT] Marcado como enviado para conversa', id);
} else {
console.log('[CSAT] NAO enviado - Evolution API pode estar offline');
}
}
res.json({ success: true, message: 'Conversa finalizada.' });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Criar nova conversa e enviar primeira mensagem
* POST /api/:alias/conversations/create
* Body: { empresaId, numero, nomeContato, mensagem, instanciaId, clienteId }
*/
static async createConversation(req, res) {
try {
const { alias } = req.params;
const { empresaId, numero, mensagem, instanciaId } = req.body;
let clienteId = req.body.clienteId || null;
let nomeContato = req.body.nomeContato || '';
if (!empresaId || !numero || !mensagem) {
return res.status(400).json({ success: false, error: 'Campos obrigatórios: empresaId, numero, mensagem' });
}
// Verifica permissão
const userEmpresas = req.user?.empresas || [];
if (!userEmpresas.some(function(e) { return String(e) === String(empresaId); })) {
return res.status(403).json({ success: false, error: 'Sem permissão para esta empresa.' });
}
const numeroLimpo = numero.replace(/\D/g, '').substring(0, 15);
// Se não foi informado clienteId, tenta localizar pelo número de telefone
// Fluxo: cliente → dependente (titular) → número novo (sem vínculo)
let clienteEncontrado = null;
let nomeEncontrado = null;
if (!clienteId) {
// Variações do número para busca: completo, sem DDI (55), últimos 8 dígitos
const variantes = [numeroLimpo];
if (numeroLimpo.startsWith('55')) variantes.push(numeroLimpo.substring(2));
const ultimos8 = numeroLimpo.slice(-8);
if (!variantes.includes(ultimos8)) variantes.push(ultimos8);
// 1) Busca CLIENTES
for (const termo of variantes) {
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME
FROM CLIENTES
WHERE CLI_EMPRESA_ID = ?
AND (REPLACE(REPLACE(REPLACE(COALESCE(CLI_CELULAR,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE1,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
OR REPLACE(REPLACE(REPLACE(COALESCE(CLI_FONE2,''),'-',''),'(',''),')','') LIKE '%' || ? || '%')
FETCH FIRST 1 ROWS ONLY
`, [empresaId, termo, termo, termo]);
if (cli.length > 0) {
clienteEncontrado = cli[0].CLI_CODIGO_ID;
nomeEncontrado = (cli[0].CLI_NOME || '').trim();
break;
}
}
// 2) Se não achou cliente, busca DEPENDENTES
if (!clienteEncontrado) {
for (const termo of variantes) {
const dep = await db.query(alias, `
SELECT d.DEPC_CLIENTE_ID AS TITULAR_ID, d.DEPC_NOME, c.CLI_NOME AS TITULAR_NOME
FROM DEPENDENTES_CLI d
LEFT JOIN CLIENTES c ON d.DEPC_CLIENTE_ID = c.CLI_CODIGO_ID
WHERE d.DEPC_EMPRESA_ID = ? AND d.DEPC_SITUACAO = 'A'
AND REPLACE(REPLACE(REPLACE(COALESCE(d.DEPC_TELEFONE,''),'-',''),'(',''),')','') LIKE '%' || ? || '%'
FETCH FIRST 1 ROWS ONLY
`, [empresaId, termo]);
if (dep.length > 0) {
clienteEncontrado = dep[0].TITULAR_ID;
nomeEncontrado = (dep[0].TITULAR_NOME || dep[0].DEPC_NOME || '').trim();
break;
}
}
}
if (clienteEncontrado) {
clienteId = clienteEncontrado;
if (!nomeContato || nomeContato === numeroLimpo) nomeContato = nomeEncontrado;
}
}
// Verifica se já existe conversa ABERTA ou EM ESPERA para este número
// Tenta com o número exato, e também com os últimos 8 dígitos
let existente = await db.query(alias, `
SELECT CON_CODIGO_ID, CON_STATUS, CON_CLIENTE_ID, CON_NUMERO
FROM CHATC2_CONVERSAS
WHERE CON_NUMERO = ? AND CON_EMPRESA_ID = ?
AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
ORDER BY CON_CODIGO_ID ASC
FETCH FIRST 1 ROWS ONLY
`, [numeroLimpo, empresaId]);
// Se não achou pelo número exato, tenta pelos últimos 8 dígitos
if (existente.length === 0) {
const ultimos8 = numeroLimpo.slice(-8);
existente = await db.query(alias, `
SELECT CON_CODIGO_ID, CON_STATUS, CON_CLIENTE_ID, CON_NUMERO
FROM CHATC2_CONVERSAS
WHERE CON_NUMERO LIKE '%' || ? || '%' AND CON_EMPRESA_ID = ?
AND CON_STATUS IN ('A','E') AND CON_SITUACAO = 'A'
ORDER BY CON_CODIGO_ID ASC
FETCH FIRST 1 ROWS ONLY
`, [ultimos8, empresaId]);
}
let conversaId;
if (existente.length > 0) {
// Reusa a conversa existente (prioriza a de menor ID)
conversaId = existente[0].CON_CODIGO_ID;
// Se tem clienteId e a conversa não tinha, atualiza
if (clienteId && !existente[0].CON_CLIENTE_ID) {
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[clienteId, nomeContato || '', conversaId]);
}
// Adiciona mensagem na conversa existente
const msgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
const textoProcessado = await ChatController.processarPlaceholders(alias, conversaId, mensagem);
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgId, conversaId, req.user?.id, textoProcessado]);
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?',
[conversaId]);
// Envia via Evolution
const resultado = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [instanciaId || null]);
if (resultado.length > 0) {
sendEvolutionMessage(resultado[0], numeroLimpo, textoProcessado, 'text', null).catch(function(){});
}
return res.json({ success: true, data: { id: conversaId, reutilizada: true } });
}
// Gera ID da nova conversa
const newId = await db.nextId(alias, 'GEN_CHATC2_CONVERSAS');
// Se não informou instância, pega a primeira ativa
let instId = instanciaId;
if (!instId) {
const inst = await db.query(alias,
"SELECT INS_CODIGO_ID FROM CHATC2_INSTANCIAS WHERE INS_SITUACAO = 'A' FETCH FIRST 1 ROWS ONLY");
if (inst.length > 0) instId = inst[0].INS_CODIGO_ID;
}
// Insere conversa
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS (CON_CODIGO_ID, CON_EMPRESA_ID, CON_INSTANCIA_ID, CON_NUMERO,
CON_NOME_CONTATO, CON_CLIENTE_ID, CON_STATUS, CON_SITUACAO, CON_PRIMEIRA_MSG, CON_DT_ULTIMA_MSG)
VALUES (?, ?, ?, ?, ?, ?, 'A', 'A', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, [newId, empresaId, instId, numeroLimpo, nomeContato || numeroLimpo, clienteId || null]);
// Insere primeira mensagem
const msgId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
const textoProcessadoNew = await ChatController.processarPlaceholders(alias, newId, mensagem);
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'A', CURRENT_TIMESTAMP)
`, [msgId, newId, req.user?.id, textoProcessadoNew]);
// Envia via Evolution API
try {
const resultado = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [instId]);
if (resultado.length > 0) {
await sendEvolutionMessage(resultado[0], numeroLimpo, textoProcessadoNew, 'text', null);
}
} catch (evoErr) {
console.error('Erro ao enviar primeira mensagem via Evolution:', evoErr.message);
}
res.json({ success: true, data: { id: newId, msgId } });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Atribuir conversa a usuário
* POST /api/:alias/conversations/:id/assign
*/
static async assignConversation(req, res) {
try {
const { alias, id } = req.params;
const { usuarioId } = req.body;
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 404 ? 'Conversa não encontrada.' : 'Sem permissão.' });
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_USUARIO_ID = ?, CON_STATUS = 'A' WHERE CON_CODIGO_ID = ?",
[usuarioId, id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Atribuir conversa a uma equipe
* POST /api/:alias/conversations/:id/assign-team
*/
static async assignTeam(req, res) {
try {
const { alias, id } = req.params;
const { equipeId } = req.body;
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 404 ? 'Conversa não encontrada.' : 'Sem permissão.' });
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_EQUIPE_ID = ? WHERE CON_CODIGO_ID = ?',
[equipeId || null, id]);
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Adicionar/remover etiqueta da conversa
* POST /api/:alias/conversations/:id/labels
*/
static async toggleLabel(req, res) {
try {
const { alias, id } = req.params;
const { etiquetaId, ativar } = req.body;
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 404 ? 'Conversa não encontrada.' : 'Sem permissão.' });
if (ativar) {
await db.execute(alias,
'INSERT INTO CHATC2_CONVERSAS_ETIQUETAS (CET_CONVERSA_ID, CET_ETIQUETA_ID) VALUES (?, ?)',
[id, etiquetaId]);
} else {
await db.execute(alias,
'DELETE FROM CHATC2_CONVERSAS_ETIQUETAS WHERE CET_CONVERSA_ID = ? AND CET_ETIQUETA_ID = ?',
[id, etiquetaId]);
}
res.json({ success: true });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Buscar mídia (imagem/audio) pelo ID
* GET /api/:alias/media/:mediaId
*/
static async getMedia(req, res) {
const { alias, mediaId } = req.params;
if (!alias || !mediaId) {
return res.status(400).json({ success: false, error: 'Parâmetros inválidos.' });
}
let responded = false;
function respond(status, data) {
if (responded) return;
responded = true;
res.status(status).json(data);
}
function respondBuffer(mimeType, nomeArquivo, buffer) {
if (responded) return;
responded = true;
const totalSize = buffer.length;
// Suporte a byte-range para áudio (permite seek no player HTML5)
const range = res.req.headers.range;
if (range && mimeType.startsWith('audio/')) {
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : totalSize - 1;
const chunkSize = (end - start) + 1;
res.status(206);
res.set('Content-Range', 'bytes ' + start + '-' + end + '/' + totalSize);
res.set('Accept-Ranges', 'bytes');
res.set('Content-Length', chunkSize);
res.set('Content-Type', mimeType);
res.set('Content-Disposition', 'inline; filename="' + encodeURIComponent(nomeArquivo) + '"');
res.send(buffer.slice(start, end + 1));
return;
}
res.set('Content-Type', mimeType);
res.set('Accept-Ranges', 'bytes');
res.set('Content-Length', totalSize);
res.set('Content-Disposition', 'inline; filename="' + encodeURIComponent(nomeArquivo) + '"');
res.send(buffer);
}
try {
// Autenticação: <img>/<audio>/<a> não enviam header, então aceitamos o
// token também via query string (?token=). Também aceita header.
const jwt = require('jsonwebtoken');
const authConfig = require('../config/auth');
const rawToken = req.query.token
|| (req.headers['authorization'] || '').replace(/^Bearer\s+/i, '')
|| req.headers['x-usu-token'];
let authUser = null;
if (rawToken) {
try {
authUser = jwt.verify(rawToken, authConfig.secret, { issuer: authConfig.issuer });
} catch (e) {
try {
const u = await db.query(alias,
"SELECT USU_CODIGO_ID FROM USUARIOS WHERE USU_TOKEN = ? AND USU_STATUS = 'A' AND COALESCE(USU_ACESSO_WEB,0) = 1",
[rawToken]);
if (u.length > 0) {
const emp = await db.query(alias,
'SELECT USE_EMPRESA_ID FROM USUARIOS_EMPRESA WHERE USE_USUARIO_ID = ?', [u[0].USU_CODIGO_ID]);
authUser = { id: u[0].USU_CODIGO_ID, empresas: emp.map(function (e) { return e.USE_EMPRESA_ID; }) };
}
} catch (_) {}
}
}
if (!authUser) {
return respond(401, { success: false, error: 'Token de acesso obrigatório.' });
}
const result = await db.query(alias,
'SELECT MAT_CODIGO_ID, MAT_TIPO_ARQUIVO, MAT_NOME_ARQUIVO, MAT_ARQUIVO FROM CHATC2_MENSAGENS_ATENDIMENTOS WHERE MAT_CODIGO_ID = ?',
[mediaId]);
if (result.length === 0) {
return respond(404, { success: false, error: 'Mídia não encontrada.' });
}
// Autorização: a mídia precisa pertencer a uma empresa do usuário
const dono = await db.query(alias,
'SELECT c.CON_EMPRESA_ID AS EMP FROM CHATC2_MENSAGENS_ATENDIMENTOS m JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = m.MAT_CONVERSA_ID WHERE m.MAT_CODIGO_ID = ?',
[mediaId]);
const empresaMidia = dono.length > 0 ? dono[0].EMP : null;
const minhasEmp = authUser.empresas || [];
if (empresaMidia && minhasEmp.indexOf(empresaMidia) === -1) {
return respond(403, { success: false, error: 'Sem permissão para esta mídia.' });
}
const m = result[0];
var mimeType = (m.MAT_TIPO_ARQUIVO || '').trim() || 'application/octet-stream';
// Corrige MIME types antigos que salvavam apenas "audio" ou "image"
if (mimeType === 'audio') mimeType = 'audio/webm';
else if (mimeType === 'image') mimeType = 'image/jpeg';
// Remove parâmetros do MIME type (ex: 'audio/ogg; codecs=opus' → 'audio/ogg')
mimeType = mimeType.split(';')[0].trim();
const nomeArquivo = (m.MAT_NOME_ARQUIVO || '').trim() || 'arquivo.bin';
// MAT_ARQUIVO chega como Buffer (firebird blob) ou string
let rawBuffer = m.MAT_ARQUIVO;
if (rawBuffer == null) {
return respond(404, { success: false, error: 'Mídia sem conteúdo.' });
}
if (!Buffer.isBuffer(rawBuffer)) {
rawBuffer = Buffer.from(typeof rawBuffer === 'string' ? rawBuffer : (rawBuffer.data || rawBuffer));
}
if (rawBuffer.length === 0) {
return respond(404, { success: false, error: 'Mídia vazia.' });
}
// Se o dado armazenado é base64 (string), decodifica
try {
var asString = rawBuffer.toString('utf8').trim();
if (/^[A-Za-z0-9+/=]+$/.test(asString) && asString.length > 20) {
const buffer = Buffer.from(asString, 'base64');
if (buffer.length > 0 && buffer.length < asString.length * 2) {
if (validarHeaderMidiaServida(buffer, mimeType)) {
console.log('[getMedia] Base64 decodificado e validado: ' + asString.length + ' chars -> ' + buffer.length + ' bytes, mime: ' + mimeType);
return respondBuffer(mimeType, nomeArquivo, buffer);
}
console.log('[getMedia] Base64 decodificado mas header invalido (header: ' + buffer.slice(0, 4).toString('hex') + '), recusando');
return respond(400, { success: false, error: 'Mídia corrompida (header de arquivo inválido).' });
}
}
} catch (e) { console.log('[getMedia] Erro ao decodificar base64:', e.message); }
// Fallback: usa o buffer bruto (com validação)
if (validarHeaderMidiaServida(rawBuffer, mimeType)) {
console.log('[getMedia] Servindo buffer bruto: ' + rawBuffer.length + ' bytes, mime: ' + mimeType);
respondBuffer(mimeType, nomeArquivo, rawBuffer);
} else {
console.log('[getMedia] Buffer bruto com header invalido (header: ' + rawBuffer.slice(0, 4).toString('hex') + '), recusando');
respond(400, { success: false, error: 'Mídia corrompida (header de arquivo inválido).' });
}
} catch (err) {
respond(500, { success: false, error: 'Erro ao carregar mídia: ' + err.message });
}
}
/**
* Associar contato a cliente
* POST /api/:alias/conversations/:id/link-client
*/
static async linkClient(req, res) {
try {
const { alias, id } = req.params;
const { clienteId } = req.body;
const chk = await ChatController.checarConversaEmpresa(alias, id, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 404 ? 'Conversa não encontrada.' : 'Sem permissão.' });
const cli = await db.query(alias, `
SELECT CLI_CODIGO_ID, CLI_NOME, CLI_CELULAR, CLI_FONE1, CLI_EMAIL, CLI_ENDERECO_FAT,
CLI_NUMERO_FAT, CLI_CIDADES_FAT_ID, CLI_BAIRROS_FAT_ID, CLI_COBRADOR_ID,
CLI_DIA_COBRANCA, CLI_PLANOS_ID, CLI_FOTO, CLI_NOME_FOTO
FROM CLIENTES WHERE CLI_CODIGO_ID = ?
`, [clienteId]);
if (cli.length === 0) return res.status(404).json({ success: false, error: 'Cliente não encontrado.' });
const c = cli[0];
// Atualiza conversa com dados do cliente
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_CLIENTE_ID = ?, CON_NOME_CONTATO = ? WHERE CON_CODIGO_ID = ?',
[clienteId, (c.CLI_NOME || '').trim(), id]);
// Busca dados adicionais para retornar
let cidadeNome = null;
if (c.CLI_CIDADES_FAT_ID) {
const cidade = await db.query(alias, 'SELECT CID_NOME FROM CIDADES WHERE CID_CODIGO_ID = ?', [c.CLI_CIDADES_FAT_ID]);
if (cidade.length > 0) cidadeNome = cidade[0].CID_NOME?.trim();
}
let bairroNome = null;
if (c.CLI_BAIRROS_FAT_ID) {
const bairro = await db.query(alias, 'SELECT BAR_DESCRICAO FROM BAIRROS WHERE BAR_CODIGO_ID = ?', [c.CLI_BAIRROS_FAT_ID]);
if (bairro.length > 0) bairroNome = bairro[0].BAR_DESCRICAO?.trim();
}
let cobradorNome = null;
if (c.CLI_COBRADOR_ID) {
const cob = await db.query(alias, 'SELECT COB_NOME FROM COBRADORES WHERE COB_CODIGO_ID = ?', [c.CLI_COBRADOR_ID]);
if (cob.length > 0) cobradorNome = cob[0].COB_NOME?.trim();
}
// Verifica inadimplência
const titulos = await db.query(alias,
'SELECT COUNT(*) AS T FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0 AND CAR_DT_VENCIMENTO < CURRENT_DATE',
[clienteId]);
const inadimplente = titulos[0]?.T > 0;
res.json({
success: true,
data: {
id: c.CLI_CODIGO_ID,
nome: (c.CLI_NOME || '').trim(),
celular: (c.CLI_CELULAR || '').trim(),
telefone: (c.CLI_FONE1 || '').trim(),
email: (c.CLI_EMAIL || '').trim(),
endereco: (c.CLI_ENDERECO_FAT || '').trim(),
numero: (c.CLI_NUMERO_FAT || '').trim(),
cidade: cidadeNome,
bairro: bairroNome,
cobrador: cobradorNome,
inadimplente,
}
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Valida que o cliente informado pertence à mesma empresa da conversa
* (e que o usuário tem acesso à conversa). Retorna { ok, clienteId } ou { status }.
*/
static async checarClienteDaConversa(alias, conversaId, clienteId, req) {
const chk = await ChatController.checarConversaEmpresa(alias, conversaId, req);
if (chk.status) return chk;
const cid = parseInt(clienteId, 10);
if (!cid) return { status: 400 };
const cli = await db.query(alias,
'SELECT CLI_EMPRESA_ID FROM CLIENTES WHERE CLI_CODIGO_ID = ?', [cid]);
if (cli.length === 0) return { status: 404 };
if (chk.empresaId && cli[0].CLI_EMPRESA_ID && String(cli[0].CLI_EMPRESA_ID) !== String(chk.empresaId))
return { status: 403 };
return { ok: true, clienteId: cid };
}
/**
* Lista títulos (boletos) em aberto do cliente vinculado à conversa,
* para o atendente escolher qual enviar.
* GET /api/:alias/conversations/:id/boletos?clienteId=
*/
static async getBoletos(req, res) {
try {
const { alias, id } = req.params;
const chk = await ChatController.checarClienteDaConversa(alias, id, req.query.clienteId, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
const carnes = await db.query(alias,
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
CAR_NOSSO_NUMERO, CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS,
CAR_CODIGO_BARRAS
FROM CARNES WHERE CAR_CLIENTE_ID = ? AND CAR_SITUACAO = 0
ORDER BY CAR_DT_VENCIMENTO`, [chk.clienteId]);
const fmtDate = (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);
};
res.json({ success: true, data: carnes.map(function(c) {
return {
id: c.CAR_CODIGO_ID,
vencimento: fmtDate(c.CAR_DT_VENCIMENTO),
valor: c.CAR_VALOR_PARCELA,
parcela: c.CAR_NUMERO_PARCELA,
totalParcelas: c.CAR_NUMERO_TOTAL_PARCELAS,
temLinhaDigitavel: !!(c.CAR_LINHA_DIGITAVEL && String(c.CAR_LINHA_DIGITAVEL).trim()),
temPix: !!(c.CAR_PIX_QRCODE && String(c.CAR_PIX_QRCODE).trim()),
};
}) });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Envia um boleto escolhido (texto: linha digitável + PIX) ao cliente no chat.
* POST /api/:alias/conversations/:id/send-boleto Body: { clienteId, carneId }
*/
static async sendBoleto(req, res) {
try {
const { alias, id } = req.params;
const { clienteId, carneId } = req.body;
const chk = await ChatController.checarClienteDaConversa(alias, id, clienteId, req);
if (chk.status) return res.status(chk.status).json({ success: false, error: chk.status === 403 ? 'Sem permissão.' : (chk.status === 404 ? 'Cliente não encontrado.' : 'Parâmetros inválidos.') });
const carneIdNum = parseInt(carneId, 10);
if (!carneIdNum) return res.status(400).json({ success: false, error: 'Título inválido.' });
// Título precisa pertencer ao cliente validado (evita IDOR)
const car = await db.query(alias,
`SELECT CAR_CODIGO_ID, CAR_DT_VENCIMENTO, CAR_VALOR_PARCELA, CAR_LINHA_DIGITAVEL,
CAR_PIX_QRCODE, CAR_NUMERO_PARCELA, CAR_NUMERO_TOTAL_PARCELAS, CAR_CODIGO_BARRAS
FROM CARNES WHERE CAR_CODIGO_ID = ? AND CAR_CLIENTE_ID = ?`, [carneIdNum, chk.clienteId]);
if (car.length === 0) return res.status(404).json({ success: false, error: 'Título não encontrado para este cliente.' });
const b = car[0];
const conv = await db.query(alias, 'SELECT * FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?', [id]);
if (conv.length === 0) return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
// Monta o texto do boleto
const fmtDate = (dt) => {
if (!dt) return '';
let s = typeof dt === 'string' ? dt.split('T')[0] : (dt instanceof Date ? dt.toISOString().split('T')[0] : String(dt));
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s);
return m ? (m[3] + '/' + m[2] + '/' + m[1]) : s;
};
const fmtValor = (v) => {
const n = Number(v);
if (isNaN(n)) return '';
return 'R$ ' + n.toFixed(2).replace('.', ',');
};
const linha = (b.CAR_LINHA_DIGITAVEL || '').trim() || (b.CAR_CODIGO_BARRAS || '').trim();
const pix = (b.CAR_PIX_QRCODE || '').trim();
let texto = '📄 *Boleto*';
if (b.CAR_NUMERO_PARCELA) {
texto += ' - Parcela ' + b.CAR_NUMERO_PARCELA + (b.CAR_NUMERO_TOTAL_PARCELAS ? '/' + b.CAR_NUMERO_TOTAL_PARCELAS : '');
}
texto += '\n';
if (b.CAR_VALOR_PARCELA != null) texto += '\n💰 Valor: ' + fmtValor(b.CAR_VALOR_PARCELA);
if (b.CAR_DT_VENCIMENTO) texto += '\n📅 Vencimento: ' + fmtDate(b.CAR_DT_VENCIMENTO);
if (linha) texto += '\n\n*Linha digitável:*\n' + linha;
if (pix) texto += '\n\n*PIX (copia e cola):*\n' + pix;
if (!linha && !pix) {
return res.status(400).json({ success: false, error: 'Este título não possui linha digitável nem PIX disponível.' });
}
// Salva mensagem do agente
const newId = await db.nextId(alias, 'GEN_CONVERSAS_MENSAGENS');
await db.execute(alias, `
INSERT INTO CHATC2_CONVERSAS_MENSAGENS (CME_CODIGO_ID, CME_CONVERSA_ID, CME_REMETENTE, CME_USUARIO_ID, CME_TEXTO, CME_TIPO, CME_PRIVADA, CME_SITUACAO, CME_DT_ENVIO)
VALUES (?, ?, 'U', ?, ?, 'text', 'N', 'A', CURRENT_TIMESTAMP)
`, [newId, id, req.user?.id, texto]);
await db.execute(alias,
'UPDATE CHATC2_CONVERSAS SET CON_DT_ULTIMA_MSG = CURRENT_TIMESTAMP WHERE CON_CODIGO_ID = ?', [id]);
// Envia via Evolution
if (conv[0].CON_NUMERO) {
try {
const instancia = await db.query(alias,
'SELECT * FROM CHATC2_INSTANCIAS WHERE INS_CODIGO_ID = ?', [conv[0].CON_INSTANCIA_ID]);
if (instancia.length > 0) {
await sendEvolutionMessage(instancia[0], conv[0].CON_NUMERO, texto, 'text', null);
}
} catch (evoErr) {
console.error('[Boleto] Erro ao enviar via Evolution:', evoErr.message);
}
}
res.json({ success: true, data: { id: newId } });
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Busca mensagens/conversas por termo de texto.
* GET /api/:alias/search?q=termo&empresaId=
*/
static async search(req, res) {
try {
const { alias } = req.params;
const q = (req.query.q || '').trim();
const empresaId = parseInt(req.query.empresaId) || req.user?.empresas?.[0];
const limit = Math.min(parseInt(req.query.limit) || 30, 100);
if (!q || q.length < 2) {
return res.status(400).json({ success: false, error: 'Termo de busca muito curto (mínimo 2 caracteres).' });
}
if (!req.user?.empresas?.some(e => Number(e) === Number(empresaId))) {
return res.status(403).json({ success: false, error: 'Sem permissão.' });
}
const results = await db.query(alias, `
SELECT m.CME_CODIGO_ID, m.CME_CONVERSA_ID, m.CME_TEXTO, m.CME_TIPO,
m.CME_DT_ENVIO, m.CME_REMETENTE,
c.CON_NUMERO, c.CON_NOME_CONTATO, c.CON_STATUS
FROM CHATC2_CONVERSAS_MENSAGENS m
JOIN CHATC2_CONVERSAS c ON c.CON_CODIGO_ID = m.CME_CONVERSA_ID
WHERE c.CON_EMPRESA_ID = ?
AND c.CON_SITUACAO = 'A'
AND m.CME_SITUACAO = 'A'
AND m.CME_TEXTO CONTAINING ?
ORDER BY m.CME_DT_ENVIO DESC
FETCH FIRST ? ROWS ONLY
`, [empresaId, q, limit]);
res.json({
success: true,
data: results.map(r => ({
mensagemId: r.CME_CODIGO_ID,
conversaId: r.CME_CONVERSA_ID,
texto: (r.CME_TEXTO || '').substring(0, 120),
tipo: (r.CME_TIPO || 'text').trim(),
dtEnvio: r.CME_DT_ENVIO,
remetente: (r.CME_REMETENTE || '').trim(),
numero: (r.CON_NUMERO || '').trim(),
nomeContato: (r.CON_NOME_CONTATO || '').trim(),
status: (r.CON_STATUS || '').trim(),
})),
count: results.length,
});
} catch (err) { res.status(500).json({ success: false, error: err.message }); }
}
/**
* Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar
*/
static async csatCheck(req, res) {
try {
const { alias } = req.params;
const conversaId = parseInt(req.query.conversa);
if (!conversaId) return res.status(400).json({ success: false, error: 'conversa obrigatório.' });
const existe = await db.query(alias,
'SELECT CSA_NOTA, CSA_COMENTARIO FROM CHATC2_CSAT_AVALIACOES WHERE CSA_CONVERSA_ID = ?',
[conversaId]);
res.json({ success: true, avaliado: existe.length > 0 });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Recebe avaliação CSAT via formulário web
* POST /api/:alias/csat/avaliar
*/
static async csatAvaliar(req, res) {
try {
const { alias } = req.params;
const { conversaId, empresaId, nota, comentario } = req.body;
if (!conversaId || !empresaId || !nota || nota < 1 || nota > 5) {
return res.status(400).json({ success: false, error: 'Dados inválidos.' });
}
// Busca dados da conversa
const conv = await db.query(alias,
'SELECT CON_CLIENTE_ID, CON_INSTANCIA_ID FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?',
[conversaId]
);
if (conv.length === 0) {
return res.status(404).json({ success: false, error: 'Conversa não encontrada.' });
}
// Salva avaliação
const newId = await db.nextId(alias, 'GEN_CHATC2_CSAT_AVALIACOES');
await db.execute(alias,
'INSERT INTO CHATC2_CSAT_AVALIACOES (CSA_CODIGO_ID, CSA_CONVERSA_ID, CSA_EMPRESA_ID, CSA_CLIENTE_ID, CSA_NOTA, CSA_COMENTARIO) VALUES (?, ?, ?, ?, ?, ?)',
[newId, conversaId, empresaId, conv[0].CON_CLIENTE_ID, nota, comentario || null]
);
await db.execute(alias,
"UPDATE CHATC2_CONVERSAS SET CON_CSAT_ENVIADO = 'S' WHERE CON_CODIGO_ID = ?",
[conversaId]
);
res.json({ success: true });
} catch (err) {
console.error('[CSAT] Erro:', err.message);
res.status(500).json({ success: false, error: err.message });
}
}
/**
* Substitui placeholders no texto da mensagem.
* [CLIENTE] → nome do cliente cadastrado (ou [CONTATO] se não tiver)
* [CONTATO] → nome do contato WhatsApp
* [EMPRESAR] → razão social da empresa
* [EMPRESAF] → nome fantasia da empresa
*/
static async processarPlaceholders(alias, conversaId, texto) {
if (!texto || (!texto.includes('[') && !texto.includes(']'))) return texto;
try {
const conv = await db.query(alias,
'SELECT CON_CLIENTE_ID, CON_NOME_CONTATO, CON_EMPRESA_ID FROM CHATC2_CONVERSAS WHERE CON_CODIGO_ID = ?',
[conversaId]);
if (conv.length === 0) return texto;
const contato = (conv[0].CON_NOME_CONTATO || '').trim();
const empresaId = conv[0].CON_EMPRESA_ID;
// Busca nome do cliente
let clienteNome = '';
if (conv[0].CON_CLIENTE_ID) {
const cli = await db.query(alias,
'SELECT CLI_NOME, CLI_NOME_FANTASIA FROM CLIENTES WHERE CLI_CODIGO_ID = ?',
[conv[0].CON_CLIENTE_ID]);
if (cli.length > 0) {
clienteNome = (cli[0].CLI_NOME_FANTASIA || cli[0].CLI_NOME || '').trim();
}
}
// Busca dados da empresa
let empresaR = '', empresaF = '';
if (empresaId) {
const emp = await db.query(alias,
'SELECT EMP_NOME, EMP_NOME_FANTASIA FROM EMPRESAS WHERE EMP_CODIGO_ID = ?',
[empresaId]);
if (emp.length > 0) {
empresaR = (emp[0].EMP_NOME || '').trim();
empresaF = (emp[0].EMP_NOME_FANTASIA || emp[0].EMP_NOME || '').trim();
} else {
console.log('[Placeholder] Empresa nao encontrada para ID:', empresaId);
}
} else {
console.log('[Placeholder] Conversa sem CON_EMPRESA_ID');
}
// Substitui: [CLIENTE] usa nome do cadastro, senão cai para [CONTATO]
let result = texto;
result = result.replace(/\[CLIENTE\]/gi, clienteNome || contato || '');
result = result.replace(/\[CONTATO\]/gi, contato || '');
result = result.replace(/\[EMPRESAR\]/gi, empresaR || empresaF || '');
result = result.replace(/\[EMPRESAF\]/gi, empresaF || empresaR || '');
console.log('[Placeholder] conversa:', conversaId, '| contato:', contato, '| cliente:', clienteNome, '| empresaR:', empresaR, '| empresaF:', empresaF, '| antes:', texto.substring(0, 80), '| depois:', result.substring(0, 80));
return result;
} catch (e) {
return texto; // fallback: retorna sem substituir
}
}
}
// Helper: normaliza número para formato WhatsApp (DDI + 9º dígito)
function _normalizarWhatsApp(numero) {
let n = String(numero || '').replace(/\D/g, '');
if (!n) return '';
if (!n.startsWith('55')) n = '55' + n;
const resto = n.slice(2);
if (resto.length === 10) n = '55' + resto.slice(0, 2) + '9' + resto.slice(2);
return n;
}
// Função auxiliar para enviar mensagem via Evolution API
async function sendEvolutionMessage(instancia, numero, texto, tipo, midiaBase64, nomeArquivo) {
const url = ((instancia.INS_URL || '').trim()).replace(/\/$/, '');
const apiKey = (instancia.INS_API_KEY || '').trim();
const instanceName = (instancia.INS_INSTANCE_NAME || '').trim();
const numeroLimpo = _normalizarWhatsApp(numero);
const https = require('https');
const http = require('http');
let payload;
let endpoint;
if (midiaBase64 && (tipo === 'image' || tipo === 'audio' || tipo === 'video' || tipo === 'document')) {
var mediaB64 = midiaBase64;
var fileName = nomeArquivo || 'arquivo';
if (tipo === 'audio') {
// A mídia pode já estar em OGG (convertida antes de salvar no BD)
// Se ainda for WebM, tenta converter agora como fallback
var jaEOgg = (fileName || '').toLowerCase().endsWith('.ogg');
if (!jaEOgg) {
try {
var inputBuffer = Buffer.from(mediaB64, 'base64');
if (inputBuffer.length > 100) {
var oggBuffer = await convertWebmToOgg(inputBuffer);
if (oggBuffer && oggBuffer.length > 100) {
mediaB64 = oggBuffer.toString('base64');
fileName = 'audio.ogg';
console.log('[Audio] Conversão fallback WebM→OGG:', inputBuffer.length, '→', oggBuffer.length);
}
}
} catch(convErr) {
console.error('[Audio] Conversão OGG falhou, mantendo como áudio:', convErr.message.substring(0, 100));
fileName = 'audio.webm';
// NÃO muda para 'document' - mantém como áudio
}
}
// Usa sendWhatsAppAudio para enviar como mensagem de voz (PTT)
endpoint = '/message/sendWhatsAppAudio/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, audio: mediaB64, encoding: true });
console.log('[Evolution] Enviando via sendWhatsAppAudio, tamanho:', mediaB64.length, 'arquivo:', fileName);
} else if (tipo === 'image') {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'image', media: mediaB64, fileName: fileName });
} else if (tipo === 'video') {
// Comprime video se for muito grande (> 5MB) para evitar rejeicao da Evolution API
try {
var videoBuf = Buffer.from(mediaB64, 'base64');
if (videoBuf.length > 5 * 1024 * 1024) {
console.log('[Video] Video grande detectado (' + (videoBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
var compressedBuf = await compressVideo(videoBuf);
if (compressedBuf && compressedBuf.length > 1000) {
mediaB64 = compressedBuf.toString('base64');
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
console.log('[Video] Compressao OK, novo tamanho base64:', mediaB64.length);
}
}
} catch(compressErr) {
console.error('[Video] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
}
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'video', media: mediaB64, fileName: fileName });
} else if (tipo === 'document') {
// Para documentos que sao videos, comprime tambem se for grande
var isVideoDoc = (fileName || '').toLowerCase().match(/\.(mp4|mov|avi|mkv|webm|3gp|m4v)$/);
if (isVideoDoc) {
try {
var docBuf = Buffer.from(mediaB64, 'base64');
if (docBuf.length > 5 * 1024 * 1024) {
console.log('[Document] Video como documento detectado (' + (docBuf.length / (1024 * 1024)).toFixed(1) + 'MB), comprimindo...');
var compressedDocBuf = await compressVideo(docBuf);
if (compressedDocBuf && compressedDocBuf.length > 1000) {
mediaB64 = compressedDocBuf.toString('base64');
fileName = (fileName || 'video').replace(/\.\w+$/, '') + '_compressed.mp4';
console.log('[Document] Compressao OK, novo tamanho base64:', mediaB64.length);
}
}
} catch(compressErr) {
console.error('[Document] Compressao falhou, enviando original:', compressErr.message.substring(0, 80));
}
}
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
} else {
endpoint = '/message/sendMedia/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, mediatype: 'document', media: mediaB64, fileName: fileName });
}
} else {
endpoint = '/message/sendText/' + encodeURIComponent(instanceName);
payload = JSON.stringify({ number: numeroLimpo, text: texto || '', delay: 0 });
}
const parsedUrl = new URL(url + endpoint);
const lib = parsedUrl.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const options = {
hostname: parsedUrl.hostname,
port: parsedUrl.port || (parsedUrl.protocol === 'https:' ? 443 : 80),
path: parsedUrl.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
'apikey': apiKey,
}
};
const req = lib.request(options, (res) => {
let body = '';
res.on('data', c => body += c);
res.on('end', () => {
if (res.statusCode >= 400) {
console.error('[Evolution] Erro ao enviar mídia:', res.statusCode, body.substring(0, 200));
reject(new Error('HTTP ' + res.statusCode + ': ' + body.substring(0, 100)));
} else {
resolve(body);
}
});
});
req.on('error', function(err) {
console.error('[Evolution] Erro na requisição:', err.message);
reject(err);
});
req.write(payload);
req.end();
});
}
module.exports = ChatController;