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')