"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildExtRoutes = buildExtRoutes; /** * External REST API — /api/ext/v1/ * * Endpoints implementados nesta fase: * * GET /api/ext/v1/sessions — lista instâncias do tenant * POST /api/ext/v1/sessions — cria nova instância * DELETE /api/ext/v1/sessions/:id — remove instância * GET /api/ext/v1/sessions/:id/qr — QR base64 da instância * GET /api/ext/v1/inbox?session=&search=&limit= — lista chats * GET /api/ext/v1/inbox/:chatId/messages — mensagens paginadas * POST /api/ext/v1/inbox/:chatId/send — envia mensagem de texto * * Envelope de resposta de erro: { error: string } * Envelope de resposta de sucesso: dados diretos (sem wrapper extra) */ const fs_1 = require("fs"); const promises_1 = require("fs/promises"); const express_1 = require("express"); let dragonfly; if (process.env.NODE_ENV === 'production') { dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly; } else { dragonfly = require('../../../backend/src/infra/cache/dragonfly').dragonfly; } const brain_1 = require("../../secretaria/brain"); let sendRichMessage; if (process.env.NODE_ENV === 'production') { sendRichMessage = require('../../../dist/modules/whatsapp/rich-message').sendRichMessage; } else { sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage; } function uuid() { try { return crypto.randomUUID(); } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } } // ── Handoff timers (in-memory, por convId) ──────────────────────────────────── const HANDOFF_TIMEOUT_MS = 15 * 60 * 1000; const handoffTimers = new Map(); function scheduleHandoffTimeout(convId, extChatId, tenantId, db, hooks) { const existing = handoffTimers.get(convId); if (existing) clearTimeout(existing); const timer = setTimeout(async () => { handoffTimers.delete(convId); try { await db('sec_conversations').where({ id: convId }) .update({ handoff_mode: 'ia', handoff_human_at: null }); // chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId") const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId; hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' }); } catch { } }, HANDOFF_TIMEOUT_MS); handoffTimers.set(convId, timer); } function cancelHandoffTimeout(convId) { const existing = handoffTimers.get(convId); if (existing) { clearTimeout(existing); handoffTimers.delete(convId); } } function buildExtRoutes(prisma, manager, db, config, hooks, io) { const router = (0, express_1.Router)(); // ── Helper: envia reply da IA via WA e persiste no banco principal ───────── async function sendSecretariaReply(opts) { const { instanceId, tenantId, chatId, jid, reply } = opts; const sock = manager?.getSocket(instanceId); if (!sock) return; const sent = await sock.sendMessage(jid, { text: reply }).catch(() => null); const persisted = await prisma.message.create({ data: { tenantId, instanceId, chatId, remoteJid: jid, messageId: sent?.key.id ?? `sec-${Date.now()}`, fromMe: true, type: 'TEXT', body: reply, status: 'SENT', timestamp: new Date(), }, }).catch(() => null); if (persisted && io) { io.to(`chat:${chatId}`).emit('message:new', persisted); } } // ── SEC-02+SEC-03: auto-trigger da Secretária para mensagens recebidas ──── // Só processa se NÃO houver AIBot ativo (exclusão mútua com Chatbot Rápido). hooks.register('ext:message.new', async (data) => { const { tenantId, instanceId, chatId, jid, text } = data; if (!text?.trim()) return; // Exclusão mútua: chatbot rápido tem prioridade se estiver ativo const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } }); if (activeBot) return; const extKey = `${tenantId}:${jid}`; let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first(); if (!conv) { const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first(); if (!agent) return; const [newConv] = await db('sec_conversations').insert({ id: uuid(), agent_id: agent.id, contact_name: jid, protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(), status: 'active', ext_chat_id: extKey, handoff_mode: 'ia', }).returning('*'); conv = newConv; } if (conv.handoff_mode === 'humano') return; // SEC-14: indicador de digitação antes de chamar a IA const sock = manager?.getSocket(instanceId); await sock?.sendPresenceUpdate('composing', jid).catch(() => { }); const brain = new brain_1.ProtocolEngine(db, config); const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks }); await sock?.sendPresenceUpdate('paused', jid).catch(() => { }); await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply }); }); // ── Escalation notification listener ────────────────────────────────────── // When escalar_humano tool fires, send a WA message to the configured admin phone. hooks.register('ext:escalated', async (data) => { try { const rawPhone = await config.get('admin_notify_phone'); const adminPhone = typeof rawPhone === 'string' ? rawPhone.trim() : ''; if (!adminPhone) return; // Normalise to JID const digits = adminPhone.replace(/\D/g, ''); const normalized = digits.startsWith('55') ? digits : '55' + digits; const jid = `${normalized}@s.whatsapp.net`; // Find first connected instance for this tenant const instance = await prisma.instance.findFirst({ where: { tenantId: data.tenantId }, select: { id: true }, }); if (!instance) return; const sock = manager.getSocket(instance.id); if (!sock) return; const proto = data.protocolNumber ? `#${data.protocolNumber}` : ''; const motivo = data.motivo ? ` — ${data.motivo}` : ''; const msg = `⚠️ *Escalação* ${proto}${motivo}\nUm cliente precisa de atendimento humano. Abra o painel para assumir.`; await sock.sendMessage(jid, { text: msg }); } catch { /* notificação é best-effort */ } }); // ── GET /sessions ────────────────────────────────────────────────────────── // Lista todas as instâncias do tenant com status e telefone. router.get('/sessions', async (req, res) => { const tenantId = req.extTenantId; try { const instances = await prisma.instance.findMany({ where: { tenantId }, orderBy: { createdAt: 'asc' }, select: { id: true, name: true, phone: true, status: true, avatar: true, createdAt: true, }, }); res.json(instances); } catch (err) { res.status(500).json({ error: err.message ?? 'Erro interno' }); } }); // ── POST /sessions ──────────────────────────────────────────────────────── // Cria uma nova instância e dispara a conexão (QR chega via WS). router.post('/sessions', async (req, res) => { const tenantId = req.extTenantId; const { name } = req.body; if (!name?.trim()) { res.status(400).json({ error: 'name é obrigatório' }); return; } try { const instance = await prisma.instance.create({ data: { tenantId, name: name.trim() }, select: { id: true, name: true, phone: true, status: true, avatar: true, createdAt: true }, }); manager.connect(instance.id, tenantId).catch((err) => { console.error('[ext-api] Falha ao conectar instância após criação:', err); }); res.status(201).json(instance); } catch (err) { res.status(500).json({ error: err.message ?? 'Erro interno' }); } }); // ── DELETE /sessions/:id ────────────────────────────────────────────────── // Desconecta e remove a instância do tenant. router.delete('/sessions/:id', async (req, res) => { const tenantId = req.extTenantId; const instanceId = req.params['id']; try { const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } }); if (!instance) { res.status(404).json({ error: 'Instância não encontrada' }); return; } await manager.disconnect(instanceId).catch(() => { }); await prisma.instance.delete({ where: { id: instanceId } }); res.json({ message: 'Instância removida' }); } catch (err) { res.status(500).json({ error: err.message ?? 'Erro interno' }); } }); // ── GET /sessions/:id/qr ────────────────────────────────────────────────── // Retorna o QR code atual em base64 (poll antes de receber via WS). router.get('/sessions/:id/qr', async (req, res) => { const tenantId = req.extTenantId; const instanceId = req.params['id']; try { const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true }, }); if (!instance) { res.status(404).json({ error: 'Instância não encontrada' }); return; } const qrBase64 = await dragonfly.get(`instance:${instanceId}:qr`); if (!qrBase64) { res.status(404).json({ error: 'QR não disponível — inicie a conexão primeiro' }); return; } res.json({ instanceId, qrBase64 }); } catch (err) { res.status(500).json({ error: err.message ?? 'Erro interno' }); } }); // ── GET /media/:messageId ───────────────────────────────────────────────── // Serve o arquivo de mídia de uma mensagem. // Se mediaUrl for URL externa (Wasabi etc.) redireciona 302. // Se for arquivo local, faz stream com suporte a Range (áudio/vídeo). router.get('/media/:messageId', async (req, res) => { const tenantId = req.extTenantId; const messageId = req.params['messageId']; try { const msg = await prisma.message.findFirst({ where: { id: messageId, tenantId }, select: { mediaUrl: true, mediaPath: true, mimeType: true, fileName: true }, }); if (!msg || (!msg.mediaPath && !msg.mediaUrl)) { res.status(404).json({ error: 'Mídia não encontrada' }); return; } // URL externa (Wasabi / CDN) → redirect if (msg.mediaUrl && msg.mediaUrl.startsWith('http')) { res.redirect(302, msg.mediaUrl); return; } const filePath = msg.mediaPath; if (!filePath) { res.status(404).json({ error: 'Arquivo não disponível' }); return; } const info = await (0, promises_1.stat)(filePath).catch(() => null); if (!info) { res.status(404).json({ error: 'Arquivo não encontrado no disco' }); return; } const mime = msg.mimeType || 'application/octet-stream'; const size = info.size; // Content-Disposition: inline para imagens/áudio/vídeo, attachment para docs const isInline = mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/'); const disposition = isInline ? 'inline' : `attachment; filename="${msg.fileName ?? 'arquivo'}"`; res.setHeader('Content-Disposition', disposition); res.setHeader('Content-Type', mime); res.setHeader('Accept-Ranges', 'bytes'); res.setHeader('Cache-Control', 'private, max-age=3600'); // Suporte a Range (essencial para