Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a5fffe25d2 | |||
| 3adbc0b59d | |||
| 2c1acd8ce8 | |||
| db9e1c9db2 | |||
| a8d404deb5 | |||
| 3cdac0b7ad | |||
| bbde5ceacb | |||
| 1fc139043f |
@@ -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 } }),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,19 +1,28 @@
|
||||
/**
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente (Chatbot Rápido).
|
||||
*
|
||||
* Padrão Cérebro (instrucoes.md §5):
|
||||
* - Não envia histórico completo para economizar tokens
|
||||
* - Mantém resumo de 2 frases por chat (botSummary no banco)
|
||||
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
|
||||
*
|
||||
* Providers suportados (SEC-06/SEC-07):
|
||||
* - GEMINI → Google Generative AI (via SDK)
|
||||
* - OPENAI → OpenAI Chat Completions (via fetch)
|
||||
* O Prisma enum LLMProvider { GEMINI, OPENAI } mapeia diretamente a estes dois.
|
||||
* A Secretária IA (ProtocolEngine) suporta adicionalmente anthropic e ollama
|
||||
* via configuração de plugin — divergência intencional, pois os dois sistemas
|
||||
* têm arquiteturas independentes.
|
||||
*/
|
||||
import { GoogleGenerativeAI, type GenerativeModel } from '@google/generative-ai'
|
||||
import { GoogleGenerativeAI } from '@google/generative-ai'
|
||||
import type { WASocket } from '../whatsapp/engine'
|
||||
import { prisma } from '../../infra/database/prisma'
|
||||
import { logger } from '../../config/logger'
|
||||
import { botRepo, chatBotState } from './chatbot.repository'
|
||||
import type { Server as SocketIOServer } from 'socket.io'
|
||||
|
||||
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
|
||||
// ─── Cache de clientes Gemini ─────────────────────────────────────────────────
|
||||
|
||||
const geminiClients = new Map<string, GoogleGenerativeAI>()
|
||||
|
||||
function getGeminiClient(apiKey: string): GoogleGenerativeAI {
|
||||
@@ -23,34 +32,82 @@ function getGeminiClient(apiKey: string): GoogleGenerativeAI {
|
||||
return geminiClients.get(apiKey)!
|
||||
}
|
||||
|
||||
function getModel(apiKey: string, modelName: string): GenerativeModel {
|
||||
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName })
|
||||
// ─── Abstração de provider (SEC-06) ──────────────────────────────────────────
|
||||
|
||||
type Provider = 'GEMINI' | 'OPENAI'
|
||||
|
||||
interface AiResult {
|
||||
text: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
|
||||
async function aiCall(opts: {
|
||||
provider: Provider
|
||||
apiKey: string
|
||||
model: string
|
||||
systemPrompt: string
|
||||
userPrompt: string
|
||||
maxTokens?: number
|
||||
temperature?: number
|
||||
}): Promise<AiResult> {
|
||||
const { provider, apiKey, model, systemPrompt, userPrompt, maxTokens = 300, temperature = 0.7 } = opts
|
||||
|
||||
async function classifyIntent(
|
||||
userMsg: string,
|
||||
summary: string | null,
|
||||
systemPrompt: string,
|
||||
model: GenerativeModel
|
||||
): Promise<'resolve' | 'escalate'> {
|
||||
const prompt = [
|
||||
if (provider === 'OPENAI') {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4o-mini',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: maxTokens,
|
||||
temperature,
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
|
||||
return {
|
||||
text: (data.choices[0].message.content as string).trim(),
|
||||
inputTokens: data.usage?.prompt_tokens ?? 0,
|
||||
outputTokens: data.usage?.completion_tokens ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Default: GEMINI
|
||||
const geminiModel = getGeminiClient(apiKey).getGenerativeModel({ model: model || 'gemini-1.5-flash' })
|
||||
const result = await geminiModel.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }] }],
|
||||
generationConfig: { maxOutputTokens: maxTokens, temperature },
|
||||
})
|
||||
const usage = result.response.usageMetadata
|
||||
return {
|
||||
text: result.response.text().trim(),
|
||||
inputTokens: usage?.promptTokenCount ?? 0,
|
||||
outputTokens: usage?.candidatesTokenCount ?? 0,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Classificação de intenção ────────────────────────────────────────────────
|
||||
|
||||
async function classifyIntent(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
userMsg: string; summary: string | null; systemPrompt: string
|
||||
}): Promise<'resolve' | 'escalate'> {
|
||||
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
|
||||
const userPrompt = [
|
||||
`Você é um classificador de intenção para um chatbot de atendimento.`,
|
||||
`Prompt do atendente: ${systemPrompt}`,
|
||||
summary ? `Contexto da conversa até agora: ${summary}` : '',
|
||||
summary ? `Contexto da conversa: ${summary}` : '',
|
||||
`Nova mensagem do cliente: "${userMsg}"`,
|
||||
`Responda APENAS com o número:`,
|
||||
`1 = Consigo responder essa pergunta dentro do meu papel`,
|
||||
`2 = Precisa de um agente humano`,
|
||||
`Responda APENAS com o número: 1 = resolver, 2 = escalar humano`,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 2, temperature: 0 },
|
||||
})
|
||||
const text = result.response.text().trim()
|
||||
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 2, temperature: 0 })
|
||||
return text.startsWith('2') ? 'escalate' : 'resolve'
|
||||
} catch (err) {
|
||||
logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
|
||||
@@ -60,53 +117,37 @@ async function classifyIntent(
|
||||
|
||||
// ─── Geração de resposta ──────────────────────────────────────────────────────
|
||||
|
||||
async function generateResponse(
|
||||
userMsg: string,
|
||||
summary: string | null,
|
||||
systemPrompt: string,
|
||||
model: GenerativeModel
|
||||
): Promise<string> {
|
||||
const prompt = [
|
||||
systemPrompt,
|
||||
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
|
||||
`\nMensagem do cliente: "${userMsg}"`,
|
||||
`\nResponda de forma natural, breve e direta. Não use markdown.`,
|
||||
async function generateResponse(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
userMsg: string; summary: string | null; systemPrompt: string
|
||||
}): Promise<AiResult> {
|
||||
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
|
||||
const userPrompt = [
|
||||
summary ? `Contexto da conversa até agora:\n${summary}` : '',
|
||||
`Mensagem do cliente: "${userMsg}"`,
|
||||
`Responda de forma natural, breve e direta. Não use markdown.`,
|
||||
].filter(Boolean).join('\n')
|
||||
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
|
||||
})
|
||||
return result.response.text().trim()
|
||||
return aiCall({ provider, apiKey, model, systemPrompt, userPrompt, maxTokens: 300, temperature: 0.7 })
|
||||
}
|
||||
|
||||
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
|
||||
|
||||
async function updateSummary(
|
||||
currentSummary: string | null,
|
||||
userMsg: string,
|
||||
botMsg: string,
|
||||
model: GenerativeModel
|
||||
): Promise<string> {
|
||||
const prompt = [
|
||||
currentSummary
|
||||
? `Resumo atual da conversa: ${currentSummary}`
|
||||
: 'Esta é a primeira troca da conversa.',
|
||||
`Nova troca:`,
|
||||
`Cliente: "${userMsg}"`,
|
||||
`Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
|
||||
`Responda apenas com o resumo, sem introdução.`,
|
||||
async function updateSummary(opts: {
|
||||
provider: Provider; apiKey: string; model: string
|
||||
currentSummary: string | null; userMsg: string; botMsg: string
|
||||
}): Promise<string> {
|
||||
const { provider, apiKey, model, currentSummary, userMsg, botMsg } = opts
|
||||
const userPrompt = [
|
||||
currentSummary ? `Resumo atual: ${currentSummary}` : 'Primeira troca da conversa.',
|
||||
`Nova troca — Cliente: "${userMsg}" / Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases. Responda só com o resumo, sem introdução.`,
|
||||
].join('\n')
|
||||
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
|
||||
})
|
||||
return result.response.text().trim()
|
||||
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 80, temperature: 0.3 })
|
||||
return text
|
||||
} catch {
|
||||
// Em caso de erro, mantém o resumo anterior
|
||||
return currentSummary ?? ''
|
||||
}
|
||||
}
|
||||
@@ -145,15 +186,16 @@ export class ChatbotService {
|
||||
if (!hasKeyword) return
|
||||
}
|
||||
|
||||
try {
|
||||
const model = getModel(bot.credential.apiKey, bot.model)
|
||||
const summary = chatState.botSummary ?? null
|
||||
const provider = (bot.credential.provider ?? 'GEMINI') as Provider
|
||||
const { apiKey } = bot.credential
|
||||
const { model } = bot
|
||||
const summary = chatState.botSummary ?? null
|
||||
|
||||
try {
|
||||
// 4. Micro-prompt: intenção
|
||||
const intent = await classifyIntent(text, summary, bot.systemPrompt, model)
|
||||
const intent = await classifyIntent({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
|
||||
|
||||
if (intent === 'escalate') {
|
||||
// Pausa o bot e notifica via Socket.IO
|
||||
await chatBotState.pauseBot(chatId)
|
||||
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
|
||||
chatId,
|
||||
@@ -163,13 +205,32 @@ export class ChatbotService {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Gera resposta
|
||||
const responseText = await generateResponse(text, summary, bot.systemPrompt, model)
|
||||
// 5. Indicador de digitação (SEC-14)
|
||||
await sock.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||
|
||||
// 6. Envia via Baileys
|
||||
const sent = await sock.sendMessage(jid, { text: responseText })
|
||||
// 6. Gera resposta (com fallback para outro provider se falhar)
|
||||
let result: AiResult
|
||||
try {
|
||||
result = await generateResponse({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
|
||||
} catch (primaryErr) {
|
||||
// Fallback: tenta outra credencial da mesma instância com provider diferente
|
||||
const fallbackCred = await prisma.aICredential.findFirst({
|
||||
where: { tenantId, instanceId, NOT: { id: bot.credentialId } },
|
||||
})
|
||||
if (!fallbackCred) throw primaryErr
|
||||
logger.warn({ chatId, primaryErr }, '[Chatbot] Provider primário falhou — tentando fallback')
|
||||
const fbProvider = (fallbackCred.provider ?? 'GEMINI') as Provider
|
||||
result = await generateResponse({
|
||||
provider: fbProvider, apiKey: fallbackCred.apiKey, model,
|
||||
userMsg: text, summary, systemPrompt: bot.systemPrompt,
|
||||
})
|
||||
}
|
||||
|
||||
// 7. Persiste mensagem do bot no banco
|
||||
// 7. Para indicador de digitação + envia
|
||||
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
const sent = await sock.sendMessage(jid, { text: result.text })
|
||||
|
||||
// 8. Persiste mensagem do bot no banco
|
||||
const chat = await prisma.chat.findUnique({ where: { id: chatId } })
|
||||
if (chat) {
|
||||
const botMsg = await prisma.message.create({
|
||||
@@ -181,22 +242,21 @@ export class ChatbotService {
|
||||
messageId: sent?.key.id ?? `bot-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: responseText,
|
||||
body: result.text,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
})
|
||||
|
||||
// Notifica frontend
|
||||
this.io.to(`chat:${chatId}`).emit('message:new', botMsg)
|
||||
}
|
||||
|
||||
// 8. Atualiza Cérebro
|
||||
const newSummary = await updateSummary(summary, text, responseText, model)
|
||||
// 9. Atualiza Cérebro + telemetria (SEC-13)
|
||||
const newSummary = await updateSummary({ provider, apiKey, model, currentSummary: summary, userMsg: text, botMsg: result.text })
|
||||
await chatBotState.updateSummary(chatId, newSummary)
|
||||
|
||||
logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada')
|
||||
logger.info({ chatId, jid, intent, provider, inputTokens: result.inputTokens, outputTokens: result.outputTokens }, '[Chatbot] Resposta enviada')
|
||||
} catch (err) {
|
||||
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
|
||||
}
|
||||
}
|
||||
|
||||
+18
-13
@@ -285,24 +285,29 @@ export class WhatsAppConnectionManager {
|
||||
const msgHandler = new MessageHandler(sock, instanceId, tenantId, this.io, this.chatbotService)
|
||||
this.msgHandlers.set(instanceId, msgHandler)
|
||||
|
||||
// ─── Intercepta stanzas brutas pra extrair peer_recipient_pn ─────────
|
||||
// O Baileys (decode-wa-message.js) expõe só sender_pn/participant_pn no
|
||||
// key — mas NÃO expõe peer_recipient_pn, que é a única fonte de verdade
|
||||
// pro telefone real do DESTINATÁRIO em mensagens fromMe=true enviadas
|
||||
// pelo celular pra contatos @lid novos (sem mapeamento salvo).
|
||||
// ─── Intercepta stanzas brutas pra extrair PN real do destinatário ──
|
||||
// Dois cenários distintos de mensagens do celular que chegam sem PN:
|
||||
//
|
||||
// Sem essa dica, `resolveLidToPhoneJid` retornaria null e a mensagem
|
||||
// seria descartada — o usuário relatou exatamente esse sintoma ("envio
|
||||
// pelo celular da sessão pro número X e a msg não aparece na inbox").
|
||||
// Cenário A (Baileys oficial): mensagem fromMe=true com remoteJid=@lid
|
||||
// O campo peer_recipient_pn na stanza bruta contém o PN do destinatário.
|
||||
// Baileys não expõe esse campo em key, por isso lemos aqui.
|
||||
//
|
||||
// O listener roda em paralelo com o handler interno do Baileys: ambos
|
||||
// são callbacks síncronos do EventEmitter de ws, então o mapa é
|
||||
// Cenário B (InfiniteAPI v7+): mensagem fromMe=true onde o InfiniteAPI
|
||||
// resolve automaticamente @lid → nosso próprio JID (usando sender_pn
|
||||
// da stanza, que para fromMe=true é NOSSO PN, não o do destinatário).
|
||||
// O PN correto do destinatário está em recipient_pn na stanza.
|
||||
// Sem essa dica, processMessage descarta a mensagem como self-chat.
|
||||
//
|
||||
// O listener é síncrono (mesmo EventEmitter do ws), então o mapa é
|
||||
// populado antes do `messages.upsert` ser emitido de forma assíncrona.
|
||||
;(sock.ws as any).on('CB:message', (node: any) => {
|
||||
const msgId = node?.attrs?.id
|
||||
const peerRecipientPn = node?.attrs?.peer_recipient_pn
|
||||
if (msgId && peerRecipientPn) {
|
||||
msgHandler.setPeerRecipientHint(msgId, peerRecipientPn)
|
||||
if (!msgId) return
|
||||
// peer_recipient_pn: Cenário A (@lid não resolvido, Baileys oficial)
|
||||
// recipient_pn: Cenário B (InfiniteAPI resolveu @lid para nosso PN)
|
||||
const hint = node?.attrs?.peer_recipient_pn ?? node?.attrs?.recipient_pn
|
||||
if (hint) {
|
||||
msgHandler.setPeerRecipientHint(msgId, hint)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -279,8 +279,12 @@ export class MessageHandler {
|
||||
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
|
||||
jidRaw = normalizeJid(remoteJidAlt)
|
||||
} else {
|
||||
// 2. senderPn / participantPn
|
||||
const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
|
||||
// 2. senderPn / participantPn — SOMENTE para mensagens recebidas (fromMe=false).
|
||||
// Para fromMe=true, senderPn é o NOSSO próprio JID (somos o remetente),
|
||||
// não o JID do destinatário — usar aqui criaria um self-chat corrompido no DB.
|
||||
const senderPn = !msg.key.fromMe
|
||||
? ((msg.key as any).senderPn || (msg.key as any).participantPn)
|
||||
: null
|
||||
if (senderPn) {
|
||||
jidRaw = normalizeJid(senderPn)
|
||||
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
|
||||
@@ -368,6 +372,7 @@ export class MessageHandler {
|
||||
null
|
||||
|
||||
const isGroup = jid.endsWith('@g.us')
|
||||
const isMed = this.isMediaMessage(contentType)
|
||||
return [{
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
@@ -383,12 +388,33 @@ export class MessageHandler {
|
||||
senderJid: isGroup ? (key.participant ?? null) : null,
|
||||
status: 'READ' as const, // históricas são consideradas já lidas
|
||||
timestamp: ts,
|
||||
// Salva payload para permitir re-download posterior via /redownload-media
|
||||
...(isMed ? { mediaPayload: this.buildMediaPayload(key, message) as any } : {}),
|
||||
}]
|
||||
})
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
|
||||
totalSaved += count
|
||||
|
||||
// Recupera mediaPayload para mensagens de mídia que já existiam no DB
|
||||
// sem esse campo (criadas antes desta correção ou via path antigo).
|
||||
// Roda em paralelo sem bloquear o loop principal.
|
||||
const mediaRows = rows.filter(r => r.mediaPayload != null)
|
||||
if (mediaRows.length > 0) {
|
||||
Promise.all(
|
||||
mediaRows.map(r =>
|
||||
prisma.message.updateMany({
|
||||
where: {
|
||||
instanceId: this.instanceId,
|
||||
messageId: r.messageId,
|
||||
mediaPayload: null,
|
||||
},
|
||||
data: { mediaPayload: r.mediaPayload },
|
||||
}).catch(() => {})
|
||||
)
|
||||
).catch(() => {})
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
|
||||
@@ -452,7 +478,17 @@ export class MessageHandler {
|
||||
*/
|
||||
let originalLidJid: string | null = null
|
||||
if (key.remoteJid.endsWith('@lid')) {
|
||||
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
|
||||
// Fast path para fromMe=false: InfiniteAPI preenche remoteJidAlt com o PN
|
||||
// do remetente (que é o contato) quando addressing_mode=pn. Para fromMe=true,
|
||||
// remoteJidAlt contem o NOSSO próprio PN (senderAlt = sender_pn = nosso número)
|
||||
// — por isso o fast path só é seguro para mensagens recebidas.
|
||||
const remoteJidAlt = (key as any).remoteJidAlt as string | undefined
|
||||
let phoneJid: string | null = null
|
||||
if (!key.fromMe && remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
|
||||
phoneJid = normalizeJid(remoteJidAlt)
|
||||
} else {
|
||||
phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
|
||||
}
|
||||
if (!phoneJid) {
|
||||
// Sem resolução: não há como saber o número real do contato.
|
||||
// A mensagem será perdida, mas o mapeamento será capturado no futuro
|
||||
@@ -480,12 +516,36 @@ export class MessageHandler {
|
||||
const fromMe = key.fromMe ?? false
|
||||
const remoteJidRaw = key.remoteJid
|
||||
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
|
||||
const remoteJid = normalizeJid(remoteJidRaw)
|
||||
let remoteJid = normalizeJid(remoteJidRaw)
|
||||
const messageId = key.id!
|
||||
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais).
|
||||
//
|
||||
// Caso especial (InfiniteAPI v7+): para mensagens fromMe=true enviadas do celular
|
||||
// a um contato LID, o InfiniteAPI pode ter resolvido remoteJid para o nosso próprio
|
||||
// JID (usando sender_pn = nosso PN em vez de recipient_pn = PN do destinatário).
|
||||
// Nesse caso, o CB:message listener armazenou recipient_pn em peerRecipientHints.
|
||||
// Tentamos recuperar o JID correto antes de descartar como self-chat.
|
||||
const ownJid = normalizeJid(this.sock.user?.id ?? '')
|
||||
if (ownJid && remoteJid === ownJid) return
|
||||
if (ownJid && remoteJid === ownJid) {
|
||||
if (fromMe) {
|
||||
const hint = this.peerRecipientHints.get(messageId)
|
||||
if (hint) {
|
||||
this.peerRecipientHints.delete(messageId)
|
||||
const correctJid = normalizeJid(hint)
|
||||
if (correctJid && correctJid.endsWith('@s.whatsapp.net') && correctJid !== ownJid) {
|
||||
remoteJid = correctJid
|
||||
;(key as any).remoteJid = correctJid
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
|
||||
// REGRA IMPORTANTE sobre pushName:
|
||||
@@ -652,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'))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -223,7 +223,10 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
richPayloadData = { text, footer, nativeCarousel }
|
||||
}
|
||||
|
||||
const msg = await prisma.message.upsert({
|
||||
// Race condition: o eco do Baileys pode chegar ANTES desta linha e criar o
|
||||
// registro primeiro, fazendo o upsert falhar com P2002 (unique constraint).
|
||||
// Nesses casos, buscamos a mensagem que já foi criada pelo eco.
|
||||
let msg = await prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
create: {
|
||||
tenantId,
|
||||
@@ -239,6 +242,15 @@ export function buildSendRoutes(manager: WhatsAppConnectionManager): Router {
|
||||
timestamp: new Date(),
|
||||
},
|
||||
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
|
||||
}).catch(async (err: any) => {
|
||||
if (err?.code === 'P2002') {
|
||||
// Eco do Baileys criou o registro antes de nós — busca e retorna ele
|
||||
const existing = await prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId, messageId } },
|
||||
})
|
||||
if (existing) return existing
|
||||
}
|
||||
throw err
|
||||
})
|
||||
|
||||
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
|
||||
<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]" />}
|
||||
{Boolean(chat.is_pinned) && <Pin className="w-4 h-4 text-[#8696a0]" />}
|
||||
{chat.unread_count > 0 && (
|
||||
|
||||
@@ -61,10 +61,11 @@ const MessageArea: React.FC<MessageAreaProps> = ({
|
||||
|
||||
// ── Re-download de mídia não baixada ─────────────────────────────────────
|
||||
const handleRedownloadMedia = useCallback(async (msg: Message) => {
|
||||
const instanceId = selectedChat?.instanceId
|
||||
// selectedChat no formato legado usa instance_name (não instanceId)
|
||||
const instanceId = selectedChat?.instance_name ?? selectedChat?.instanceId
|
||||
if (!instanceId || !msg.id) return
|
||||
await mediaApi.redownload(instanceId, String(msg.id))
|
||||
}, [selectedChat?.instanceId])
|
||||
}, [selectedChat?.instance_name, selectedChat?.instanceId])
|
||||
const mediaMessages = messages.filter(m => m.type === 'image' || m.type === 'video')
|
||||
|
||||
const handleInternalScroll = useCallback(() => {
|
||||
|
||||
@@ -123,7 +123,15 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Waveform + meta */}
|
||||
{/* Speed — antes do waveform, alinhado ao centro vertical */}
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none shrink-0 w-7 text-center"
|
||||
>
|
||||
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
|
||||
</button>
|
||||
|
||||
{/* Waveform + tempo */}
|
||||
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-end gap-[2px] h-7">
|
||||
{waveHeights.map((h, i) => (
|
||||
@@ -137,17 +145,9 @@ const AudioBubble: React.FC<AudioBubbleProps> = ({ msg, fullMediaUrl, isOut, Tim
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
|
||||
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
|
||||
</span>
|
||||
<button
|
||||
onClick={cycleSpeed}
|
||||
className="text-[11px] font-bold text-[#667781] hover:text-[#111b21] transition-colors leading-none"
|
||||
>
|
||||
{speed === 1 ? '1×' : speed === 1.5 ? '1.5×' : '2×'}
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-[11px] text-[#667781] tabular-nums leading-none">
|
||||
{currentTime > 0 ? formatTime(currentTime) : formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{TimestampRow}
|
||||
@@ -163,6 +163,7 @@ interface DocumentBubbleProps {
|
||||
TimestampRow: React.ReactNode
|
||||
onRedownload?: () => void
|
||||
isRedownloading?: boolean
|
||||
mediaUnavailable?: boolean
|
||||
}
|
||||
|
||||
const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
||||
@@ -177,7 +178,7 @@ const DOC_TYPES: Record<string, { bg: string; label: string }> = {
|
||||
csv: { bg: '#276749', label: 'CSV' },
|
||||
}
|
||||
|
||||
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading }) => {
|
||||
const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, TimestampRow, onRedownload, isRedownloading, mediaUnavailable }) => {
|
||||
const [previewOpen, setPreviewOpen] = useState(false)
|
||||
|
||||
// Derive extension from media_type or media_url
|
||||
@@ -203,6 +204,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
const isPdf = ext === 'pdf'
|
||||
|
||||
const handleClick = () => {
|
||||
if (mediaUnavailable) return
|
||||
if (!fullMediaUrl) {
|
||||
onRedownload?.()
|
||||
return
|
||||
@@ -219,7 +221,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
<div>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className="flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 cursor-pointer hover:bg-[#e8eaed] transition-all active:scale-[0.98] min-w-[220px] max-w-[280px]"
|
||||
className={`flex items-center gap-3 bg-[#f0f2f5] p-3 rounded-xl border border-black/5 transition-all min-w-[220px] max-w-[280px] ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed] active:scale-[0.98]'}`}
|
||||
>
|
||||
{/* Type icon */}
|
||||
<div
|
||||
@@ -237,7 +239,7 @@ const DocumentBubble: React.FC<DocumentBubbleProps> = ({ msg, fullMediaUrl, Time
|
||||
{filename}
|
||||
</span>
|
||||
<span className="text-[11px] text-[#667781] mt-[2px]">
|
||||
{isRedownloading ? 'Baixando...' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Arquivo não disponível' : `${ext.toUpperCase()}${sizeStr ? ` · ${sizeStr}` : ''}`}
|
||||
</span>
|
||||
</div>
|
||||
<Download className="w-4 h-4 text-[#8696a0] shrink-0" />
|
||||
@@ -328,16 +330,20 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
const [isRedownloading, setIsRedownloading] = useState(false);
|
||||
const [mediaUnavailable, setMediaUnavailable] = useState(false);
|
||||
|
||||
const handleRedownload = useCallback(async () => {
|
||||
if (!onRedownloadMedia || isRedownloading) return
|
||||
if (!onRedownloadMedia || isRedownloading || mediaUnavailable) return
|
||||
setIsRedownloading(true)
|
||||
try {
|
||||
await onRedownloadMedia(msg)
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status ?? err?.status
|
||||
if (status === 400 || status === 404) setMediaUnavailable(true)
|
||||
} finally {
|
||||
setIsRedownloading(false)
|
||||
}
|
||||
}, [onRedownloadMedia, msg, isRedownloading])
|
||||
}, [onRedownloadMedia, msg, isRedownloading, mediaUnavailable])
|
||||
const isOut = msg.direction === 'out';
|
||||
const showActionsLeft = isOut;
|
||||
const showActionsRight = !isOut;
|
||||
@@ -614,8 +620,8 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||
<div
|
||||
className="relative group/media w-[268px] h-[200px] bg-black/5 cursor-pointer overflow-hidden"
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
|
||||
className={`relative group/media w-[268px] h-[200px] bg-black/5 overflow-hidden ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
|
||||
>
|
||||
{fullMediaUrl ? (
|
||||
<img
|
||||
@@ -624,10 +630,10 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
className="w-full h-full object-cover transition-transform group-hover/media:scale-[1.03] block"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2">
|
||||
<div className={`flex flex-col items-center justify-center w-full h-full gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">Toque para baixar</span></>
|
||||
: <><ImageIcon className="w-8 h-8 text-slate-400" /><span className="text-[11px] text-slate-400">{mediaUnavailable ? 'Imagem não disponível' : 'Toque para baixar'}</span></>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
@@ -654,8 +660,8 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
return (
|
||||
<div className="rounded-lg overflow-hidden w-[268px]">
|
||||
<div
|
||||
className="relative w-[268px] h-[200px] bg-black cursor-pointer group/vid overflow-hidden"
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : handleRedownload()}
|
||||
className={`relative w-[268px] h-[200px] bg-black overflow-hidden group/vid ${fullMediaUrl || mediaUnavailable ? '' : 'cursor-pointer'}`}
|
||||
onClick={() => fullMediaUrl ? (onOpenMedia ? onOpenMedia(msg) : window.open(fullMediaUrl, '_blank')) : (mediaUnavailable ? undefined : handleRedownload())}
|
||||
>
|
||||
{fullMediaUrl ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 hover:bg-black/30 transition-colors">
|
||||
@@ -664,10 +670,10 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2">
|
||||
<div className={`absolute inset-0 flex flex-col items-center justify-center bg-black/40 gap-2 ${mediaUnavailable ? 'opacity-50' : ''}`}>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-white/60 border-t-transparent rounded-full animate-spin" />
|
||||
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">Toque para baixar</span></>
|
||||
: <><Video className="w-8 h-8 text-white/60" /><span className="text-[11px] text-white/60">{mediaUnavailable ? 'Vídeo não disponível' : 'Toque para baixar'}</span></>
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
@@ -696,14 +702,16 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onClick={handleRedownload}
|
||||
className="flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] cursor-pointer hover:bg-[#e8eaed] transition-colors"
|
||||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||||
className={`flex items-center gap-2.5 bg-[#f0f2f5] px-3 py-2.5 rounded-xl border border-black/5 w-[260px] transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:bg-[#e8eaed]'}`}
|
||||
>
|
||||
{isRedownloading
|
||||
? <div className="w-8 h-8 border-2 border-slate-400 border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
: <div className="w-10 h-10 rounded-full bg-[#8696a0] flex items-center justify-center shrink-0"><Mic className="w-4 h-4 text-white" /></div>
|
||||
}
|
||||
<span className="text-[12px] text-[#667781]">{isRedownloading ? 'Baixando...' : 'Toque para baixar áudio'}</span>
|
||||
<span className="text-[12px] text-[#667781]">
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Áudio não disponível' : 'Toque para baixar áudio'}
|
||||
</span>
|
||||
</div>
|
||||
{TimestampRow}
|
||||
</div>
|
||||
@@ -711,7 +719,7 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
}
|
||||
return <AudioBubble msg={msg} fullMediaUrl={fullMediaUrl} isOut={isOut} TimestampRow={TimestampRow} />;
|
||||
case 'document':
|
||||
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} />;
|
||||
return <DocumentBubble msg={msg} fullMediaUrl={fullMediaUrl} TimestampRow={TimestampRow} onRedownload={!fullMediaUrl ? handleRedownload : undefined} isRedownloading={isRedownloading} mediaUnavailable={mediaUnavailable} />;
|
||||
case 'sticker':
|
||||
return (
|
||||
<div>
|
||||
@@ -719,14 +727,14 @@ const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
? <img src={fullMediaUrl} alt="Sticker" className="max-w-[180px] max-h-[180px]" />
|
||||
: (
|
||||
<div
|
||||
onClick={handleRedownload}
|
||||
className="flex items-center gap-2 text-[13px] text-[#667781] italic cursor-pointer hover:text-[#111b21] transition-colors"
|
||||
onClick={mediaUnavailable ? undefined : handleRedownload}
|
||||
className={`flex items-center gap-2 text-[13px] text-[#667781] italic transition-colors ${mediaUnavailable ? 'cursor-default opacity-60' : 'cursor-pointer hover:text-[#111b21]'}`}
|
||||
>
|
||||
{isRedownloading
|
||||
? <div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin" />
|
||||
: <Star className="w-4 h-4" />
|
||||
}
|
||||
{isRedownloading ? 'Baixando...' : 'Toque para baixar figurinha'}
|
||||
{isRedownloading ? 'Baixando...' : mediaUnavailable ? 'Figurinha não disponível' : 'Toque para baixar figurinha'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ function chatToLegacy(c: ReturnType<typeof useChatStore.getState>['chats'][0]):
|
||||
unread_count: c.unreadCount,
|
||||
is_pinned: c.isPinned,
|
||||
is_archived: c.isArchived,
|
||||
bot_paused: c.botPaused ?? null,
|
||||
labels: [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }>
|
||||
}
|
||||
|
||||
|
||||
@@ -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,84 @@ 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<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
|
||||
|
||||
// SEC-14: indicador de digitação antes de chamar a IA
|
||||
const sock = manager?.getSocket(instanceId)
|
||||
await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||
|
||||
const brain = new ProtocolEngine(db, config)
|
||||
const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks })
|
||||
|
||||
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) => {
|
||||
@@ -696,6 +772,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 +829,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,
|
||||
|
||||
@@ -23,7 +23,7 @@ const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
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 ────────────────────────────────────────────────────────────────
|
||||
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')
|
||||
|
||||
@@ -25,6 +25,8 @@ services:
|
||||
volumes:
|
||||
- /var/log:/var/log:ro
|
||||
- /opt/soc/promtail:/etc/promtail:ro
|
||||
- /run/user/1000/docker.sock:/var/run/docker.sock:ro
|
||||
- /home/deploy/.local/share/docker/containers:/home/deploy/.local/share/docker/containers:ro
|
||||
command: -config.file=/etc/promtail/config.yml
|
||||
networks:
|
||||
- soc
|
||||
|
||||
Reference in New Issue
Block a user