3adbc0b59d
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
SEC-01+SEC-11: /secretaria/ask agora envia reply via WhatsApp (sock.sendMessage) e persiste a mensagem no banco Prisma após brain.chat(), com notify Socket.IO. SEC-02+SEC-03: MessageHandler emite hookBus 'ext:message.new' para todo msg recebido. Listener no ext-api auto-dispara a Secretária apenas se NÃO houver AIBot ativo na instância (exclusão mútua — chatbot tem prioridade). SEC-04: ChatListItem exibe ícone BotOff (âmbar) quando chat.bot_paused = true, com tooltip "Bot pausado — aguardando atendente". SEC-05: chatToLegacy() mapeia c.botPaused → bot_paused na bridge legada. NewWhatsChat recebe campo bot_paused opcional. SEC-08: POST /secretaria/ask valida que existe instância CONNECTED antes de criar conversa. Retorna 503 se nenhuma instância disponível. SEC-10: POST /api/chatbot/bots/:instanceId valida que credentialId pertence ao mesmo tenantId antes de criar o bot. Retorna 403 se não pertencer. SEC-12: chatBotState.resumeBot() limpa botSummary (= null) ao retomar, evitando contexto defasado após escalação humana. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
200 lines
8.6 KiB
TypeScript
200 lines
8.6 KiB
TypeScript
/**
|
|
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
|
|
*
|
|
* Montado em /api/chatbot (ver server.ts)
|
|
* Todos os endpoints requerem authMiddleware.
|
|
*/
|
|
import { Router, type Request, type Response } from 'express'
|
|
import { z } from 'zod'
|
|
import { prisma } from '../../infra/database/prisma'
|
|
import { credentialRepo, botRepo, chatBotState } from './chatbot.repository'
|
|
import { logger } from '../../config/logger'
|
|
|
|
export function buildChatbotRoutes(): Router {
|
|
const router = Router()
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
|
|
const validateInstance = async (instanceId: string, tenantId: string) => {
|
|
return prisma.instance.findFirst({ where: { id: instanceId, tenantId } })
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// CREDENTIALS
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/chatbot/credentials/:instanceId
|
|
router.get('/credentials/:instanceId', async (req: Request, res: Response) => {
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const instanceId = req.params['instanceId'] as string
|
|
if (!await validateInstance(instanceId, tenantId)) {
|
|
res.status(404).json({ error: 'Instância não encontrada' }); return
|
|
}
|
|
const creds = await credentialRepo.findAll(tenantId, instanceId)
|
|
res.json(creds)
|
|
} catch (err: any) {
|
|
logger.error({ err }, 'Erro ao listar credenciais')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// POST /api/chatbot/credentials/:instanceId
|
|
router.post('/credentials/:instanceId', async (req: Request, res: Response) => {
|
|
const schema = z.object({
|
|
name: z.string().min(1).max(80),
|
|
apiKey: z.string().min(10),
|
|
provider: z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
|
|
})
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const instanceId = req.params['instanceId'] as string
|
|
if (!await validateInstance(instanceId, tenantId)) {
|
|
res.status(404).json({ error: 'Instância não encontrada' }); return
|
|
}
|
|
const body = schema.parse(req.body)
|
|
const cred = await credentialRepo.create({ tenantId, instanceId, ...body } as any)
|
|
res.status(201).json(cred)
|
|
} catch (err: any) {
|
|
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
|
|
logger.error({ err }, 'Erro ao criar credencial')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// DELETE /api/chatbot/credentials/:instanceId/:credId
|
|
router.delete('/credentials/:instanceId/:credId', async (req: Request, res: Response) => {
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const credId = req.params['credId'] as string
|
|
await credentialRepo.delete(credId, tenantId)
|
|
res.status(204).send()
|
|
} catch (err: any) {
|
|
logger.error({ err }, 'Erro ao deletar credencial')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// BOTS
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// GET /api/chatbot/bots/:instanceId
|
|
router.get('/bots/:instanceId', async (req: Request, res: Response) => {
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const instanceId = req.params['instanceId'] as string
|
|
if (!await validateInstance(instanceId, tenantId)) {
|
|
res.status(404).json({ error: 'Instância não encontrada' }); return
|
|
}
|
|
const bots = await botRepo.findAll(tenantId, instanceId)
|
|
res.json(bots)
|
|
} catch (err: any) {
|
|
logger.error({ err }, 'Erro ao listar bots')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// POST /api/chatbot/bots/:instanceId
|
|
router.post('/bots/:instanceId', async (req: Request, res: Response) => {
|
|
const schema = z.object({
|
|
credentialId: z.string().uuid(),
|
|
name: z.string().min(1).max(80),
|
|
systemPrompt: z.string().min(10),
|
|
model: z.string().default('gemini-1.5-flash'),
|
|
enabled: z.boolean().default(false),
|
|
triggerMode: z.enum(['ALL', 'KEYWORD']).default('ALL'),
|
|
keywords: z.array(z.string()).default([]),
|
|
})
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const instanceId = req.params['instanceId'] as string
|
|
if (!await validateInstance(instanceId, tenantId)) {
|
|
res.status(404).json({ error: 'Instância não encontrada' }); return
|
|
}
|
|
const body = schema.parse(req.body)
|
|
const existing = await botRepo.findAll(tenantId, instanceId)
|
|
if (existing.length > 0) {
|
|
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
|
|
return
|
|
}
|
|
// SEC-10: garante que a credencial pertence a este tenant
|
|
const cred = await prisma.aICredential.findFirst({ where: { id: body.credentialId, tenantId } })
|
|
if (!cred) {
|
|
res.status(403).json({ error: 'Credencial não encontrada ou não pertence a este tenant.' })
|
|
return
|
|
}
|
|
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
|
|
res.status(201).json(bot)
|
|
} catch (err: any) {
|
|
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
|
|
logger.error({ err }, 'Erro ao criar bot')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// PUT /api/chatbot/bots/:instanceId/:botId
|
|
router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
|
|
const schema = z.object({
|
|
credentialId: z.string().uuid().optional(),
|
|
name: z.string().min(1).max(80).optional(),
|
|
systemPrompt: z.string().min(10).optional(),
|
|
model: z.string().optional(),
|
|
enabled: z.boolean().optional(),
|
|
triggerMode: z.enum(['ALL', 'KEYWORD']).optional(),
|
|
keywords: z.array(z.string()).optional(),
|
|
})
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const botId = req.params['botId'] as string
|
|
const body = schema.parse(req.body)
|
|
await botRepo.update(botId, tenantId, body as any)
|
|
const updated = await botRepo.findById(botId, tenantId)
|
|
res.json(updated)
|
|
} catch (err: any) {
|
|
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
|
|
logger.error({ err }, 'Erro ao atualizar bot')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// DELETE /api/chatbot/bots/:instanceId/:botId
|
|
router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
|
|
try {
|
|
const tenantId = req.tenantId!
|
|
const botId = req.params['botId'] as string
|
|
await botRepo.delete(botId, tenantId)
|
|
res.status(204).send()
|
|
} catch (err: any) {
|
|
logger.error({ err }, 'Erro ao deletar bot')
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
// BOT STATE — Human Takeover por chat
|
|
// ─────────────────────────────────────────────────────────────────────────
|
|
|
|
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
|
|
router.post('/chats/:chatId/pause', async (req: Request, res: Response) => {
|
|
try {
|
|
await chatBotState.pauseBot(req.params['chatId'] as string)
|
|
res.json({ paused: true })
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
|
|
router.post('/chats/:chatId/resume', async (req: Request, res: Response) => {
|
|
try {
|
|
await chatBotState.resumeBot(req.params['chatId'] as string)
|
|
res.json({ paused: false })
|
|
} catch (err: any) {
|
|
res.status(500).json({ error: err.message })
|
|
}
|
|
})
|
|
|
|
return router
|
|
}
|