/** * 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) */ import { createReadStream } from 'fs' import { stat } from 'fs/promises' import { Router, type Request, type Response } from 'express' import type { PrismaClient } from '@prisma/client' import type { Knex } from 'knex' import type { WhatsAppConnectionManager } from '../../../backend/src/modules/whatsapp/connection/WhatsAppConnectionManager' import type { PluginConfigStore } from '../../../backend/src/core/plugin-config' import type { HookBus } from '../../../backend/src/core/hook-bus' import type { Server as SocketServer } from 'socket.io' let dragonfly: any; if (process.env.NODE_ENV === 'production') { dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly; } else { dragonfly = require('../../../backend/src/infra/cache/dragonfly').dragonfly; } import { ProtocolEngine } from '../../secretaria/brain' let sendRichMessage: any; 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(): string { try { return (crypto as any).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: string, extChatId: string, tenantId: string, db: Knex, hooks: HookBus, ): void { 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: string): void { const existing = handoffTimers.get(convId) if (existing) { clearTimeout(existing); handoffTimers.delete(convId) } } export function buildExtRoutes( prisma: PrismaClient, manager: WhatsAppConnectionManager, db: Knex, config: PluginConfigStore, hooks: HookBus, io?: SocketServer, ): Router { const router = Router() // ── Helper: envia reply da IA via WA e persiste no banco principal ───────── async function sendSecretariaReply(opts: { instanceId: string tenantId: string chatId: string jid: string reply: string }): Promise { 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: any) => { const { tenantId, instanceId, chatId, jid, text } = data as { tenantId: string; instanceId: string; chatId: string; jid: string; text: string } 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: 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 ProtocolEngine(db, config) const reply = await brain.chat(conv.id as string, 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: any) => { try { const rawPhone = await (config as any).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: Request, res: Response) => { 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: any) { 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: Request, res: Response) => { const tenantId = req.extTenantId! const { name } = req.body as { name?: string } 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: unknown) => { console.error('[ext-api] Falha ao conectar instância após criação:', err) }) res.status(201).json(instance) } catch (err: any) { 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: Request, res: Response) => { const tenantId = req.extTenantId! const instanceId = req.params['id'] as string 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: any) { 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: Request, res: Response) => { const tenantId = req.extTenantId! const instanceId = req.params['id'] as string 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: any) { 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: Request, res: Response) => { const tenantId = req.extTenantId! const messageId = req.params['messageId'] as string 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 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