Merge pull request 'feat(secretaria): Secretária IA separada por sessão (agente por número)' (#20) from feat/secretaria-por-sessao into main
This commit was merged in pull request #20.
This commit is contained in:
@@ -240,6 +240,7 @@ model Message {
|
|||||||
replyToId String? // ID da mensagem citada (reply)
|
replyToId String? // ID da mensagem citada (reply)
|
||||||
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
|
||||||
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
|
||||||
|
reactions Json? // { remetente: emoji } — reações persistidas (sent/recv)
|
||||||
status MessageStatus @default(PENDING)
|
status MessageStatus @default(PENDING)
|
||||||
timestamp DateTime
|
timestamp DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|||||||
@@ -305,6 +305,10 @@ export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
|
|||||||
const chat = await chatRepo.findById(id, req.tenantId!)
|
const chat = await chatRepo.findById(id, req.tenantId!)
|
||||||
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||||
|
|
||||||
|
// Assina a presença do contato (composing/recording) ao abrir o chat — só assim
|
||||||
|
// o Baileys emite presence.update, que o core repassa como 'presence:update'.
|
||||||
|
try { (manager.getSocket(chat.instanceId) as any)?.presenceSubscribe(chat.jid) } catch { /* best-effort */ }
|
||||||
|
|
||||||
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
|
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
|
||||||
before,
|
before,
|
||||||
after,
|
after,
|
||||||
|
|||||||
+38
@@ -326,6 +326,21 @@ export class WhatsAppConnectionManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Presença do CONTATO (composing/recording/paused/available) → frontend nativo
|
||||||
|
// via Socket.IO (por tenant). Só chega se assinarmos (presenceSubscribe, feito na
|
||||||
|
// rota de mensagens ao abrir o chat). id = jid do chat.
|
||||||
|
;(sock.ev as any).on('presence.update', ({ id, presences }: any) => {
|
||||||
|
let state = 'available'
|
||||||
|
for (const p of Object.values(presences || {}) as any[]) {
|
||||||
|
const s = (p as any)?.lastKnownPresence
|
||||||
|
if (s === 'composing' || s === 'recording') { state = s; break }
|
||||||
|
if (s) state = s
|
||||||
|
}
|
||||||
|
this.io.to(`tenant:${tenantId}`).emit('presence:update', { instanceId, jid: id, state })
|
||||||
|
// Também via hook → ws-bridge → satélite (scoreodonto) mostra "digitando" em realtime.
|
||||||
|
hookBus.emit('ext:presence', { instanceId, tenantId, jid: id, state, timestamp: Date.now() }).catch(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
;(sock.ev as any).on('messages.upsert', (m: any) => {
|
;(sock.ev as any).on('messages.upsert', (m: any) => {
|
||||||
this.touchActivity(instanceId)
|
this.touchActivity(instanceId)
|
||||||
|
|
||||||
@@ -400,6 +415,29 @@ export class WhatsAppConnectionManager {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── Reações recebidas ───────────────────────────────────────────────
|
||||||
|
// Listener ADITIVO: repassa a reação (emoji) da mensagem-alvo ao frontend via
|
||||||
|
// hook (ws-bridge → 'message.reaction'). text vazio = reação removida.
|
||||||
|
;(sock.ev as any).on('messages.reaction', (reactions: any) => {
|
||||||
|
this.touchActivity(instanceId)
|
||||||
|
for (const { key, reaction } of reactions) {
|
||||||
|
if (!key?.id) continue
|
||||||
|
const emoji = reaction?.text ?? ''
|
||||||
|
const sender = reaction?.key?.fromMe ? 'me' : (reaction?.key?.participant || key.participant || key.remoteJid || 'contato')
|
||||||
|
// Persiste na mensagem-alvo (para sobreviver ao reload) + emite ao vivo.
|
||||||
|
prisma.message.findFirst({ where: { instanceId, messageId: key.id }, select: { id: true, reactions: true } })
|
||||||
|
.then((m) => {
|
||||||
|
if (!m) return
|
||||||
|
const r: Record<string, string> = { ...((m.reactions as any) || {}) }
|
||||||
|
if (emoji) r[sender] = emoji; else delete r[sender]
|
||||||
|
return prisma.message.update({ where: { id: m.id }, data: { reactions: r } })
|
||||||
|
}).catch(() => {})
|
||||||
|
hookBus.emit('ext:message.reaction', {
|
||||||
|
instanceId, tenantId, messageId: key.id, emoji, sender, timestamp: Date.now(),
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ─── Contatos ────────────────────────────────────────────────────────
|
// ─── Contatos ────────────────────────────────────────────────────────
|
||||||
// contacts.upsert: lista completa de contatos (conexão inicial/sync)
|
// contacts.upsert: lista completa de contatos (conexão inicial/sync)
|
||||||
// contacts.update: atualizações parciais (nome alterado, etc)
|
// contacts.update: atualizações parciais (nome alterado, etc)
|
||||||
|
|||||||
+47
-4
@@ -344,7 +344,7 @@ export class MessageHandler {
|
|||||||
unreadCount: 0
|
unreadCount: 0
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
contactId: contact.id,
|
contact: { connect: { id: contact.id } },
|
||||||
...(lastAt && { lastMessageAt: lastAt })
|
...(lastAt && { lastMessageAt: lastAt })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -359,6 +359,8 @@ export class MessageHandler {
|
|||||||
if (!contentType) return []
|
if (!contentType) return []
|
||||||
// Descarta tipos de protocolo/sistema que não representam conteúdo real
|
// Descarta tipos de protocolo/sistema que não representam conteúdo real
|
||||||
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return []
|
if (PROTOCOL_CONTENT_TYPES.has(contentType)) return []
|
||||||
|
// Reação: aplica no alvo e NÃO armazena como mensagem (evita bolha-lixo).
|
||||||
|
if (this.applyReaction(message, key)) return []
|
||||||
const msgType = this.mapContentType(contentType)
|
const msgType = this.mapContentType(contentType)
|
||||||
if (msgType === MessageType.UNSUPPORTED) return []
|
if (msgType === MessageType.UNSUPPORTED) return []
|
||||||
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date()
|
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date()
|
||||||
@@ -445,6 +447,34 @@ export class MessageHandler {
|
|||||||
* 7. Agenda download de mídia (assíncrono)
|
* 7. Agenda download de mídia (assíncrono)
|
||||||
* 8. Dispara chatbot (assíncrono)
|
* 8. Dispara chatbot (assíncrono)
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Se a mensagem for uma reação (reactionMessage), aplica o emoji na mensagem-ALVO
|
||||||
|
* (troca/remoção — text vazio remove), persiste em `reactions` e emite (io + hook
|
||||||
|
* satélite). Retorna true se era reação — nesse caso NÃO deve ser armazenada como
|
||||||
|
* mensagem (evita bolhas-lixo type=REACTION no inbox). Usado no fluxo real e no histórico.
|
||||||
|
*/
|
||||||
|
private applyReaction(message: any, key: any): boolean {
|
||||||
|
const rm = message?.reactionMessage
|
||||||
|
if (!rm) return false
|
||||||
|
const targetId: string | undefined = rm?.key?.id
|
||||||
|
if (targetId) {
|
||||||
|
const emoji = rm?.text ?? ''
|
||||||
|
const fromMe = key.fromMe ?? false
|
||||||
|
const sender = fromMe ? 'me' : (key.participant || normalizeJid(key.remoteJid || '') || 'contato')
|
||||||
|
prisma.message.findFirst({ where: { instanceId: this.instanceId, messageId: targetId }, select: { id: true, chatId: true, reactions: true } })
|
||||||
|
.then((m) => {
|
||||||
|
if (!m) return
|
||||||
|
const r: Record<string, string> = { ...((m.reactions as any) || {}) }
|
||||||
|
if (emoji) r[sender] = emoji; else delete r[sender]
|
||||||
|
return prisma.message.update({ where: { id: m.id }, data: { reactions: r } }).then(() => {
|
||||||
|
this.io.to(`chat:${m.chatId}`).emit('message:reaction', { messageId: targetId, emoji, sender })
|
||||||
|
hookBus.emit('ext:message.reaction', { tenantId: this.tenantId, instanceId: this.instanceId, messageId: targetId, emoji, sender, timestamp: Date.now() }).catch(() => {})
|
||||||
|
})
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private async processMessage(msg: WAMessage): Promise<void> {
|
private async processMessage(msg: WAMessage): Promise<void> {
|
||||||
const { key, messageTimestamp, pushName } = msg
|
const { key, messageTimestamp, pushName } = msg
|
||||||
let message = msg.message
|
let message = msg.message
|
||||||
@@ -520,6 +550,9 @@ export class MessageHandler {
|
|||||||
let remoteJid = normalizeJid(remoteJidRaw)
|
let remoteJid = normalizeJid(remoteJidRaw)
|
||||||
const messageId = key.id!
|
const messageId = key.id!
|
||||||
|
|
||||||
|
// Reação: aplica na msg-alvo (persiste + emite) e NÃO armazena como mensagem.
|
||||||
|
if (this.applyReaction(message, key)) return
|
||||||
|
|
||||||
// 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
|
// Caso especial (InfiniteAPI v7+): para mensagens fromMe=true enviadas do celular
|
||||||
@@ -595,7 +628,7 @@ export class MessageHandler {
|
|||||||
lastMessageAt: new Date((messageTimestamp as number) * 1000),
|
lastMessageAt: new Date((messageTimestamp as number) * 1000),
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
|
contact: { connect: { id: contact.id } }, // Garante o vínculo (corrige chats órfãos antigos)
|
||||||
lastMessageAt: new Date((messageTimestamp as number) * 1000),
|
lastMessageAt: new Date((messageTimestamp as number) * 1000),
|
||||||
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
|
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
|
||||||
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
|
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
|
||||||
@@ -701,7 +734,8 @@ export class MessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
|
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
|
||||||
if (!fromMe && body && this.chatbotService) {
|
// Só em conversas 1:1 — a Secretária/chatbot NÃO responde em grupos (@g.us).
|
||||||
|
if (!fromMe && body && !isGroupMessage && this.chatbotService) {
|
||||||
setImmediate(() =>
|
setImmediate(() =>
|
||||||
this.chatbotService!.handleIncoming({
|
this.chatbotService!.handleIncoming({
|
||||||
tenantId: this.tenantId,
|
tenantId: this.tenantId,
|
||||||
@@ -981,6 +1015,8 @@ export class MessageHandler {
|
|||||||
messageId: savedMsgId,
|
messageId: savedMsgId,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
})
|
})
|
||||||
|
// Hook → ws-bridge → satélite (scoreodonto) atualiza o preview em realtime.
|
||||||
|
hookBus.emit('ext:message.media_ready', { tenantId: this.tenantId, messageId: savedMsgId, mediaUrl, timestamp: Date.now() }).catch(() => {})
|
||||||
|
|
||||||
// Persiste sticker na biblioteca (deduplicado por sha256)
|
// Persiste sticker na biblioteca (deduplicado por sha256)
|
||||||
if (contentType === 'stickerMessage') {
|
if (contentType === 'stickerMessage') {
|
||||||
@@ -989,7 +1025,9 @@ export class MessageHandler {
|
|||||||
|
|
||||||
// Transcrição (áudio) / descrição (imagem) via IA — fire-and-forget.
|
// Transcrição (áudio) / descrição (imagem) via IA — fire-and-forget.
|
||||||
if (contentType === 'audioMessage' || contentType === 'imageMessage') {
|
if (contentType === 'audioMessage' || contentType === 'imageMessage') {
|
||||||
this.transcribeMedia(savedMsgId, chatId, buffer as Buffer, originalMimetype, contentType).catch(() => {})
|
const mediaJid = normalizeJid(msg.key.remoteJid || '')
|
||||||
|
const hadCaption = !!(msg.message?.imageMessage?.caption || msg.message?.videoMessage?.caption)
|
||||||
|
this.transcribeMedia(savedMsgId, chatId, buffer as Buffer, originalMimetype, contentType, mediaJid, msg.key.fromMe ?? false, hadCaption).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
|
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
|
||||||
@@ -1001,11 +1039,16 @@ export class MessageHandler {
|
|||||||
/** Transcreve áudio / descreve imagem via Gemini e salva em messages.transcription. */
|
/** Transcreve áudio / descreve imagem via Gemini e salva em messages.transcription. */
|
||||||
private async transcribeMedia(
|
private async transcribeMedia(
|
||||||
messageId: string, chatId: string, buffer: Buffer, mimetype: string | undefined, contentType: string,
|
messageId: string, chatId: string, buffer: Buffer, mimetype: string | undefined, contentType: string,
|
||||||
|
jid?: string, fromMe?: boolean, hadCaption?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const text = await transcribeOrDescribe(buffer, mimetype, contentType)
|
const text = await transcribeOrDescribe(buffer, mimetype, contentType)
|
||||||
if (!text) return
|
if (!text) return
|
||||||
await prisma.message.update({ where: { id: messageId }, data: { transcription: text } }).catch(() => {})
|
await prisma.message.update({ where: { id: messageId }, data: { transcription: text } }).catch(() => {})
|
||||||
this.io.to(`chat:${chatId}`).emit('message:transcription', { messageId, transcription: text })
|
this.io.to(`chat:${chatId}`).emit('message:transcription', { messageId, transcription: text })
|
||||||
|
// jid/chatId/contentType/fromMe permitem a Secretária reagir a imagem/áudio
|
||||||
|
// (o satélite/ext-api religa isto no fluxo de ext:message.new). hadCaption evita
|
||||||
|
// processar 2x quando a imagem já veio com legenda (que já disparou ext:message.new).
|
||||||
|
hookBus.emit('ext:message.transcription', { tenantId: this.tenantId, instanceId: this.instanceId, chatId, jid, fromMe, contentType, hadCaption: !!hadCaption, messageId, transcription: text, timestamp: Date.now() }).catch(() => {})
|
||||||
logger.debug({ messageId, contentType }, '[media-transcribe] transcrição salva')
|
logger.debug({ messageId, contentType }, '[media-transcribe] transcrição salva')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,8 +186,8 @@ function scheduleHandoffTimeout(convId, extChatId, tenantId, db, hooks) {
|
|||||||
try {
|
try {
|
||||||
await db('sec_conversations').where({ id: convId })
|
await db('sec_conversations').where({ id: convId })
|
||||||
.update({ handoff_mode: 'ia', handoff_human_at: null });
|
.update({ handoff_mode: 'ia', handoff_human_at: null });
|
||||||
// chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId")
|
// jid = último segmento do extChatId ("tenantId:jid" ou "tenantId:instanceId:jid")
|
||||||
const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId;
|
const chatId = extChatId.split(':').pop() ?? extChatId;
|
||||||
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' });
|
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' });
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
@@ -205,11 +205,17 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
const router = (0, express_1.Router)();
|
const router = (0, express_1.Router)();
|
||||||
// ── Helper: envia reply da IA via WA e persiste no banco principal ─────────
|
// ── Helper: envia reply da IA via WA e persiste no banco principal ─────────
|
||||||
async function sendSecretariaReply(opts) {
|
async function sendSecretariaReply(opts) {
|
||||||
const { instanceId, tenantId, chatId, jid, reply } = opts;
|
const { instanceId, tenantId, chatId, jid, reply, agentName } = opts;
|
||||||
const sock = manager?.getSocket(instanceId);
|
const sock = manager?.getSocket(instanceId);
|
||||||
if (!sock)
|
if (!sock)
|
||||||
return;
|
return;
|
||||||
const sent = await sock.sendMessage(jid, { text: reply }).catch(() => null);
|
// Assinatura da Secretária: prefixa "*Nome:*\n" (negrito) com o PRIMEIRO nome
|
||||||
|
// do agente (ex.: "Ana — Atendente Virtual" → "Ana"). Identifica a IA na caixa
|
||||||
|
// compartilhada — como a assinatura do operador humano. O ":" fica DENTRO do
|
||||||
|
// negrito p/ todos os dispositivos verem "Ana:" em negrito consistente.
|
||||||
|
const first = (agentName ?? '').trim().split(/[\s—–-]+/).filter(Boolean)[0];
|
||||||
|
const text = first ? `*${first}:*\n${reply}` : reply;
|
||||||
|
const sent = await sock.sendMessage(jid, { text }).catch(() => null);
|
||||||
const persisted = await prisma.message.create({
|
const persisted = await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -219,7 +225,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
messageId: sent?.key.id ?? `sec-${Date.now()}`,
|
messageId: sent?.key.id ?? `sec-${Date.now()}`,
|
||||||
fromMe: true,
|
fromMe: true,
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
body: reply,
|
body: text,
|
||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
},
|
||||||
@@ -291,6 +297,47 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
// newsletter, lid e status — a Secretária não cria conversa para esses.
|
// newsletter, lid e status — a Secretária não cria conversa para esses.
|
||||||
if (!jid.endsWith('@s.whatsapp.net'))
|
if (!jid.endsWith('@s.whatsapp.net'))
|
||||||
return;
|
return;
|
||||||
|
// ── Parte A: é a RESPOSTA do DENTISTA a um pedido de encaixe (SIM/NÃO)? ──
|
||||||
|
// Roda mesmo com auto_reply off (é fluxo de sistema). Se sim, agenda/avisa o
|
||||||
|
// paciente e NÃO segue o fluxo normal da Secretária para esta mensagem.
|
||||||
|
try {
|
||||||
|
const approval = await db('sec_agenda_approvals').where({ dentista_jid: jid, status: 'aguardando' }).orderBy('created_at', 'desc').first();
|
||||||
|
if (approval) {
|
||||||
|
const low = String(text).toLowerCase().trim();
|
||||||
|
const disseSim = /(^|\W)(sim|pode|consigo|d[áa]\s*tempo|ok|claro|beleza|confirmo|isso|fechou|manda|pos)/.test(low) || low.includes('👍');
|
||||||
|
const disseNao = /(^|\W)(n[ãa]o|nao)/.test(low);
|
||||||
|
const sinalOn = async (instId, j) => (await prisma.chat.findFirst({ where: { instanceId: instId, jid: j }, orderBy: { updatedAt: 'desc' }, select: { id: true } }))?.id;
|
||||||
|
const chnl = await db('sec_numbers').where({ instance_id: approval.instance_id }).first();
|
||||||
|
const ag = chnl?.agent_id ? await db('sec_agents').where({ id: chnl.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
||||||
|
const dataStr = approval.data instanceof Date ? approval.data.toISOString().slice(0, 10) : String(approval.data).slice(0, 10);
|
||||||
|
const horaStr = String(approval.hora).slice(0, 5);
|
||||||
|
if (disseSim && !disseNao) {
|
||||||
|
let confirmado = false;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${String(approval.agenda_url).replace(/\/$/, '')}/book`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': approval.agenda_secret || '' }, signal: AbortSignal.timeout(12000),
|
||||||
|
body: JSON.stringify({ clinica_id: approval.clinica_id, dentista_id: approval.dentista_id, start: `${dataStr} ${horaStr}:00`, paciente_nome: approval.paciente_nome, paciente_celular: approval.paciente_telefone, procedimento: approval.procedimento }),
|
||||||
|
});
|
||||||
|
confirmado = r.ok;
|
||||||
|
}
|
||||||
|
catch { /* falha de rede */ }
|
||||||
|
await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: confirmado ? 'confirmado' : 'recusado', updated_at: new Date() });
|
||||||
|
const pmsg = confirmado
|
||||||
|
? `Oie! O Dr(a) ${String(approval.dentista_nome || '').split(' ')[0]} confirmou 🎉 Agendei você para ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)} às ${horaStr}. Te espero!`
|
||||||
|
: `Oie! Consegui o ok do dentista, mas tive um probleminha pra registrar o horário — já te retorno pra fechar, tá?`;
|
||||||
|
await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: pmsg, agentName: ag?.name });
|
||||||
|
await sendSecretariaReply({ instanceId, tenantId, chatId: (await sinalOn(instanceId, jid)) ?? jid, jid, reply: 'Perfeito, obrigado! Já confirmei com o paciente. 🙌' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (disseNao) {
|
||||||
|
await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: 'recusado', updated_at: new Date() });
|
||||||
|
await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: 'Oie! Infelizmente não deu pra encaixar nesse horário 😕 Mas posso te oferecer outro — prefere de manhã ou à tarde?', agentName: ag?.name });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return; // resposta do dentista ainda ambígua — aguarda um SIM/NÃO claro
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* segue o fluxo normal */ }
|
||||||
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
|
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
|
||||||
// disparo automático da Secretária (envios manuais seguem normais). Só
|
// disparo automático da Secretária (envios manuais seguem normais). Só
|
||||||
// bloqueia quando explicitamente false — sem a flag, comportamento inalterado.
|
// bloqueia quando explicitamente false — sem a flag, comportamento inalterado.
|
||||||
@@ -301,10 +348,22 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
|
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
|
||||||
if (activeBot)
|
if (activeBot)
|
||||||
return;
|
return;
|
||||||
|
// Canal deste número (sec_numbers da instância): define o agente/cérebro e a
|
||||||
|
// clínica (escopo da ponte de agenda). É o que separa a Secretária por sessão.
|
||||||
|
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first();
|
||||||
|
// ext_chat_id = tenant:jid (2 partes) — mantido assim porque as rotas de
|
||||||
|
// handoff reconstroem a chave só com o jid. A separação por sessão vem do
|
||||||
|
// AGENTE resolvido pelo número (channel.agent_id), não da chave da conversa.
|
||||||
|
// (Limitação conhecida: o MESMO contato falando com dois números do mesmo
|
||||||
|
// tenant reusa a 1ª conversa/agente — caso raro, follow-up.)
|
||||||
const extKey = `${tenantId}:${jid}`;
|
const extKey = `${tenantId}:${jid}`;
|
||||||
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first();
|
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first();
|
||||||
if (!conv) {
|
if (!conv) {
|
||||||
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
// Agente do número (channel.agent_id); fallback: primeiro agente ativo (legado).
|
||||||
|
const agent = (channel?.agent_id
|
||||||
|
? await db('sec_agents').where({ id: channel.agent_id, active: true }).first()
|
||||||
|
: null)
|
||||||
|
?? await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
||||||
if (!agent)
|
if (!agent)
|
||||||
return;
|
return;
|
||||||
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
|
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
|
||||||
@@ -329,13 +388,94 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
// SEC-14: indicador de digitação antes de chamar a IA
|
// SEC-14: indicador de digitação antes de chamar a IA
|
||||||
const sock = manager?.getSocket(instanceId);
|
const sock = manager?.getSocket(instanceId);
|
||||||
await sock?.sendPresenceUpdate('composing', jid).catch(() => { });
|
await sock?.sendPresenceUpdate('composing', jid).catch(() => { });
|
||||||
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
|
// Clínica e agenda vêm do NÚMERO que recebeu a mensagem (channel, resolvido acima).
|
||||||
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
|
|
||||||
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first();
|
|
||||||
const brain = new brain_1.ProtocolEngine(db, config);
|
const brain = new brain_1.ProtocolEngine(db, config);
|
||||||
const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id });
|
const reply = await brain.chat(conv.id, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id, instanceId });
|
||||||
await sock?.sendPresenceUpdate('paused', jid).catch(() => { });
|
await sock?.sendPresenceUpdate('paused', jid).catch(() => { });
|
||||||
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply });
|
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first();
|
||||||
|
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name });
|
||||||
|
});
|
||||||
|
// ── ext:message.transcription → religa a Secretária p/ imagem/áudio ────────
|
||||||
|
// Imagem/áudio não geram ext:message.new (não têm `body` de texto). Quando a
|
||||||
|
// IA descreve a imagem / transcreve o áudio, reaproveitamos TODO o fluxo da
|
||||||
|
// Secretária re-emitindo ext:message.new com o texto resultante. Imagem vira
|
||||||
|
// um marcador "[Imagem: …]" (a IA identifica e, se for dúvida, encaminha ao
|
||||||
|
// humano via encaminhar_humano); áudio entra como a própria fala transcrita.
|
||||||
|
hooks.register('ext:message.transcription', async (data) => {
|
||||||
|
const { tenantId, instanceId, chatId, jid, fromMe, hadCaption, contentType, transcription } = data || {};
|
||||||
|
if (fromMe)
|
||||||
|
return; // só mensagem RECEBIDA do cliente
|
||||||
|
if (hadCaption)
|
||||||
|
return; // legenda já disparou ext:message.new — não processar 2x
|
||||||
|
if (!jid || !instanceId || !transcription?.trim())
|
||||||
|
return;
|
||||||
|
if (!String(jid).endsWith('@s.whatsapp.net'))
|
||||||
|
return; // só 1:1
|
||||||
|
const text = contentType === 'imageMessage'
|
||||||
|
? `[Imagem enviada pelo cliente] ${transcription.trim()}`
|
||||||
|
: transcription.trim();
|
||||||
|
try {
|
||||||
|
await hooks.emit('ext:message.new', { tenantId, instanceId, chatId, jid, text });
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
});
|
||||||
|
// ── Worker: follow-ups proativos da agenda (Parte B) ──────────────────────
|
||||||
|
// A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a
|
||||||
|
// agenda perto do horário desejado e manda ao paciente "Oie, consegui!".
|
||||||
|
setInterval(async () => {
|
||||||
|
let due = [];
|
||||||
|
try {
|
||||||
|
due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10);
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const fu of due) {
|
||||||
|
try {
|
||||||
|
const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' });
|
||||||
|
if (!claimed)
|
||||||
|
continue;
|
||||||
|
const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null;
|
||||||
|
const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`);
|
||||||
|
u.searchParams.set('clinica_id', fu.clinica_id);
|
||||||
|
if (dataStr)
|
||||||
|
u.searchParams.set('date', dataStr);
|
||||||
|
if (fu.periodo)
|
||||||
|
u.searchParams.set('periodo', fu.periodo);
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12000) });
|
||||||
|
const j = r.ok ? await r.json() : {};
|
||||||
|
const slots = j?.slots ?? [];
|
||||||
|
// slot mais próximo do horário desejado (senão o primeiro).
|
||||||
|
let escolhido = slots[0];
|
||||||
|
if (fu.hora_desejada && slots.length) {
|
||||||
|
const alvo = String(fu.hora_desejada).slice(0, 5);
|
||||||
|
escolhido = slots.slice().sort((a, b) => Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0];
|
||||||
|
}
|
||||||
|
const texto = escolhido
|
||||||
|
? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?`
|
||||||
|
: 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?';
|
||||||
|
// assina com o agente do número (channel) para manter o padrão.
|
||||||
|
const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first();
|
||||||
|
const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
|
||||||
|
await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name });
|
||||||
|
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' });
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => { });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
// ── Parte A: envia a pergunta de encaixe ao DENTISTA (tool perguntar_dentista) ─
|
||||||
|
hooks.register('ext:agenda.notify-dentist', async (data) => {
|
||||||
|
const { instanceId, tenantId, jid, text } = data || {};
|
||||||
|
if (!instanceId || !jid || !text)
|
||||||
|
return;
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { instanceId, jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
|
||||||
|
await sendSecretariaReply({ instanceId, tenantId, chatId: chat?.id ?? jid, jid, reply: text });
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
});
|
});
|
||||||
// ── 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.
|
||||||
@@ -698,6 +838,10 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl });
|
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl });
|
||||||
}
|
}
|
||||||
catch { }
|
catch { }
|
||||||
|
try {
|
||||||
|
hooks.emit('ext:message.media_ready', { tenantId, messageId, mediaUrl, timestamp: Date.now() });
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
// Thumbnail (imagem, não-figurinha) → carregamento rápido nas prévias. Best-effort:
|
// Thumbnail (imagem, não-figurinha) → carregamento rápido nas prévias. Best-effort:
|
||||||
// grava no Wasabi como <mediaUrl>.thumb.webp e é servido/cacheado igual à mídia.
|
// grava no Wasabi como <mediaUrl>.thumb.webp e é servido/cacheado igual à mídia.
|
||||||
if (mediaStorage?.isRegistered?.() && !mediaUrl.startsWith('/media/') && /^image\//i.test(mime) && contentType !== 'stickerMessage') {
|
if (mediaStorage?.isRegistered?.() && !mediaUrl.startsWith('/media/') && /^image\//i.test(mime) && contentType !== 'stickerMessage') {
|
||||||
@@ -720,6 +864,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
// ── GET /inbox ────────────────────────────────────────────────────────────
|
// ── GET /inbox ────────────────────────────────────────────────────────────
|
||||||
// Lista chats da instância com snapshot da última mensagem.
|
// Lista chats da instância com snapshot da última mensagem.
|
||||||
router.get('/inbox', async (req, res) => {
|
router.get('/inbox', async (req, res) => {
|
||||||
|
var _a;
|
||||||
const tenantId = req.extTenantId;
|
const tenantId = req.extTenantId;
|
||||||
const sessionId = req.query['session'];
|
const sessionId = req.query['session'];
|
||||||
const search = req.query['search'];
|
const search = req.query['search'];
|
||||||
@@ -784,6 +929,17 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Etiquetas por chat (join chat_labels + labels do tenant).
|
||||||
|
const chatIds = chats.map((c) => c.id);
|
||||||
|
const labelRows = chatIds.length
|
||||||
|
? await db('chat_labels as cl').join('labels as l', 'l.id', 'cl.label_id')
|
||||||
|
.whereIn('cl.chat_id', chatIds).where('l.tenant_id', tenantId)
|
||||||
|
.select('cl.chat_id', 'l.id', 'l.name', 'l.color')
|
||||||
|
: [];
|
||||||
|
const labelsByChat = {};
|
||||||
|
for (const r of labelRows) {
|
||||||
|
(labelsByChat[_a = r.chat_id] ?? (labelsByChat[_a] = [])).push({ id: r.id, name: r.name, color: r.color });
|
||||||
|
}
|
||||||
// Resolve nome de exibição: Contact.name > Contact.verifiedName > Contact.notify > Chat.name
|
// Resolve nome de exibição: Contact.name > Contact.verifiedName > Contact.notify > Chat.name
|
||||||
const result = chats.map((c) => {
|
const result = chats.map((c) => {
|
||||||
const ct = c.contact;
|
const ct = c.contact;
|
||||||
@@ -799,6 +955,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
lastMessageAt: c.lastMessageAt,
|
lastMessageAt: c.lastMessageAt,
|
||||||
isPinned: c.isPinned,
|
isPinned: c.isPinned,
|
||||||
isArchived: c.isArchived,
|
isArchived: c.isArchived,
|
||||||
|
labels: labelsByChat[c.id] ?? [],
|
||||||
lastMessage: lastMsg
|
lastMessage: lastMsg
|
||||||
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
|
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
|
||||||
: null,
|
: null,
|
||||||
@@ -837,13 +994,38 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
|
// profilePictureUrl pode TRAVAR (sem resposta do WhatsApp) ou FALHAR na
|
||||||
// sem resposta do WhatsApp). Corremos contra um timeout para responder
|
// alta-resolução ('image') mesmo quando a foto EXISTE e é visível no app.
|
||||||
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
|
// Tentamos 'image' e, se vier vazio/erro, caímos para 'preview' (baixa-res,
|
||||||
const url = await Promise.race([
|
// bem mais confiável). Cada tentativa corre contra um timeout para nunca
|
||||||
sock.profilePictureUrl(jid, 'image').catch(() => null),
|
// estourar 504 no gateway. O erro real é logado (antes era silenciado).
|
||||||
new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
|
const raceTimeout = (p, ms = 8000) => Promise.race([p, new Promise((r) => setTimeout(() => r(null), ms))]);
|
||||||
]);
|
const tryFetch = (target, type) => raceTimeout(sock.profilePictureUrl(target, type).catch((e) => {
|
||||||
|
console.warn(`[avatar] ${target} ${type} falhou:`, e?.message || e);
|
||||||
|
return null;
|
||||||
|
}));
|
||||||
|
let url = (await tryFetch(jid, 'image')) || (await tryFetch(jid, 'preview'));
|
||||||
|
// O WhatsApp novo endereça contatos por @lid (identidade oculta): a foto NÃO
|
||||||
|
// resolve pelo JID de telefone (@s.whatsapp.net) — só pelo @lid. Resolvemos o
|
||||||
|
// @lid pelo mapa lid↔pn do engine (infinite/baileys7 mantêm um LIDMappingStore,
|
||||||
|
// populado quando a mensagem chega) e, em último caso, via onWhatsApp().
|
||||||
|
if (!url && jid.endsWith('@s.whatsapp.net')) {
|
||||||
|
let lid;
|
||||||
|
try {
|
||||||
|
const map = sock.signalRepository?.lidMapping;
|
||||||
|
if (map?.getLIDForPN) {
|
||||||
|
lid = (await raceTimeout(Promise.resolve(map.getLIDForPN(jid)), 6000)) ?? undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { /* engine sem lidMapping */ }
|
||||||
|
if (!lid) {
|
||||||
|
const info = await raceTimeout(sock.onWhatsApp(jid).catch(() => null), 8000);
|
||||||
|
lid = Array.isArray(info) ? info[0]?.lid : undefined;
|
||||||
|
}
|
||||||
|
if (lid) {
|
||||||
|
url = (await tryFetch(lid, 'image')) || (await tryFetch(lid, 'preview'));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!url) {
|
if (!url) {
|
||||||
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
|
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
|
||||||
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
|
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
|
||||||
@@ -851,7 +1033,8 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
|
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
|
||||||
data: { avatarUrl: null },
|
data: { avatarUrl: null },
|
||||||
});
|
});
|
||||||
res.status(404).json({ avatar: null });
|
// 200 (não 404) — "sem foto" é resultado válido; evita ruído de erro no console.
|
||||||
|
res.status(200).json({ avatar: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await prisma.contact.updateMany({
|
await prisma.contact.updateMany({
|
||||||
@@ -904,6 +1087,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
timestamp: true,
|
timestamp: true,
|
||||||
pushName: true,
|
pushName: true,
|
||||||
senderJid: true,
|
senderJid: true,
|
||||||
|
reactions: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
// mediaRecoverable: já tem URL, ou tem o WAMessage salvo (mediaPayload) / arquivo
|
// mediaRecoverable: já tem URL, ou tem o WAMessage salvo (mediaPayload) / arquivo
|
||||||
@@ -1070,13 +1254,269 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
res.status(500).json({ error: err.message ?? 'Erro ao apagar mensagem' });
|
res.status(500).json({ error: err.message ?? 'Erro ao apagar mensagem' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// ── DELETE /inbox/:chatId/messages ────────────────────────────────────────
|
||||||
|
// "Limpar conversa": remove TODAS as mensagens do chat no motor, mantendo o chat.
|
||||||
|
// Limpeza LOCAL do inbox (não revoga nada no WhatsApp) — análogo ao "Limpar conversa".
|
||||||
|
// A permissão (dono OU can_delete_msg) é validada no satélite (proxy) antes daqui.
|
||||||
|
router.delete('/inbox/:chatId/messages', async (req, res) => {
|
||||||
|
const tenantId = req.extTenantId;
|
||||||
|
const chatId = req.params['chatId'];
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } });
|
||||||
|
if (!chat) {
|
||||||
|
res.status(404).json({ error: 'Chat não encontrado' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { count } = await prisma.message.deleteMany({ where: { chatId, tenantId } });
|
||||||
|
res.json({ ok: true, deleted: count });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao limpar conversa' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ── POST /inbox/:chatId/messages/:messageId/react ─────────────────────────
|
||||||
|
// Reage a uma mensagem (emoji). emoji vazio = remove a reação. :messageId = id
|
||||||
|
// interno (uuid). Envia via Baileys ({ react: { text, key } }).
|
||||||
|
router.post('/inbox/:chatId/messages/:messageId/react', async (req, res) => {
|
||||||
|
const tenantId = req.extTenantId;
|
||||||
|
const chatId = req.params['chatId'];
|
||||||
|
const messageId = req.params['messageId'];
|
||||||
|
const { emoji } = req.body;
|
||||||
|
try {
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, remoteJid: true, senderJid: true, instanceId: true, reactions: true },
|
||||||
|
});
|
||||||
|
if (!msg) {
|
||||||
|
res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = manager.getSocket(msg.instanceId);
|
||||||
|
if (!sock) {
|
||||||
|
res.status(400).json({ error: 'Instância não conectada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId };
|
||||||
|
if (msg.remoteJid.endsWith('@g.us') && msg.senderJid)
|
||||||
|
key.participant = msg.senderJid;
|
||||||
|
await sock.sendMessage(msg.remoteJid, { react: { text: emoji ?? '', key } });
|
||||||
|
// Persiste a MINHA reação (chave 'me') para sobreviver ao reload.
|
||||||
|
const r = { ...(msg.reactions || {}) };
|
||||||
|
if (emoji)
|
||||||
|
r['me'] = emoji;
|
||||||
|
else
|
||||||
|
delete r['me'];
|
||||||
|
await prisma.message.update({ where: { id: messageId }, data: { reactions: r } }).catch(() => { });
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao reagir' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ── POST /inbox/:chatId/typing ────────────────────────────────────────────
|
||||||
|
// Envia MINHA presença ao contato: state = 'composing' | 'paused' | 'recording'.
|
||||||
|
router.post('/inbox/:chatId/typing', async (req, res) => {
|
||||||
|
const tenantId = req.extTenantId;
|
||||||
|
const chatId = req.params['chatId'];
|
||||||
|
const state = (req.body?.state ?? 'composing');
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { jid: true, instanceId: true } });
|
||||||
|
if (!chat) {
|
||||||
|
res.status(404).json({ error: 'Chat não encontrado' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = manager.getSocket(chat.instanceId);
|
||||||
|
if (!sock) {
|
||||||
|
res.status(400).json({ error: 'Instância não conectada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const st = ['composing', 'paused', 'recording'].includes(state) ? state : 'composing';
|
||||||
|
await sock.sendPresenceUpdate(st, chat.jid);
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao enviar presença' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ═══ Etiquetas (labels) — por tenant; atribuídas a chats ═══════════════════
|
||||||
|
router.get('/labels', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const rows = await db('labels').where({ tenant_id: req.extTenantId }).orderBy('created_at', 'asc');
|
||||||
|
res.json(rows.map(l => ({ id: l.id, name: l.name, color: l.color })));
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao listar etiquetas' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post('/labels', async (req, res) => {
|
||||||
|
const { name, color } = req.body;
|
||||||
|
if (!name?.trim()) {
|
||||||
|
res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const id = uuid();
|
||||||
|
const c = (typeof color === 'string' && color) ? color : '#00a884';
|
||||||
|
await db('labels').insert({ id, tenant_id: req.extTenantId, name: name.trim(), color: c });
|
||||||
|
res.json({ id, name: name.trim(), color: c });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao criar etiqueta' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.patch('/labels/:id', async (req, res) => {
|
||||||
|
const { name, color } = req.body;
|
||||||
|
const patch = {};
|
||||||
|
if (typeof name === 'string' && name.trim())
|
||||||
|
patch['name'] = name.trim();
|
||||||
|
if (typeof color === 'string' && color)
|
||||||
|
patch['color'] = color;
|
||||||
|
if (!Object.keys(patch).length) {
|
||||||
|
res.status(400).json({ error: 'Nada para atualizar' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const n = await db('labels').where({ id: req.params['id'], tenant_id: req.extTenantId }).update(patch);
|
||||||
|
if (!n) {
|
||||||
|
res.status(404).json({ error: 'Etiqueta não encontrada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao editar etiqueta' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.delete('/labels/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const n = await db('labels').where({ id: req.params['id'], tenant_id: req.extTenantId }).del();
|
||||||
|
res.json({ ok: true, deleted: n });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao apagar etiqueta' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Define as etiquetas de um chat (substitui o conjunto). Body: { labelIds: string[] }.
|
||||||
|
router.put('/inbox/:chatId/labels', async (req, res) => {
|
||||||
|
const tenantId = req.extTenantId;
|
||||||
|
const chatId = req.params['chatId'];
|
||||||
|
const { labelIds } = req.body;
|
||||||
|
if (!Array.isArray(labelIds)) {
|
||||||
|
res.status(400).json({ error: 'labelIds (array) obrigatório' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } });
|
||||||
|
if (!chat) {
|
||||||
|
res.status(404).json({ error: 'Chat não encontrado' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const valid = await db('labels').where({ tenant_id: tenantId }).whereIn('id', labelIds.length ? labelIds : ['']).pluck('id');
|
||||||
|
await db('chat_labels').where({ chat_id: chatId }).del();
|
||||||
|
if (valid.length)
|
||||||
|
await db('chat_labels').insert(valid.map(id => ({ chat_id: chatId, label_id: id })));
|
||||||
|
res.json({ ok: true, labelIds: valid });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao aplicar etiquetas' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ── PATCH /inbox/:chatId/messages/:messageId ──────────────────────────────
|
||||||
|
// Edita uma mensagem de texto enviada por você (edit do WhatsApp via Baileys).
|
||||||
|
router.patch('/inbox/:chatId/messages/:messageId', async (req, res) => {
|
||||||
|
const tenantId = req.extTenantId;
|
||||||
|
const chatId = req.params['chatId'];
|
||||||
|
const messageId = req.params['messageId'];
|
||||||
|
const { text } = req.body;
|
||||||
|
if (!text?.trim()) {
|
||||||
|
res.status(400).json({ error: 'Campo "text" obrigatório' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, remoteJid: true, instanceId: true },
|
||||||
|
});
|
||||||
|
if (!msg) {
|
||||||
|
res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!msg.fromMe) {
|
||||||
|
res.status(400).json({ error: 'Só é possível editar mensagens enviadas por você.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = manager.getSocket(msg.instanceId);
|
||||||
|
if (!sock) {
|
||||||
|
res.status(400).json({ error: 'Instância não conectada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await sock.sendMessage(msg.remoteJid, { text: text.trim(), edit: { remoteJid: msg.remoteJid, fromMe: true, id: msg.messageId } });
|
||||||
|
await prisma.message.update({ where: { id: messageId }, data: { body: text.trim() } }).catch(() => { });
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao editar mensagem' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// ── POST /inbox/:chatId/messages/:messageId/forward ───────────────────────
|
||||||
|
// Encaminha uma mensagem para outros chats. Body: { toChatIds: string[] } (ids internos).
|
||||||
|
router.post('/inbox/:chatId/messages/:messageId/forward', async (req, res) => {
|
||||||
|
const tenantId = req.extTenantId;
|
||||||
|
const chatId = req.params['chatId'];
|
||||||
|
const messageId = req.params['messageId'];
|
||||||
|
const { toChatIds } = req.body;
|
||||||
|
if (!Array.isArray(toChatIds) || toChatIds.length === 0) {
|
||||||
|
res.status(400).json({ error: 'toChatIds (array) obrigatório' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, remoteJid: true, senderJid: true, body: true, mediaPayload: true, instanceId: true },
|
||||||
|
});
|
||||||
|
if (!msg) {
|
||||||
|
res.status(404).json({ error: 'Mensagem não encontrada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sock = manager.getSocket(msg.instanceId);
|
||||||
|
if (!sock) {
|
||||||
|
res.status(400).json({ error: 'Instância não conectada' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// WAMessage a encaminhar: usa o payload salvo (mídia/rico) ou reconstrói texto.
|
||||||
|
const key = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId };
|
||||||
|
if (msg.remoteJid.endsWith('@g.us') && msg.senderJid)
|
||||||
|
key.participant = msg.senderJid;
|
||||||
|
let waMsg;
|
||||||
|
if (msg.mediaPayload) {
|
||||||
|
const d = deserializeMediaPayload(msg.mediaPayload);
|
||||||
|
waMsg = { key: d?.key ?? key, message: d?.message ?? { conversation: msg.body ?? '' } };
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
waMsg = { key, message: { conversation: msg.body ?? '' } };
|
||||||
|
}
|
||||||
|
const targets = await prisma.chat.findMany({ where: { id: { in: toChatIds }, tenantId }, select: { jid: true } });
|
||||||
|
let forwarded = 0;
|
||||||
|
for (const t of targets) {
|
||||||
|
try {
|
||||||
|
await sock.sendMessage(t.jid, { forward: waMsg });
|
||||||
|
forwarded++;
|
||||||
|
}
|
||||||
|
catch { /* segue os demais */ }
|
||||||
|
}
|
||||||
|
res.json({ ok: true, forwarded });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao encaminhar' });
|
||||||
|
}
|
||||||
|
});
|
||||||
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
|
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
|
||||||
// Envia mensagem de texto ou rica para o chat.
|
// Envia mensagem de texto ou rica para o chat.
|
||||||
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
||||||
router.post('/inbox/:chatId/send', async (req, res) => {
|
router.post('/inbox/:chatId/send', async (req, res) => {
|
||||||
const tenantId = req.extTenantId;
|
const tenantId = req.extTenantId;
|
||||||
const chatId = req.params['chatId'];
|
const chatId = req.params['chatId'];
|
||||||
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = req.body;
|
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll, replyToMessageId } = req.body;
|
||||||
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll);
|
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll);
|
||||||
if (!text && !hasRich) {
|
if (!text && !hasRich) {
|
||||||
res.status(400).json({ error: 'Campo "text" ou conteúdo rico obrigatório' });
|
res.status(400).json({ error: 'Campo "text" ou conteúdo rico obrigatório' });
|
||||||
@@ -1096,6 +1536,29 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
res.status(400).json({ error: 'Instância não conectada' });
|
res.status(400).json({ error: 'Instância não conectada' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Reply/citação: monta o WAMessage citado (key + message) COM o conteúdo da
|
||||||
|
// mensagem original. Sem o quotedMessage o destinatário vê "Aguardando mensagem"
|
||||||
|
// (o WhatsApp não resolve só pelo stanzaId). replyToMessageId = messageId do WA.
|
||||||
|
let quoted;
|
||||||
|
if (replyToMessageId) {
|
||||||
|
const orig = await prisma.message.findFirst({
|
||||||
|
where: { messageId: replyToMessageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, senderJid: true, body: true, mediaPayload: true },
|
||||||
|
});
|
||||||
|
if (orig?.messageId) {
|
||||||
|
const key = { remoteJid: chat.jid, fromMe: orig.fromMe, id: orig.messageId };
|
||||||
|
if (chat.jid.endsWith('@g.us') && orig.senderJid)
|
||||||
|
key.participant = orig.senderJid;
|
||||||
|
let message;
|
||||||
|
if (orig.mediaPayload) {
|
||||||
|
const wa = deserializeMediaPayload(orig.mediaPayload);
|
||||||
|
message = wa?.message ?? undefined;
|
||||||
|
}
|
||||||
|
if (!message)
|
||||||
|
message = { conversation: orig.body ?? '' };
|
||||||
|
quoted = { key, message };
|
||||||
|
}
|
||||||
|
}
|
||||||
// Usa o rich-message builder para converter nativeButtons/nativeList/
|
// Usa o rich-message builder para converter nativeButtons/nativeList/
|
||||||
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
|
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
|
||||||
// O footer legado (aninhado em nativeList) é extraído para o top-level.
|
// O footer legado (aninhado em nativeList) é extraído para o top-level.
|
||||||
@@ -1110,7 +1573,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
nativeList: cleanList,
|
nativeList: cleanList,
|
||||||
nativeCarousel: nativeCarousel,
|
nativeCarousel: nativeCarousel,
|
||||||
poll: poll,
|
poll: poll,
|
||||||
});
|
}, quoted);
|
||||||
// Echo em tempo real: todos os operadores do número veem o envio na hora.
|
// Echo em tempo real: todos os operadores do número veem o envio na hora.
|
||||||
if (waMessageId) {
|
if (waMessageId) {
|
||||||
await echoSentMessage({
|
await echoSentMessage({
|
||||||
@@ -1462,12 +1925,14 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
select: { id: true, instanceId: true },
|
select: { id: true, instanceId: true },
|
||||||
});
|
});
|
||||||
if (chat) {
|
if (chat) {
|
||||||
|
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first();
|
||||||
await sendSecretariaReply({
|
await sendSecretariaReply({
|
||||||
instanceId: chat.instanceId,
|
instanceId: chat.instanceId,
|
||||||
tenantId,
|
tenantId,
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
jid: chatId,
|
jid: chatId,
|
||||||
reply,
|
reply,
|
||||||
|
agentName: secAgent?.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
res.json({
|
res.json({
|
||||||
@@ -1498,6 +1963,30 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
|
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
|
||||||
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
|
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// ── Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) ───────────────
|
||||||
|
// Desliga o auto-reply para TODOS os chats, sem timer — fica off até religarem
|
||||||
|
// manualmente. Botão de segurança enquanto a IA amadurece; aberto a todos (por
|
||||||
|
// ora). Efeito imediato: config.set atualiza o cache que o ext:message.new lê.
|
||||||
|
router.get('/secretaria/power', async (_req, res) => {
|
||||||
|
try {
|
||||||
|
const cfg = (await config.get('secretaria'));
|
||||||
|
res.json({ enabled: cfg?.auto_reply !== false });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post('/secretaria/power', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const enabled = !!(req.body?.enabled);
|
||||||
|
const cfg = { ...(await config.get('secretaria')), auto_reply: enabled };
|
||||||
|
await config.set('secretaria', cfg);
|
||||||
|
res.json({ enabled });
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
// ── Agents ──────────────────────────────────────────────────────────────────
|
// ── Agents ──────────────────────────────────────────────────────────────────
|
||||||
router.get('/secretaria/agents', async (_req, res) => {
|
router.get('/secretaria/agents', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -1590,9 +2079,15 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||||
router.get('/secretaria/calendar', async (_req, res) => {
|
router.get('/secretaria/calendar', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
res.json(await db('sec_calendar').orderBy('date').orderBy('time_start'));
|
// Escopo por sessão: instance_id na query → slots daquele número + globais
|
||||||
|
// (instance_id NULL). Sem query → todos (compat).
|
||||||
|
const instanceId = req.query['instance_id']?.trim() || undefined;
|
||||||
|
let q = db('sec_calendar').orderBy('date').orderBy('time_start');
|
||||||
|
if (instanceId)
|
||||||
|
q = q.where((qb) => qb.whereNull('instance_id').orWhere('instance_id', instanceId));
|
||||||
|
res.json(await q);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
@@ -1603,6 +2098,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
const [s] = await db('sec_calendar').insert({
|
const [s] = await db('sec_calendar').insert({
|
||||||
id: uuid(), title: b.title ?? null, date: b.date, time_start: b.time_start, time_end: b.time_end,
|
id: uuid(), title: b.title ?? null, date: b.date, time_start: b.time_start, time_end: b.time_end,
|
||||||
|
instance_id: b.instance_id ?? null,
|
||||||
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
|
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
|
||||||
status: b.status ?? 'available', notes: b.notes ?? null,
|
status: b.status ?? 'available', notes: b.notes ?? null,
|
||||||
}).returning('*');
|
}).returning('*');
|
||||||
@@ -1615,7 +2111,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
router.put('/secretaria/calendar/:id', async (req, res) => {
|
router.put('/secretaria/calendar/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
||||||
for (const k of ['title', 'date', 'time_start', 'time_end', 'attendee_name', 'attendee_phone', 'status', 'notes'])
|
for (const k of ['title', 'date', 'time_start', 'time_end', 'instance_id', 'attendee_name', 'attendee_phone', 'status', 'notes'])
|
||||||
if (k in b)
|
if (k in b)
|
||||||
patch[k] = b[k];
|
patch[k] = b[k];
|
||||||
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*');
|
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*');
|
||||||
@@ -1640,6 +2136,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
const [n] = await db('sec_numbers').insert({
|
const [n] = await db('sec_numbers').insert({
|
||||||
id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
|
id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
|
||||||
|
agent_id: b.agent_id ?? null,
|
||||||
phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
|
phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
|
||||||
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
|
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
|
||||||
}).returning('*');
|
}).returning('*');
|
||||||
@@ -1652,7 +2149,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
router.put('/secretaria/numbers/:id', async (req, res) => {
|
router.put('/secretaria/numbers/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
||||||
for (const k of ['instance_id', 'clinica_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes'])
|
for (const k of ['instance_id', 'clinica_id', 'agent_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes'])
|
||||||
if (k in b)
|
if (k in b)
|
||||||
patch[k] = b[k];
|
patch[k] = b[k];
|
||||||
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*');
|
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*');
|
||||||
@@ -1805,7 +2302,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
cancelHandoffTimeout(conv.id);
|
cancelHandoffTimeout(conv.id);
|
||||||
}
|
}
|
||||||
await db('sec_conversations').where({ id: conv.id }).update(updateData);
|
await db('sec_conversations').where({ id: conv.id }).update(updateData);
|
||||||
res.json({ mode, conversationId: conv.id });
|
// Emite p/ outros operadores verem a pausa/retomada ao vivo (satélite via ws-bridge).
|
||||||
|
hooks.emit('ext:handoff', { tenantId, conversationId: conv.id, chatId, mode, reason: 'manual', handoffHumanAt: updateData.handoff_human_at ?? null }).catch(() => { });
|
||||||
|
res.json({ mode, conversationId: conv.id, handoffHumanAt: updateData.handoff_human_at ?? null });
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -151,8 +151,8 @@ function scheduleHandoffTimeout(
|
|||||||
try {
|
try {
|
||||||
await db('sec_conversations').where({ id: convId })
|
await db('sec_conversations').where({ id: convId })
|
||||||
.update({ handoff_mode: 'ia', handoff_human_at: null })
|
.update({ handoff_mode: 'ia', handoff_human_at: null })
|
||||||
// chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId")
|
// jid = último segmento do extChatId ("tenantId:jid" ou "tenantId:instanceId:jid")
|
||||||
const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId
|
const chatId = extChatId.split(':').pop() ?? extChatId
|
||||||
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' })
|
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' })
|
||||||
} catch {}
|
} catch {}
|
||||||
}, HANDOFF_TIMEOUT_MS)
|
}, HANDOFF_TIMEOUT_MS)
|
||||||
@@ -182,11 +182,18 @@ export function buildExtRoutes(
|
|||||||
chatId: string
|
chatId: string
|
||||||
jid: string
|
jid: string
|
||||||
reply: string
|
reply: string
|
||||||
|
agentName?: string
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const { instanceId, tenantId, chatId, jid, reply } = opts
|
const { instanceId, tenantId, chatId, jid, reply, agentName } = opts
|
||||||
const sock = manager?.getSocket(instanceId)
|
const sock = manager?.getSocket(instanceId)
|
||||||
if (!sock) return
|
if (!sock) return
|
||||||
const sent = await sock.sendMessage(jid, { text: reply }).catch(() => null)
|
// Assinatura da Secretária: prefixa "*Nome:*\n" (negrito) com o PRIMEIRO nome
|
||||||
|
// do agente (ex.: "Ana — Atendente Virtual" → "Ana"). Identifica a IA na caixa
|
||||||
|
// compartilhada — como a assinatura do operador humano. O ":" fica DENTRO do
|
||||||
|
// negrito p/ todos os dispositivos verem "Ana:" em negrito consistente.
|
||||||
|
const first = (agentName ?? '').trim().split(/[\s—–-]+/).filter(Boolean)[0]
|
||||||
|
const text = first ? `*${first}:*\n${reply}` : reply
|
||||||
|
const sent = await sock.sendMessage(jid, { text }).catch(() => null)
|
||||||
const persisted = await prisma.message.create({
|
const persisted = await prisma.message.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -196,7 +203,7 @@ export function buildExtRoutes(
|
|||||||
messageId: sent?.key.id ?? `sec-${Date.now()}`,
|
messageId: sent?.key.id ?? `sec-${Date.now()}`,
|
||||||
fromMe: true,
|
fromMe: true,
|
||||||
type: 'TEXT',
|
type: 'TEXT',
|
||||||
body: reply,
|
body: text,
|
||||||
status: 'SENT',
|
status: 'SENT',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
},
|
},
|
||||||
@@ -270,6 +277,46 @@ export function buildExtRoutes(
|
|||||||
// newsletter, lid e status — a Secretária não cria conversa para esses.
|
// newsletter, lid e status — a Secretária não cria conversa para esses.
|
||||||
if (!jid.endsWith('@s.whatsapp.net')) return
|
if (!jid.endsWith('@s.whatsapp.net')) return
|
||||||
|
|
||||||
|
// ── Parte A: é a RESPOSTA do DENTISTA a um pedido de encaixe (SIM/NÃO)? ──
|
||||||
|
// Roda mesmo com auto_reply off (é fluxo de sistema). Se sim, agenda/avisa o
|
||||||
|
// paciente e NÃO segue o fluxo normal da Secretária para esta mensagem.
|
||||||
|
try {
|
||||||
|
const approval = await db('sec_agenda_approvals').where({ dentista_jid: jid, status: 'aguardando' }).orderBy('created_at', 'desc').first()
|
||||||
|
if (approval) {
|
||||||
|
const low = String(text).toLowerCase().trim()
|
||||||
|
const disseSim = /(^|\W)(sim|pode|consigo|d[áa]\s*tempo|ok|claro|beleza|confirmo|isso|fechou|manda|pos)/.test(low) || low.includes('👍')
|
||||||
|
const disseNao = /(^|\W)(n[ãa]o|nao)/.test(low)
|
||||||
|
const sinalOn = async (instId: string, j: string) => (await prisma.chat.findFirst({ where: { instanceId: instId, jid: j }, orderBy: { updatedAt: 'desc' }, select: { id: true } }))?.id
|
||||||
|
const chnl = await db('sec_numbers').where({ instance_id: approval.instance_id }).first()
|
||||||
|
const ag = chnl?.agent_id ? await db('sec_agents').where({ id: chnl.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first()
|
||||||
|
const dataStr = approval.data instanceof Date ? approval.data.toISOString().slice(0, 10) : String(approval.data).slice(0, 10)
|
||||||
|
const horaStr = String(approval.hora).slice(0, 5)
|
||||||
|
if (disseSim && !disseNao) {
|
||||||
|
let confirmado = false
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${String(approval.agenda_url).replace(/\/$/, '')}/book`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': approval.agenda_secret || '' }, signal: AbortSignal.timeout(12_000),
|
||||||
|
body: JSON.stringify({ clinica_id: approval.clinica_id, dentista_id: approval.dentista_id, start: `${dataStr} ${horaStr}:00`, paciente_nome: approval.paciente_nome, paciente_celular: approval.paciente_telefone, procedimento: approval.procedimento }),
|
||||||
|
})
|
||||||
|
confirmado = r.ok
|
||||||
|
} catch { /* falha de rede */ }
|
||||||
|
await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: confirmado ? 'confirmado' : 'recusado', updated_at: new Date() })
|
||||||
|
const pmsg = confirmado
|
||||||
|
? `Oie! O Dr(a) ${String(approval.dentista_nome || '').split(' ')[0]} confirmou 🎉 Agendei você para ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)} às ${horaStr}. Te espero!`
|
||||||
|
: `Oie! Consegui o ok do dentista, mas tive um probleminha pra registrar o horário — já te retorno pra fechar, tá?`
|
||||||
|
await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: pmsg, agentName: ag?.name })
|
||||||
|
await sendSecretariaReply({ instanceId, tenantId, chatId: (await sinalOn(instanceId, jid)) ?? jid, jid, reply: 'Perfeito, obrigado! Já confirmei com o paciente. 🙌' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (disseNao) {
|
||||||
|
await db('sec_agenda_approvals').where({ id: approval.id }).update({ status: 'recusado', updated_at: new Date() })
|
||||||
|
await sendSecretariaReply({ instanceId: approval.instance_id, tenantId: approval.tenant_id, chatId: (await sinalOn(approval.instance_id, approval.paciente_jid)) ?? approval.paciente_jid, jid: approval.paciente_jid, reply: 'Oie! Infelizmente não deu pra encaixar nesse horário 😕 Mas posso te oferecer outro — prefere de manhã ou à tarde?', agentName: ag?.name })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return // resposta do dentista ainda ambígua — aguarda um SIM/NÃO claro
|
||||||
|
}
|
||||||
|
} catch { /* segue o fluxo normal */ }
|
||||||
|
|
||||||
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
|
// Kill-switch do auto-reply: secretaria.auto_reply === false desliga SÓ o
|
||||||
// disparo automático da Secretária (envios manuais seguem normais). Só
|
// disparo automático da Secretária (envios manuais seguem normais). Só
|
||||||
// bloqueia quando explicitamente false — sem a flag, comportamento inalterado.
|
// bloqueia quando explicitamente false — sem a flag, comportamento inalterado.
|
||||||
@@ -280,11 +327,24 @@ export function buildExtRoutes(
|
|||||||
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } })
|
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } })
|
||||||
if (activeBot) return
|
if (activeBot) return
|
||||||
|
|
||||||
|
// Canal deste número (sec_numbers da instância): define o agente/cérebro e a
|
||||||
|
// clínica (escopo da ponte de agenda). É o que separa a Secretária por sessão.
|
||||||
|
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first()
|
||||||
|
|
||||||
|
// ext_chat_id = tenant:jid (2 partes) — mantido assim porque as rotas de
|
||||||
|
// handoff reconstroem a chave só com o jid. A separação por sessão vem do
|
||||||
|
// AGENTE resolvido pelo número (channel.agent_id), não da chave da conversa.
|
||||||
|
// (Limitação conhecida: o MESMO contato falando com dois números do mesmo
|
||||||
|
// tenant reusa a 1ª conversa/agente — caso raro, follow-up.)
|
||||||
const extKey = `${tenantId}:${jid}`
|
const extKey = `${tenantId}:${jid}`
|
||||||
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first()
|
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first()
|
||||||
|
|
||||||
if (!conv) {
|
if (!conv) {
|
||||||
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
|
// Agente do número (channel.agent_id); fallback: primeiro agente ativo (legado).
|
||||||
|
const agent = (channel?.agent_id
|
||||||
|
? await db('sec_agents').where({ id: channel.agent_id, active: true }).first()
|
||||||
|
: null)
|
||||||
|
?? await db('sec_agents').where({ active: true }).orderBy('created_at').first()
|
||||||
if (!agent) return
|
if (!agent) return
|
||||||
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
|
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
|
||||||
const contact = await prisma.contact.findFirst({
|
const contact = await prisma.contact.findFirst({
|
||||||
@@ -310,14 +370,83 @@ export function buildExtRoutes(
|
|||||||
const sock = manager?.getSocket(instanceId)
|
const sock = manager?.getSocket(instanceId)
|
||||||
await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
|
await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||||
|
|
||||||
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
|
// Clínica e agenda vêm do NÚMERO que recebeu a mensagem (channel, resolvido acima).
|
||||||
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
|
|
||||||
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first()
|
|
||||||
const brain = new ProtocolEngine(db, config)
|
const brain = new ProtocolEngine(db, config)
|
||||||
const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id })
|
const reply = await brain.chat(conv.id as string, text.trim(), { tenantId, hooks, clinicaId: channel?.clinica_id, instanceId })
|
||||||
|
|
||||||
await sock?.sendPresenceUpdate('paused', jid).catch(() => {})
|
await sock?.sendPresenceUpdate('paused', jid).catch(() => {})
|
||||||
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply })
|
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first()
|
||||||
|
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name })
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── ext:message.transcription → religa a Secretária p/ imagem/áudio ────────
|
||||||
|
// Imagem/áudio não geram ext:message.new (não têm `body` de texto). Quando a
|
||||||
|
// IA descreve a imagem / transcreve o áudio, reaproveitamos TODO o fluxo da
|
||||||
|
// Secretária re-emitindo ext:message.new com o texto resultante. Imagem vira
|
||||||
|
// um marcador "[Imagem: …]" (a IA identifica e, se for dúvida, encaminha ao
|
||||||
|
// humano via encaminhar_humano); áudio entra como a própria fala transcrita.
|
||||||
|
hooks.register('ext:message.transcription', async (data: any) => {
|
||||||
|
const { tenantId, instanceId, chatId, jid, fromMe, hadCaption, contentType, transcription } = data || {}
|
||||||
|
if (fromMe) return // só mensagem RECEBIDA do cliente
|
||||||
|
if (hadCaption) return // legenda já disparou ext:message.new — não processar 2x
|
||||||
|
if (!jid || !instanceId || !transcription?.trim()) return
|
||||||
|
if (!String(jid).endsWith('@s.whatsapp.net')) return // só 1:1
|
||||||
|
const text = contentType === 'imageMessage'
|
||||||
|
? `[Imagem enviada pelo cliente] ${transcription.trim()}`
|
||||||
|
: transcription.trim()
|
||||||
|
try { await hooks.emit('ext:message.new', { tenantId, instanceId, chatId, jid, text }) }
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Worker: follow-ups proativos da agenda (Parte B) ──────────────────────
|
||||||
|
// A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a
|
||||||
|
// agenda perto do horário desejado e manda ao paciente "Oie, consegui!".
|
||||||
|
setInterval(async () => {
|
||||||
|
let due: any[] = []
|
||||||
|
try { due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10) }
|
||||||
|
catch { return }
|
||||||
|
for (const fu of due) {
|
||||||
|
try {
|
||||||
|
const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' })
|
||||||
|
if (!claimed) continue
|
||||||
|
const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null
|
||||||
|
const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`)
|
||||||
|
u.searchParams.set('clinica_id', fu.clinica_id)
|
||||||
|
if (dataStr) u.searchParams.set('date', dataStr)
|
||||||
|
if (fu.periodo) u.searchParams.set('periodo', fu.periodo)
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12_000) })
|
||||||
|
const j: any = r.ok ? await r.json() : {}
|
||||||
|
const slots: any[] = j?.slots ?? []
|
||||||
|
// slot mais próximo do horário desejado (senão o primeiro).
|
||||||
|
let escolhido = slots[0]
|
||||||
|
if (fu.hora_desejada && slots.length) {
|
||||||
|
const alvo = String(fu.hora_desejada).slice(0, 5)
|
||||||
|
escolhido = slots.slice().sort((a, b) =>
|
||||||
|
Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0]
|
||||||
|
}
|
||||||
|
const texto = escolhido
|
||||||
|
? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?`
|
||||||
|
: 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?'
|
||||||
|
// assina com o agente do número (channel) para manter o padrão.
|
||||||
|
const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first()
|
||||||
|
const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first()
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } })
|
||||||
|
await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name })
|
||||||
|
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' })
|
||||||
|
} catch {
|
||||||
|
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 30_000)
|
||||||
|
|
||||||
|
// ── Parte A: envia a pergunta de encaixe ao DENTISTA (tool perguntar_dentista) ─
|
||||||
|
hooks.register('ext:agenda.notify-dentist', async (data: any) => {
|
||||||
|
const { instanceId, tenantId, jid, text } = data || {}
|
||||||
|
if (!instanceId || !jid || !text) return
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { instanceId, jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } })
|
||||||
|
await sendSecretariaReply({ instanceId, tenantId, chatId: chat?.id ?? jid, jid, reply: text })
|
||||||
|
} catch { /* best-effort */ }
|
||||||
})
|
})
|
||||||
|
|
||||||
// ── Escalation notification listener ──────────────────────────────────────
|
// ── Escalation notification listener ──────────────────────────────────────
|
||||||
@@ -666,6 +795,7 @@ export function buildExtRoutes(
|
|||||||
|
|
||||||
await prisma.message.update({ where: { id: messageId }, data: { mediaUrl } })
|
await prisma.message.update({ where: { id: messageId }, data: { mediaUrl } })
|
||||||
try { manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl }) } catch {}
|
try { manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl }) } catch {}
|
||||||
|
try { hooks.emit('ext:message.media_ready', { tenantId, messageId, mediaUrl, timestamp: Date.now() }) } catch {}
|
||||||
|
|
||||||
// Thumbnail (imagem, não-figurinha) → carregamento rápido nas prévias. Best-effort:
|
// Thumbnail (imagem, não-figurinha) → carregamento rápido nas prévias. Best-effort:
|
||||||
// grava no Wasabi como <mediaUrl>.thumb.webp e é servido/cacheado igual à mídia.
|
// grava no Wasabi como <mediaUrl>.thumb.webp e é servido/cacheado igual à mídia.
|
||||||
@@ -757,6 +887,16 @@ export function buildExtRoutes(
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Etiquetas por chat (join chat_labels + labels do tenant).
|
||||||
|
const chatIds = chats.map((c) => c.id)
|
||||||
|
const labelRows = chatIds.length
|
||||||
|
? await db('chat_labels as cl').join('labels as l', 'l.id', 'cl.label_id')
|
||||||
|
.whereIn('cl.chat_id', chatIds).where('l.tenant_id', tenantId)
|
||||||
|
.select('cl.chat_id', 'l.id', 'l.name', 'l.color')
|
||||||
|
: []
|
||||||
|
const labelsByChat: Record<string, Array<{ id: string; name: string; color: string }>> = {}
|
||||||
|
for (const r of labelRows as any[]) { (labelsByChat[r.chat_id] ??= []).push({ id: r.id, name: r.name, color: r.color }) }
|
||||||
|
|
||||||
// Resolve nome de exibição: Contact.name > Contact.verifiedName > Contact.notify > Chat.name
|
// Resolve nome de exibição: Contact.name > Contact.verifiedName > Contact.notify > Chat.name
|
||||||
const result = chats.map((c) => {
|
const result = chats.map((c) => {
|
||||||
const ct = c.contact
|
const ct = c.contact
|
||||||
@@ -772,6 +912,7 @@ export function buildExtRoutes(
|
|||||||
lastMessageAt: c.lastMessageAt,
|
lastMessageAt: c.lastMessageAt,
|
||||||
isPinned: c.isPinned,
|
isPinned: c.isPinned,
|
||||||
isArchived: c.isArchived,
|
isArchived: c.isArchived,
|
||||||
|
labels: labelsByChat[c.id] ?? [],
|
||||||
lastMessage: lastMsg
|
lastMessage: lastMsg
|
||||||
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
|
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
|
||||||
: null,
|
: null,
|
||||||
@@ -814,13 +955,41 @@ export function buildExtRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
|
// profilePictureUrl pode TRAVAR (sem resposta do WhatsApp) ou FALHAR na
|
||||||
// sem resposta do WhatsApp). Corremos contra um timeout para responder
|
// alta-resolução ('image') mesmo quando a foto EXISTE e é visível no app.
|
||||||
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
|
// Tentamos 'image' e, se vier vazio/erro, caímos para 'preview' (baixa-res,
|
||||||
const url = await Promise.race([
|
// bem mais confiável). Cada tentativa corre contra um timeout para nunca
|
||||||
sock.profilePictureUrl(jid, 'image').catch(() => null),
|
// estourar 504 no gateway. O erro real é logado (antes era silenciado).
|
||||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
|
const raceTimeout = <T>(p: Promise<T>, ms = 8000): Promise<T | null> =>
|
||||||
])
|
Promise.race<T | null>([p, new Promise<null>((r) => setTimeout(() => r(null), ms))])
|
||||||
|
const tryFetch = (target: string, type: 'image' | 'preview') =>
|
||||||
|
raceTimeout(
|
||||||
|
sock.profilePictureUrl(target, type).catch((e: any) => {
|
||||||
|
console.warn(`[avatar] ${target} ${type} falhou:`, e?.message || e)
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
let url = (await tryFetch(jid, 'image')) || (await tryFetch(jid, 'preview'))
|
||||||
|
// O WhatsApp novo endereça contatos por @lid (identidade oculta): a foto NÃO
|
||||||
|
// resolve pelo JID de telefone (@s.whatsapp.net) — só pelo @lid. Resolvemos o
|
||||||
|
// @lid pelo mapa lid↔pn do engine (infinite/baileys7 mantêm um LIDMappingStore,
|
||||||
|
// populado quando a mensagem chega) e, em último caso, via onWhatsApp().
|
||||||
|
if (!url && jid.endsWith('@s.whatsapp.net')) {
|
||||||
|
let lid: string | undefined
|
||||||
|
try {
|
||||||
|
const map = (sock as any).signalRepository?.lidMapping
|
||||||
|
if (map?.getLIDForPN) {
|
||||||
|
lid = (await raceTimeout(Promise.resolve(map.getLIDForPN(jid)), 6000)) ?? undefined
|
||||||
|
}
|
||||||
|
} catch { /* engine sem lidMapping */ }
|
||||||
|
if (!lid) {
|
||||||
|
const info = await raceTimeout(sock.onWhatsApp(jid).catch(() => null), 8000)
|
||||||
|
lid = Array.isArray(info) ? info[0]?.lid : undefined
|
||||||
|
}
|
||||||
|
if (lid) {
|
||||||
|
url = (await tryFetch(lid, 'image')) || (await tryFetch(lid, 'preview'))
|
||||||
|
}
|
||||||
|
}
|
||||||
if (!url) {
|
if (!url) {
|
||||||
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
|
// Sem foto acessível → limpa a URL obsoleta do banco para não servir de
|
||||||
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
|
// novo a URL 404 (futuras cargas vêm avatar=null → iniciais na hora).
|
||||||
@@ -828,7 +997,8 @@ export function buildExtRoutes(
|
|||||||
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
|
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
|
||||||
data: { avatarUrl: null },
|
data: { avatarUrl: null },
|
||||||
})
|
})
|
||||||
res.status(404).json({ avatar: null })
|
// 200 (não 404) — "sem foto" é resultado válido; evita ruído de erro no console.
|
||||||
|
res.status(200).json({ avatar: null })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await prisma.contact.updateMany({
|
await prisma.contact.updateMany({
|
||||||
@@ -883,6 +1053,7 @@ export function buildExtRoutes(
|
|||||||
timestamp: true,
|
timestamp: true,
|
||||||
pushName: true,
|
pushName: true,
|
||||||
senderJid: true,
|
senderJid: true,
|
||||||
|
reactions: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1025,15 +1196,199 @@ export function buildExtRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── DELETE /inbox/:chatId/messages ────────────────────────────────────────
|
||||||
|
// "Limpar conversa": remove TODAS as mensagens do chat no motor, mantendo o chat.
|
||||||
|
// Limpeza LOCAL do inbox (não revoga nada no WhatsApp) — análogo ao "Limpar conversa".
|
||||||
|
// A permissão (dono OU can_delete_msg) é validada no satélite (proxy) antes daqui.
|
||||||
|
router.delete('/inbox/:chatId/messages', async (req: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const chatId = req.params['chatId'] as string
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } })
|
||||||
|
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||||
|
const { count } = await prisma.message.deleteMany({ where: { chatId, tenantId } })
|
||||||
|
res.json({ ok: true, deleted: count })
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao limpar conversa' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── POST /inbox/:chatId/messages/:messageId/react ─────────────────────────
|
||||||
|
// Reage a uma mensagem (emoji). emoji vazio = remove a reação. :messageId = id
|
||||||
|
// interno (uuid). Envia via Baileys ({ react: { text, key } }).
|
||||||
|
router.post('/inbox/:chatId/messages/:messageId/react', async (req: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const chatId = req.params['chatId'] as string
|
||||||
|
const messageId = req.params['messageId'] as string
|
||||||
|
const { emoji } = req.body as { emoji?: string }
|
||||||
|
try {
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, remoteJid: true, senderJid: true, instanceId: true, reactions: true },
|
||||||
|
})
|
||||||
|
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
|
||||||
|
const sock = manager.getSocket(msg.instanceId)
|
||||||
|
if (!sock) { res.status(400).json({ error: 'Instância não conectada' }); return }
|
||||||
|
const key: any = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId }
|
||||||
|
if (msg.remoteJid.endsWith('@g.us') && msg.senderJid) key.participant = msg.senderJid
|
||||||
|
await sock.sendMessage(msg.remoteJid, { react: { text: emoji ?? '', key } })
|
||||||
|
// Persiste a MINHA reação (chave 'me') para sobreviver ao reload.
|
||||||
|
const r: Record<string, string> = { ...((msg.reactions as any) || {}) }
|
||||||
|
if (emoji) r['me'] = emoji; else delete r['me']
|
||||||
|
await prisma.message.update({ where: { id: messageId }, data: { reactions: r } }).catch(() => {})
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao reagir' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── POST /inbox/:chatId/typing ────────────────────────────────────────────
|
||||||
|
// Envia MINHA presença ao contato: state = 'composing' | 'paused' | 'recording'.
|
||||||
|
router.post('/inbox/:chatId/typing', async (req: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const chatId = req.params['chatId'] as string
|
||||||
|
const state = ((req.body as any)?.state ?? 'composing') as string
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { jid: true, instanceId: true } })
|
||||||
|
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||||
|
const sock = manager.getSocket(chat.instanceId)
|
||||||
|
if (!sock) { res.status(400).json({ error: 'Instância não conectada' }); return }
|
||||||
|
const st = ['composing', 'paused', 'recording'].includes(state) ? state : 'composing'
|
||||||
|
await sock.sendPresenceUpdate(st as any, chat.jid)
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao enviar presença' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ═══ Etiquetas (labels) — por tenant; atribuídas a chats ═══════════════════
|
||||||
|
router.get('/labels', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const rows = await db('labels').where({ tenant_id: req.extTenantId! }).orderBy('created_at', 'asc')
|
||||||
|
res.json((rows as any[]).map(l => ({ id: l.id, name: l.name, color: l.color })))
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message ?? 'Erro ao listar etiquetas' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.post('/labels', async (req: Request, res: Response) => {
|
||||||
|
const { name, color } = req.body as { name?: string; color?: string }
|
||||||
|
if (!name?.trim()) { res.status(400).json({ error: 'Campo "name" obrigatório' }); return }
|
||||||
|
try {
|
||||||
|
const id = uuid()
|
||||||
|
const c = (typeof color === 'string' && color) ? color : '#00a884'
|
||||||
|
await db('labels').insert({ id, tenant_id: req.extTenantId!, name: name.trim(), color: c })
|
||||||
|
res.json({ id, name: name.trim(), color: c })
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message ?? 'Erro ao criar etiqueta' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.patch('/labels/:id', async (req: Request, res: Response) => {
|
||||||
|
const { name, color } = req.body as { name?: string; color?: string }
|
||||||
|
const patch: Record<string, string> = {}
|
||||||
|
if (typeof name === 'string' && name.trim()) patch['name'] = name.trim()
|
||||||
|
if (typeof color === 'string' && color) patch['color'] = color
|
||||||
|
if (!Object.keys(patch).length) { res.status(400).json({ error: 'Nada para atualizar' }); return }
|
||||||
|
try {
|
||||||
|
const n = await db('labels').where({ id: req.params['id'], tenant_id: req.extTenantId! }).update(patch)
|
||||||
|
if (!n) { res.status(404).json({ error: 'Etiqueta não encontrada' }); return }
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message ?? 'Erro ao editar etiqueta' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/labels/:id', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const n = await db('labels').where({ id: req.params['id'], tenant_id: req.extTenantId! }).del()
|
||||||
|
res.json({ ok: true, deleted: n })
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message ?? 'Erro ao apagar etiqueta' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Define as etiquetas de um chat (substitui o conjunto). Body: { labelIds: string[] }.
|
||||||
|
router.put('/inbox/:chatId/labels', async (req: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const chatId = req.params['chatId'] as string
|
||||||
|
const { labelIds } = req.body as { labelIds?: string[] }
|
||||||
|
if (!Array.isArray(labelIds)) { res.status(400).json({ error: 'labelIds (array) obrigatório' }); return }
|
||||||
|
try {
|
||||||
|
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { id: true } })
|
||||||
|
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
|
||||||
|
const valid = await db('labels').where({ tenant_id: tenantId }).whereIn('id', labelIds.length ? labelIds : ['']).pluck('id')
|
||||||
|
await db('chat_labels').where({ chat_id: chatId }).del()
|
||||||
|
if (valid.length) await db('chat_labels').insert((valid as string[]).map(id => ({ chat_id: chatId, label_id: id })))
|
||||||
|
res.json({ ok: true, labelIds: valid })
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message ?? 'Erro ao aplicar etiquetas' }) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── PATCH /inbox/:chatId/messages/:messageId ──────────────────────────────
|
||||||
|
// Edita uma mensagem de texto enviada por você (edit do WhatsApp via Baileys).
|
||||||
|
router.patch('/inbox/:chatId/messages/:messageId', async (req: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const chatId = req.params['chatId'] as string
|
||||||
|
const messageId = req.params['messageId'] as string
|
||||||
|
const { text } = req.body as { text?: string }
|
||||||
|
if (!text?.trim()) { res.status(400).json({ error: 'Campo "text" obrigatório' }); return }
|
||||||
|
try {
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, remoteJid: true, instanceId: true },
|
||||||
|
})
|
||||||
|
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
|
||||||
|
if (!msg.fromMe) { res.status(400).json({ error: 'Só é possível editar mensagens enviadas por você.' }); return }
|
||||||
|
const sock = manager.getSocket(msg.instanceId)
|
||||||
|
if (!sock) { res.status(400).json({ error: 'Instância não conectada' }); return }
|
||||||
|
await sock.sendMessage(msg.remoteJid, { text: text.trim(), edit: { remoteJid: msg.remoteJid, fromMe: true, id: msg.messageId } as any })
|
||||||
|
await prisma.message.update({ where: { id: messageId }, data: { body: text.trim() } }).catch(() => {})
|
||||||
|
res.json({ ok: true })
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao editar mensagem' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── POST /inbox/:chatId/messages/:messageId/forward ───────────────────────
|
||||||
|
// Encaminha uma mensagem para outros chats. Body: { toChatIds: string[] } (ids internos).
|
||||||
|
router.post('/inbox/:chatId/messages/:messageId/forward', async (req: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const chatId = req.params['chatId'] as string
|
||||||
|
const messageId = req.params['messageId'] as string
|
||||||
|
const { toChatIds } = req.body as { toChatIds?: string[] }
|
||||||
|
if (!Array.isArray(toChatIds) || toChatIds.length === 0) {
|
||||||
|
res.status(400).json({ error: 'toChatIds (array) obrigatório' }); return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const msg = await prisma.message.findFirst({
|
||||||
|
where: { id: messageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, remoteJid: true, senderJid: true, body: true, mediaPayload: true, instanceId: true },
|
||||||
|
})
|
||||||
|
if (!msg) { res.status(404).json({ error: 'Mensagem não encontrada' }); return }
|
||||||
|
const sock = manager.getSocket(msg.instanceId)
|
||||||
|
if (!sock) { res.status(400).json({ error: 'Instância não conectada' }); return }
|
||||||
|
// WAMessage a encaminhar: usa o payload salvo (mídia/rico) ou reconstrói texto.
|
||||||
|
const key: any = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId }
|
||||||
|
if (msg.remoteJid.endsWith('@g.us') && msg.senderJid) key.participant = msg.senderJid
|
||||||
|
let waMsg: any
|
||||||
|
if (msg.mediaPayload) {
|
||||||
|
const d = deserializeMediaPayload(msg.mediaPayload as Record<string, unknown>) as any
|
||||||
|
waMsg = { key: d?.key ?? key, message: d?.message ?? { conversation: msg.body ?? '' } }
|
||||||
|
} else {
|
||||||
|
waMsg = { key, message: { conversation: msg.body ?? '' } }
|
||||||
|
}
|
||||||
|
const targets = await prisma.chat.findMany({ where: { id: { in: toChatIds }, tenantId }, select: { jid: true } })
|
||||||
|
let forwarded = 0
|
||||||
|
for (const t of targets) {
|
||||||
|
try { await sock.sendMessage(t.jid, { forward: waMsg }); forwarded++ } catch { /* segue os demais */ }
|
||||||
|
}
|
||||||
|
res.json({ ok: true, forwarded })
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro ao encaminhar' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
|
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
|
||||||
// Envia mensagem de texto ou rica para o chat.
|
// Envia mensagem de texto ou rica para o chat.
|
||||||
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
||||||
router.post('/inbox/:chatId/send', async (req: Request, res: Response) => {
|
router.post('/inbox/:chatId/send', async (req: Request, res: Response) => {
|
||||||
const tenantId = req.extTenantId!
|
const tenantId = req.extTenantId!
|
||||||
const chatId = req.params['chatId'] as string
|
const chatId = req.params['chatId'] as string
|
||||||
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = req.body as {
|
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll, replyToMessageId } = req.body as {
|
||||||
text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any;
|
text?: string; footer?: string; nativeButtons?: any[]; nativeList?: any;
|
||||||
nativeCarousel?: any; poll?: any;
|
nativeCarousel?: any; poll?: any; replyToMessageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll)
|
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll)
|
||||||
@@ -1058,6 +1413,28 @@ export function buildExtRoutes(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reply/citação: monta o WAMessage citado (key + message) COM o conteúdo da
|
||||||
|
// mensagem original. Sem o quotedMessage o destinatário vê "Aguardando mensagem"
|
||||||
|
// (o WhatsApp não resolve só pelo stanzaId). replyToMessageId = messageId do WA.
|
||||||
|
let quoted: { key: any; message: any } | undefined
|
||||||
|
if (replyToMessageId) {
|
||||||
|
const orig = await prisma.message.findFirst({
|
||||||
|
where: { messageId: replyToMessageId, chatId, tenantId },
|
||||||
|
select: { messageId: true, fromMe: true, senderJid: true, body: true, mediaPayload: true },
|
||||||
|
})
|
||||||
|
if (orig?.messageId) {
|
||||||
|
const key: any = { remoteJid: chat.jid, fromMe: orig.fromMe, id: orig.messageId }
|
||||||
|
if (chat.jid.endsWith('@g.us') && orig.senderJid) key.participant = orig.senderJid
|
||||||
|
let message: any
|
||||||
|
if (orig.mediaPayload) {
|
||||||
|
const wa = deserializeMediaPayload(orig.mediaPayload as Record<string, unknown>) as any
|
||||||
|
message = wa?.message ?? undefined
|
||||||
|
}
|
||||||
|
if (!message) message = { conversation: orig.body ?? '' }
|
||||||
|
quoted = { key, message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Usa o rich-message builder para converter nativeButtons/nativeList/
|
// Usa o rich-message builder para converter nativeButtons/nativeList/
|
||||||
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
|
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
|
||||||
// O footer legado (aninhado em nativeList) é extraído para o top-level.
|
// O footer legado (aninhado em nativeList) é extraído para o top-level.
|
||||||
@@ -1073,7 +1450,7 @@ export function buildExtRoutes(
|
|||||||
nativeList: cleanList as any,
|
nativeList: cleanList as any,
|
||||||
nativeCarousel: nativeCarousel as any,
|
nativeCarousel: nativeCarousel as any,
|
||||||
poll: poll as any,
|
poll: poll as any,
|
||||||
})
|
}, quoted)
|
||||||
// Echo em tempo real: todos os operadores do número veem o envio na hora.
|
// Echo em tempo real: todos os operadores do número veem o envio na hora.
|
||||||
if (waMessageId) {
|
if (waMessageId) {
|
||||||
await echoSentMessage({
|
await echoSentMessage({
|
||||||
@@ -1403,12 +1780,14 @@ export function buildExtRoutes(
|
|||||||
select: { id: true, instanceId: true },
|
select: { id: true, instanceId: true },
|
||||||
})
|
})
|
||||||
if (chat) {
|
if (chat) {
|
||||||
|
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first()
|
||||||
await sendSecretariaReply({
|
await sendSecretariaReply({
|
||||||
instanceId: chat.instanceId,
|
instanceId: chat.instanceId,
|
||||||
tenantId,
|
tenantId,
|
||||||
chatId: chat.id,
|
chatId: chat.id,
|
||||||
jid: chatId,
|
jid: chatId,
|
||||||
reply,
|
reply,
|
||||||
|
agentName: secAgent?.name,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1441,6 +1820,25 @@ export function buildExtRoutes(
|
|||||||
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
|
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
// ── Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) ───────────────
|
||||||
|
// Desliga o auto-reply para TODOS os chats, sem timer — fica off até religarem
|
||||||
|
// manualmente. Botão de segurança enquanto a IA amadurece; aberto a todos (por
|
||||||
|
// ora). Efeito imediato: config.set atualiza o cache que o ext:message.new lê.
|
||||||
|
router.get('/secretaria/power', async (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const cfg = (await config.get('secretaria')) as any
|
||||||
|
res.json({ enabled: cfg?.auto_reply !== false })
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||||
|
})
|
||||||
|
router.post('/secretaria/power', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const enabled = !!(req.body?.enabled)
|
||||||
|
const cfg = { ...((await config.get('secretaria')) as any), auto_reply: enabled }
|
||||||
|
await config.set('secretaria', cfg)
|
||||||
|
res.json({ enabled })
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||||
|
})
|
||||||
|
|
||||||
// ── Agents ──────────────────────────────────────────────────────────────────
|
// ── Agents ──────────────────────────────────────────────────────────────────
|
||||||
router.get('/secretaria/agents', async (_req: Request, res: Response) => {
|
router.get('/secretaria/agents', async (_req: Request, res: Response) => {
|
||||||
try { res.json(await db('sec_agents').orderBy('created_at')) }
|
try { res.json(await db('sec_agents').orderBy('created_at')) }
|
||||||
@@ -1503,15 +1901,22 @@ export function buildExtRoutes(
|
|||||||
})
|
})
|
||||||
|
|
||||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||||
router.get('/secretaria/calendar', async (_req: Request, res: Response) => {
|
router.get('/secretaria/calendar', async (req: Request, res: Response) => {
|
||||||
try { res.json(await db('sec_calendar').orderBy('date').orderBy('time_start')) }
|
try {
|
||||||
catch (err: any) { res.status(500).json({ error: err.message }) }
|
// Escopo por sessão: instance_id na query → slots daquele número + globais
|
||||||
|
// (instance_id NULL). Sem query → todos (compat).
|
||||||
|
const instanceId = (req.query['instance_id'] as string | undefined)?.trim() || undefined
|
||||||
|
let q = db('sec_calendar').orderBy('date').orderBy('time_start')
|
||||||
|
if (instanceId) q = q.where((qb: any) => qb.whereNull('instance_id').orWhere('instance_id', instanceId))
|
||||||
|
res.json(await q)
|
||||||
|
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||||
})
|
})
|
||||||
router.post('/secretaria/calendar', async (req: Request, res: Response) => {
|
router.post('/secretaria/calendar', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body ?? {}
|
const b = req.body ?? {}
|
||||||
const [s] = await db('sec_calendar').insert({
|
const [s] = await db('sec_calendar').insert({
|
||||||
id: uuid(), title: b.title ?? null, date: b.date, time_start: b.time_start, time_end: b.time_end,
|
id: uuid(), title: b.title ?? null, date: b.date, time_start: b.time_start, time_end: b.time_end,
|
||||||
|
instance_id: b.instance_id ?? null,
|
||||||
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
|
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
|
||||||
status: b.status ?? 'available', notes: b.notes ?? null,
|
status: b.status ?? 'available', notes: b.notes ?? null,
|
||||||
}).returning('*')
|
}).returning('*')
|
||||||
@@ -1521,7 +1926,7 @@ export function buildExtRoutes(
|
|||||||
router.put('/secretaria/calendar/:id', async (req: Request, res: Response) => {
|
router.put('/secretaria/calendar/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
|
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
|
||||||
for (const k of ['title', 'date', 'time_start', 'time_end', 'attendee_name', 'attendee_phone', 'status', 'notes']) if (k in b) patch[k] = b[k]
|
for (const k of ['title', 'date', 'time_start', 'time_end', 'instance_id', 'attendee_name', 'attendee_phone', 'status', 'notes']) if (k in b) patch[k] = b[k]
|
||||||
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*')
|
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*')
|
||||||
res.json(s)
|
res.json(s)
|
||||||
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||||
@@ -1537,6 +1942,7 @@ export function buildExtRoutes(
|
|||||||
const b = req.body ?? {}
|
const b = req.body ?? {}
|
||||||
const [n] = await db('sec_numbers').insert({
|
const [n] = await db('sec_numbers').insert({
|
||||||
id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
|
id: uuid(), instance_id: b.instance_id ?? null, clinica_id: b.clinica_id ?? null,
|
||||||
|
agent_id: b.agent_id ?? null,
|
||||||
phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
|
phone: b.phone ?? null, label: b.label ?? b.phone ?? 'Número',
|
||||||
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
|
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
|
||||||
}).returning('*')
|
}).returning('*')
|
||||||
@@ -1546,7 +1952,7 @@ export function buildExtRoutes(
|
|||||||
router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => {
|
router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
|
const b = req.body ?? {}, patch: any = { updated_at: new Date() }
|
||||||
for (const k of ['instance_id', 'clinica_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes']) if (k in b) patch[k] = b[k]
|
for (const k of ['instance_id', 'clinica_id', 'agent_id', 'phone', 'label', 'role', 'area', 'priority', 'active', 'notes']) if (k in b) patch[k] = b[k]
|
||||||
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*')
|
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*')
|
||||||
res.json(n)
|
res.json(n)
|
||||||
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||||
@@ -1665,7 +2071,9 @@ export function buildExtRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await db('sec_conversations').where({ id: conv.id }).update(updateData)
|
await db('sec_conversations').where({ id: conv.id }).update(updateData)
|
||||||
res.json({ mode, conversationId: conv.id })
|
// Emite p/ outros operadores verem a pausa/retomada ao vivo (satélite via ws-bridge).
|
||||||
|
hooks.emit('ext:handoff', { tenantId, conversationId: conv.id, chatId, mode, reason: 'manual', handoffHumanAt: updateData.handoff_human_at ?? null }).catch(() => {})
|
||||||
|
res.json({ mode, conversationId: conv.id, handoffHumanAt: updateData.handoff_human_at ?? null })
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message })
|
res.status(500).json({ error: err.message })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,9 @@ function buildWsBridge(httpServer, prisma, hooks) {
|
|||||||
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
||||||
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
||||||
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
||||||
|
{ hook: 'ext:message.reaction', wsEvent: 'message.reaction' },
|
||||||
|
{ hook: 'ext:message.media_ready', wsEvent: 'message.media_ready' },
|
||||||
|
{ hook: 'ext:message.transcription', wsEvent: 'message.transcription' },
|
||||||
{ hook: 'ext:presence', wsEvent: 'presence' },
|
{ hook: 'ext:presence', wsEvent: 'presence' },
|
||||||
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
||||||
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":";;AAwCA,sCAoHC;AA5JD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2BAAoD;AAKpD,+CAA8F;AAE9F,MAAM,OAAO,GAAa,oBAAoB,CAAA;AAC9C,MAAM,gBAAgB,GAAI,KAAM,CAAA;AAQhC,SAAS,aAAa,CAAC,KAAa,EAAE,IAAa;IACjD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED,SAAgB,aAAa,CAC3B,UAAsB,EACtB,MAAoB,EACpB,KAAc;IAEd,MAAM,GAAG,GAAG,IAAI,oBAAe,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAEnD,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;QACpE,IAAI,GAAG,CAAC,GAAG,KAAK,OAAO;YAAE,OAAM,CAAE,iCAAiC;QAElE,+CAA+C;QAC/C,MAAM,MAAM,GAAI,GAAG,CAAC,OAAO,CAAC,UAAU,CAAwB,EAAE,IAAI,EAAE,CAAA;QACtE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,KAAK,CAAC,sFAAsF,CAAC,CAAA;YACpG,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,QAAuB,CAAA;QAC3B,IAAI,CAAC;YACH,4EAA4E;YAC5E,0DAA0D;YAC1D,4EAA4E;YAC5E,8DAA8D;YAC9D,mCAAmC;YACnC,MAAM,KAAK,GAAI,GAAG,CAAC,OAAO,CAAC,YAAY,CAAwB,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;YACrF,IAAI,IAAA,yBAAW,EAAC,MAAM,CAAC,EAAE,CAAC;gBACxB,IAAI,CAAC,KAAK,EAAE,CAAC;oBACX,MAAM,CAAC,KAAK,CAAC,+FAA+F,CAAC,CAAA;oBAC7G,MAAM,CAAC,OAAO,EAAE,CAAA;oBAChB,OAAM;gBACR,CAAC;gBACD,QAAQ,GAAG,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAA;YACpD,CAAC;iBAAM,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;gBACrC,MAAM,KAAK,GAAG,MAAM,IAAA,4BAAc,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;gBAClD,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;YACrF,CAAC;iBAAM,CAAC;gBACN,QAAQ,GAAG,MAAM,IAAA,2BAAa,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;YAChD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAA;YAC1D,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,CAAC,KAAK,CAAC,6GAA6G,CAAC,CAAA;YAC3H,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,0BAA0B;QAC1B,GAAG,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE;YAC1C,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAa,EAAE,IAAqB,EAAE,QAAgB,EAAE,EAAE;QAC9E,MAAM,MAAM,GAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;QAEtD,8BAA8B;QAC9B,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QAEhE,yEAAyE;QACzE,MAAM,SAAS,GAA6C;YAC1D,EAAE,IAAI,EAAE,gBAAgB,EAAM,OAAO,EAAE,YAAY,EAAW;YAC9D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,iBAAiB,EAAK,OAAO,EAAE,aAAa,EAAW;YAC/D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,cAAc,EAAQ,OAAO,EAAE,UAAU,EAAc;YAC/D,EAAE,IAAI,EAAE,aAAa,EAAS,OAAO,EAAE,sBAAsB,EAAI;YACjE,EAAE,IAAI,EAAE,eAAe,EAAM,OAAO,EAAE,wBAAwB,EAAE;SACjE,CAAA;QAED,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,SAAS,EAAE,CAAC;YAC1C,MAAM,OAAO,GAAG,KAAK,EAAE,IAAS,EAAE,EAAE;gBAClC,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ;oBAAE,OAAM,CAAQ,mBAAmB;gBACjE,IAAI,EAAE,CAAC,UAAU,KAAK,EAAE,CAAC,IAAI;oBAAI,OAAM;gBACvC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,GAAG,IAAI,CAAA,CAAG,gCAAgC;gBAC9E,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAA;YAC1C,CAAC,CAAA;YACD,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;YAC7B,iEAAiE;YACjE,iEAAiE;YACjE,sEAAsE;YACtE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;gBACtB,sEAAsE;gBACtE,0EAA0E;YAC5E,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,yEAAyE;QACzE,IAAI,OAAO,GAAG,IAAI,CAAA;QAClB,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAAC,EAAE,CAAC,SAAS,EAAE,CAAC;gBAAC,OAAM;YAAC,CAAC;YACxC,OAAO,GAAG,KAAK,CAAA;YACf,EAAE,CAAC,IAAI,EAAE,CAAA;QACX,CAAC,EAAE,gBAAgB,CAAC,CAAA;QAEpB,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,OAAO,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAAA;QAEvC,yEAAyE;QACzE,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;YACxB,sEAAsE;QACxE,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QAEF,kEAAkE;QAClE,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":";;AAwCA,sCAuHC;AA/JD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2BAAoD;AAKpD,+CAA8F;AAE9F,MAAM,OAAO,GAAa,oBAAoB,CAAA;AAC9C,MAAM,gBAAgB,GAAI,KAAM,CAAA;AAQhC,SAAS,aAAa,CAAC,KAAa,EAAE,IAAa;IACjD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED,SAAgB,aAAa,CAC3B,UAAsB,EACtB,MAAoB,EACpB,KAAc;IAEd,MAAM,GAAG,GAAG,IAAI,oBAAe,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAEnD,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;QACpE,IAAI,GAAG,CAAC,GAAG,KAAK,OAAO;YAAE,OAAM,CAAE,iCAAiC;QAElE,+CAA+C;QAC/C,MAAM,MAAM,GAAI,GAAG,CAAC,OAAO,CAAC,UAAU,CAAwB,EAAE,IAAI,EAAE,CAAA;QACtE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,KAAK,CAAC,sFAAsF,CAAC,CAAA;YACpG,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,QAAuB,CAAA;QAC3B,IAAI,CAAC;YACH,4EAA4E;YAC5E,0DAA0D;YAC1D,4EAA4E;YAC5E,8DAA8D;YAC9D,mCAAmC;YACnC,MAAM,KAAK,GAAI,GAAG,CAAC,OAAO,CAAC,YAAY,CAAwB,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;YACrF,IAAI,IAAA,yBAAW,EAAC,MAAM,CAAC,EAAE,CAAC;gBACxB,IAAI,CAAC,KAAK,EAAE,CAAC;oBACX,MAAM,CAAC,KAAK,CAAC,+FAA+F,CAAC,CAAA;oBAC7G,MAAM,CAAC,OAAO,EAAE,CAAA;oBAChB,OAAM;gBACR,CAAC;gBACD,QAAQ,GAAG,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAA;YACpD,CAAC;iBAAM,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;gBACrC,MAAM,KAAK,GAAG,MAAM,IAAA,4BAAc,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;gBAClD,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;YACrF,CAAC;iBAAM,CAAC;gBACN,QAAQ,GAAG,MAAM,IAAA,2BAAa,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;YAChD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAA;YAC1D,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,CAAC,KAAK,CAAC,6GAA6G,CAAC,CAAA;YAC3H,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,0BAA0B;QAC1B,GAAG,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE;YAC1C,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAa,EAAE,IAAqB,EAAE,QAAgB,EAAE,EAAE;QAC9E,MAAM,MAAM,GAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;QAEtD,8BAA8B;QAC9B,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QAEhE,yEAAyE;QACzE,MAAM,SAAS,GAA6C;YAC1D,EAAE,IAAI,EAAE,gBAAgB,EAAM,OAAO,EAAE,YAAY,EAAW;YAC9D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,iBAAiB,EAAK,OAAO,EAAE,aAAa,EAAW;YAC/D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,sBAAsB,EAAE,OAAO,EAAE,kBAAkB,EAAI;YAC/D,EAAE,IAAI,EAAE,yBAAyB,EAAE,OAAO,EAAE,qBAAqB,EAAE;YACnE,EAAE,IAAI,EAAE,2BAA2B,EAAE,OAAO,EAAE,uBAAuB,EAAE;YACvE,EAAE,IAAI,EAAE,cAAc,EAAQ,OAAO,EAAE,UAAU,EAAc;YAC/D,EAAE,IAAI,EAAE,aAAa,EAAS,OAAO,EAAE,sBAAsB,EAAI;YACjE,EAAE,IAAI,EAAE,eAAe,EAAM,OAAO,EAAE,wBAAwB,EAAE;SACjE,CAAA;QAED,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,SAAS,EAAE,CAAC;YAC1C,MAAM,OAAO,GAAG,KAAK,EAAE,IAAS,EAAE,EAAE;gBAClC,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ;oBAAE,OAAM,CAAQ,mBAAmB;gBACjE,IAAI,EAAE,CAAC,UAAU,KAAK,EAAE,CAAC,IAAI;oBAAI,OAAM;gBACvC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,GAAG,IAAI,CAAA,CAAG,gCAAgC;gBAC9E,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAA;YAC1C,CAAC,CAAA;YACD,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;YAC7B,iEAAiE;YACjE,iEAAiE;YACjE,sEAAsE;YACtE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;gBACtB,sEAAsE;gBACtE,0EAA0E;YAC5E,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,yEAAyE;QACzE,IAAI,OAAO,GAAG,IAAI,CAAA;QAClB,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAAC,EAAE,CAAC,SAAS,EAAE,CAAC;gBAAC,OAAM;YAAC,CAAC;YACxC,OAAO,GAAG,KAAK,CAAA;YACf,EAAE,CAAC,IAAI,EAAE,CAAA;QACX,CAAC,EAAE,gBAAgB,CAAC,CAAA;QAEpB,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,OAAO,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAAA;QAEvC,yEAAyE;QACzE,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;YACxB,sEAAsE;QACxE,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QAEF,kEAAkE;QAClE,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
||||||
@@ -109,6 +109,9 @@ export function buildWsBridge(
|
|||||||
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
||||||
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
||||||
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
||||||
|
{ hook: 'ext:message.reaction', wsEvent: 'message.reaction' },
|
||||||
|
{ hook: 'ext:message.media_ready', wsEvent: 'message.media_ready' },
|
||||||
|
{ hook: 'ext:message.transcription', wsEvent: 'message.transcription' },
|
||||||
{ hook: 'ext:presence', wsEvent: 'presence' },
|
{ hook: 'ext:presence', wsEvent: 'presence' },
|
||||||
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
||||||
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class ProtocolEngine {
|
|||||||
if (!agent)
|
if (!agent)
|
||||||
throw new Error('Agente não encontrado');
|
throw new Error('Agente não encontrado');
|
||||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
// Monta system prompt a partir dos nós ativos + contexto externo
|
||||||
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage);
|
let systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage);
|
||||||
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||||
const contextWindow = agent.context_window ?? 8;
|
const contextWindow = agent.context_window ?? 8;
|
||||||
const recentMessages = await this.db('sec_messages')
|
const recentMessages = await this.db('sec_messages')
|
||||||
@@ -105,6 +105,8 @@ class ProtocolEngine {
|
|||||||
conversationId,
|
conversationId,
|
||||||
extChatId: conversation.ext_chat_id ?? undefined,
|
extChatId: conversation.ext_chat_id ?? undefined,
|
||||||
tenantId: opts?.tenantId,
|
tenantId: opts?.tenantId,
|
||||||
|
instanceId: opts?.instanceId,
|
||||||
|
followupDelayMin: Number(secCfg?.followup_delay_min) || 15,
|
||||||
hooks: opts?.hooks,
|
hooks: opts?.hooks,
|
||||||
agenda: {
|
agenda: {
|
||||||
url: agendaUrl,
|
url: agendaUrl,
|
||||||
@@ -113,6 +115,28 @@ class ProtocolEngine {
|
|||||||
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
|
clinicaId: opts?.clinicaId ?? secCfg?.clinica_id,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// Funcionamento da clínica (aberto hoje?, feriados) — injeta no prompt para a
|
||||||
|
// Secretária saber quando dizer que está fechado, sem depender de tool call.
|
||||||
|
const statusClinicaId = opts?.clinicaId ?? secCfg?.clinica_id;
|
||||||
|
if (agendaUrl && statusClinicaId) {
|
||||||
|
try {
|
||||||
|
const u = new URL(`${agendaUrl.replace(/\/$/, '')}/status`);
|
||||||
|
u.searchParams.set('clinica_id', statusClinicaId);
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) });
|
||||||
|
if (r.ok)
|
||||||
|
systemPrompt += ProtocolEngine.formatClinicStatus(await r.json());
|
||||||
|
}
|
||||||
|
catch { /* best-effort: sem status, a Secretária segue com a data já injetada */ }
|
||||||
|
// Contexto clínico: dentistas+especialidades + situações (regras) + roteamento.
|
||||||
|
try {
|
||||||
|
const uc = new URL(`${agendaUrl.replace(/\/$/, '')}/contexto`);
|
||||||
|
uc.searchParams.set('clinica_id', statusClinicaId);
|
||||||
|
const rc = await fetch(uc, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) });
|
||||||
|
if (rc.ok)
|
||||||
|
systemPrompt += ProtocolEngine.formatClinicContext(await rc.json());
|
||||||
|
}
|
||||||
|
catch { /* best-effort */ }
|
||||||
|
}
|
||||||
let response;
|
let response;
|
||||||
let usageInfo = null;
|
let usageInfo = null;
|
||||||
let providerUsed = null;
|
let providerUsed = null;
|
||||||
@@ -172,6 +196,68 @@ class ProtocolEngine {
|
|||||||
const p = (n, d = 2) => String(n).padStart(d, '0');
|
const p = (n, d = 2) => String(n).padStart(d, '0');
|
||||||
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
||||||
}
|
}
|
||||||
|
// Formata o /contexto (dentistas+especialidades + situações/regras) p/ o prompt.
|
||||||
|
// Guia o roteamento por especialidade, as regras da clínica e o encaminhamento
|
||||||
|
// de dúvidas para a secretária humana.
|
||||||
|
static formatClinicContext(ctx) {
|
||||||
|
const L = [];
|
||||||
|
if (Array.isArray(ctx?.dentistas) && ctx.dentistas.length) {
|
||||||
|
L.push('=== DENTISTAS E ESPECIALIDADES ===');
|
||||||
|
for (const d of ctx.dentistas)
|
||||||
|
L.push(`- ${d.nome}: ${(d.especialidades || []).join(', ') || 'clínico geral'}`);
|
||||||
|
L.push('ROTEAMENTO: descubra SEMPRE o motivo da consulta. Para avaliação, limpeza, restauração e dúvidas → priorize um CLÍNICO GERAL. Para casos específicos (ortodontia, endodontia/canal, prótese, implante etc.) → o dentista da ESPECIALIDADE.');
|
||||||
|
}
|
||||||
|
if (Array.isArray(ctx?.situacoes) && ctx.situacoes.length) {
|
||||||
|
L.push('');
|
||||||
|
L.push('=== SITUAÇÕES E REGRAS DA CLÍNICA (siga à risca) ===');
|
||||||
|
for (const s of ctx.situacoes)
|
||||||
|
L.push(`- ${s}`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(ctx?.cautelas) && ctx.cautelas.length) {
|
||||||
|
L.push('');
|
||||||
|
L.push('=== CAUTELAS (o sistema ainda não tem todos os dados — NÃO chute) ===');
|
||||||
|
for (const c of ctx.cautelas)
|
||||||
|
L.push(`- ${c}`);
|
||||||
|
L.push('Na dúvida sobre QUEM é o paciente, QUAL dentista atende, ou O QUE ele quer: NÃO agende — use encaminhar_humano e peça para aguardar.');
|
||||||
|
}
|
||||||
|
L.push('');
|
||||||
|
L.push('DÚVIDAS / CASOS INCERTOS: se for uma dúvida inicial, um caso que você não sabe resolver, ou que uma regra acima mande avaliar, NÃO agende automaticamente — use a ferramenta encaminhar_humano para a secretária humana decidir e peça ao cliente para AGUARDAR. Nem sempre o certo é agendar.');
|
||||||
|
L.push('IMAGENS: se o cliente enviar uma imagem (foto de dente, exame), tente identificar do que se trata; se não estiver claro ou for caso clínico, trate como dúvida (encaminhar_humano).');
|
||||||
|
return L.length ? `\n\n${L.join('\n')}\n` : '';
|
||||||
|
}
|
||||||
|
// Formata o /status da agenda (funcionamento da clínica) para o system prompt.
|
||||||
|
// A Secretária usa isso para NÃO oferecer horários/agendar em dia/hora fechado.
|
||||||
|
static formatClinicStatus(st) {
|
||||||
|
if (!st?.hoje)
|
||||||
|
return '';
|
||||||
|
const dm = (s) => { const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[3]}/${m[2]}` : s; };
|
||||||
|
const janelas = (arr) => (arr || []).map((h) => `${h.inicio}–${h.fim}`).join(', ');
|
||||||
|
const L = ['=== FUNCIONAMENTO DA CLÍNICA ==='];
|
||||||
|
const h = st.hoje;
|
||||||
|
L.push(h.aberto
|
||||||
|
? `Hoje é ${h.dia_nome} (${dm(h.data)}): ABERTO — ${janelas(h.horarios)}.`
|
||||||
|
: `Hoje é ${h.dia_nome} (${dm(h.data)}): FECHADO${h.motivo ? ` (${h.motivo})` : ''}.`);
|
||||||
|
if (st.proximo_aberto && (!h.aberto || st.proximo_aberto.data !== h.data)) {
|
||||||
|
const p = st.proximo_aberto;
|
||||||
|
L.push(`Próximo dia aberto: ${p.dia_nome} (${dm(p.data)}), ${janelas(p.horarios)}.`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(st.semana) && st.semana.length) {
|
||||||
|
const grade = st.semana.map((d) => `${d.dia_nome.slice(0, 3)} ${d.aberto ? janelas(d.horarios) : 'fechado'}`).join('; ');
|
||||||
|
L.push(`Grade da semana: ${grade}.`);
|
||||||
|
}
|
||||||
|
if (Array.isArray(st.feriados_proximos) && st.feriados_proximos.length) {
|
||||||
|
L.push(`Próximos feriados/fechamentos: ${st.feriados_proximos.map((f) => `${dm(f.data)} ${f.nome}`).join('; ')}.`);
|
||||||
|
}
|
||||||
|
L.push('IMPORTANTE: só ofereça horários e confirme agendamento quando a clínica estiver ABERTA. Se pedirem em dia/horário fechado ou feriado, informe o horário de funcionamento e ofereça o próximo dia disponível.');
|
||||||
|
L.push('ROTEIRO DE AGENDAMENTO — siga à risca:');
|
||||||
|
L.push('1) NUNCA liste vários horários de uma vez. PRIMEIRO pergunte: "Você prefere de manhã ou à tarde?".');
|
||||||
|
L.push('2) Com a resposta, chame listar_horarios passando periodo=manha ou tarde e ofereça UM ÚNICO horário — sempre o PRIMEIRO livre daquele período. Ex.: "Consigo te encaixar às 10:00, fica bom?".');
|
||||||
|
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.');
|
||||||
|
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.');
|
||||||
|
L.push('4) Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial e responda algo como "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?" (sem mencionar "secretária").');
|
||||||
|
L.push('5) Se o cliente pedir um ENCAIXE que QUEBRA o espaçamento de 1h (horário "quebrado", ex.: 09:45, 10:20, ou uma avaliação rápida entre atendimentos), use a ferramenta perguntar_dentista: ela pergunta ao dentista responsável se dá tempo. Diga ao cliente que vai verificar com o dentista e confirma em seguida.');
|
||||||
|
return `\n\n${L.join('\n')}\n`;
|
||||||
|
}
|
||||||
// ── System Prompt Builder ─────────────────────────────────────────────────
|
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||||
async buildSystemPrompt(agent, conversation, opts, userMessage) {
|
async buildSystemPrompt(agent, conversation, opts, userMessage) {
|
||||||
const nodes = await this.db('sec_brain_nodes')
|
const nodes = await this.db('sec_brain_nodes')
|
||||||
@@ -713,6 +799,18 @@ class ProtocolEngine {
|
|||||||
}
|
}
|
||||||
// ── Calendar Context ──────────────────────────────────────────────────────
|
// ── Calendar Context ──────────────────────────────────────────────────────
|
||||||
async getCalendarContext() {
|
async getCalendarContext() {
|
||||||
|
// Quando há AGENDA REAL (satélite pareado / ponte configurada), NÃO despejar o
|
||||||
|
// sec_calendar aqui — ele é a agenda INTERNA/de teste do motor e, injetado no
|
||||||
|
// prompt, vira uma "agenda-fantasma" que conflita com a agenda real. Nesses
|
||||||
|
// casos a disponibilidade vem SEMPRE da ferramenta listar_horarios.
|
||||||
|
let cfg = {};
|
||||||
|
try {
|
||||||
|
cfg = await this.config.get('secretaria');
|
||||||
|
}
|
||||||
|
catch { /* segue */ }
|
||||||
|
if (cfg?.agenda_url || cfg?.clinica_id) {
|
||||||
|
return 'A agenda real da clínica é consultada pela ferramenta listar_horarios (por dia e período). NÃO ofereça nenhuma lista fixa de horários aqui — sempre consulte a ferramenta antes de propor um horário.';
|
||||||
|
}
|
||||||
const today = new Date().toISOString().split('T')[0];
|
const today = new Date().toISOString().split('T')[0];
|
||||||
const nextWeek = new Date();
|
const nextWeek = new Date();
|
||||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -46,6 +46,7 @@ export class ProtocolEngine {
|
|||||||
hooks?: HookBus
|
hooks?: HookBus
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
|
clinicaId?: string // clínica do canal/workspace (escopo da ponte de agenda)
|
||||||
|
instanceId?: string // número/sessão que recebeu a mensagem (escopo do calendário interno)
|
||||||
},
|
},
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId }
|
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId }
|
||||||
@@ -56,7 +57,7 @@ export class ProtocolEngine {
|
|||||||
if (!agent) throw new Error('Agente não encontrado')
|
if (!agent) throw new Error('Agente não encontrado')
|
||||||
|
|
||||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
// Monta system prompt a partir dos nós ativos + contexto externo
|
||||||
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage)
|
let systemPrompt = await this.buildSystemPrompt(agent, conversation, opts, userMessage)
|
||||||
|
|
||||||
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||||
const contextWindow: number = agent.context_window ?? 8
|
const contextWindow: number = agent.context_window ?? 8
|
||||||
@@ -127,6 +128,8 @@ export class ProtocolEngine {
|
|||||||
conversationId,
|
conversationId,
|
||||||
extChatId: conversation.ext_chat_id ?? undefined,
|
extChatId: conversation.ext_chat_id ?? undefined,
|
||||||
tenantId: opts?.tenantId,
|
tenantId: opts?.tenantId,
|
||||||
|
instanceId: opts?.instanceId,
|
||||||
|
followupDelayMin: Number(secCfg?.followup_delay_min) || 15,
|
||||||
hooks: opts?.hooks,
|
hooks: opts?.hooks,
|
||||||
agenda: {
|
agenda: {
|
||||||
url: agendaUrl,
|
url: agendaUrl,
|
||||||
@@ -136,6 +139,25 @@ export class ProtocolEngine {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Funcionamento da clínica (aberto hoje?, feriados) — injeta no prompt para a
|
||||||
|
// Secretária saber quando dizer que está fechado, sem depender de tool call.
|
||||||
|
const statusClinicaId = opts?.clinicaId ?? secCfg?.clinica_id
|
||||||
|
if (agendaUrl && statusClinicaId) {
|
||||||
|
try {
|
||||||
|
const u = new URL(`${agendaUrl.replace(/\/$/, '')}/status`)
|
||||||
|
u.searchParams.set('clinica_id', statusClinicaId)
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) })
|
||||||
|
if (r.ok) systemPrompt += ProtocolEngine.formatClinicStatus(await r.json())
|
||||||
|
} catch { /* best-effort: sem status, a Secretária segue com a data já injetada */ }
|
||||||
|
// Contexto clínico: dentistas+especialidades + situações (regras) + roteamento.
|
||||||
|
try {
|
||||||
|
const uc = new URL(`${agendaUrl.replace(/\/$/, '')}/contexto`)
|
||||||
|
uc.searchParams.set('clinica_id', statusClinicaId)
|
||||||
|
const rc = await fetch(uc, { headers: { 'x-nw-agenda-secret': agendaSecret ?? '' }, signal: AbortSignal.timeout(6000) })
|
||||||
|
if (rc.ok) systemPrompt += ProtocolEngine.formatClinicContext(await rc.json())
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
let response: string
|
let response: string
|
||||||
let usageInfo: any = null
|
let usageInfo: any = null
|
||||||
let providerUsed: string | null = null
|
let providerUsed: string | null = null
|
||||||
@@ -201,6 +223,66 @@ export class ProtocolEngine {
|
|||||||
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
|
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Formata o /contexto (dentistas+especialidades + situações/regras) p/ o prompt.
|
||||||
|
// Guia o roteamento por especialidade, as regras da clínica e o encaminhamento
|
||||||
|
// de dúvidas para a secretária humana.
|
||||||
|
static formatClinicContext(ctx: any): string {
|
||||||
|
const L: string[] = []
|
||||||
|
if (Array.isArray(ctx?.dentistas) && ctx.dentistas.length) {
|
||||||
|
L.push('=== DENTISTAS E ESPECIALIDADES ===')
|
||||||
|
for (const d of ctx.dentistas) L.push(`- ${d.nome}: ${(d.especialidades || []).join(', ') || 'clínico geral'}`)
|
||||||
|
L.push('ROTEAMENTO: descubra SEMPRE o motivo da consulta. Para avaliação, limpeza, restauração e dúvidas → priorize um CLÍNICO GERAL. Para casos específicos (ortodontia, endodontia/canal, prótese, implante etc.) → o dentista da ESPECIALIDADE.')
|
||||||
|
}
|
||||||
|
if (Array.isArray(ctx?.situacoes) && ctx.situacoes.length) {
|
||||||
|
L.push('')
|
||||||
|
L.push('=== SITUAÇÕES E REGRAS DA CLÍNICA (siga à risca) ===')
|
||||||
|
for (const s of ctx.situacoes) L.push(`- ${s}`)
|
||||||
|
}
|
||||||
|
if (Array.isArray(ctx?.cautelas) && ctx.cautelas.length) {
|
||||||
|
L.push('')
|
||||||
|
L.push('=== CAUTELAS (o sistema ainda não tem todos os dados — NÃO chute) ===')
|
||||||
|
for (const c of ctx.cautelas) L.push(`- ${c}`)
|
||||||
|
L.push('Na dúvida sobre QUEM é o paciente, QUAL dentista atende, ou O QUE ele quer: NÃO agende — use encaminhar_humano e peça para aguardar.')
|
||||||
|
}
|
||||||
|
L.push('')
|
||||||
|
L.push('DÚVIDAS / CASOS INCERTOS: se for uma dúvida inicial, um caso que você não sabe resolver, ou que uma regra acima mande avaliar, NÃO agende automaticamente — use a ferramenta encaminhar_humano para a secretária humana decidir e peça ao cliente para AGUARDAR. Nem sempre o certo é agendar.')
|
||||||
|
L.push('IMAGENS: se o cliente enviar uma imagem (foto de dente, exame), tente identificar do que se trata; se não estiver claro ou for caso clínico, trate como dúvida (encaminhar_humano).')
|
||||||
|
return L.length ? `\n\n${L.join('\n')}\n` : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Formata o /status da agenda (funcionamento da clínica) para o system prompt.
|
||||||
|
// A Secretária usa isso para NÃO oferecer horários/agendar em dia/hora fechado.
|
||||||
|
static formatClinicStatus(st: any): string {
|
||||||
|
if (!st?.hoje) return ''
|
||||||
|
const dm = (s: string) => { const m = String(s).match(/^(\d{4})-(\d{2})-(\d{2})/); return m ? `${m[3]}/${m[2]}` : s }
|
||||||
|
const janelas = (arr: any[]) => (arr || []).map((h) => `${h.inicio}–${h.fim}`).join(', ')
|
||||||
|
const L: string[] = ['=== FUNCIONAMENTO DA CLÍNICA ===']
|
||||||
|
const h = st.hoje
|
||||||
|
L.push(h.aberto
|
||||||
|
? `Hoje é ${h.dia_nome} (${dm(h.data)}): ABERTO — ${janelas(h.horarios)}.`
|
||||||
|
: `Hoje é ${h.dia_nome} (${dm(h.data)}): FECHADO${h.motivo ? ` (${h.motivo})` : ''}.`)
|
||||||
|
if (st.proximo_aberto && (!h.aberto || st.proximo_aberto.data !== h.data)) {
|
||||||
|
const p = st.proximo_aberto
|
||||||
|
L.push(`Próximo dia aberto: ${p.dia_nome} (${dm(p.data)}), ${janelas(p.horarios)}.`)
|
||||||
|
}
|
||||||
|
if (Array.isArray(st.semana) && st.semana.length) {
|
||||||
|
const grade = st.semana.map((d: any) => `${d.dia_nome.slice(0, 3)} ${d.aberto ? janelas(d.horarios) : 'fechado'}`).join('; ')
|
||||||
|
L.push(`Grade da semana: ${grade}.`)
|
||||||
|
}
|
||||||
|
if (Array.isArray(st.feriados_proximos) && st.feriados_proximos.length) {
|
||||||
|
L.push(`Próximos feriados/fechamentos: ${st.feriados_proximos.map((f: any) => `${dm(f.data)} ${f.nome}`).join('; ')}.`)
|
||||||
|
}
|
||||||
|
L.push('IMPORTANTE: só ofereça horários e confirme agendamento quando a clínica estiver ABERTA. Se pedirem em dia/horário fechado ou feriado, informe o horário de funcionamento e ofereça o próximo dia disponível.')
|
||||||
|
L.push('ROTEIRO DE AGENDAMENTO — siga à risca:')
|
||||||
|
L.push('1) NUNCA liste vários horários de uma vez. PRIMEIRO pergunte: "Você prefere de manhã ou à tarde?".')
|
||||||
|
L.push('2) Com a resposta, chame listar_horarios passando periodo=manha ou tarde e ofereça UM ÚNICO horário — sempre o PRIMEIRO livre daquele período. Ex.: "Consigo te encaixar às 10:00, fica bom?".')
|
||||||
|
L.push('3) Se não servir, ofereça o PRÓXIMO horário livre, um de cada vez (nunca uma lista). Mantenha sempre 1 hora entre os atendimentos.')
|
||||||
|
L.push('3b) Se o cliente insistir num horário MAIS TARDE no dia, use a ferramenta confirmar_depois: diga que vai confirmar a agenda e para aguardar uns minutinhos — o sistema retorna sozinho com o horário. NÃO ofereça o horário na hora.')
|
||||||
|
L.push('4) Se o cliente pedir um horário PONTUAL fora dos disponíveis (mais cedo/mais tarde, ex.: 8h30 ou depois das 17h), NÃO recuse nem confirme: use a ferramenta solicitar_horario_especial e responda algo como "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?" (sem mencionar "secretária").')
|
||||||
|
L.push('5) Se o cliente pedir um ENCAIXE que QUEBRA o espaçamento de 1h (horário "quebrado", ex.: 09:45, 10:20, ou uma avaliação rápida entre atendimentos), use a ferramenta perguntar_dentista: ela pergunta ao dentista responsável se dá tempo. Diga ao cliente que vai verificar com o dentista e confirma em seguida.')
|
||||||
|
return `\n\n${L.join('\n')}\n`
|
||||||
|
}
|
||||||
|
|
||||||
// ── System Prompt Builder ─────────────────────────────────────────────────
|
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||||
|
|
||||||
private async buildSystemPrompt(
|
private async buildSystemPrompt(
|
||||||
@@ -752,6 +834,16 @@ export class ProtocolEngine {
|
|||||||
// ── Calendar Context ──────────────────────────────────────────────────────
|
// ── Calendar Context ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async getCalendarContext(): Promise<string> {
|
private async getCalendarContext(): Promise<string> {
|
||||||
|
// Quando há AGENDA REAL (satélite pareado / ponte configurada), NÃO despejar o
|
||||||
|
// sec_calendar aqui — ele é a agenda INTERNA/de teste do motor e, injetado no
|
||||||
|
// prompt, vira uma "agenda-fantasma" que conflita com a agenda real. Nesses
|
||||||
|
// casos a disponibilidade vem SEMPRE da ferramenta listar_horarios.
|
||||||
|
let cfg: any = {}
|
||||||
|
try { cfg = await this.config.get('secretaria') } catch { /* segue */ }
|
||||||
|
if (cfg?.agenda_url || cfg?.clinica_id) {
|
||||||
|
return 'A agenda real da clínica é consultada pela ferramenta listar_horarios (por dia e período). NÃO ofereça nenhuma lista fixa de horários aqui — sempre consulte a ferramenta antes de propor um horário.'
|
||||||
|
}
|
||||||
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const nextWeek = new Date()
|
const nextWeek = new Date()
|
||||||
nextWeek.setDate(nextWeek.getDate() + 7)
|
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||||
|
|||||||
@@ -122,6 +122,18 @@ async function runMigrations(db) {
|
|||||||
t.timestamps(true, true);
|
t.timestamps(true, true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// ── sec_calendar — instance_id (agenda interna por sessão) ────────────────
|
||||||
|
// Separa o calendário interno por número de WhatsApp. Nulo = slot legado/global
|
||||||
|
// (visível a todas as sessões, retrocompatível). No odonto a agenda real vem da
|
||||||
|
// ponte (clinica_id por número); este é o calendário interno/fallback.
|
||||||
|
if (await db.schema.hasTable('sec_calendar')) {
|
||||||
|
if (!(await db.schema.hasColumn('sec_calendar', 'instance_id'))) {
|
||||||
|
await db.schema.alterTable('sec_calendar', (t) => {
|
||||||
|
t.string('instance_id', 100).nullable();
|
||||||
|
});
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_calendar_instance ON sec_calendar (instance_id)');
|
||||||
|
}
|
||||||
|
}
|
||||||
// ── sec_numbers ───────────────────────────────────────────────────────────
|
// ── sec_numbers ───────────────────────────────────────────────────────────
|
||||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||||
await db.schema.createTable('sec_numbers', (t) => {
|
await db.schema.createTable('sec_numbers', (t) => {
|
||||||
@@ -142,6 +154,16 @@ async function runMigrations(db) {
|
|||||||
// Tabela pré-existente: adiciona o mapa canal→clínica.
|
// Tabela pré-existente: adiciona o mapa canal→clínica.
|
||||||
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable());
|
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable());
|
||||||
}
|
}
|
||||||
|
// ── sec_numbers — agent_id (agente/cérebro deste número) ──────────────────
|
||||||
|
// Amarra cada número de WhatsApp a um agente (pool reutilizável). Nulo = usa o
|
||||||
|
// primeiro agente ativo (comportamento legado). É a chave da separação por
|
||||||
|
// sessão: o runtime resolve o agente pelo número que recebeu a mensagem.
|
||||||
|
if (await db.schema.hasTable('sec_numbers') && !(await db.schema.hasColumn('sec_numbers', 'agent_id'))) {
|
||||||
|
await db.schema.alterTable('sec_numbers', (t) => {
|
||||||
|
t.uuid('agent_id').nullable().references('id').inTable('sec_agents').onDelete('SET NULL');
|
||||||
|
});
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_numbers_agent ON sec_numbers (agent_id)');
|
||||||
|
}
|
||||||
// ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────
|
// ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────
|
||||||
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
||||||
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
||||||
@@ -173,6 +195,55 @@ async function runMigrations(db) {
|
|||||||
});
|
});
|
||||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)');
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)');
|
||||||
}
|
}
|
||||||
|
// ── sec_agenda_followups — follow-up proativo (Parte B): a IA promete "confirmo
|
||||||
|
// em uns minutos" e um worker envia depois o horário confirmado, com atraso.
|
||||||
|
if (!(await db.schema.hasTable('sec_agenda_followups'))) {
|
||||||
|
await db.schema.createTable('sec_agenda_followups', (t) => {
|
||||||
|
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||||
|
t.string('tenant_id', 80);
|
||||||
|
t.string('instance_id', 100);
|
||||||
|
t.string('chat_id', 300);
|
||||||
|
t.string('jid', 120); // paciente
|
||||||
|
t.string('clinica_id', 100);
|
||||||
|
t.string('agenda_url', 300);
|
||||||
|
t.string('agenda_secret', 200);
|
||||||
|
t.string('dentista_nome', 120);
|
||||||
|
t.date('data');
|
||||||
|
t.string('hora_desejada', 8);
|
||||||
|
t.string('periodo', 10); // manha | tarde
|
||||||
|
t.string('paciente_nome', 120);
|
||||||
|
t.timestamp('fire_at').notNullable();
|
||||||
|
t.string('status', 20).defaultTo('pendente'); // pendente | enviado | falhou
|
||||||
|
t.timestamps(true, true);
|
||||||
|
});
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_followups_fire ON sec_agenda_followups (status, fire_at)');
|
||||||
|
}
|
||||||
|
// ── sec_agenda_approvals — Parte A: encaixe sub-hora aguardando o DENTISTA.
|
||||||
|
// A IA manda WhatsApp ao dentista; quando ele responde SIM/NÃO, o sistema
|
||||||
|
// agenda (ou não) e avisa o paciente automaticamente.
|
||||||
|
if (!(await db.schema.hasTable('sec_agenda_approvals'))) {
|
||||||
|
await db.schema.createTable('sec_agenda_approvals', (t) => {
|
||||||
|
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||||
|
t.string('tenant_id', 80);
|
||||||
|
t.string('instance_id', 100);
|
||||||
|
t.string('clinica_id', 100);
|
||||||
|
t.string('agenda_url', 300);
|
||||||
|
t.string('agenda_secret', 200);
|
||||||
|
t.string('paciente_jid', 120);
|
||||||
|
t.string('paciente_chat_id', 300);
|
||||||
|
t.string('paciente_nome', 120);
|
||||||
|
t.string('paciente_telefone', 30);
|
||||||
|
t.string('dentista_id', 120);
|
||||||
|
t.string('dentista_nome', 120);
|
||||||
|
t.string('dentista_jid', 120); // WhatsApp do dentista
|
||||||
|
t.date('data');
|
||||||
|
t.string('hora', 8);
|
||||||
|
t.string('procedimento', 160);
|
||||||
|
t.string('status', 20).defaultTo('aguardando'); // aguardando | confirmado | recusado
|
||||||
|
t.timestamps(true, true);
|
||||||
|
});
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_approvals_dent ON sec_agenda_approvals (dentista_jid, status)');
|
||||||
|
}
|
||||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||||
const agentCount = await db('sec_agents').count('id as c').first();
|
const agentCount = await db('sec_agents').count('id as c').first();
|
||||||
if (Number(agentCount?.c ?? 0) === 0) {
|
if (Number(agentCount?.c ?? 0) === 0) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -131,6 +131,19 @@ export async function runMigrations(db: Knex): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── sec_calendar — instance_id (agenda interna por sessão) ────────────────
|
||||||
|
// Separa o calendário interno por número de WhatsApp. Nulo = slot legado/global
|
||||||
|
// (visível a todas as sessões, retrocompatível). No odonto a agenda real vem da
|
||||||
|
// ponte (clinica_id por número); este é o calendário interno/fallback.
|
||||||
|
if (await db.schema.hasTable('sec_calendar')) {
|
||||||
|
if (!(await db.schema.hasColumn('sec_calendar', 'instance_id'))) {
|
||||||
|
await db.schema.alterTable('sec_calendar', (t) => {
|
||||||
|
t.string('instance_id', 100).nullable()
|
||||||
|
})
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_calendar_instance ON sec_calendar (instance_id)')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── sec_numbers ───────────────────────────────────────────────────────────
|
// ── sec_numbers ───────────────────────────────────────────────────────────
|
||||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||||
await db.schema.createTable('sec_numbers', (t) => {
|
await db.schema.createTable('sec_numbers', (t) => {
|
||||||
@@ -151,6 +164,17 @@ export async function runMigrations(db: Knex): Promise<void> {
|
|||||||
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable())
|
await db.schema.alterTable('sec_numbers', (t) => t.string('clinica_id', 100).nullable())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── sec_numbers — agent_id (agente/cérebro deste número) ──────────────────
|
||||||
|
// Amarra cada número de WhatsApp a um agente (pool reutilizável). Nulo = usa o
|
||||||
|
// primeiro agente ativo (comportamento legado). É a chave da separação por
|
||||||
|
// sessão: o runtime resolve o agente pelo número que recebeu a mensagem.
|
||||||
|
if (await db.schema.hasTable('sec_numbers') && !(await db.schema.hasColumn('sec_numbers', 'agent_id'))) {
|
||||||
|
await db.schema.alterTable('sec_numbers', (t) => {
|
||||||
|
t.uuid('agent_id').nullable().references('id').inTable('sec_agents').onDelete('SET NULL')
|
||||||
|
})
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_numbers_agent ON sec_numbers (agent_id)')
|
||||||
|
}
|
||||||
|
|
||||||
// ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────
|
// ── sec_knowledge_chunks (RAG sem pgvector) ───────────────────────────────
|
||||||
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
||||||
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
||||||
@@ -184,6 +208,57 @@ export async function runMigrations(db: Knex): Promise<void> {
|
|||||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)')
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_mem_contact ON sec_contact_memory (agent_id, contact_key)')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── sec_agenda_followups — follow-up proativo (Parte B): a IA promete "confirmo
|
||||||
|
// em uns minutos" e um worker envia depois o horário confirmado, com atraso.
|
||||||
|
if (!(await db.schema.hasTable('sec_agenda_followups'))) {
|
||||||
|
await db.schema.createTable('sec_agenda_followups', (t) => {
|
||||||
|
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||||
|
t.string('tenant_id', 80)
|
||||||
|
t.string('instance_id', 100)
|
||||||
|
t.string('chat_id', 300)
|
||||||
|
t.string('jid', 120) // paciente
|
||||||
|
t.string('clinica_id', 100)
|
||||||
|
t.string('agenda_url', 300)
|
||||||
|
t.string('agenda_secret', 200)
|
||||||
|
t.string('dentista_nome', 120)
|
||||||
|
t.date('data')
|
||||||
|
t.string('hora_desejada', 8)
|
||||||
|
t.string('periodo', 10) // manha | tarde
|
||||||
|
t.string('paciente_nome', 120)
|
||||||
|
t.timestamp('fire_at').notNullable()
|
||||||
|
t.string('status', 20).defaultTo('pendente') // pendente | enviado | falhou
|
||||||
|
t.timestamps(true, true)
|
||||||
|
})
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_followups_fire ON sec_agenda_followups (status, fire_at)')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── sec_agenda_approvals — Parte A: encaixe sub-hora aguardando o DENTISTA.
|
||||||
|
// A IA manda WhatsApp ao dentista; quando ele responde SIM/NÃO, o sistema
|
||||||
|
// agenda (ou não) e avisa o paciente automaticamente.
|
||||||
|
if (!(await db.schema.hasTable('sec_agenda_approvals'))) {
|
||||||
|
await db.schema.createTable('sec_agenda_approvals', (t) => {
|
||||||
|
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||||
|
t.string('tenant_id', 80)
|
||||||
|
t.string('instance_id', 100)
|
||||||
|
t.string('clinica_id', 100)
|
||||||
|
t.string('agenda_url', 300)
|
||||||
|
t.string('agenda_secret', 200)
|
||||||
|
t.string('paciente_jid', 120)
|
||||||
|
t.string('paciente_chat_id', 300)
|
||||||
|
t.string('paciente_nome', 120)
|
||||||
|
t.string('paciente_telefone', 30)
|
||||||
|
t.string('dentista_id', 120)
|
||||||
|
t.string('dentista_nome', 120)
|
||||||
|
t.string('dentista_jid', 120) // WhatsApp do dentista
|
||||||
|
t.date('data')
|
||||||
|
t.string('hora', 8)
|
||||||
|
t.string('procedimento', 160)
|
||||||
|
t.string('status', 20).defaultTo('aguardando') // aguardando | confirmado | recusado
|
||||||
|
t.timestamps(true, true)
|
||||||
|
})
|
||||||
|
await db.raw('CREATE INDEX IF NOT EXISTS idx_approvals_dent ON sec_agenda_approvals (dentista_jid, status)')
|
||||||
|
}
|
||||||
|
|
||||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||||
const agentCount = await db('sec_agents').count('id as c').first()
|
const agentCount = await db('sec_agents').count('id as c').first()
|
||||||
if (Number(agentCount?.c ?? 0) === 0) {
|
if (Number(agentCount?.c ?? 0) === 0) {
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ function createSecretariaRoutes(db, config) {
|
|||||||
router.get('/calendar', async (req, res) => {
|
router.get('/calendar', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const qs = (k) => { const v = req.query[k]; return v ? String(v) : undefined; };
|
const qs = (k) => { const v = req.query[k]; return v ? String(v) : undefined; };
|
||||||
const from = qs('from'), to = qs('to'), status = qs('status');
|
const from = qs('from'), to = qs('to'), status = qs('status'), instanceId = qs('instance_id');
|
||||||
let q = db('sec_calendar').orderBy('date').orderBy('time_start');
|
let q = db('sec_calendar').orderBy('date').orderBy('time_start');
|
||||||
if (from)
|
if (from)
|
||||||
q = q.where('date', '>=', from);
|
q = q.where('date', '>=', from);
|
||||||
@@ -203,6 +203,9 @@ function createSecretariaRoutes(db, config) {
|
|||||||
q = q.where('date', '<=', to);
|
q = q.where('date', '<=', to);
|
||||||
if (status)
|
if (status)
|
||||||
q = q.where({ status });
|
q = q.where({ status });
|
||||||
|
// Escopo por sessão: slots do número + globais (instance_id NULL).
|
||||||
|
if (instanceId)
|
||||||
|
q = q.where((qb) => qb.whereNull('instance_id').orWhere('instance_id', instanceId));
|
||||||
res.json(await q);
|
res.json(await q);
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
@@ -211,9 +214,9 @@ function createSecretariaRoutes(db, config) {
|
|||||||
});
|
});
|
||||||
router.post('/calendar', async (req, res) => {
|
router.post('/calendar', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes } = req.body;
|
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes, instance_id } = req.body;
|
||||||
const [slot] = await db('sec_calendar').insert({
|
const [slot] = await db('sec_calendar').insert({
|
||||||
id: uuid(), title, date, time_start, time_end,
|
id: uuid(), title, date, time_start, time_end, instance_id: instance_id ?? null,
|
||||||
attendee_name, attendee_phone, notes, status: 'available',
|
attendee_name, attendee_phone, notes, status: 'available',
|
||||||
}).returning('*');
|
}).returning('*');
|
||||||
res.status(201).json(slot);
|
res.status(201).json(slot);
|
||||||
@@ -224,9 +227,12 @@ function createSecretariaRoutes(db, config) {
|
|||||||
});
|
});
|
||||||
router.put('/calendar/:id', async (req, res) => {
|
router.put('/calendar/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes } = req.body;
|
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, instance_id } = req.body;
|
||||||
|
const patch = { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() };
|
||||||
|
if ('instance_id' in req.body)
|
||||||
|
patch.instance_id = instance_id ?? null;
|
||||||
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
||||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
.update(patch)
|
||||||
.returning('*');
|
.returning('*');
|
||||||
res.json(slot);
|
res.json(slot);
|
||||||
}
|
}
|
||||||
@@ -255,19 +261,20 @@ function createSecretariaRoutes(db, config) {
|
|||||||
});
|
});
|
||||||
router.post('/numbers', async (req, res) => {
|
router.post('/numbers', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { instance_id, label, role, area, priority, notes } = req.body;
|
const { instance_id, label, role, area, priority, notes, agent_id } = req.body;
|
||||||
if (!instance_id)
|
if (!instance_id)
|
||||||
return res.status(400).json({ error: 'instance_id é obrigatório' });
|
return res.status(400).json({ error: 'instance_id é obrigatório' });
|
||||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||||
const existing = await db('sec_numbers').where({ instance_id }).first();
|
const existing = await db('sec_numbers').where({ instance_id }).first();
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [num] = await db('sec_numbers').where({ instance_id })
|
const patch = { label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() };
|
||||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
if ('agent_id' in req.body)
|
||||||
.returning('*');
|
patch.agent_id = agent_id ?? null;
|
||||||
|
const [num] = await db('sec_numbers').where({ instance_id }).update(patch).returning('*');
|
||||||
return res.json(num);
|
return res.json(num);
|
||||||
}
|
}
|
||||||
const [num] = await db('sec_numbers').insert({
|
const [num] = await db('sec_numbers').insert({
|
||||||
id: uuid(), instance_id, label, role: role ?? 'clinic',
|
id: uuid(), instance_id, label, role: role ?? 'clinic', agent_id: agent_id ?? null,
|
||||||
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
||||||
}).returning('*');
|
}).returning('*');
|
||||||
res.status(201).json(num);
|
res.status(201).json(num);
|
||||||
@@ -278,9 +285,12 @@ function createSecretariaRoutes(db, config) {
|
|||||||
});
|
});
|
||||||
router.put('/numbers/:id', async (req, res) => {
|
router.put('/numbers/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { label, role, area, instance_id, priority, active, notes } = req.body;
|
const { label, role, area, instance_id, priority, active, notes, agent_id } = req.body;
|
||||||
|
const patch = { label, role, area, instance_id, priority, active, notes, updated_at: new Date() };
|
||||||
|
if ('agent_id' in req.body)
|
||||||
|
patch.agent_id = agent_id ?? null;
|
||||||
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
||||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
.update(patch)
|
||||||
.returning('*');
|
.returning('*');
|
||||||
res.json(num);
|
res.json(num);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -198,11 +198,13 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
|
|||||||
router.get('/calendar', async (req: Request, res: Response) => {
|
router.get('/calendar', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const qs = (k: string) => { const v = req.query[k]; return v ? String(v) : undefined }
|
const qs = (k: string) => { const v = req.query[k]; return v ? String(v) : undefined }
|
||||||
const from = qs('from'), to = qs('to'), status = qs('status')
|
const from = qs('from'), to = qs('to'), status = qs('status'), instanceId = qs('instance_id')
|
||||||
let q = db('sec_calendar').orderBy('date').orderBy('time_start')
|
let q = db('sec_calendar').orderBy('date').orderBy('time_start')
|
||||||
if (from) q = q.where('date', '>=', from)
|
if (from) q = q.where('date', '>=', from)
|
||||||
if (to) q = q.where('date', '<=', to)
|
if (to) q = q.where('date', '<=', to)
|
||||||
if (status) q = q.where({ status })
|
if (status) q = q.where({ status })
|
||||||
|
// Escopo por sessão: slots do número + globais (instance_id NULL).
|
||||||
|
if (instanceId) q = q.where((qb: any) => qb.whereNull('instance_id').orWhere('instance_id', instanceId))
|
||||||
res.json(await q)
|
res.json(await q)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
res.status(500).json({ error: err.message })
|
res.status(500).json({ error: err.message })
|
||||||
@@ -211,9 +213,9 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
|
|||||||
|
|
||||||
router.post('/calendar', async (req: Request, res: Response) => {
|
router.post('/calendar', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes } = req.body
|
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes, instance_id } = req.body
|
||||||
const [slot] = await db('sec_calendar').insert({
|
const [slot] = await db('sec_calendar').insert({
|
||||||
id: uuid(), title, date, time_start, time_end,
|
id: uuid(), title, date, time_start, time_end, instance_id: instance_id ?? null,
|
||||||
attendee_name, attendee_phone, notes, status: 'available',
|
attendee_name, attendee_phone, notes, status: 'available',
|
||||||
}).returning('*')
|
}).returning('*')
|
||||||
res.status(201).json(slot)
|
res.status(201).json(slot)
|
||||||
@@ -224,9 +226,11 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
|
|||||||
|
|
||||||
router.put('/calendar/:id', async (req: Request, res: Response) => {
|
router.put('/calendar/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes } = req.body
|
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, instance_id } = req.body
|
||||||
|
const patch: any = { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() }
|
||||||
|
if ('instance_id' in req.body) patch.instance_id = instance_id ?? null
|
||||||
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
||||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
.update(patch)
|
||||||
.returning('*')
|
.returning('*')
|
||||||
res.json(slot)
|
res.json(slot)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -256,18 +260,18 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
|
|||||||
|
|
||||||
router.post('/numbers', async (req: Request, res: Response) => {
|
router.post('/numbers', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { instance_id, label, role, area, priority, notes } = req.body
|
const { instance_id, label, role, area, priority, notes, agent_id } = req.body
|
||||||
if (!instance_id) return res.status(400).json({ error: 'instance_id é obrigatório' })
|
if (!instance_id) return res.status(400).json({ error: 'instance_id é obrigatório' })
|
||||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||||
const existing = await db('sec_numbers').where({ instance_id }).first()
|
const existing = await db('sec_numbers').where({ instance_id }).first()
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const [num] = await db('sec_numbers').where({ instance_id })
|
const patch: any = { label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() }
|
||||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
if ('agent_id' in req.body) patch.agent_id = agent_id ?? null
|
||||||
.returning('*')
|
const [num] = await db('sec_numbers').where({ instance_id }).update(patch).returning('*')
|
||||||
return res.json(num)
|
return res.json(num)
|
||||||
}
|
}
|
||||||
const [num] = await db('sec_numbers').insert({
|
const [num] = await db('sec_numbers').insert({
|
||||||
id: uuid(), instance_id, label, role: role ?? 'clinic',
|
id: uuid(), instance_id, label, role: role ?? 'clinic', agent_id: agent_id ?? null,
|
||||||
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
||||||
}).returning('*')
|
}).returning('*')
|
||||||
res.status(201).json(num)
|
res.status(201).json(num)
|
||||||
@@ -278,9 +282,11 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
|
|||||||
|
|
||||||
router.put('/numbers/:id', async (req: Request, res: Response) => {
|
router.put('/numbers/:id', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const { label, role, area, instance_id, priority, active, notes } = req.body
|
const { label, role, area, instance_id, priority, active, notes, agent_id } = req.body
|
||||||
|
const patch: any = { label, role, area, instance_id, priority, active, notes, updated_at: new Date() }
|
||||||
|
if ('agent_id' in req.body) patch.agent_id = agent_id ?? null
|
||||||
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
||||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
.update(patch)
|
||||||
.returning('*')
|
.returning('*')
|
||||||
res.json(num)
|
res.json(num)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ exports.BUILTIN_TOOLS = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||||
},
|
},
|
||||||
|
periodo: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Período do dia: "manha" ou "tarde". SEMPRE informe (pergunte ao cliente antes se não souber).',
|
||||||
|
enum: ['manha', 'tarde'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(args, ctx) {
|
async execute(args, ctx) {
|
||||||
@@ -31,6 +36,8 @@ exports.BUILTIN_TOOLS = [
|
|||||||
u.searchParams.set('clinica_id', ctx.agenda.clinicaId);
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId);
|
||||||
if (args['data'])
|
if (args['data'])
|
||||||
u.searchParams.set('date', String(args['data']));
|
u.searchParams.set('date', String(args['data']));
|
||||||
|
if (args['periodo'])
|
||||||
|
u.searchParams.set('periodo', String(args['periodo']));
|
||||||
const r = await fetch(u, {
|
const r = await fetch(u, {
|
||||||
headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
signal: AbortSignal.timeout(12000),
|
signal: AbortSignal.timeout(12000),
|
||||||
@@ -42,12 +49,14 @@ exports.BUILTIN_TOOLS = [
|
|||||||
return {
|
return {
|
||||||
disponivel: true,
|
disponivel: true,
|
||||||
horarios: slots.map((s) => ({
|
horarios: slots.map((s) => ({
|
||||||
// slot_id sintético (não há registro persistente — o slot é calculado)
|
// slot_id sintético (não há registro persistente — o slot é calculado).
|
||||||
id: `${s.dentista_id}|${s.start}|${s.end}`,
|
// Inclui sala_id (vazio p/ clínica) p/ o agendar saber onde reservar.
|
||||||
|
id: `${s.dentista_id}|${s.start}|${s.end}|${s.sala_id ?? ''}`,
|
||||||
dentista: s.dentista_nome,
|
dentista: s.dentista_nome,
|
||||||
data: String(s.start).slice(0, 10),
|
data: String(s.start).slice(0, 10),
|
||||||
inicio: String(s.start).slice(11, 16),
|
inicio: String(s.start).slice(11, 16),
|
||||||
fim: String(s.end).slice(11, 16),
|
fim: String(s.end).slice(11, 16),
|
||||||
|
local: s.local ?? 'Clínica', // nome da sala alugada, ou "Clínica"
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -55,12 +64,18 @@ exports.BUILTIN_TOOLS = [
|
|||||||
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` };
|
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: calendário interno do motor.
|
// Fallback: calendário interno do motor, escopado por sessão (instance_id).
|
||||||
|
// Slots com instance_id NULL são legado/global (visíveis a todas as sessões).
|
||||||
const from = args['data'] ?? fmtDate(new Date());
|
const from = args['data'] ?? fmtDate(new Date());
|
||||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
|
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
|
||||||
const slots = await ctx.db('sec_calendar')
|
const slots = await ctx.db('sec_calendar')
|
||||||
.where('status', 'available')
|
.where('status', 'available')
|
||||||
.whereBetween('date', [from, to])
|
.whereBetween('date', [from, to])
|
||||||
|
.where((qb) => {
|
||||||
|
qb.whereNull('instance_id');
|
||||||
|
if (ctx.instanceId)
|
||||||
|
qb.orWhere('instance_id', ctx.instanceId);
|
||||||
|
})
|
||||||
.orderBy('date').orderBy('time_start')
|
.orderBy('date').orderBy('time_start')
|
||||||
.limit(20);
|
.limit(20);
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
@@ -96,13 +111,15 @@ exports.BUILTIN_TOOLS = [
|
|||||||
// Agenda REAL do scoreodonto (ponte) quando configurada.
|
// Agenda REAL do scoreodonto (ponte) quando configurada.
|
||||||
if (ctx.agenda?.url && ctx.agenda.clinicaId) {
|
if (ctx.agenda?.url && ctx.agenda.clinicaId) {
|
||||||
const parts = String(args['slot_id'] ?? '').split('|');
|
const parts = String(args['slot_id'] ?? '').split('|');
|
||||||
if (parts.length !== 3)
|
if (parts.length < 3)
|
||||||
return { ok: false, erro: 'slot_id inválido. Chame listar_horarios novamente.' };
|
return { ok: false, erro: 'slot_id inválido. Chame listar_horarios novamente.' };
|
||||||
const [dentistaId, start, end] = parts;
|
const [dentistaId, start, end, salaId] = parts; // salaId vazio = agenda da clínica
|
||||||
// telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid)
|
// telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid)
|
||||||
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
||||||
if (!telefone && ctx.extChatId) {
|
if (!telefone && ctx.extChatId) {
|
||||||
const jid = ctx.extChatId.split(':').slice(1).join(':');
|
// ext_chat_id = "tenant:jid" (legado) ou "tenant:instanceId:jid"; o jid
|
||||||
|
// não tem ':', então é sempre o último segmento.
|
||||||
|
const jid = ctx.extChatId.split(':').pop() ?? '';
|
||||||
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -112,6 +129,7 @@ exports.BUILTIN_TOOLS = [
|
|||||||
signal: AbortSignal.timeout(12000),
|
signal: AbortSignal.timeout(12000),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
|
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
|
||||||
|
...(salaId ? { sala_id: salaId } : {}), // reserva no quarto alugado, se aplicável
|
||||||
paciente_nome: args['nome_cliente'], paciente_celular: telefone || null,
|
paciente_nome: args['nome_cliente'], paciente_celular: telefone || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -160,6 +178,101 @@ exports.BUILTIN_TOOLS = [
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── identificar_numero ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
name: 'identificar_numero',
|
||||||
|
description: 'SEMPRE use PRIMEIRO ao iniciar cadastro ou agendamento. Retorna quais pacientes já ' +
|
||||||
|
'estão cadastrados NESTE número de WhatsApp (a "família" do número). Lista vazia = número novo. ' +
|
||||||
|
'Use para: (1) saber se quem fala já é paciente; (2) ver se há dependentes no mesmo número ' +
|
||||||
|
'(ex.: mãe + filhos); (3) decidir a quem se refere o atendimento antes de agendar.',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
async execute(_args, ctx) {
|
||||||
|
if (!ctx.agenda?.url)
|
||||||
|
return { erro: 'Ponte de agenda indisponível.' };
|
||||||
|
const phone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, '');
|
||||||
|
if (!phone)
|
||||||
|
return { erro: 'Não foi possível identificar o número desta conversa.' };
|
||||||
|
try {
|
||||||
|
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes/lookup`);
|
||||||
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId ?? '');
|
||||||
|
u.searchParams.set('phone', phone);
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10000) });
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok)
|
||||||
|
return { erro: j?.error ?? 'Falha ao consultar a base.' };
|
||||||
|
return {
|
||||||
|
numero: phone,
|
||||||
|
cadastrado: (j?.encontrados ?? 0) > 0,
|
||||||
|
pacientes: j?.pacientes ?? [],
|
||||||
|
dica: (j?.encontrados ?? 0) > 1
|
||||||
|
? 'Há mais de um paciente neste número (grupo familiar) — confirme para quem é.'
|
||||||
|
: (j?.encontrados === 1 ? 'Confirme se quem fala é este paciente.' : 'Número novo — colete os dados para cadastrar.'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { erro: `Base indisponível: ${e.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── cadastrar_paciente ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
name: 'cadastrar_paciente',
|
||||||
|
description: 'Cadastra (ou atualiza) um paciente. REGRAS: ' +
|
||||||
|
'(1) Se quem fala é o próprio paciente adulto → eh_titular=true. ' +
|
||||||
|
'(2) Se está cadastrando um DEPENDENTE (ex.: filho menor de 18) → NÃO marque eh_titular; ' +
|
||||||
|
'o sistema vincula ao responsável (o dono deste número) e usa o telefone da família. ' +
|
||||||
|
'(3) Menor de 18 é dependente POR PADRÃO — mas se o menor tiver WhatsApp próprio e estiver ' +
|
||||||
|
'falando do número dele, pergunte e trate como titular. ' +
|
||||||
|
'(4) SEMPRE pergunte a data de nascimento (a idade decide se é dependente). ' +
|
||||||
|
'(5) CPF é o identificador forte (evita duplicar) — colete para adultos. ' +
|
||||||
|
'Use identificar_numero antes para não duplicar quem já existe.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
nome: { type: 'string', description: 'Nome completo do paciente.' },
|
||||||
|
data_nascimento: { type: 'string', description: 'Data de nascimento YYYY-MM-DD (define a idade).' },
|
||||||
|
cpf: { type: 'string', description: 'CPF do paciente (recomendado para adultos).' },
|
||||||
|
telefone: { type: 'string', description: 'Telefone próprio do paciente. Omita se for dependente sem número próprio.' },
|
||||||
|
convenio: { type: 'string', description: 'Convênio/plano, se houver.' },
|
||||||
|
eh_titular: { type: 'string', description: '"true" se quem fala é o próprio paciente (dono deste número).' },
|
||||||
|
relacao: { type: 'string', description: 'Relação no grupo (ex.: filho(a), cônjuge, dependente). Opcional.' },
|
||||||
|
responsavel_cpf: { type: 'string', description: 'CPF do responsável, se cadastrando um dependente.' },
|
||||||
|
},
|
||||||
|
required: ['nome'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url)
|
||||||
|
return { ok: false, erro: 'Ponte de agenda indisponível.' };
|
||||||
|
const convPhone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, '');
|
||||||
|
const ehTitular = String(args['eh_titular'] ?? '').toLowerCase() === 'true';
|
||||||
|
const body = {
|
||||||
|
clinica_id: ctx.agenda.clinicaId,
|
||||||
|
nome: args['nome'], cpf: args['cpf'] || null,
|
||||||
|
data_nascimento: args['data_nascimento'] || null,
|
||||||
|
telefone: args['telefone'] || (ehTitular ? convPhone : null),
|
||||||
|
convenio: args['convenio'] || null,
|
||||||
|
eh_titular: ehTitular, relacao: args['relacao'] || null,
|
||||||
|
responsavel_cpf: args['responsavel_cpf'] || null,
|
||||||
|
// Dependente sem responsável explícito → o dono DESTE número é o responsável.
|
||||||
|
responsavel_telefone: (!ehTitular && !args['responsavel_cpf']) ? convPhone : null,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
|
signal: AbortSignal.timeout(12000),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok)
|
||||||
|
return { ok: false, erro: j?.error ?? 'Falha ao cadastrar.' };
|
||||||
|
return { ok: true, ...j };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, erro: `Base indisponível: ${e.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: 'escalar_humano',
|
name: 'escalar_humano',
|
||||||
@@ -183,9 +296,8 @@ exports.BUILTIN_TOOLS = [
|
|||||||
});
|
});
|
||||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||||
const chatId = ctx.extChatId.includes(':')
|
// jid = último segmento (formato "tenant:jid" ou "tenant:instanceId:jid").
|
||||||
? ctx.extChatId.split(':').slice(1).join(':')
|
const chatId = ctx.extChatId.split(':').pop() ?? ctx.extChatId;
|
||||||
: ctx.extChatId;
|
|
||||||
const payload = {
|
const payload = {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
conversationId: ctx.conversationId,
|
conversationId: ctx.conversationId,
|
||||||
@@ -226,6 +338,201 @@ exports.BUILTIN_TOOLS = [
|
|||||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary };
|
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// ── solicitar_horario_especial ──────────────────────────────────────────────
|
||||||
|
// Zona cinza: o cliente pede um horário FORA da janela livre (mais cedo/mais
|
||||||
|
// tarde). A IA NÃO agenda — registra um pedido para a secretária HUMANA confirmar
|
||||||
|
// e diz que vai retornar.
|
||||||
|
{
|
||||||
|
name: 'solicitar_horario_especial',
|
||||||
|
description: 'Use QUANDO o cliente pedir um horário que NÃO aparece na lista de horários disponíveis — ' +
|
||||||
|
'mais cedo ou mais tarde que o normal (ex.: "tem às 8h30?", "consegue depois das 17h?"). ' +
|
||||||
|
'NUNCA diga que não tem: registre o pedido com esta tool e responda algo como "No momento esse ' +
|
||||||
|
'horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?". ' +
|
||||||
|
'Não garanta o horário nem mencione "secretária". Só para horários pontuais fora do padrão.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
data: { type: 'string', description: 'Data desejada no formato YYYY-MM-DD.' },
|
||||||
|
hora: { type: 'string', description: 'Horário desejado HH:MM.' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||||
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||||
|
dentista: { type: 'string', description: 'Nome do dentista, se o cliente indicou (opcional).' },
|
||||||
|
procedimento: { type: 'string', description: 'Procedimento/motivo (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['data', 'hora', 'nome_cliente'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
|
||||||
|
return { ok: false, erro: 'Agenda indisponível.' };
|
||||||
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
||||||
|
if (!telefone && ctx.extChatId) {
|
||||||
|
const jid = ctx.extChatId.split(':').pop() ?? '';
|
||||||
|
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pedido`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
|
signal: AbortSignal.timeout(12000),
|
||||||
|
body: JSON.stringify({
|
||||||
|
clinica_id: ctx.agenda.clinicaId, data: args['data'], hora: args['hora'],
|
||||||
|
dentista_nome: args['dentista'] ?? null, nome_cliente: args['nome_cliente'],
|
||||||
|
telefone, procedimento: args['procedimento'] ?? null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok || j.ok === false)
|
||||||
|
return { ok: false, mensagem: j.motivo ?? 'Não foi possível registrar o pedido.' };
|
||||||
|
return { ok: true, registrado: true, instrucao: 'Responda ao cliente algo como: "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?". NÃO garanta o horário nem mencione "secretária"; deixe claro que confirma em breve.' };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, erro: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── encaminhar_humano ───────────────────────────────────────────────────────
|
||||||
|
// Dúvida/caso incerto (ou regra da clínica que mande AVALIAR): a IA NÃO agenda —
|
||||||
|
// encaminha à secretária humana decidir e pede ao cliente para aguardar.
|
||||||
|
{
|
||||||
|
name: 'encaminhar_humano',
|
||||||
|
description: 'Use QUANDO NÃO for caso de agendar direto: dúvida inicial do cliente, caso clínico que você não sabe ' +
|
||||||
|
'resolver, imagem/foto que precise de avaliação humana, ou quando uma REGRA/SITUAÇÃO da clínica mandar ' +
|
||||||
|
'AVALIAR antes (ex.: "pelo plano não temos profissional para canal posterior — ver chance de agendar ' +
|
||||||
|
'avaliação"). NÃO agende: registre com esta tool e peça ao cliente para AGUARDAR, que a clínica retorna ' +
|
||||||
|
'com uma posição. Não mencione "secretária humana" nem garanta nada. Nem sempre o certo é agendar.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
resumo: { type: 'string', description: 'Resumo objetivo da dúvida/situação para a secretária humana decidir (inclua o que o cliente quer, o motivo e, se houve imagem, o que você identificou).' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome do cliente, se souber (opcional).' },
|
||||||
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['resumo'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
|
||||||
|
return { ok: false, erro: 'Agenda indisponível.' };
|
||||||
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
||||||
|
if (!telefone && ctx.extChatId) {
|
||||||
|
const jid = ctx.extChatId.split(':').pop() ?? '';
|
||||||
|
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/duvida`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
|
signal: AbortSignal.timeout(12000),
|
||||||
|
body: JSON.stringify({
|
||||||
|
clinica_id: ctx.agenda.clinicaId, resumo: args['resumo'],
|
||||||
|
nome_cliente: args['nome_cliente'] ?? null, telefone,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const j = await r.json().catch(() => ({}));
|
||||||
|
if (!r.ok || j.ok === false)
|
||||||
|
return { ok: false, mensagem: j.error ?? 'Não foi possível encaminhar.' };
|
||||||
|
return { ok: true, registrado: true, instrucao: 'Responda ao cliente que você vai verificar isso com a clínica e retorna em breve com uma posição — peça para aguardar. NÃO agende, NÃO garanta nada e NÃO mencione "secretária".' };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, erro: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── confirmar_depois ────────────────────────────────────────────────────────
|
||||||
|
// Parte B: o cliente pede um horário MAIS TARDE que o oferecido. A IA diz "vou
|
||||||
|
// confirmar, aguarde uns minutinhos" e registra um follow-up; um worker retorna
|
||||||
|
// depois (com atraso) com o horário confirmado.
|
||||||
|
{
|
||||||
|
name: 'confirmar_depois',
|
||||||
|
description: 'Use quando o cliente pedir um horário MAIS TARDE no dia (dentro do mesmo período) do que o que você ' +
|
||||||
|
'ofereceu — ou quando quiser "checar a agenda com calma" antes de confirmar. Diga ao cliente para aguardar ' +
|
||||||
|
'uns minutinhos que você vai confirmar; em poucos minutos o sistema retorna sozinho com o horário. NÃO ofereça horário agora.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
data: { type: 'string', description: 'Data desejada YYYY-MM-DD.' },
|
||||||
|
periodo: { type: 'string', description: '"manha" ou "tarde".', enum: ['manha', 'tarde'] },
|
||||||
|
hora_desejada: { type: 'string', description: 'Horário aproximado que o cliente pediu (HH:MM), se houver.' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
|
||||||
|
dentista: { type: 'string', description: 'Dentista, se indicado (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['data'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
|
||||||
|
return { ok: false, erro: 'Agenda indisponível.' };
|
||||||
|
const jid = (ctx.extChatId ?? '').split(':').pop() ?? '';
|
||||||
|
if (!jid || !ctx.instanceId)
|
||||||
|
return { ok: false, erro: 'Contexto incompleto.' };
|
||||||
|
const delay = Math.max(1, ctx.followupDelayMin ?? 15);
|
||||||
|
try {
|
||||||
|
await ctx.db('sec_agenda_followups').insert({
|
||||||
|
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, jid,
|
||||||
|
clinica_id: ctx.agenda.clinicaId, agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
|
||||||
|
dentista_nome: args['dentista'] ?? null, data: args['data'], hora_desejada: args['hora_desejada'] ?? null,
|
||||||
|
periodo: args['periodo'] ?? null, paciente_nome: args['nome_cliente'] ?? null,
|
||||||
|
fire_at: new Date(Date.now() + delay * 60000), status: 'pendente',
|
||||||
|
});
|
||||||
|
return { ok: true, agendado_retorno: true, instrucao: 'Diga ao cliente para aguardar uns minutinhos que você vai confirmar a agenda e JÁ retorna. NÃO ofereça horário agora — o sistema retorna sozinho em instantes.' };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, erro: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// ── perguntar_dentista ──────────────────────────────────────────────────────
|
||||||
|
// Parte A: encaixe SUB-HORA (ex.: 09:45, que quebra o espaçamento de 1h). A IA
|
||||||
|
// pergunta ao DENTISTA por WhatsApp; ele responde SIM/NÃO e o sistema agenda.
|
||||||
|
{
|
||||||
|
name: 'perguntar_dentista',
|
||||||
|
description: 'Use quando o cliente pedir um encaixe que QUEBRA o espaçamento de 1 hora (ex.: 09:45, 10:20) — um horário ' +
|
||||||
|
'curto/fora da grade normal. NÃO agende: pergunte ao DENTISTA responsável se dá tempo. Diga ao cliente que ' +
|
||||||
|
'você vai verificar com o dentista e confirma em seguida.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
dentista: { type: 'string', description: 'Nome do dentista responsável.' },
|
||||||
|
data: { type: 'string', description: 'Data YYYY-MM-DD.' },
|
||||||
|
hora: { type: 'string', description: 'Horário do encaixe HH:MM (ex.: 09:45).' },
|
||||||
|
procedimento: { type: 'string', description: 'Tipo (ex.: avaliação, avaliação de prótese).' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
|
||||||
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['dentista', 'data', 'hora', 'nome_cliente'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId)
|
||||||
|
return { ok: false, erro: 'Agenda indisponível.' };
|
||||||
|
const jid = (ctx.extChatId ?? '').split(':').pop() ?? '';
|
||||||
|
if (!jid || !ctx.instanceId)
|
||||||
|
return { ok: false, erro: 'Contexto incompleto.' };
|
||||||
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
||||||
|
if (!telefone)
|
||||||
|
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '');
|
||||||
|
try {
|
||||||
|
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/dentista-contato`);
|
||||||
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId);
|
||||||
|
u.searchParams.set('nome', String(args['dentista']));
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10000) });
|
||||||
|
const dj = await r.json().catch(() => ({}));
|
||||||
|
if (!dj.encontrado || !dj.telefone)
|
||||||
|
return { ok: false, mensagem: 'Sem contato do dentista.', instrucao: 'Diga que não conseguiu falar com o dentista agora e ofereça um horário normal da agenda.' };
|
||||||
|
const dentistaJid = `${dj.telefone}@s.whatsapp.net`;
|
||||||
|
await ctx.db('sec_agenda_approvals').insert({
|
||||||
|
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, clinica_id: ctx.agenda.clinicaId,
|
||||||
|
agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
|
||||||
|
paciente_jid: jid, paciente_nome: args['nome_cliente'] ?? null, paciente_telefone: telefone || null,
|
||||||
|
dentista_id: dj.dentista_id, dentista_nome: dj.nome, dentista_jid: dentistaJid,
|
||||||
|
data: args['data'], hora: args['hora'], procedimento: args['procedimento'] ?? 'avaliação', status: 'aguardando',
|
||||||
|
});
|
||||||
|
const texto = `Oi, Dr(a) ${String(dj.nome).split(' ')[0]}! 👋 Tenho um paciente (${args['nome_cliente']}) querendo ${args['procedimento'] ?? 'uma avaliação'} às ${args['hora']} do dia ${args['data']}. Consegue encaixar? Responda *SIM* ou *NÃO*.`;
|
||||||
|
ctx.hooks?.emit?.('ext:agenda.notify-dentist', { instanceId: ctx.instanceId, tenantId: ctx.tenantId, jid: dentistaJid, text: texto });
|
||||||
|
return { ok: true, perguntou: true, instrucao: 'Diga ao cliente que você já perguntou ao dentista se dá pra encaixar e que, assim que ele responder, você confirma. NÃO garanta o horário.' };
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
return { ok: false, erro: e.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
function resolveTools(names) {
|
function resolveTools(names) {
|
||||||
return names
|
return names
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,6 +37,10 @@ export interface ToolContext {
|
|||||||
conversationId: string
|
conversationId: string
|
||||||
extChatId?: string
|
extChatId?: string
|
||||||
tenantId?: string
|
tenantId?: string
|
||||||
|
/** Número/sessão que recebeu a mensagem — escopa o calendário interno por sessão. */
|
||||||
|
instanceId?: string
|
||||||
|
/** Atraso (min) do follow-up proativo da agenda (Parte B). Default 15. */
|
||||||
|
followupDelayMin?: number
|
||||||
hooks?: HookBus
|
hooks?: HookBus
|
||||||
/** Ponte de agenda do satélite (scoreodonto). Quando presente, as tools de
|
/** Ponte de agenda do satélite (scoreodonto). Quando presente, as tools de
|
||||||
* agenda operam na agenda REAL da clínica em vez do calendário interno. */
|
* agenda operam na agenda REAL da clínica em vez do calendário interno. */
|
||||||
@@ -74,6 +78,11 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
type: 'string',
|
type: 'string',
|
||||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||||
},
|
},
|
||||||
|
periodo: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Período do dia: "manha" ou "tarde". SEMPRE informe (pergunte ao cliente antes se não souber).',
|
||||||
|
enum: ['manha', 'tarde'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async execute(args, ctx) {
|
async execute(args, ctx) {
|
||||||
@@ -83,6 +92,7 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/slots`)
|
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/slots`)
|
||||||
u.searchParams.set('clinica_id', ctx.agenda.clinicaId)
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId)
|
||||||
if (args['data']) u.searchParams.set('date', String(args['data']))
|
if (args['data']) u.searchParams.set('date', String(args['data']))
|
||||||
|
if (args['periodo']) u.searchParams.set('periodo', String(args['periodo']))
|
||||||
const r = await fetch(u, {
|
const r = await fetch(u, {
|
||||||
headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
signal: AbortSignal.timeout(12_000),
|
signal: AbortSignal.timeout(12_000),
|
||||||
@@ -93,24 +103,31 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
return {
|
return {
|
||||||
disponivel: true,
|
disponivel: true,
|
||||||
horarios: slots.map((s) => ({
|
horarios: slots.map((s) => ({
|
||||||
// slot_id sintético (não há registro persistente — o slot é calculado)
|
// slot_id sintético (não há registro persistente — o slot é calculado).
|
||||||
id: `${s.dentista_id}|${s.start}|${s.end}`,
|
// Inclui sala_id (vazio p/ clínica) p/ o agendar saber onde reservar.
|
||||||
|
id: `${s.dentista_id}|${s.start}|${s.end}|${s.sala_id ?? ''}`,
|
||||||
dentista: s.dentista_nome,
|
dentista: s.dentista_nome,
|
||||||
data: String(s.start).slice(0, 10),
|
data: String(s.start).slice(0, 10),
|
||||||
inicio: String(s.start).slice(11, 16),
|
inicio: String(s.start).slice(11, 16),
|
||||||
fim: String(s.end).slice(11, 16),
|
fim: String(s.end).slice(11, 16),
|
||||||
|
local: s.local ?? 'Clínica', // nome da sala alugada, ou "Clínica"
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` }
|
return { disponivel: false, mensagem: `Agenda indisponível: ${e.message}` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: calendário interno do motor.
|
// Fallback: calendário interno do motor, escopado por sessão (instance_id).
|
||||||
|
// Slots com instance_id NULL são legado/global (visíveis a todas as sessões).
|
||||||
const from = args['data'] ?? fmtDate(new Date())
|
const from = args['data'] ?? fmtDate(new Date())
|
||||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000))
|
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000))
|
||||||
const slots = await ctx.db('sec_calendar')
|
const slots = await ctx.db('sec_calendar')
|
||||||
.where('status', 'available')
|
.where('status', 'available')
|
||||||
.whereBetween('date', [from, to])
|
.whereBetween('date', [from, to])
|
||||||
|
.where((qb: any) => {
|
||||||
|
qb.whereNull('instance_id')
|
||||||
|
if (ctx.instanceId) qb.orWhere('instance_id', ctx.instanceId)
|
||||||
|
})
|
||||||
.orderBy('date').orderBy('time_start')
|
.orderBy('date').orderBy('time_start')
|
||||||
.limit(20)
|
.limit(20)
|
||||||
if (!slots.length) {
|
if (!slots.length) {
|
||||||
@@ -148,12 +165,14 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
// Agenda REAL do scoreodonto (ponte) quando configurada.
|
// Agenda REAL do scoreodonto (ponte) quando configurada.
|
||||||
if (ctx.agenda?.url && ctx.agenda.clinicaId) {
|
if (ctx.agenda?.url && ctx.agenda.clinicaId) {
|
||||||
const parts = String(args['slot_id'] ?? '').split('|')
|
const parts = String(args['slot_id'] ?? '').split('|')
|
||||||
if (parts.length !== 3) return { ok: false, erro: 'slot_id inválido. Chame listar_horarios novamente.' }
|
if (parts.length < 3) return { ok: false, erro: 'slot_id inválido. Chame listar_horarios novamente.' }
|
||||||
const [dentistaId, start, end] = parts
|
const [dentistaId, start, end, salaId] = parts // salaId vazio = agenda da clínica
|
||||||
// telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid)
|
// telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid)
|
||||||
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
||||||
if (!telefone && ctx.extChatId) {
|
if (!telefone && ctx.extChatId) {
|
||||||
const jid = ctx.extChatId.split(':').slice(1).join(':')
|
// ext_chat_id = "tenant:jid" (legado) ou "tenant:instanceId:jid"; o jid
|
||||||
|
// não tem ':', então é sempre o último segmento.
|
||||||
|
const jid = ctx.extChatId.split(':').pop() ?? ''
|
||||||
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '')
|
telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -163,6 +182,7 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
signal: AbortSignal.timeout(12_000),
|
signal: AbortSignal.timeout(12_000),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
|
clinica_id: ctx.agenda.clinicaId, dentista_id: dentistaId, start, end,
|
||||||
|
...(salaId ? { sala_id: salaId } : {}), // reserva no quarto alugado, se aplicável
|
||||||
paciente_nome: args['nome_cliente'], paciente_celular: telefone || null,
|
paciente_nome: args['nome_cliente'], paciente_celular: telefone || null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -209,6 +229,94 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── identificar_numero ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
name: 'identificar_numero',
|
||||||
|
description:
|
||||||
|
'SEMPRE use PRIMEIRO ao iniciar cadastro ou agendamento. Retorna quais pacientes já ' +
|
||||||
|
'estão cadastrados NESTE número de WhatsApp (a "família" do número). Lista vazia = número novo. ' +
|
||||||
|
'Use para: (1) saber se quem fala já é paciente; (2) ver se há dependentes no mesmo número ' +
|
||||||
|
'(ex.: mãe + filhos); (3) decidir a quem se refere o atendimento antes de agendar.',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
async execute(_args: Record<string, any>, ctx: ToolContext) {
|
||||||
|
if (!ctx.agenda?.url) return { erro: 'Ponte de agenda indisponível.' }
|
||||||
|
const phone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, '')
|
||||||
|
if (!phone) return { erro: 'Não foi possível identificar o número desta conversa.' }
|
||||||
|
try {
|
||||||
|
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes/lookup`)
|
||||||
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId ?? '')
|
||||||
|
u.searchParams.set('phone', phone)
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10_000) })
|
||||||
|
const j: any = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) return { erro: j?.error ?? 'Falha ao consultar a base.' }
|
||||||
|
return {
|
||||||
|
numero: phone,
|
||||||
|
cadastrado: (j?.encontrados ?? 0) > 0,
|
||||||
|
pacientes: j?.pacientes ?? [],
|
||||||
|
dica: (j?.encontrados ?? 0) > 1
|
||||||
|
? 'Há mais de um paciente neste número (grupo familiar) — confirme para quem é.'
|
||||||
|
: (j?.encontrados === 1 ? 'Confirme se quem fala é este paciente.' : 'Número novo — colete os dados para cadastrar.'),
|
||||||
|
}
|
||||||
|
} catch (e: any) { return { erro: `Base indisponível: ${e.message}` } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── cadastrar_paciente ───────────────────────────────────────────────────────
|
||||||
|
{
|
||||||
|
name: 'cadastrar_paciente',
|
||||||
|
description:
|
||||||
|
'Cadastra (ou atualiza) um paciente. REGRAS: ' +
|
||||||
|
'(1) Se quem fala é o próprio paciente adulto → eh_titular=true. ' +
|
||||||
|
'(2) Se está cadastrando um DEPENDENTE (ex.: filho menor de 18) → NÃO marque eh_titular; ' +
|
||||||
|
'o sistema vincula ao responsável (o dono deste número) e usa o telefone da família. ' +
|
||||||
|
'(3) Menor de 18 é dependente POR PADRÃO — mas se o menor tiver WhatsApp próprio e estiver ' +
|
||||||
|
'falando do número dele, pergunte e trate como titular. ' +
|
||||||
|
'(4) SEMPRE pergunte a data de nascimento (a idade decide se é dependente). ' +
|
||||||
|
'(5) CPF é o identificador forte (evita duplicar) — colete para adultos. ' +
|
||||||
|
'Use identificar_numero antes para não duplicar quem já existe.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
nome: { type: 'string', description: 'Nome completo do paciente.' },
|
||||||
|
data_nascimento: { type: 'string', description: 'Data de nascimento YYYY-MM-DD (define a idade).' },
|
||||||
|
cpf: { type: 'string', description: 'CPF do paciente (recomendado para adultos).' },
|
||||||
|
telefone: { type: 'string', description: 'Telefone próprio do paciente. Omita se for dependente sem número próprio.' },
|
||||||
|
convenio: { type: 'string', description: 'Convênio/plano, se houver.' },
|
||||||
|
eh_titular: { type: 'string', description: '"true" se quem fala é o próprio paciente (dono deste número).' },
|
||||||
|
relacao: { type: 'string', description: 'Relação no grupo (ex.: filho(a), cônjuge, dependente). Opcional.' },
|
||||||
|
responsavel_cpf: { type: 'string', description: 'CPF do responsável, se cadastrando um dependente.' },
|
||||||
|
},
|
||||||
|
required: ['nome'],
|
||||||
|
},
|
||||||
|
async execute(args: Record<string, any>, ctx: ToolContext) {
|
||||||
|
if (!ctx.agenda?.url) return { ok: false, erro: 'Ponte de agenda indisponível.' }
|
||||||
|
const convPhone = (((ctx.extChatId ?? '').split(':').pop() ?? '').split('@')[0] ?? '').replace(/\D/g, '')
|
||||||
|
const ehTitular = String(args['eh_titular'] ?? '').toLowerCase() === 'true'
|
||||||
|
const body: any = {
|
||||||
|
clinica_id: ctx.agenda.clinicaId,
|
||||||
|
nome: args['nome'], cpf: args['cpf'] || null,
|
||||||
|
data_nascimento: args['data_nascimento'] || null,
|
||||||
|
telefone: args['telefone'] || (ehTitular ? convPhone : null),
|
||||||
|
convenio: args['convenio'] || null,
|
||||||
|
eh_titular: ehTitular, relacao: args['relacao'] || null,
|
||||||
|
responsavel_cpf: args['responsavel_cpf'] || null,
|
||||||
|
// Dependente sem responsável explícito → o dono DESTE número é o responsável.
|
||||||
|
responsavel_telefone: (!ehTitular && !args['responsavel_cpf']) ? convPhone : null,
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pacientes`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
const j: any = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok) return { ok: false, erro: j?.error ?? 'Falha ao cadastrar.' }
|
||||||
|
return { ok: true, ...j }
|
||||||
|
} catch (e: any) { return { ok: false, erro: `Base indisponível: ${e.message}` } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: 'escalar_humano',
|
name: 'escalar_humano',
|
||||||
@@ -233,9 +341,8 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
})
|
})
|
||||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
||||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||||
const chatId = ctx.extChatId.includes(':')
|
// jid = último segmento (formato "tenant:jid" ou "tenant:instanceId:jid").
|
||||||
? ctx.extChatId.split(':').slice(1).join(':')
|
const chatId = ctx.extChatId.split(':').pop() ?? ctx.extChatId
|
||||||
: ctx.extChatId
|
|
||||||
const payload = {
|
const payload = {
|
||||||
tenantId: ctx.tenantId,
|
tenantId: ctx.tenantId,
|
||||||
conversationId: ctx.conversationId,
|
conversationId: ctx.conversationId,
|
||||||
@@ -278,6 +385,180 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
|||||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary }
|
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary }
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── solicitar_horario_especial ──────────────────────────────────────────────
|
||||||
|
// Zona cinza: o cliente pede um horário FORA da janela livre (mais cedo/mais
|
||||||
|
// tarde). A IA NÃO agenda — registra um pedido para a secretária HUMANA confirmar
|
||||||
|
// e diz que vai retornar.
|
||||||
|
{
|
||||||
|
name: 'solicitar_horario_especial',
|
||||||
|
description:
|
||||||
|
'Use QUANDO o cliente pedir um horário que NÃO aparece na lista de horários disponíveis — ' +
|
||||||
|
'mais cedo ou mais tarde que o normal (ex.: "tem às 8h30?", "consegue depois das 17h?"). ' +
|
||||||
|
'NUNCA diga que não tem: registre o pedido com esta tool e responda algo como "No momento esse ' +
|
||||||
|
'horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?". ' +
|
||||||
|
'Não garanta o horário nem mencione "secretária". Só para horários pontuais fora do padrão.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
data: { type: 'string', description: 'Data desejada no formato YYYY-MM-DD.' },
|
||||||
|
hora: { type: 'string', description: 'Horário desejado HH:MM.' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||||
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||||
|
dentista: { type: 'string', description: 'Nome do dentista, se o cliente indicou (opcional).' },
|
||||||
|
procedimento: { type: 'string', description: 'Procedimento/motivo (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['data', 'hora', 'nome_cliente'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }
|
||||||
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
||||||
|
if (!telefone && ctx.extChatId) { const jid = ctx.extChatId.split(':').pop() ?? ''; telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '') }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/pedido`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
body: JSON.stringify({
|
||||||
|
clinica_id: ctx.agenda.clinicaId, data: args['data'], hora: args['hora'],
|
||||||
|
dentista_nome: args['dentista'] ?? null, nome_cliente: args['nome_cliente'],
|
||||||
|
telefone, procedimento: args['procedimento'] ?? null,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const j: any = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok || j.ok === false) return { ok: false, mensagem: j.motivo ?? 'Não foi possível registrar o pedido.' }
|
||||||
|
return { ok: true, registrado: true, instrucao: 'Responda ao cliente algo como: "No momento esse horário não está disponível, mas ainda não confirmei a agenda — posso te confirmar daqui a pouco?". NÃO garanta o horário nem mencione "secretária"; deixe claro que confirma em breve.' }
|
||||||
|
} catch (e: any) { return { ok: false, erro: e.message } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── encaminhar_humano ───────────────────────────────────────────────────────
|
||||||
|
// Dúvida/caso incerto (ou regra da clínica que mande AVALIAR): a IA NÃO agenda —
|
||||||
|
// encaminha à secretária humana decidir e pede ao cliente para aguardar.
|
||||||
|
{
|
||||||
|
name: 'encaminhar_humano',
|
||||||
|
description:
|
||||||
|
'Use QUANDO NÃO for caso de agendar direto: dúvida inicial do cliente, caso clínico que você não sabe ' +
|
||||||
|
'resolver, imagem/foto que precise de avaliação humana, ou quando uma REGRA/SITUAÇÃO da clínica mandar ' +
|
||||||
|
'AVALIAR antes (ex.: "pelo plano não temos profissional para canal posterior — ver chance de agendar ' +
|
||||||
|
'avaliação"). NÃO agende: registre com esta tool e peça ao cliente para AGUARDAR, que a clínica retorna ' +
|
||||||
|
'com uma posição. Não mencione "secretária humana" nem garanta nada. Nem sempre o certo é agendar.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
resumo: { type: 'string', description: 'Resumo objetivo da dúvida/situação para a secretária humana decidir (inclua o que o cliente quer, o motivo e, se houve imagem, o que você identificou).' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome do cliente, se souber (opcional).' },
|
||||||
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['resumo'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }
|
||||||
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
||||||
|
if (!telefone && ctx.extChatId) { const jid = ctx.extChatId.split(':').pop() ?? ''; telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '') }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${ctx.agenda.url.replace(/\/$/, '')}/duvida`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-nw-agenda-secret': ctx.agenda.secret ?? '' },
|
||||||
|
signal: AbortSignal.timeout(12_000),
|
||||||
|
body: JSON.stringify({
|
||||||
|
clinica_id: ctx.agenda.clinicaId, resumo: args['resumo'],
|
||||||
|
nome_cliente: args['nome_cliente'] ?? null, telefone,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const j: any = await r.json().catch(() => ({}))
|
||||||
|
if (!r.ok || j.ok === false) return { ok: false, mensagem: j.error ?? 'Não foi possível encaminhar.' }
|
||||||
|
return { ok: true, registrado: true, instrucao: 'Responda ao cliente que você vai verificar isso com a clínica e retorna em breve com uma posição — peça para aguardar. NÃO agende, NÃO garanta nada e NÃO mencione "secretária".' }
|
||||||
|
} catch (e: any) { return { ok: false, erro: e.message } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── confirmar_depois ────────────────────────────────────────────────────────
|
||||||
|
// Parte B: o cliente pede um horário MAIS TARDE que o oferecido. A IA diz "vou
|
||||||
|
// confirmar, aguarde uns minutinhos" e registra um follow-up; um worker retorna
|
||||||
|
// depois (com atraso) com o horário confirmado.
|
||||||
|
{
|
||||||
|
name: 'confirmar_depois',
|
||||||
|
description:
|
||||||
|
'Use quando o cliente pedir um horário MAIS TARDE no dia (dentro do mesmo período) do que o que você ' +
|
||||||
|
'ofereceu — ou quando quiser "checar a agenda com calma" antes de confirmar. Diga ao cliente para aguardar ' +
|
||||||
|
'uns minutinhos que você vai confirmar; em poucos minutos o sistema retorna sozinho com o horário. NÃO ofereça horário agora.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
data: { type: 'string', description: 'Data desejada YYYY-MM-DD.' },
|
||||||
|
periodo: { type: 'string', description: '"manha" ou "tarde".', enum: ['manha', 'tarde'] },
|
||||||
|
hora_desejada: { type: 'string', description: 'Horário aproximado que o cliente pediu (HH:MM), se houver.' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
|
||||||
|
dentista: { type: 'string', description: 'Dentista, se indicado (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['data'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }
|
||||||
|
const jid = (ctx.extChatId ?? '').split(':').pop() ?? ''
|
||||||
|
if (!jid || !ctx.instanceId) return { ok: false, erro: 'Contexto incompleto.' }
|
||||||
|
const delay = Math.max(1, ctx.followupDelayMin ?? 15)
|
||||||
|
try {
|
||||||
|
await ctx.db('sec_agenda_followups').insert({
|
||||||
|
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, jid,
|
||||||
|
clinica_id: ctx.agenda.clinicaId, agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
|
||||||
|
dentista_nome: args['dentista'] ?? null, data: args['data'], hora_desejada: args['hora_desejada'] ?? null,
|
||||||
|
periodo: args['periodo'] ?? null, paciente_nome: args['nome_cliente'] ?? null,
|
||||||
|
fire_at: new Date(Date.now() + delay * 60_000), status: 'pendente',
|
||||||
|
})
|
||||||
|
return { ok: true, agendado_retorno: true, instrucao: 'Diga ao cliente para aguardar uns minutinhos que você vai confirmar a agenda e JÁ retorna. NÃO ofereça horário agora — o sistema retorna sozinho em instantes.' }
|
||||||
|
} catch (e: any) { return { ok: false, erro: e.message } }
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── perguntar_dentista ──────────────────────────────────────────────────────
|
||||||
|
// Parte A: encaixe SUB-HORA (ex.: 09:45, que quebra o espaçamento de 1h). A IA
|
||||||
|
// pergunta ao DENTISTA por WhatsApp; ele responde SIM/NÃO e o sistema agenda.
|
||||||
|
{
|
||||||
|
name: 'perguntar_dentista',
|
||||||
|
description:
|
||||||
|
'Use quando o cliente pedir um encaixe que QUEBRA o espaçamento de 1 hora (ex.: 09:45, 10:20) — um horário ' +
|
||||||
|
'curto/fora da grade normal. NÃO agende: pergunte ao DENTISTA responsável se dá tempo. Diga ao cliente que ' +
|
||||||
|
'você vai verificar com o dentista e confirma em seguida.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
dentista: { type: 'string', description: 'Nome do dentista responsável.' },
|
||||||
|
data: { type: 'string', description: 'Data YYYY-MM-DD.' },
|
||||||
|
hora: { type: 'string', description: 'Horário do encaixe HH:MM (ex.: 09:45).' },
|
||||||
|
procedimento: { type: 'string', description: 'Tipo (ex.: avaliação, avaliação de prótese).' },
|
||||||
|
nome_cliente: { type: 'string', description: 'Nome do cliente.' },
|
||||||
|
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||||
|
},
|
||||||
|
required: ['dentista', 'data', 'hora', 'nome_cliente'],
|
||||||
|
},
|
||||||
|
async execute(args, ctx) {
|
||||||
|
if (!ctx.agenda?.url || !ctx.agenda.clinicaId) return { ok: false, erro: 'Agenda indisponível.' }
|
||||||
|
const jid = (ctx.extChatId ?? '').split(':').pop() ?? ''
|
||||||
|
if (!jid || !ctx.instanceId) return { ok: false, erro: 'Contexto incompleto.' }
|
||||||
|
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
||||||
|
if (!telefone) telefone = (jid.split('@')[0] ?? '').replace(/\D/g, '')
|
||||||
|
try {
|
||||||
|
const u = new URL(`${ctx.agenda.url.replace(/\/$/, '')}/dentista-contato`)
|
||||||
|
u.searchParams.set('clinica_id', ctx.agenda.clinicaId); u.searchParams.set('nome', String(args['dentista']))
|
||||||
|
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': ctx.agenda.secret ?? '' }, signal: AbortSignal.timeout(10_000) })
|
||||||
|
const dj: any = await r.json().catch(() => ({}))
|
||||||
|
if (!dj.encontrado || !dj.telefone) return { ok: false, mensagem: 'Sem contato do dentista.', instrucao: 'Diga que não conseguiu falar com o dentista agora e ofereça um horário normal da agenda.' }
|
||||||
|
const dentistaJid = `${dj.telefone}@s.whatsapp.net`
|
||||||
|
await ctx.db('sec_agenda_approvals').insert({
|
||||||
|
tenant_id: ctx.tenantId ?? null, instance_id: ctx.instanceId, clinica_id: ctx.agenda.clinicaId,
|
||||||
|
agenda_url: ctx.agenda.url, agenda_secret: ctx.agenda.secret ?? '',
|
||||||
|
paciente_jid: jid, paciente_nome: args['nome_cliente'] ?? null, paciente_telefone: telefone || null,
|
||||||
|
dentista_id: dj.dentista_id, dentista_nome: dj.nome, dentista_jid: dentistaJid,
|
||||||
|
data: args['data'], hora: args['hora'], procedimento: args['procedimento'] ?? 'avaliação', status: 'aguardando',
|
||||||
|
})
|
||||||
|
const texto = `Oi, Dr(a) ${String(dj.nome).split(' ')[0]}! 👋 Tenho um paciente (${args['nome_cliente']}) querendo ${args['procedimento'] ?? 'uma avaliação'} às ${args['hora']} do dia ${args['data']}. Consegue encaixar? Responda *SIM* ou *NÃO*.`
|
||||||
|
ctx.hooks?.emit?.('ext:agenda.notify-dentist', { instanceId: ctx.instanceId, tenantId: ctx.tenantId, jid: dentistaJid, text: texto })
|
||||||
|
return { ok: true, perguntou: true, instrucao: 'Diga ao cliente que você já perguntou ao dentista se dá pra encaixar e que, assim que ele responder, você confirma. NÃO garanta o horário.' }
|
||||||
|
} catch (e: any) { return { ok: false, erro: e.message } }
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function resolveTools(names: string[]): ToolDef[] {
|
export function resolveTools(names: string[]): ToolDef[] {
|
||||||
|
|||||||
Reference in New Issue
Block a user