From fc37a54376663b139590a5474526cd18c8acb852 Mon Sep 17 00:00:00 2001 From: VPS 4 Deploy Agent Date: Tue, 12 May 2026 05:29:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(secretaria):=20corrige=207=20inconsist?= =?UTF-8?q?=C3=AAncias=20do=20issue=20#14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/modules/chatbot/chatbot.repository.ts | 2 +- .../src/modules/chatbot/chatbot.routes.ts | 6 ++ .../whatsapp/handlers/MessageHandler.ts | 13 +++ .../frontend/components/ChatListItem.tsx | 7 +- .../frontend/hooks/inbox/useNewInboxBridge.ts | 1 + .../frontend/types/inboxTypes.ts | 1 + .../plugins/ext-api/backend/routes.ts | 97 +++++++++++++++++++ .../newwhats.local/plugins/ext-api/index.ts | 4 +- 8 files changed, 127 insertions(+), 4 deletions(-) diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts index c60a3a0..e75af4e 100644 --- a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.repository.ts @@ -108,7 +108,7 @@ export const chatBotState = { prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }), resumeBot: (chatId: string) => - prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }), + prisma.chat.update({ where: { id: chatId }, data: { botPaused: false, botSummary: null } }), updateSummary: (chatId: string, botSummary: string) => prisma.chat.update({ where: { id: chatId }, data: { botSummary } }), diff --git a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts index eb96e87..0bb0881 100644 --- a/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts +++ b/clube67/newwhats.local/backend/src/modules/chatbot/chatbot.routes.ts @@ -118,6 +118,12 @@ export function buildChatbotRoutes(): Router { 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) { diff --git a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts index 686d1b1..73ef50a 100644 --- a/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts +++ b/clube67/newwhats.local/backend/src/modules/whatsapp/handlers/MessageHandler.ts @@ -712,6 +712,19 @@ export class MessageHandler { }).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono')) ) } + + // ── 9. Emite para plugins externos (ex: Secretária IA via ext:message.new) ─ + if (!fromMe && body) { + setImmediate(() => + hookBus.emit('ext:message.new', { + tenantId: this.tenantId, + instanceId: this.instanceId, + chatId: chat.id, + jid: remoteJid, + text: body, + }).catch((err: unknown) => logger.error({ err }, '[HookBus] Erro em ext:message.new')) + ) + } } // ═══════════════════════════════════════════════════════════════════════════ diff --git a/clube67/newwhats.local/frontend/components/ChatListItem.tsx b/clube67/newwhats.local/frontend/components/ChatListItem.tsx index 80e1e1f..5539246 100644 --- a/clube67/newwhats.local/frontend/components/ChatListItem.tsx +++ b/clube67/newwhats.local/frontend/components/ChatListItem.tsx @@ -7,7 +7,7 @@ import { VolumeX, Star, StarOff, Archive, ArchiveRestore, Trash2, MailOpen, Volume2, PinOff, Mic, FileText, Video, Image as ImageIcon, - AlertCircle, + AlertCircle, BotOff, } from 'lucide-react'; import type { NewWhatsChat, PresenceState } from '../types/inboxTypes'; import { @@ -225,6 +225,11 @@ export default function ChatListItem({
+ {chat.bot_paused && ( + + + + )} {isMuted && } {Boolean(chat.is_pinned) && } {chat.unread_count > 0 && ( diff --git a/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts b/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts index 11acfb2..1fbc605 100644 --- a/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts +++ b/clube67/newwhats.local/frontend/hooks/inbox/useNewInboxBridge.ts @@ -64,6 +64,7 @@ function chatToLegacy(c: ReturnType['chats'][0]): unread_count: c.unreadCount, is_pinned: c.isPinned, is_archived: c.isArchived, + bot_paused: c.botPaused ?? null, labels: [], } } diff --git a/clube67/newwhats.local/frontend/types/inboxTypes.ts b/clube67/newwhats.local/frontend/types/inboxTypes.ts index 4d65e9f..e37af04 100644 --- a/clube67/newwhats.local/frontend/types/inboxTypes.ts +++ b/clube67/newwhats.local/frontend/types/inboxTypes.ts @@ -93,6 +93,7 @@ export interface NewWhatsChat { is_pinned: boolean | null is_archived: boolean | null muted_until?: string | null + bot_paused?: boolean | null labels?: Array<{ label_id: string; name?: string }> } diff --git a/clube67/newwhats.local/plugins/ext-api/backend/routes.ts b/clube67/newwhats.local/plugins/ext-api/backend/routes.ts index cd053e9..bf94ec8 100644 --- a/clube67/newwhats.local/plugins/ext-api/backend/routes.ts +++ b/clube67/newwhats.local/plugins/ext-api/backend/routes.ts @@ -22,6 +22,7 @@ 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; @@ -76,9 +77,79 @@ export function buildExtRoutes( 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 + + const brain = new ProtocolEngine(db, config) + const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks }) + + 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) => { @@ -696,6 +767,16 @@ export function buildExtRoutes( } 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') @@ -743,6 +824,22 @@ export function buildExtRoutes( tenantId, }) + // SEC-01+SEC-11: envia reply via WA e persiste no banco principal + const chat = await prisma.chat.findFirst({ + where: { tenantId, remoteJid: 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, diff --git a/clube67/newwhats.local/plugins/ext-api/index.ts b/clube67/newwhats.local/plugins/ext-api/index.ts index 01c85c3..19f5773 100644 --- a/clube67/newwhats.local/plugins/ext-api/index.ts +++ b/clube67/newwhats.local/plugins/ext-api/index.ts @@ -23,7 +23,7 @@ const plugin: PluginInstance = { manifest: manifest as any, async activate(ctx: PluginContext): Promise { - const { app, prisma, db, config, hooks, logger, httpServer } = ctx + const { app, prisma, db, config, hooks, logger, httpServer, io } = ctx // ── REST ──────────────────────────────────────────────────────────────── const manager = globalThis.__whatsAppManager @@ -32,7 +32,7 @@ const plugin: PluginInstance = { } const authMiddleware = buildApiKeyAuth(prisma) - const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks) + const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks, io) app.use('/api/ext/v1', authMiddleware, extRouter) logger.info('[ext-api] Rotas REST registradas em /api/ext/v1')