"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; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; 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"); const child_process_1 = require("child_process"); const multer_1 = __importDefault(require("multer")); const sharp_1 = __importDefault(require("sharp")); // Upload de mídia (imagem/vídeo/documento/figurinha) em memória. Até 64MB — o proxy // do satélite encaminha o multipart cru (sem base64/limite de JSON). const mediaUpload = (0, multer_1.default)({ storage: multer_1.default.memoryStorage(), limits: { fileSize: 64 * 1024 * 1024 } }); // Converte um áudio (ex.: webm/opus do navegador) para OGG/Opus — formato de nota de // voz do WhatsApp. Usa ffmpeg via stdin/stdout. Se falhar, o chamador usa o original. function convertToOggOpus(input) { return new Promise((resolve, reject) => { const ff = (0, child_process_1.spawn)('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-c:a', 'libopus', '-b:a', '32k', '-ar', '48000', '-ac', '1', '-f', 'ogg', 'pipe:1']); const chunks = []; const errs = []; ff.stdout.on('data', (d) => chunks.push(d)); ff.stderr.on('data', (d) => errs.push(d)); ff.on('error', reject); ff.on('close', (code) => code === 0 && chunks.length ? resolve(Buffer.concat(chunks)) : reject(new Error('ffmpeg: ' + Buffer.concat(errs).toString().slice(0, 200)))); ff.stdin.on('error', () => { }); ff.stdin.write(input); ff.stdin.end(); }); } 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; } // Serializa para storage (Json): Uint8Array/Buffer → { _b64 } (reverso do deserialize // acima). Remove thumbnails. Espelha buildMediaPayload/serializeForStorage do core. const THUMB_KEYS = ['jpegThumbnail', 'thumbnailDirectPath', 'thumbnailSha256', 'thumbnailEncSha256']; function serializeMediaForStorage(val) { if (val instanceof Uint8Array || Buffer.isBuffer(val)) return { _b64: Buffer.from(val).toString('base64') }; if (Array.isArray(val)) return val.map(serializeMediaForStorage); if (val && typeof val === 'object') { const out = {}; for (const [k, v] of Object.entries(val)) { if (THUMB_KEYS.includes(k)) continue; out[k] = serializeMediaForStorage(v); } return out; } return val; } // mediaPayload de uma mensagem ENVIADA (WAMessage retornado por sendMessage) — assim o // re-download do próprio envio funciona (senão /media/:id/download dá 400). function buildSentMediaPayload(sent) { if (!sent?.key || !sent?.message) return null; return { key: { remoteJid: sent.key.remoteJid, fromMe: sent.key.fromMe, id: sent.key.id }, message: serializeMediaForStorage(sent.message), }; } // 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 }); // jid = último segmento do extChatId ("tenantId:jid" ou "tenantId:instanceId:jid") const chatId = extChatId.split(':').pop() ?? 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, agentName } = opts; const sock = manager?.getSocket(instanceId); if (!sock) return; // Assinatura da Secretária: prefixa "*Nome:*\n" (negrito) com o PRIMEIRO nome // do agente (ex.: "Ana — Atendente Virtual" → "Ana"). Identifica a IA na caixa // compartilhada — como a assinatura do operador humano. O ":" fica DENTRO do // negrito p/ todos os dispositivos verem "Ana:" em negrito consistente. const first = (agentName ?? '').trim().split(/[\s—–-]+/).filter(Boolean)[0]; const text = first ? `*${first}:*\n${reply}` : reply; const sent = await sock.sendMessage(jid, { text }).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: text, status: 'SENT', timestamp: new Date(), }, }).catch(() => null); if (persisted && io) { io.to(`chat:${chatId}`).emit('message:new', persisted); } } // ── Helper: ecoa uma mensagem ENVIADA para os operadores em tempo real ───── // O Baileys entrega o próprio envio como history 'append' (que NÃO emite hook), // então um 2º operador do mesmo número não veria o envio ao vivo. Aqui persistimos // (idempotente via @@unique[instanceId,messageId] → o append posterior é deduplicado) // e disparamos ext:message.new (stream do satélite) + message:new (Socket.IO nativo). async function echoSentMessage(opts) { const { instanceId, tenantId, chatId, jid, waMessageId } = opts; try { const persisted = await prisma.message.upsert({ where: { instanceId_messageId: { instanceId, messageId: waMessageId } }, create: { tenantId, instanceId, chatId, remoteJid: jid, messageId: waMessageId, fromMe: true, type: opts.type ?? 'TEXT', body: opts.body ?? null, status: 'SENT', timestamp: new Date(), ...(opts.mediaPayload ? { mediaPayload: opts.mediaPayload } : {}), }, update: {}, // já existe (append/echo anterior) → não sobrescreve }); hooks.emit('ext:message.new', { instanceId, tenantId, chatId, message: { ...persisted, pushName: null, senderJid: null }, timestamp: Date.now(), }).catch(() => { }); if (io) io.to(`chat:${chatId}`).emit('message:new', persisted); } catch { /* best-effort: o append do Baileys ainda persiste a mensagem */ } } // ── Presença do CONTATO (composing/recording) → stream do satélite ──────── // O Baileys só emite presence.update de um contato se assinarmos (presenceSubscribe). // Anexamos o listener UMA vez por socket (WeakSet: se reconectar, o novo socket é // outro objeto → re-anexa). Cada update vira ext:presence → ws-bridge → 'presence'. const presenceAttached = new WeakSet(); function ensurePresenceListener(sock, instanceId, tenantId) { if (!sock || presenceAttached.has(sock)) return; presenceAttached.add(sock); sock.ev.on('presence.update', ({ id, presences }) => { // id = jid do chat; presences = { [jid]: { lastKnownPresence } }. // Individual: 1 entrada (o contato). Grupo: se alguém digita, marca digitando. let state = 'available'; for (const p of Object.values(presences || {})) { const s = p?.lastKnownPresence; if (s === 'composing' || s === 'recording') { state = s; break; } if (s) state = s; } hooks.emit('ext:presence', { tenantId, instanceId, jid: id, state, timestamp: Date.now() }).catch(() => { }); }); } // ── 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; // ── Parte A: é a RESPOSTA do DENTISTA a um pedido de encaixe (SIM/NÃO)? ── // Roda mesmo com auto_reply off (é fluxo de sistema). Se sim, agenda/avisa o // paciente e NÃO segue o fluxo normal da Secretária para esta mensagem. try { const approval = await db('sec_agenda_approvals').where({ dentista_jid: jid, status: 'aguardando' }).orderBy('created_at', 'desc').first(); if (approval) { const low = String(text).toLowerCase().trim(); const disseSim = /(^|\W)(sim|pode|consigo|d[áa]\s*tempo|ok|claro|beleza|confirmo|isso|fechou|manda|pos)/.test(low) || low.includes('👍'); const disseNao = /(^|\W)(n[ãa]o|nao)/.test(low); const sinalOn = async (instId, j) => (await prisma.chat.findFirst({ where: { instanceId: instId, jid: j }, orderBy: { updatedAt: 'desc' }, select: { id: true } }))?.id; const chnl = await db('sec_numbers').where({ instance_id: approval.instance_id }).first(); const ag = chnl?.agent_id ? await db('sec_agents').where({ id: chnl.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first(); const dataStr = approval.data instanceof Date ? approval.data.toISOString().slice(0, 10) : String(approval.data).slice(0, 10); const horaStr = String(approval.hora).slice(0, 5); if (disseSim && !disseNao) { let confirmado = false; try { const r = await fetch(`${String(approval.agenda_url).replace(/\/$/, '')}/book`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': approval.agenda_secret || '' }, signal: AbortSignal.timeout(12000), body: JSON.stringify({ clinica_id: approval.clinica_id, dentista_id: approval.dentista_id, start: `${dataStr} ${horaStr}:00`, paciente_nome: approval.paciente_nome, paciente_celular: approval.paciente_telefone, procedimento: approval.procedimento }), }); confirmado = r.ok; } catch { /* falha de rede */ } await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: confirmado ? 'confirmado' : 'recusado', updated_at: new Date() }); const pmsg = confirmado ? `Oie! O Dr(a) ${String(approval.dentista_nome || '').split(' ')[0]} confirmou 🎉 Agendei você para ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)} às ${horaStr}. Te espero!` : `Oie! Consegui o ok do dentista, mas tive um probleminha pra registrar o horário — já te retorno pra fechar, tá?`; await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: pmsg, agentName: ag?.name }); await sendSecretariaReply({ instanceId, tenantId, chatId: (await sinalOn(instanceId, jid)) ?? jid, jid, reply: 'Perfeito, obrigado! Já confirmei com o paciente. 🙌' }); return; } if (disseNao) { await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: 'recusado', updated_at: new Date() }); await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: 'Oie! Infelizmente não deu pra encaixar nesse horário 😕 Mas posso te oferecer outro — prefere de manhã ou à tarde?', agentName: ag?.name }); return; } return; // resposta do dentista ainda ambígua — aguarda um SIM/NÃO claro } } catch { /* segue o fluxo normal */ } // 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; // Canal deste número (sec_numbers da instância): define o agente/cérebro e a // clínica (escopo da ponte de agenda). É o que separa a Secretária por sessão. const channel = await db('sec_numbers').where({ instance_id: instanceId }).first(); // ext_chat_id = tenant:jid (2 partes) — mantido assim porque as rotas de // handoff reconstroem a chave só com o jid. A separação por sessão vem do // AGENTE resolvido pelo número (channel.agent_id), não da chave da conversa. // (Limitação conhecida: o MESMO contato falando com dois números do mesmo // tenant reusa a 1ª conversa/agente — caso raro, follow-up.) const extKey = `${tenantId}:${jid}`; let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first(); if (!conv) { // Agente do número (channel.agent_id); fallback: primeiro agente ativo (legado). const agent = (channel?.agent_id ? await db('sec_agents').where({ id: channel.agent_id, active: true }).first() : null) ?? 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(() => { }); // Clínica e agenda vêm do NÚMERO que recebeu a mensagem (channel, resolvido acima). const brain = new brain_1.ProtocolEngine(db, config); const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id, instanceId }); await sock?.sendPresenceUpdate('paused', jid).catch(() => { }); const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first(); await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name }); }); // ── Worker: follow-ups proativos da agenda (Parte B) ────────────────────── // A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a // agenda perto do horário desejado e manda ao paciente "Oie, consegui!". setInterval(async () => { let due = []; try { due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10); } catch { return; } for (const fu of due) { try { const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' }); if (!claimed) continue; const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null; const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`); u.searchParams.set('clinica_id', fu.clinica_id); if (dataStr) u.searchParams.set('date', dataStr); if (fu.periodo) u.searchParams.set('periodo', fu.periodo); const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12000) }); const j = r.ok ? await r.json() : {}; const slots = j?.slots ?? []; // slot mais próximo do horário desejado (senão o primeiro). let escolhido = slots[0]; if (fu.hora_desejada && slots.length) { const alvo = String(fu.hora_desejada).slice(0, 5); escolhido = slots.slice().sort((a, b) => Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0]; } const texto = escolhido ? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?` : 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?'; // assina com o agente do número (channel) para manter o padrão. const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first(); const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first(); const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } }); await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name }); await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' }); } catch { await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => { }); } } }, 30000); // ── Parte A: envia a pergunta de encaixe ao DENTISTA (tool perguntar_dentista) ─ hooks.register('ext:agenda.notify-dentist', async (data) => { const { instanceId, tenantId, jid, text } = data || {}; if (!instanceId || !jid || !text) return; try { const chat = await prisma.chat.findFirst({ where: { instanceId, jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } }); await sendSecretariaReply({ instanceId, tenantId, chatId: chat?.id ?? jid, jid, reply: text }); } catch { /* best-effort */ } }); // ── 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