Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/ext-api/backend/routes.ts
T
VPS 4 Deploy Agent 6d9850d8d3 feat(secretaria): monitor de cota dos providers + notificação ao admin
- brain: detecta 429/cota (quota/billing/insufficient/exceeded/limite da api/
  rate limit — inclui a msg PT do Gemini) e emite ext:provider.exhausted por
  provider da chain (throttle de 6h por provider), com fallback informado.
- routes: handler ext:provider.exhausted monta notificação profissional e envia
  por WhatsApp ao admin (admin_notify_phone da config secretaria), resolvendo o
  JID canônico via onWhatsApp (trata 9º dígito BR; pula se número não existe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 07:49:00 +02:00

1621 lines
73 KiB
TypeScript

/**
* 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<string, ReturnType<typeof setTimeout>>()
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<void> {
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
// 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')) as any
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: 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 ProtocolEngine(db, config)
const reply = await brain.chat(conv.id as string, 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: 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 */ }
})
// ── 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: Record<string, string> = {
openai: 'OpenAI', gemini: 'Google Gemini', anthropic: 'Anthropic', ollama: 'Ollama (local)',
}
hooks.register('ext:provider.exhausted', async (data: any) => {
try {
const prov = PROVIDER_LABEL[data.provider] ?? data.provider
const fb = Array.isArray(data.fallbackTo) && data.fallbackTo.length
? data.fallbackTo.map((p: string) => 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')) as any
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: 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 <audio> e <video> no browser)
const rangeHeader = req.headers['range']
if (rangeHeader) {
const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-')
const start = parseInt(startStr, 10)
const end = endStr ? parseInt(endStr, 10) : size - 1
const chunkSize = end - start + 1
res.status(206)
res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`)
res.setHeader('Content-Length', chunkSize)
createReadStream(filePath, { start, end }).pipe(res)
} else {
res.setHeader('Content-Length', size)
createReadStream(filePath).pipe(res)
}
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── GET /inbox ────────────────────────────────────────────────────────────
// Lista chats da instância com snapshot da última mensagem.
router.get('/inbox', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const sessionId = req.query['session'] as string | undefined
const search = req.query['search'] as string | undefined
const limit = Math.min(parseInt(String(req.query['limit'] || '50')), 200)
if (!sessionId) {
res.status(400).json({ error: 'Query param "session" (instanceId) obrigatório' })
return
}
try {
// Valida que a instância pertence ao tenant
const instance = await prisma.instance.findFirst({
where: { id: sessionId, tenantId },
select: { id: true },
})
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' })
return
}
const chats = await prisma.chat.findMany({
where: {
tenantId,
instanceId: sessionId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { startsWith: '0@' } },
],
...(search
? { OR: [
{ name: { contains: search, mode: 'insensitive' } },
{ jid: { contains: search } },
] }
: {}),
},
// nulls: 'last' — sem isto o Postgres ordena NULLS FIRST em DESC, e os
// chats-stub sem mensagem (lastMessageAt null) tomam o topo do inbox.
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: limit,
select: {
id: true,
jid: true,
name: true,
unreadCount: true,
lastMessageAt: true,
isPinned: true,
isArchived: true,
contact: {
select: {
name: true,
verifiedName: true,
notify: true,
avatarUrl: true,
phone: true,
},
},
messages: {
orderBy: { timestamp: 'desc' },
take: 1,
select: { body: true, type: true, fromMe: true, timestamp: true, status: true },
},
},
})
// Resolve nome de exibição: Contact.name > Contact.verifiedName > Contact.notify > Chat.name
const result = chats.map((c) => {
const ct = c.contact
const displayName = ct?.name ?? ct?.verifiedName ?? ct?.notify ?? c.name ?? null
const lastMsg = c.messages[0] ?? null
return {
id: c.id,
jid: c.jid,
displayName,
avatar: ct?.avatarUrl ?? null,
phone: ct?.phone ?? c.jid.split('@')[0],
unreadCount: c.unreadCount,
lastMessageAt: c.lastMessageAt,
isPinned: c.isPinned,
isArchived: c.isArchived,
lastMessage: lastMsg
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
: null,
}
})
res.json(result)
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── GET /contacts/:jid/avatar/refresh ─────────────────────────────────────
// Self-heal de avatar: a URL do CDN do WhatsApp (pps.whatsapp.net) expira
// (param oe=) e passa a 404. Aqui re-buscamos a foto ATUAL pela conexão
// Baileys viva, atualizamos o Contact.avatarUrl e devolvemos a URL nova.
// Chamado pelo componente Avatar do satélite no onError da imagem.
router.get('/contacts/:jid/avatar/refresh', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const sessionId = req.query['session'] as string | undefined
const jid = decodeURIComponent(req.params['jid'] ?? '')
if (!sessionId || !jid) {
res.status(400).json({ error: 'Query "session" e param "jid" obrigatórios' })
return
}
const instance = await prisma.instance.findFirst({
where: { id: sessionId, tenantId },
select: { id: true },
})
if (!instance) {
res.status(404).json({ error: 'Instância não encontrada' })
return
}
const sock = manager?.getSocket(sessionId)
if (!sock) {
res.status(503).json({ error: 'Sessão não conectada' })
return
}
try {
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
// sem resposta do WhatsApp). Corremos contra um timeout para responder
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
const url = await Promise.race([
sock.profilePictureUrl(jid, 'image').catch(() => null),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
])
if (!url) {
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
await prisma.contact.updateMany({
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
data: { avatarUrl: null },
})
res.status(404).json({ avatar: null })
return
}
await prisma.contact.updateMany({
where: { tenantId, instanceId: sessionId, jid },
data: { avatarUrl: url },
})
res.json({ avatar: url })
} catch (err: any) {
res.status(502).json({ error: err.message ?? 'Falha ao buscar avatar' })
}
})
// ── GET /inbox/:chatId/messages ───────────────────────────────────────────
// Histórico paginado (cursor: before=<timestamp ISO>).
router.get('/inbox/:chatId/messages', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = req.params['chatId'] as string
const limit = Math.min(parseInt(String(req.query['limit'] || '50')), 200)
const before = req.query['before'] as string | undefined
try {
const chat = await prisma.chat.findFirst({
where: { id: chatId, tenantId },
select: { id: true },
})
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' })
return
}
const messages = await prisma.message.findMany({
where: {
chatId,
tenantId,
...(before ? { timestamp: { lt: new Date(before) } } : {}),
},
orderBy: { timestamp: 'desc' },
take: limit,
select: {
id: true,
messageId: true,
fromMe: true,
type: true,
body: true,
caption: true,
mediaUrl: true,
mimeType: true,
fileName: true,
status: true,
timestamp: true,
pushName: true,
senderJid: true,
},
})
res.json(messages.reverse())
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── POST /inbox/:chatId/read ──────────────────────────────────────────────
// Zera o contador de mensagens não lidas do chat.
router.post('/inbox/:chatId/read', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = req.params['chatId'] as string
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } })
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
await prisma.chat.update({ where: { id: chatId }, data: { unreadCount: 0 } })
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── PATCH /inbox/:chatId/archive ──────────────────────────────────────────
// Arquiva ou desarquiva um chat.
// Body: { archived: boolean }
router.patch('/inbox/:chatId/archive', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = req.params['chatId'] as string
const { archived } = req.body as { archived?: boolean }
if (typeof archived !== 'boolean') {
res.status(400).json({ error: 'Campo "archived" (boolean) obrigatório' }); return
}
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } })
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
await prisma.chat.update({ where: { id: chatId }, data: { isArchived: archived } })
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── PATCH /inbox/:chatId/pin ──────────────────────────────────────────────
// Fixa ou desfixa um chat.
// Body: { pinned: boolean }
router.patch('/inbox/:chatId/pin', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = req.params['chatId'] as string
const { pinned } = req.body as { pinned?: boolean }
if (typeof pinned !== 'boolean') {
res.status(400).json({ error: 'Campo "pinned" (boolean) obrigatório' }); return
}
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } })
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
await prisma.chat.update({ where: { id: chatId }, data: { isPinned: pinned } })
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── DELETE /inbox/:chatId ─────────────────────────────────────────────────
// Remove o chat e todas as mensagens associadas.
router.delete('/inbox/:chatId', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = req.params['chatId'] as string
try {
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } })
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
await prisma.chat.delete({ where: { id: chatId } })
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
// Envia mensagem de texto ou rica para o chat.
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
router.post('/inbox/:chatId/send', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = req.params['chatId'] as string
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = req.body as {
text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any;
nativeCarousel?: any; poll?: any;
}
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll)
if (!text && !hasRich) {
res.status(400).json({ error: 'Campo "text" ou conteúdo rico obrigatório' })
return
}
try {
const chat = await prisma.chat.findFirst({
where: { id: chatId, tenantId },
select: { id: true, jid: true, instanceId: true },
})
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' })
return
}
const sock = manager.getSocket(chat.instanceId)
if (!sock) {
res.status(400).json({ error: 'Instância não conectada' })
return
}
// Usa o rich-message builder para converter nativeButtons/nativeList/
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
// O footer legado (aninhado em nativeList) é extraído para o top-level.
const resolvedFooter = footer || (nativeList as any)?.footer || undefined
const cleanList = nativeList
? { buttonText: nativeList.buttonText, sections: nativeList.sections }
: undefined
await sendRichMessage(sock as any, chat.jid, {
text: text?.trim() || undefined,
footer: resolvedFooter,
nativeButtons: nativeButtons as any,
nativeList: cleanList as any,
nativeCarousel: nativeCarousel as any,
poll: poll as any,
})
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro ao enviar' })
}
})
// ── POST /conversations ───────────────────────────────────────────────────
// Inicia uma nova conversa enviando a primeira mensagem para um número ainda
// sem chat existente. Body: { sessionId, phone, text }
// O phone deve estar no formato brasileiro: DDD+número (10-11 dígitos) ou
// já com código do país (55 + DDD + número = 12-13 dígitos).
router.post('/conversations', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { sessionId, phone, text } = req.body as { sessionId?: string; phone?: string; text?: string }
if (!sessionId || !phone || !text?.trim()) {
res.status(400).json({ error: 'sessionId, phone e text são obrigatórios' })
return
}
// Normaliza para JID: remove não-dígitos e garante código do Brasil
const digits = phone.replace(/\D/g, '')
const normalized = (digits.startsWith('55') && (digits.length === 12 || digits.length === 13))
? digits
: (digits.length === 10 || digits.length === 11) ? '55' + digits : digits
if (normalized.length < 10) {
res.status(400).json({ error: 'Número inválido — informe DDD + número (ex: 67999138794)' })
return
}
const jid = `${normalized}@s.whatsapp.net`
try {
const instance = await prisma.instance.findFirst({
where: { id: sessionId, tenantId },
select: { id: true },
})
if (!instance) {
res.status(404).json({ error: 'Sessão não encontrada' })
return
}
const sock = manager.getSocket(sessionId)
if (!sock) {
res.status(400).json({ error: 'Sessão não conectada' })
return
}
await sock.sendMessage(jid, { text: text.trim() })
res.status(201).json({ ok: true, jid })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro ao enviar' })
}
})
// ── GET /webhooks ─────────────────────────────────────────────────────────
// Lista os webhooks registrados pelo tenant.
router.get('/webhooks', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
try {
const hooks = await prisma.extWebhook.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
select: { id: true, url: true, events: true, active: true, createdAt: true },
})
res.json(hooks)
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── POST /webhooks ────────────────────────────────────────────────────────
// Registra um novo webhook.
// Body: { url: string, events: string[], secret: string }
router.post('/webhooks', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { url, events, secret } = req.body as { url?: string; events?: string[]; secret?: string }
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
res.status(400).json({ error: 'Campo "url" obrigatório e deve ser HTTP/HTTPS' }); return
}
if (!Array.isArray(events) || events.length === 0) {
res.status(400).json({ error: 'Campo "events" obrigatório (array não vazio)' }); return
}
if (!secret || typeof secret !== 'string' || secret.length < 16) {
res.status(400).json({ error: 'Campo "secret" obrigatório (mínimo 16 caracteres)' }); return
}
const VALID_EVENTS = ['message.new', 'session.status']
const invalid = events.filter(e => !VALID_EVENTS.includes(e))
if (invalid.length > 0) {
res.status(400).json({ error: `Eventos inválidos: ${invalid.join(', ')}. Válidos: ${VALID_EVENTS.join(', ')}` }); return
}
try {
const hook = await prisma.extWebhook.create({
data: { tenantId, url, events, secret, active: true },
select: { id: true, url: true, events: true, active: true, createdAt: true },
})
res.status(201).json(hook)
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── DELETE /webhooks/:id ──────────────────────────────────────────────────
// Remove um webhook do tenant.
router.delete('/webhooks/:id', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
try {
const deleted = await prisma.extWebhook.deleteMany({ where: { id, tenantId } })
if (deleted.count === 0) { res.status(404).json({ error: 'Webhook não encontrado' }); return }
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── PATCH /webhooks/:id ───────────────────────────────────────────────────
// Ativa / desativa um webhook sem removê-lo.
// Body: { active: boolean }
router.patch('/webhooks/:id', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
const { active } = req.body as { active?: boolean }
if (typeof active !== 'boolean') {
res.status(400).json({ error: 'Campo "active" (boolean) obrigatório' }); return
}
try {
const hook = await prisma.extWebhook.findFirst({ where: { id, tenantId }, select: { id: true } })
if (!hook) { res.status(404).json({ error: 'Webhook não encontrado' }); return }
const updated = await prisma.extWebhook.update({
where: { id },
data: { active },
select: { id: true, url: true, events: true, active: true, createdAt: true },
})
res.json(updated)
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── POST /secretaria/ask ──────────────────────────────────────────────────
// Envia uma mensagem ao cérebro da Secretária IA em nome de um contato WA.
// O contexto é persistido por (tenantId, chatId) — cada conversa do WhatsApp
// tem seu próprio estado no ProtocolEngine.
// Body: { chatId, message, contactName?, agentId?, context?, systemExtra?, tools? }
router.post('/secretaria/ask', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { chatId, message, contactName, agentId, context: contextData, systemExtra, tools } = req.body as {
chatId?: string
message?: string
contactName?: string
agentId?: string
context?: Record<string, unknown>
systemExtra?: string
tools?: string[]
}
if (!chatId || !message?.trim()) {
res.status(400).json({ error: 'chatId e message são obrigatórios' })
return
}
try {
// SEC-08: valida que existe instância conectada para este tenant
const connectedInstance = await prisma.instance.findFirst({
where: { tenantId, status: 'CONNECTED' },
select: { id: true },
})
if (!connectedInstance) {
res.status(503).json({ error: 'Nenhuma instância WhatsApp conectada para este tenant.' })
return
}
const extKey = `${tenantId}:${chatId}`
let conv = await db('sec_conversations')
.where({ ext_chat_id: extKey, status: 'active' })
.first()
if (!conv) {
let resolvedAgentId = agentId
if (!resolvedAgentId) {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
if (!agent) {
res.status(503).json({ error: 'Nenhum agente ativo na Secretária IA. Configure em Admin → Secretária.' })
return
}
resolvedAgentId = agent.id
}
const [newConv] = await db('sec_conversations').insert({
id: uuid(),
agent_id: resolvedAgentId,
contact_name: contactName ?? chatId,
protocol_number: ProtocolEngine.generateProtocolNumber(),
status: 'active',
ext_chat_id: extKey,
handoff_mode: 'ia',
}).returning('*')
conv = newConv
} else if (contactName && conv.contact_name !== contactName) {
await db('sec_conversations').where({ id: conv.id }).update({ contact_name: contactName })
conv.contact_name = contactName
}
// Handoff: se humano está respondendo, não chama IA
if (conv.handoff_mode === 'humano') {
res.json({ skipped: true, reason: 'humano', conversationId: conv.id, protocolNumber: conv.protocol_number })
return
}
const brain = new ProtocolEngine(db, config)
const reply = await brain.chat(conv.id as string, message.trim(), {
contextData,
systemExtra,
tools: Array.isArray(tools) && tools.length ? tools : undefined,
hooks,
tenantId,
})
// SEC-01+SEC-11: envia reply via WA e persiste no banco principal
const chat = await prisma.chat.findFirst({
where: { tenantId, jid: chatId },
orderBy: { updatedAt: 'desc' },
select: { id: true, instanceId: true },
})
if (chat) {
await sendSecretariaReply({
instanceId: chat.instanceId,
tenantId,
chatId: chat.id,
jid: chatId,
reply,
})
}
res.json({
reply,
conversationId: conv.id,
protocolNumber: conv.protocol_number,
})
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
// ── GET /secretaria/numbers ───────────────────────────────────────────────
// Alias do contrato com o satélite (webhook-receiver chama
// /api/ext/v1/secretaria/numbers). Espelha a rota interna /api/secretaria/numbers.
router.get('/secretaria/numbers', async (_req: Request, res: Response) => {
try {
const numbers = await db('sec_numbers').orderBy('priority').orderBy('label')
res.json(numbers)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ═══════════════════════════════════════════════════════════════════════════
// Secretária — API REST de administração (/secretaria/*) do painel do satélite.
// As tabelas sec_* são GLOBAIS no motor (single-tenant); estes endpoints
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
// ═══════════════════════════════════════════════════════════════════════════
// ── Agents ──────────────────────────────────────────────────────────────────
router.get('/secretaria/agents', async (_req: Request, res: Response) => {
try { res.json(await db('sec_agents').orderBy('created_at')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/agents', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [a] = await db('sec_agents').insert({
id: uuid(), name: b.name ?? 'Agente', description: b.description ?? null,
model: b.model ?? 'gemini-2.0-flash', provider: b.provider ?? 'gemini',
temperature: b.temperature ?? 0.7, context_window: b.context_window ?? 10, active: true,
}).returning('*')
res.status(201).json(a)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/agents/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['name', 'description', 'model', 'provider', 'temperature', 'context_window', 'active']) if (k in b) patch[k] = b[k]
const [a] = await db('sec_agents').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(a)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/agents/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id']
await db('sec_brain_nodes').where({ agent_id: id }).delete()
await db('sec_agents').where({ id }).delete()
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Brain nodes (por agente) ──────────────────────────────────────────────
router.get('/secretaria/agents/:agentId/nodes', async (req: Request, res: Response) => {
try { res.json(await db('sec_brain_nodes').where({ agent_id: req.params['agentId'] }).orderBy('sort_order')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/agents/:agentId/nodes', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [n] = await db('sec_brain_nodes').insert({
id: uuid(), agent_id: req.params['agentId'], type: b.type, title: b.title ?? '', content: b.content ?? '',
node_model: b.node_model ?? null, active: true, sort_order: b.sort_order ?? 99,
}).returning('*')
res.status(201).json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/nodes/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['type', 'title', 'content', 'node_model', 'active', 'sort_order']) if (k in b) patch[k] = b[k]
const [n] = await db('sec_brain_nodes').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/nodes/:id', async (req: Request, res: Response) => {
try { await db('sec_brain_nodes').where({ id: req.params['id'] }).delete(); res.json({ ok: true }) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Calendar ──────────────────────────────────────────────────────────────
router.get('/secretaria/calendar', async (_req: Request, res: Response) => {
try { res.json(await db('sec_calendar').orderBy('date').orderBy('time_start')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/calendar', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [s] = await db('sec_calendar').insert({
id: uuid(), title: b.title ?? null, date: b.date, time_start: b.time_start, time_end: b.time_end,
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
status: b.status ?? 'available', notes: b.notes ?? null,
}).returning('*')
res.status(201).json(s)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/calendar/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['title', 'date', 'time_start', 'time_end', 'attendee_name', 'attendee_phone', 'status', 'notes']) if (k in b) patch[k] = b[k]
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(s)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/calendar/:id', async (req: Request, res: Response) => {
try { await db('sec_calendar').where({ id: req.params['id'] }).delete(); res.json({ ok: true }) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Numbers (GET acima) — mutações ─────────────────────────────────────────
router.post('/secretaria/numbers', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [n] = await db('sec_numbers').insert({
id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
}).returning('*')
res.status(201).json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['instance_id', 'clinica_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes']) if (k in b) patch[k] = b[k]
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(n)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/numbers/:id', async (req: Request, res: Response) => {
try { await db('sec_numbers').where({ id: req.params['id'] }).delete(); res.json({ ok: true }) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── Conversations (teste do painel) ────────────────────────────────────────
router.get('/secretaria/conversations', async (req: Request, res: Response) => {
try {
let q = db('sec_conversations')
.select('id', 'agent_id', 'protocol_number', 'contact_name', 'status', 'handoff_mode', 'summary', 'created_at', 'updated_at')
.orderBy('updated_at', 'desc').limit(100)
const agentId = req.query['agent_id'] as string | undefined
if (agentId) q = q.where({ agent_id: agentId })
res.json(await q)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/conversations', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}
const [c] = await db('sec_conversations').insert({
id: uuid(), agent_id: b.agent_id, contact_name: b.contact_name ?? 'Teste',
protocol_number: ProtocolEngine.generateProtocolNumber(), status: 'active', handoff_mode: 'ia',
}).returning('*')
res.status(201).json(c)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.patch('/secretaria/conversations/:id', async (req: Request, res: Response) => {
try {
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
for (const k of ['status', 'contact_name', 'handoff_mode', 'summary']) if (k in b) patch[k] = b[k]
const [c] = await db('sec_conversations').where({ id: req.params['id'] }).update(patch).returning('*')
res.json(c)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.delete('/secretaria/conversations/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id']
await db('sec_messages').where({ conversation_id: id }).delete()
await db('sec_conversations').where({ id }).delete()
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.get('/secretaria/conversations/:id/messages', async (req: Request, res: Response) => {
try { res.json(await db('sec_messages').where({ conversation_id: req.params['id'] }).orderBy('created_at')) }
catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/conversations/:id/chat', async (req: Request, res: Response) => {
try {
const message = String((req.body ?? {}).message ?? '').trim()
if (!message) { res.status(400).json({ error: 'message obrigatório' }); return }
// clínica por-requisição: workspace ativo do satélite (header x-nw-clinica).
const clinicaId = (req.headers['x-nw-clinica'] as string | undefined)?.trim() || undefined
const brain = new ProtocolEngine(db, config)
const reply = await brain.chat(req.params['id']!, message, { tenantId: req.extTenantId, hooks, clinicaId })
res.json({ reply })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
router.post('/secretaria/conversations/:id/finalize', async (req: Request, res: Response) => {
try {
const brain = new ProtocolEngine(db, config)
res.json(await brain.finalizeProtocol(req.params['id']!))
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── GET /sec/handoff/:chatId ──────────────────────────────────────────────
// Retorna o modo de handoff atual para um chat específico.
router.get('/sec/handoff/:chatId', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = decodeURIComponent(req.params['chatId'] as string)
try {
const extKey = `${tenantId}:${chatId}`
const conv = await db('sec_conversations')
.where({ ext_chat_id: extKey, status: 'active' })
.select('id', 'handoff_mode', 'handoff_human_at')
.first()
res.json({ mode: conv?.handoff_mode ?? 'ia', conversationId: conv?.id ?? null, handoffHumanAt: conv?.handoff_human_at ?? null })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── PATCH /sec/handoff/:chatId ────────────────────────────────────────────
// Alterna entre modo 'ia' e 'humano' para um chat específico.
// Body: { mode: 'ia' | 'humano' }
router.patch('/sec/handoff/:chatId', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const chatId = decodeURIComponent(req.params['chatId'] as string)
const { mode } = req.body as { mode?: 'ia' | 'humano' }
if (mode !== 'ia' && mode !== 'humano') {
res.status(400).json({ error: 'mode deve ser "ia" ou "humano"' }); return
}
try {
const extKey = `${tenantId}:${chatId}`
const conv = await db('sec_conversations')
.where({ ext_chat_id: extKey, status: 'active' })
.first()
if (!conv) {
// Sem conversa ativa — retorna o modo padrão sem erro
res.json({ mode: 'ia', conversationId: null }); return
}
const updateData: Record<string, any> = { handoff_mode: mode }
if (mode === 'humano') {
updateData.handoff_human_at = new Date()
scheduleHandoffTimeout(conv.id as string, extKey, tenantId, db, hooks)
} else {
updateData.handoff_human_at = null
cancelHandoffTimeout(conv.id as string)
}
await db('sec_conversations').where({ id: conv.id }).update(updateData)
res.json({ mode, conversationId: conv.id })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /sec/agent ────────────────────────────────────────────────────────
// Retorna o agente primário (primeiro criado / ativo).
router.get('/sec/agent', async (_req: Request, res: Response) => {
try {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
res.json(agent ?? null)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── POST /sec/agent ───────────────────────────────────────────────────────
router.post('/sec/agent', async (req: Request, res: Response) => {
try {
const { name, model, provider, temperature, context_window } = req.body
const [agent] = await db('sec_agents').insert({
id: uuid(), name, model: model ?? 'gpt-4o-mini',
provider: provider ?? 'openai', temperature: temperature ?? 0.7,
context_window: context_window ?? 10, active: true,
}).returning('*')
res.status(201).json(agent)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── PUT /sec/agent/:id ────────────────────────────────────────────────────
router.put('/sec/agent/:id', async (req: Request, res: Response) => {
try {
const { name, model, provider, temperature, context_window } = req.body
const [agent] = await db('sec_agents').where({ id: req.params['id'] })
.update({ name, model, provider, temperature, context_window, updated_at: new Date() })
.returning('*')
res.json(agent)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /sec/nodes ────────────────────────────────────────────────────────
// Retorna todos os brain nodes do agente primário.
router.get('/sec/nodes', async (_req: Request, res: Response) => {
try {
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
if (!agent) { res.json([]); return }
const nodes = await db('sec_brain_nodes').where({ agent_id: agent.id }).orderBy('sort_order')
res.json(nodes)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── POST /sec/nodes ───────────────────────────────────────────────────────
router.post('/sec/nodes', async (req: Request, res: Response) => {
try {
const { type, title, content } = req.body
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
if (!agent) { res.status(503).json({ error: 'Nenhum agente ativo' }); return }
const [node] = await db('sec_brain_nodes').insert({
id: uuid(), agent_id: agent.id, type, title: title ?? '', content: content ?? '', active: true, sort_order: 99,
}).returning('*')
res.status(201).json(node)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── PUT /sec/nodes/:id ────────────────────────────────────────────────────
router.put('/sec/nodes/:id', async (req: Request, res: Response) => {
try {
const { type, title, content } = req.body
const [node] = await db('sec_brain_nodes').where({ id: req.params['id'] })
.update({ type, title: title ?? '', content, updated_at: new Date() }).returning('*')
res.json(node)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── DELETE /sec/nodes/:id ─────────────────────────────────────────────────
router.delete('/sec/nodes/:id', async (req: Request, res: Response) => {
try {
await db('sec_brain_nodes').where({ id: req.params['id'] }).delete()
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /sec/slots ────────────────────────────────────────────────────────
router.get('/sec/slots', async (_req: Request, res: Response) => {
try {
const slots = await db('sec_calendar').orderBy('date').orderBy('time_start')
res.json(slots)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── POST /sec/slots ───────────────────────────────────────────────────────
router.post('/sec/slots', async (req: Request, res: Response) => {
try {
const { date, time_start, time_end, attendee, notes } = req.body
const [slot] = await db('sec_calendar').insert({
id: uuid(), date, time_start, time_end,
attendee_name: attendee ?? null, notes: notes ?? null, status: 'available',
}).returning('*')
res.status(201).json(slot)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── DELETE /sec/slots/:id ─────────────────────────────────────────────────
router.delete('/sec/slots/:id', async (req: Request, res: Response) => {
try {
await db('sec_calendar').where({ id: req.params['id'] }).delete()
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /sec/conversations ────────────────────────────────────────────────
router.get('/sec/conversations', async (_req: Request, res: Response) => {
try {
const convs = await db('sec_conversations')
.select('id','protocol_number','contact_name','status','handoff_mode','summary','created_at','updated_at')
.orderBy('updated_at', 'desc').limit(100)
res.json(convs)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /sec/conversations/:id/messages ───────────────────────────────────
router.get('/sec/conversations/:id/messages', async (req: Request, res: Response) => {
try {
const msgs = await db('sec_messages')
.where({ conversation_id: req.params['id'] })
.orderBy('created_at')
res.json(msgs)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── POST /sec/conversations/:id/finalize ──────────────────────────────────
router.post('/sec/conversations/:id/finalize', async (req: Request, res: Response) => {
try {
const brain = new ProtocolEngine(db, config)
const result = await brain.finalizeProtocol(req.params['id'])
res.json(result)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /sec/keys ─────────────────────────────────────────────────────────
// Retorna as API keys — valores reais mascarados (primeiros chars + ****)
// admin_notify_phone é retornado sem máscara (não é segredo)
router.get('/sec/keys', async (_req: Request, res: Response) => {
try {
// As chaves vivem DENTRO da config do plugin 'secretaria' (é o que o
// brain lê via config.get('secretaria')). config é por-plugin, não por-chave.
const cfg = ((await config.get('secretaria')) ?? {}) as Record<string, any>
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url']
const result: Record<string, string> = {}
for (const k of secretKeys) {
const raw = typeof cfg[k] === 'string' ? cfg[k] as string : ''
result[k] = raw.length > 0
? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****')
: ''
}
// Número de notificação: retorna em claro
result['admin_notify_phone'] = typeof cfg['admin_notify_phone'] === 'string' ? cfg['admin_notify_phone'] : ''
res.json(result)
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── PUT /sec/keys ─────────────────────────────────────────────────────────
// Só persiste keys que não são máscara (não terminam em ****)
router.put('/sec/keys', async (req: Request, res: Response) => {
try {
// Lê a config atual do plugin 'secretaria', mescla as chaves novas e
// regrava o objeto INTEIRO (config.set(pluginName, obj)) — antes gravava
// cada chave como se fosse um plugin próprio, e o brain nunca as via.
const cfg = { ...(((await config.get('secretaria')) ?? {}) as Record<string, any>) }
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url']
for (const k of secretKeys) {
const v = req.body[k]
if (typeof v === 'string' && v.length > 0 && !v.endsWith('****')) {
cfg[k] = v
}
}
// admin_notify_phone: sempre persiste (pode ser string vazia para limpar)
if (typeof req.body['admin_notify_phone'] === 'string') {
cfg['admin_notify_phone'] = req.body['admin_notify_phone'].trim()
}
await config.set('secretaria', cfg)
res.json({ ok: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// ── GET /templates ────────────────────────────────────────────────────────
router.get('/templates', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { type, search } = req.query as { type?: string; search?: string }
try {
const templates = await prisma.messageTemplate.findMany({
where: {
tenantId,
...(type && type !== 'ALL' ? { type: type as any } : {}),
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
},
orderBy: { createdAt: 'desc' },
})
res.json(templates)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── POST /templates ───────────────────────────────────────────────────────
router.post('/templates', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { name, type, payload, tags, description } = req.body
if (!name?.trim() || !type || !payload) {
res.status(400).json({ error: 'name, type e payload são obrigatórios' }); return
}
try {
const tmpl = await prisma.messageTemplate.create({
data: { tenantId, name: name.trim(), type, payload: payload as any, tags: tags ?? [], description: description ?? null },
})
res.status(201).json(tmpl)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── DELETE /templates/:id ─────────────────────────────────────────────────
router.delete('/templates/:id', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
try {
const existing = await prisma.messageTemplate.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.delete({ where: { id } })
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── POST /templates/:id/use ───────────────────────────────────────────────
router.post('/templates/:id/use', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
try {
const existing = await prisma.messageTemplate.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.update({ where: { id }, data: { usageCount: { increment: 1 } } })
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── GET /scheduled ────────────────────────────────────────────────────────
router.get('/scheduled', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
try {
const items = await prisma.scheduledMessage.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
})
res.json(items)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── POST /scheduled ───────────────────────────────────────────────────────
router.post('/scheduled', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { instanceId, name, payload, recipients, scheduleType, cronExpr, scheduledAt, eventType, timezone } = req.body
if (!instanceId || !name?.trim() || !payload || !Array.isArray(recipients) || !scheduleType) {
res.status(400).json({ error: 'instanceId, name, payload, recipients e scheduleType são obrigatórios' }); return
}
try {
const nextRunAt = scheduleType === 'ONCE' && scheduledAt ? new Date(scheduledAt) : null
const item = await prisma.scheduledMessage.create({
data: {
tenantId, instanceId, name: name.trim(),
payload: payload as any, recipients: recipients as any,
scheduleType, cronExpr: cronExpr ?? null,
scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
eventType: eventType ?? null,
timezone: timezone ?? 'America/Sao_Paulo',
nextRunAt,
status: 'ACTIVE',
},
})
res.status(201).json(item)
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── DELETE /scheduled/:id ─────────────────────────────────────────────────
router.delete('/scheduled/:id', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
try {
const existing = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.delete({ where: { id } })
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── POST /scheduled/:id/pause ─────────────────────────────────────────────
router.post('/scheduled/:id/pause', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
try {
const existing = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } })
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── POST /scheduled/:id/resume ────────────────────────────────────────────
router.post('/scheduled/:id/resume', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const id = req.params['id'] as string
try {
const existing = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } })
res.json({ ok: true })
} catch (err: any) { res.status(500).json({ error: err.message }) }
})
// ── PATCH /protocol/assign-agent ─────────────────────────────────────────
// Atribui um agente externo (account do sistema-nuvem) ao protocolo ativo do chat.
// Body: { chatId: string, agentId: string, agentName?: string }
// Chamado pelo sistema-nuvem quando um pedido avança de etapa e um colaborador é atribuído.
router.patch('/protocol/assign-agent', async (req: Request, res: Response) => {
const tenantId = req.extTenantId!
const { chatId, agentId, agentName } = req.body as {
chatId?: string
agentId?: string
agentName?: string
}
if (!chatId || !agentId) {
res.status(400).json({ error: 'chatId e agentId são obrigatórios' })
return
}
try {
// Busca o protocolo ativo mais recente para o chat
const protocol = await prisma.protocol.findFirst({
where: {
tenantId,
chatId,
status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] },
},
orderBy: { createdAt: 'desc' },
select: { id: true },
})
if (!protocol) {
// Sem protocolo ativo — não é erro, pedido pode não ter chat vinculado
res.json({ ok: true, updated: false, reason: 'no_active_protocol' })
return
}
await prisma.protocol.update({
where: { id: protocol.id },
data: {
agentId: String(agentId),
status: 'IN_PROGRESS',
},
})
res.json({ ok: true, updated: true, protocolId: protocol.id, agentId, agentName: agentName ?? null })
} catch (err: any) {
res.status(500).json({ error: err.message ?? 'Erro interno' })
}
})
return router
}