fix(secretaria): corrige 7 inconsistências do issue #14
continuous-integration/webhook Deploy concluido com sucesso (VPS 4)
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>
This commit is contained in:
@@ -108,7 +108,7 @@ export const chatBotState = {
|
|||||||
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
||||||
|
|
||||||
resumeBot: (chatId: string) =>
|
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) =>
|
updateSummary: (chatId: string, botSummary: string) =>
|
||||||
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
||||||
|
|||||||
@@ -118,6 +118,12 @@ export function buildChatbotRoutes(): Router {
|
|||||||
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
|
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
|
||||||
return
|
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)
|
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
|
||||||
res.status(201).json(bot)
|
res.status(201).json(bot)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -712,6 +712,19 @@ export class MessageHandler {
|
|||||||
}).catch((err) => logger.error({ err }, '[Chatbot] Erro assíncrono'))
|
}).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'))
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
VolumeX, Star, StarOff, Archive, ArchiveRestore,
|
VolumeX, Star, StarOff, Archive, ArchiveRestore,
|
||||||
Trash2, MailOpen, Volume2, PinOff,
|
Trash2, MailOpen, Volume2, PinOff,
|
||||||
Mic, FileText, Video, Image as ImageIcon,
|
Mic, FileText, Video, Image as ImageIcon,
|
||||||
AlertCircle,
|
AlertCircle, BotOff,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
|
import type { NewWhatsChat, PresenceState } from '../types/inboxTypes';
|
||||||
import {
|
import {
|
||||||
@@ -225,6 +225,11 @@ export default function ChatListItem({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
|
<div className="flex items-center gap-2 flex-shrink-0 ml-1">
|
||||||
|
{chat.bot_paused && (
|
||||||
|
<span title="Bot pausado — aguardando atendente">
|
||||||
|
<BotOff className="w-4 h-4 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
|
{isMuted && <VolumeX className="w-4 h-4 text-[#8696a0]" />}
|
||||||
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
|
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
|
||||||
{chat.unread_count > 0 && (
|
{chat.unread_count > 0 && (
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
|||||||
unread_count: c.unreadCount,
|
unread_count: c.unreadCount,
|
||||||
is_pinned: c.isPinned,
|
is_pinned: c.isPinned,
|
||||||
is_archived: c.isArchived,
|
is_archived: c.isArchived,
|
||||||
|
bot_paused: c.botPaused ?? null,
|
||||||
labels: [],
|
labels: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ export interface NewWhatsChat {
|
|||||||
is_pinned: boolean | null
|
is_pinned: boolean | null
|
||||||
is_archived: boolean | null
|
is_archived: boolean | null
|
||||||
muted_until?: string | null
|
muted_until?: string | null
|
||||||
|
bot_paused?: boolean | null
|
||||||
labels?: Array<{ label_id: string; name?: string }>
|
labels?: Array<{ label_id: string; name?: string }>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { Knex } from 'knex'
|
|||||||
import type { WhatsAppConnectionManager } from '../../../backend/src/modules/whatsapp/connection/WhatsAppConnectionManager'
|
import type { WhatsAppConnectionManager } from '../../../backend/src/modules/whatsapp/connection/WhatsAppConnectionManager'
|
||||||
import type { PluginConfigStore } from '../../../backend/src/core/plugin-config'
|
import type { PluginConfigStore } from '../../../backend/src/core/plugin-config'
|
||||||
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
||||||
|
import type { Server as SocketServer } from 'socket.io'
|
||||||
let dragonfly: any;
|
let dragonfly: any;
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly;
|
dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly;
|
||||||
@@ -76,9 +77,79 @@ export function buildExtRoutes(
|
|||||||
db: Knex,
|
db: Knex,
|
||||||
config: PluginConfigStore,
|
config: PluginConfigStore,
|
||||||
hooks: HookBus,
|
hooks: HookBus,
|
||||||
|
io?: SocketServer,
|
||||||
): Router {
|
): Router {
|
||||||
const router = 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
|
||||||
|
|
||||||
|
// 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 ──────────────────────────────────────
|
// ── Escalation notification listener ──────────────────────────────────────
|
||||||
// When escalar_humano tool fires, send a WA message to the configured admin phone.
|
// When escalar_humano tool fires, send a WA message to the configured admin phone.
|
||||||
hooks.register('ext:escalated', async (data: any) => {
|
hooks.register('ext:escalated', async (data: any) => {
|
||||||
@@ -696,6 +767,16 @@ export function buildExtRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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}`
|
const extKey = `${tenantId}:${chatId}`
|
||||||
|
|
||||||
let conv = await db('sec_conversations')
|
let conv = await db('sec_conversations')
|
||||||
@@ -743,6 +824,22 @@ export function buildExtRoutes(
|
|||||||
tenantId,
|
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({
|
res.json({
|
||||||
reply,
|
reply,
|
||||||
conversationId: conv.id,
|
conversationId: conv.id,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const plugin: PluginInstance = {
|
|||||||
manifest: manifest as any,
|
manifest: manifest as any,
|
||||||
|
|
||||||
async activate(ctx: PluginContext): Promise<void> {
|
async activate(ctx: PluginContext): Promise<void> {
|
||||||
const { app, prisma, db, config, hooks, logger, httpServer } = ctx
|
const { app, prisma, db, config, hooks, logger, httpServer, io } = ctx
|
||||||
|
|
||||||
// ── REST ────────────────────────────────────────────────────────────────
|
// ── REST ────────────────────────────────────────────────────────────────
|
||||||
const manager = globalThis.__whatsAppManager
|
const manager = globalThis.__whatsAppManager
|
||||||
@@ -32,7 +32,7 @@ const plugin: PluginInstance = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authMiddleware = buildApiKeyAuth(prisma)
|
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)
|
app.use('/api/ext/v1', authMiddleware, extRouter)
|
||||||
logger.info('[ext-api] Rotas REST registradas em /api/ext/v1')
|
logger.info('[ext-api] Rotas REST registradas em /api/ext/v1')
|
||||||
|
|||||||
Reference in New Issue
Block a user