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 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: /