"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); 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 nodePath = __importStar(require("path")); 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; } // Engine (download/getContentType) + StorageProvider (Wasabi) para re-download de // mídia sob demanda. Mesmos módulos que a app principal usa; carregados lazy pelo // mesmo esquema prod(dist)/dev(backend/dist) do sendRichMessage acima. let mediaEngine; let mediaStorage; if (process.env.NODE_ENV === 'production') { mediaEngine = require('../../../dist/modules/whatsapp/engine'); mediaStorage = require('../../../dist/core/StorageProvider').storageProvider; } else { mediaEngine = require('../../../backend/dist/modules/whatsapp/engine'); mediaStorage = require('../../../backend/dist/core/StorageProvider').storageProvider; } const downloadMediaMessage = mediaEngine.downloadMediaMessage; const getContentType = mediaEngine.getContentType; // Extensão/mime por tipo de mensagem (espelha whatsapp.media.routes.ts). const MEDIA_EXT_MAP = { imageMessage: 'jpg', videoMessage: 'mp4', audioMessage: 'ogg', documentMessage: 'bin', stickerMessage: 'webp', }; const MEDIA_MIME_MAP = { imageMessage: 'image/jpeg', videoMessage: 'video/mp4', audioMessage: 'audio/ogg', documentMessage: 'application/octet-stream', stickerMessage: 'image/webp', }; // Payload salvo em mediaPayload guarda buffers como { _b64: base64 } → Buffer. function deserializeMediaPayload(val) { if (Array.isArray(val)) return val.map(deserializeMediaPayload); if (val !== null && typeof val === 'object') { const obj = val; if ('_b64' in obj && typeof obj._b64 === 'string') return Buffer.from(obj._b64, 'base64'); return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializeMediaPayload(v)])); } return val; } // Logger pino-like mínimo exigido pelo downloadMediaMessage do Baileys. const mediaLogger = { level: 'silent', child: () => mediaLogger, trace() { }, debug() { }, info() { }, warn() { }, error() { }, fatal() { }, }; 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; // Só atendimento 1:1 (@s.whatsapp.net): exclui grupos (@g.us), broadcast, // newsletter, lid e status — a Secretária não cria conversa para esses. if (!jid.endsWith('@s.whatsapp.net')) return; // Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o // disparo automático da Secretária (envios manuais seguem normais). Só // bloqueia quando explicitamente false — sem a flag, comportamento inalterado. const secCfg = (await config.get('secretaria')); if (secCfg?.auto_reply === false) 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; // Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid. const contact = await prisma.contact.findFirst({ where: { jid, instanceId }, select: { name: true, verifiedName: true, notify: true }, }); const phone = jid.split('@')[0]; const displayName = contact?.name ?? contact?.verifiedName ?? contact?.notify ?? (phone ? `+${phone}` : jid); const [newConv] = await db('sec_conversations').insert({ id: uuid(), agent_id: agent.id, contact_name: displayName, 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(() => { }); // Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem // (sec_numbers.clinica_id da instância) — escopo da ponte de agenda. const channel = await db('sec_numbers').where({ instance_id: instanceId }).first(); const brain = new brain_1.ProtocolEngine(db, config); const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id }); 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 */ } }); // ── Monitor de cota — provedor LLM sem saldo → notifica o admin ──────────── // Emitido pelo brain quando um provider retorna 429 (cota/crédito esgotado). const PROVIDER_LABEL = { openai: 'OpenAI', gemini: 'Google Gemini', anthropic: 'Anthropic', ollama: 'Ollama (local)', }; hooks.register('ext:provider.exhausted', async (data) => { try { const prov = PROVIDER_LABEL[data.provider] ?? data.provider; const fb = Array.isArray(data.fallbackTo) && data.fallbackTo.length ? data.fallbackTo.map((p) => PROVIDER_LABEL[p] ?? p).join(' → ') : 'nenhum (todos esgotados)'; const when = new Date(data.at ?? Date.now()).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }); const msg = [ '🔴 *Secretária IA — provedor sem saldo*', '', `O provedor *${prov}*${data.model ? ` (${data.model})` : ''} atingiu o limite de *cota/crédito*.`, '▫️ Status: cota/billing esgotado (429)', `▫️ Fallback em uso: ${fb}`, '', '⚠️ *Ação necessária:* recarregue o crédito da conta do provedor para manter o atendimento automático 24h.', '', `🕒 ${when}`, '_NewWhats · monitor de provedores de IA_', ].join('\n'); console.warn(`[secretaria] provider sem saldo: ${prov} (${data.model ?? '—'}) — fallback: ${fb}`); const secCfg = (await config.get('secretaria')); const adminPhone = typeof secCfg?.admin_notify_phone === 'string' ? secCfg.admin_notify_phone.trim() : ''; if (!adminPhone) return; 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; // Resolve o JID canônico via onWhatsApp — trata o 9º dígito (BR) e valida // se o número está no WhatsApp; sem isso, a concatenação crua pode não // corresponder ao JID real (mensagem "enviada" mas não entregue). const cand = (() => { const d = adminPhone.replace(/\D/g, ''); return d.startsWith('55') ? d : '55' + d; })(); let jid = `${cand}@s.whatsapp.net`; try { const found = await sock.onWhatsApp(cand); if (found?.[0]?.exists && found[0]?.jid) jid = found[0].jid; else { console.warn(`[secretaria] admin ${cand} não está no WhatsApp — notificação não enviada`); return; } } catch { /* fallback: usa o jid montado */ } await sock.sendMessage(jid, { text: msg }); } catch { /* 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' }); } }); // ── POST /sessions/:id/connect ──────────────────────────────────────────── // (Re)inicia a conexão de uma instância JÁ existente — gera novo QR se preciso. // O POST /sessions já conecta na criação; este endpoint cobre reconectar uma // sessão que caiu/desconectou (o "re-scan" do modelo de ownership do satélite). router.post('/sessions/:id/connect', 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; } manager.connect(instanceId, tenantId).catch((err) => { console.error('[ext-api] Falha ao reconectar instância:', err); }); res.json({ message: 'Conexão iniciada', instanceId }); } catch (err) { res.status(500).json({ error: err.message ?? 'Erro interno' }); } }); // ── POST /sessions/:id/disconnect ───────────────────────────────────────── // Encerra a sessão do WhatsApp SEM remover a instância (permite reconectar). router.post('/sessions/:id/disconnect', 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; } await manager.disconnect(instanceId).catch(() => { }); res.json({ message: 'Sessão encerrada', instanceId }); } 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