feat(secretaria): Secretária IA separada por sessão (agente por número)
Núcleo desta mudança (separação por sessão): - migrate.ts: sec_numbers.agent_id (FK→sec_agents, ON DELETE SET NULL) e sec_calendar.instance_id, com índices (aditivo/idempotente). - ext-api routes.ts: runtime resolve o agente pelo NÚMERO que recebeu a mensagem (sec_numbers[instanceId].agent_id, fallback: 1º ativo); rotas admin /secretaria/numbers e /secretaria/calendar aceitam os novos campos. - brain.ts/tools.ts: instanceId no contexto; sec_calendar interno filtrado por sessão; parse de jid robusto (.pop(), suporta 2 e 3 partes). - secretaria/routes.ts: paridade (agent_id em numbers, instance_id no calendar). Testado em dev (newwhats.dev refrescado): agentes resolvidos por instância; fallback e FK ON DELETE SET NULL verificados via ext-api. Obs.: por estarem no mesmo working tree não-commitado do newwhats.local, ext-api/routes.ts e secretaria/tools.ts também trazem o trabalho acumulado da integração do satélite (ações de inbox: delete/react/typing/labels/forward e admin /secretaria/*; tools identificar_numero/cadastrar_paciente do odonto). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -186,8 +186,8 @@ function scheduleHandoffTimeout(convId, extChatId, tenantId, db, hooks) {
|
||||
try {
|
||||
await db('sec_conversations').where({ id: convId })
|
||||
.update({ handoff_mode: 'ia', handoff_human_at: null });
|
||||
// chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId")
|
||||
const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId;
|
||||
// jid = último segmento do extChatId ("tenantId:jid" ou "tenantId:instanceId:jid")
|
||||
const chatId = extChatId.split(':').pop() ?? extChatId;
|
||||
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' });
|
||||
}
|
||||
catch { }
|
||||
@@ -301,10 +301,22 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
|
||||
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}`;
|
||||
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first();
|
||||
if (!conv) {
|
||||
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
||||
// 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;
|
||||
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
|
||||
@@ -329,11 +341,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
// SEC-14: indicador de digitação antes de chamar a IA
|
||||
const sock = manager?.getSocket(instanceId);
|
||||
await sock?.sendPresenceUpdate('composing', jid).catch(() => { });
|
||||
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
|
||||
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
|
||||
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first();
|
||||
// Clínica e agenda vêm do NÚMERO que recebeu a mensagem (channel, resolvido acima).
|
||||
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 sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply });
|
||||
});
|
||||
@@ -698,6 +708,10 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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:
|
||||
// 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') {
|
||||
@@ -720,6 +734,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
// ── GET /inbox ────────────────────────────────────────────────────────────
|
||||
// Lista chats da instância com snapshot da última mensagem.
|
||||
router.get('/inbox', async (req, res) => {
|
||||
var _a;
|
||||
const tenantId = req.extTenantId;
|
||||
const sessionId = req.query['session'];
|
||||
const search = req.query['search'];
|
||||
@@ -784,6 +799,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
|
||||
const result = chats.map((c) => {
|
||||
const ct = c.contact;
|
||||
@@ -799,6 +825,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
lastMessageAt: c.lastMessageAt,
|
||||
isPinned: c.isPinned,
|
||||
isArchived: c.isArchived,
|
||||
labels: labelsByChat[c.id] ?? [],
|
||||
lastMessage: lastMsg
|
||||
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
|
||||
: null,
|
||||
@@ -837,13 +864,38 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
|
||||
// sem resposta do WhatsApp). Corremos contra um timeout para responder
|
||||
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
|
||||
const url = await Promise.race([
|
||||
sock.profilePictureUrl(jid, 'image').catch(() => null),
|
||||
new Promise((resolve) => setTimeout(() => resolve(null), 5000)),
|
||||
]);
|
||||
// profilePictureUrl pode TRAVAR (sem resposta do WhatsApp) ou FALHAR na
|
||||
// alta-resolução ('image') mesmo quando a foto EXISTE e é visível no app.
|
||||
// Tentamos 'image' e, se vier vazio/erro, caímos para 'preview' (baixa-res,
|
||||
// bem mais confiável). Cada tentativa corre contra um timeout para nunca
|
||||
// estourar 504 no gateway. O erro real é logado (antes era silenciado).
|
||||
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) {
|
||||
// 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).
|
||||
@@ -851,7 +903,8 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: 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;
|
||||
}
|
||||
await prisma.contact.updateMany({
|
||||
@@ -904,6 +957,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
timestamp: true,
|
||||
pushName: true,
|
||||
senderJid: true,
|
||||
reactions: true,
|
||||
},
|
||||
});
|
||||
// mediaRecoverable: já tem URL, ou tem o WAMessage salvo (mediaPayload) / arquivo
|
||||
@@ -1070,13 +1124,269 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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 ──────────────────────────────────────────────
|
||||
// Envia mensagem de texto ou rica para o chat.
|
||||
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
||||
router.post('/inbox/:chatId/send', async (req, res) => {
|
||||
const tenantId = req.extTenantId;
|
||||
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);
|
||||
if (!text && !hasRich) {
|
||||
res.status(400).json({ error: 'Campo "text" ou conteúdo rico obrigatório' });
|
||||
@@ -1096,6 +1406,29 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
res.status(400).json({ error: 'Instância não conectada' });
|
||||
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/
|
||||
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
|
||||
// O footer legado (aninhado em nativeList) é extraído para o top-level.
|
||||
@@ -1110,7 +1443,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
nativeList: cleanList,
|
||||
nativeCarousel: nativeCarousel,
|
||||
poll: poll,
|
||||
});
|
||||
}, quoted);
|
||||
// Echo em tempo real: todos os operadores do número veem o envio na hora.
|
||||
if (waMessageId) {
|
||||
await echoSentMessage({
|
||||
@@ -1590,9 +1923,15 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
}
|
||||
});
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
router.get('/secretaria/calendar', async (_req, res) => {
|
||||
router.get('/secretaria/calendar', async (req, res) => {
|
||||
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) {
|
||||
res.status(500).json({ error: err.message });
|
||||
@@ -1603,6 +1942,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
const b = req.body ?? {};
|
||||
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,
|
||||
instance_id: b.instance_id ?? null,
|
||||
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
|
||||
status: b.status ?? 'available', notes: b.notes ?? null,
|
||||
}).returning('*');
|
||||
@@ -1615,7 +1955,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
router.put('/secretaria/calendar/:id', async (req, res) => {
|
||||
try {
|
||||
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)
|
||||
patch[k] = b[k];
|
||||
const [s] = await db('sec_calendar').where({ id: req.params['id'] }).update(patch).returning('*');
|
||||
@@ -1640,6 +1980,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
const b = req.body ?? {};
|
||||
const [n] = await db('sec_numbers').insert({
|
||||
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',
|
||||
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
|
||||
}).returning('*');
|
||||
@@ -1652,7 +1993,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
router.put('/secretaria/numbers/:id', async (req, res) => {
|
||||
try {
|
||||
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)
|
||||
patch[k] = b[k];
|
||||
const [n] = await db('sec_numbers').where({ id: req.params['id'] }).update(patch).returning('*');
|
||||
@@ -1805,7 +2146,9 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
cancelHandoffTimeout(conv.id);
|
||||
}
|
||||
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) {
|
||||
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 {
|
||||
await db('sec_conversations').where({ id: convId })
|
||||
.update({ handoff_mode: 'ia', handoff_human_at: null })
|
||||
// chatId = parte após o primeiro ":" no extChatId ("tenantId:chatId")
|
||||
const chatId = extChatId.includes(':') ? extChatId.split(':').slice(1).join(':') : extChatId
|
||||
// jid = último segmento do extChatId ("tenantId:jid" ou "tenantId:instanceId:jid")
|
||||
const chatId = extChatId.split(':').pop() ?? extChatId
|
||||
hooks.emit('ext:handoff', { tenantId, conversationId: convId, chatId, mode: 'ia', reason: 'timeout' })
|
||||
} catch {}
|
||||
}, HANDOFF_TIMEOUT_MS)
|
||||
@@ -280,11 +280,24 @@ export function buildExtRoutes(
|
||||
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } })
|
||||
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}`
|
||||
let conv = await db('sec_conversations').where({ ext_chat_id: extKey, status: 'active' }).first()
|
||||
|
||||
if (!conv) {
|
||||
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first()
|
||||
// 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
|
||||
// Nome legível: Contact (agenda/WhatsApp) → telefone formatado → jid.
|
||||
const contact = await prisma.contact.findFirst({
|
||||
@@ -310,11 +323,9 @@ export function buildExtRoutes(
|
||||
const sock = manager?.getSocket(instanceId)
|
||||
await sock?.sendPresenceUpdate('composing', jid).catch(() => {})
|
||||
|
||||
// Modelo de canal: a clínica vem do NÚMERO que recebeu a mensagem
|
||||
// (sec_numbers.clinica_id da instância) — escopo da ponte de agenda.
|
||||
const channel = await db('sec_numbers').where({ instance_id: instanceId }).first()
|
||||
// Clínica e agenda vêm do NÚMERO que recebeu a mensagem (channel, resolvido acima).
|
||||
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 sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply })
|
||||
@@ -666,6 +677,7 @@ export function buildExtRoutes(
|
||||
|
||||
await prisma.message.update({ where: { id: messageId }, data: { mediaUrl } })
|
||||
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:
|
||||
// grava no Wasabi como <mediaUrl>.thumb.webp e é servido/cacheado igual à mídia.
|
||||
@@ -757,6 +769,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
|
||||
const result = chats.map((c) => {
|
||||
const ct = c.contact
|
||||
@@ -772,6 +794,7 @@ export function buildExtRoutes(
|
||||
lastMessageAt: c.lastMessageAt,
|
||||
isPinned: c.isPinned,
|
||||
isArchived: c.isArchived,
|
||||
labels: labelsByChat[c.id] ?? [],
|
||||
lastMessage: lastMsg
|
||||
? { body: lastMsg.body, type: lastMsg.type, fromMe: lastMsg.fromMe, status: lastMsg.status, timestamp: lastMsg.timestamp }
|
||||
: null,
|
||||
@@ -814,13 +837,41 @@ export function buildExtRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
// profilePictureUrl pode TRAVAR (contato com foto restrita/privacidade ou
|
||||
// sem resposta do WhatsApp). Corremos contra um timeout para responder
|
||||
// rápido (404 → cai nas iniciais) e nunca estourar 504 no gateway.
|
||||
const url = await Promise.race([
|
||||
sock.profilePictureUrl(jid, 'image').catch(() => null),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
|
||||
])
|
||||
// profilePictureUrl pode TRAVAR (sem resposta do WhatsApp) ou FALHAR na
|
||||
// alta-resolução ('image') mesmo quando a foto EXISTE e é visível no app.
|
||||
// Tentamos 'image' e, se vier vazio/erro, caímos para 'preview' (baixa-res,
|
||||
// bem mais confiável). Cada tentativa corre contra um timeout para nunca
|
||||
// estourar 504 no gateway. O erro real é logado (antes era silenciado).
|
||||
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) {
|
||||
// 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).
|
||||
@@ -828,7 +879,8 @@ export function buildExtRoutes(
|
||||
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: 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
|
||||
}
|
||||
await prisma.contact.updateMany({
|
||||
@@ -883,6 +935,7 @@ export function buildExtRoutes(
|
||||
timestamp: true,
|
||||
pushName: true,
|
||||
senderJid: true,
|
||||
reactions: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1025,15 +1078,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 ──────────────────────────────────────────────
|
||||
// Envia mensagem de texto ou rica para o chat.
|
||||
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
||||
router.post('/inbox/:chatId/send', async (req: Request, res: Response) => {
|
||||
const tenantId = req.extTenantId!
|
||||
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;
|
||||
nativeCarousel?: any; poll?: any;
|
||||
nativeCarousel?: any; poll?: any; replyToMessageId?: string;
|
||||
}
|
||||
|
||||
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll)
|
||||
@@ -1058,6 +1295,28 @@ export function buildExtRoutes(
|
||||
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/
|
||||
// nativeCarousel nos protos corretos do @whiskeysockets/baileys.
|
||||
// O footer legado (aninhado em nativeList) é extraído para o top-level.
|
||||
@@ -1073,7 +1332,7 @@ export function buildExtRoutes(
|
||||
nativeList: cleanList as any,
|
||||
nativeCarousel: nativeCarousel as any,
|
||||
poll: poll as any,
|
||||
})
|
||||
}, quoted)
|
||||
// Echo em tempo real: todos os operadores do número veem o envio na hora.
|
||||
if (waMessageId) {
|
||||
await echoSentMessage({
|
||||
@@ -1503,15 +1762,22 @@ export function buildExtRoutes(
|
||||
})
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
router.get('/secretaria/calendar', async (_req: Request, res: Response) => {
|
||||
try { res.json(await db('sec_calendar').orderBy('date').orderBy('time_start')) }
|
||||
catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||
router.get('/secretaria/calendar', async (req: Request, res: Response) => {
|
||||
try {
|
||||
// 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) => {
|
||||
try {
|
||||
const b = req.body ?? {}
|
||||
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,
|
||||
instance_id: b.instance_id ?? null,
|
||||
attendee_name: b.attendee_name ?? null, attendee_phone: b.attendee_phone ?? null,
|
||||
status: b.status ?? 'available', notes: b.notes ?? null,
|
||||
}).returning('*')
|
||||
@@ -1521,7 +1787,7 @@ export function buildExtRoutes(
|
||||
router.put('/secretaria/calendar/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
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('*')
|
||||
res.json(s)
|
||||
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||
@@ -1537,6 +1803,7 @@ export function buildExtRoutes(
|
||||
const b = req.body ?? {}
|
||||
const [n] = await db('sec_numbers').insert({
|
||||
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',
|
||||
role: b.role ?? 'clinic', area: b.area ?? null, priority: b.priority ?? 10, active: b.active ?? true, notes: b.notes ?? null,
|
||||
}).returning('*')
|
||||
@@ -1546,7 +1813,7 @@ export function buildExtRoutes(
|
||||
router.put('/secretaria/numbers/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
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('*')
|
||||
res.json(n)
|
||||
} catch (err: any) { res.status(500).json({ error: err.message }) }
|
||||
@@ -1665,7 +1932,9 @@ export function buildExtRoutes(
|
||||
}
|
||||
|
||||
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) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ class ProtocolEngine {
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
instanceId: opts?.instanceId,
|
||||
hooks: opts?.hooks,
|
||||
agenda: {
|
||||
url: agendaUrl,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -46,6 +46,7 @@ export class ProtocolEngine {
|
||||
hooks?: HookBus
|
||||
tenantId?: string
|
||||
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> {
|
||||
this.notifyCtx = { hooks: opts?.hooks, tenantId: opts?.tenantId }
|
||||
@@ -127,6 +128,7 @@ export class ProtocolEngine {
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
instanceId: opts?.instanceId,
|
||||
hooks: opts?.hooks,
|
||||
agenda: {
|
||||
url: agendaUrl,
|
||||
|
||||
@@ -122,6 +122,18 @@ async function runMigrations(db) {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||
await db.schema.createTable('sec_numbers', (t) => {
|
||||
@@ -142,6 +154,16 @@ async function runMigrations(db) {
|
||||
// Tabela pré-existente: adiciona o mapa canal→clínica.
|
||||
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) ───────────────────────────────
|
||||
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
||||
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
||||
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||
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())
|
||||
}
|
||||
|
||||
// ── 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) ───────────────────────────────
|
||||
// Chunks de conhecimento + embedding (JSON em text). Busca por similaridade
|
||||
// roda na aplicação (cosseno), então não exige a extensão pgvector.
|
||||
|
||||
@@ -195,7 +195,7 @@ function createSecretariaRoutes(db, config) {
|
||||
router.get('/calendar', async (req, res) => {
|
||||
try {
|
||||
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');
|
||||
if (from)
|
||||
q = q.where('date', '>=', from);
|
||||
@@ -203,6 +203,9 @@ function createSecretariaRoutes(db, config) {
|
||||
q = q.where('date', '<=', to);
|
||||
if (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);
|
||||
}
|
||||
catch (err) {
|
||||
@@ -211,9 +214,9 @@ function createSecretariaRoutes(db, config) {
|
||||
});
|
||||
router.post('/calendar', async (req, res) => {
|
||||
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({
|
||||
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',
|
||||
}).returning('*');
|
||||
res.status(201).json(slot);
|
||||
@@ -224,9 +227,12 @@ function createSecretariaRoutes(db, config) {
|
||||
});
|
||||
router.put('/calendar/:id', async (req, res) => {
|
||||
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 })
|
||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
||||
.update(patch)
|
||||
.returning('*');
|
||||
res.json(slot);
|
||||
}
|
||||
@@ -255,19 +261,20 @@ function createSecretariaRoutes(db, config) {
|
||||
});
|
||||
router.post('/numbers', async (req, res) => {
|
||||
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' });
|
||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||
const existing = await db('sec_numbers').where({ instance_id }).first();
|
||||
if (existing) {
|
||||
const [num] = await db('sec_numbers').where({ instance_id })
|
||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
||||
.returning('*');
|
||||
const patch = { 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;
|
||||
const [num] = await db('sec_numbers').where({ instance_id }).update(patch).returning('*');
|
||||
return res.json(num);
|
||||
}
|
||||
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,
|
||||
}).returning('*');
|
||||
res.status(201).json(num);
|
||||
@@ -278,9 +285,12 @@ function createSecretariaRoutes(db, config) {
|
||||
});
|
||||
router.put('/numbers/:id', async (req, res) => {
|
||||
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 })
|
||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
||||
.update(patch)
|
||||
.returning('*');
|
||||
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) => {
|
||||
try {
|
||||
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')
|
||||
if (from) q = q.where('date', '>=', from)
|
||||
if (to) q = q.where('date', '<=', to)
|
||||
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)
|
||||
} catch (err: any) {
|
||||
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) => {
|
||||
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({
|
||||
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',
|
||||
}).returning('*')
|
||||
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) => {
|
||||
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 })
|
||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
||||
.update(patch)
|
||||
.returning('*')
|
||||
res.json(slot)
|
||||
} catch (err: any) {
|
||||
@@ -256,18 +260,18 @@ export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Rou
|
||||
|
||||
router.post('/numbers', async (req: Request, res: Response) => {
|
||||
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' })
|
||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||
const existing = await db('sec_numbers').where({ instance_id }).first()
|
||||
if (existing) {
|
||||
const [num] = await db('sec_numbers').where({ instance_id })
|
||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
||||
.returning('*')
|
||||
const patch: any = { 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
|
||||
const [num] = await db('sec_numbers').where({ instance_id }).update(patch).returning('*')
|
||||
return res.json(num)
|
||||
}
|
||||
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,
|
||||
}).returning('*')
|
||||
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) => {
|
||||
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 })
|
||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
||||
.update(patch)
|
||||
.returning('*')
|
||||
res.json(num)
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -55,12 +55,18 @@ exports.BUILTIN_TOOLS = [
|
||||
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 to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.whereBetween('date', [from, to])
|
||||
.where((qb) => {
|
||||
qb.whereNull('instance_id');
|
||||
if (ctx.instanceId)
|
||||
qb.orWhere('instance_id', ctx.instanceId);
|
||||
})
|
||||
.orderBy('date').orderBy('time_start')
|
||||
.limit(20);
|
||||
if (!slots.length) {
|
||||
@@ -102,7 +108,9 @@ exports.BUILTIN_TOOLS = [
|
||||
// telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid)
|
||||
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '');
|
||||
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, '');
|
||||
}
|
||||
try {
|
||||
@@ -160,6 +168,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 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
@@ -183,9 +286,8 @@ exports.BUILTIN_TOOLS = [
|
||||
});
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId;
|
||||
// jid = último segmento (formato "tenant:jid" ou "tenant:instanceId:jid").
|
||||
const chatId = ctx.extChatId.split(':').pop() ?? ctx.extChatId;
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -37,6 +37,8 @@ export interface ToolContext {
|
||||
conversationId: string
|
||||
extChatId?: string
|
||||
tenantId?: string
|
||||
/** Número/sessão que recebeu a mensagem — escopa o calendário interno por sessão. */
|
||||
instanceId?: string
|
||||
hooks?: HookBus
|
||||
/** 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. */
|
||||
@@ -105,12 +107,17 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
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 to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000))
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.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')
|
||||
.limit(20)
|
||||
if (!slots.length) {
|
||||
@@ -153,7 +160,9 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
// telefone: do argumento OU derivado do JID do contato (extChatId = tenant:jid)
|
||||
let telefone = String(args['telefone_cliente'] ?? '').replace(/\D/g, '')
|
||||
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, '')
|
||||
}
|
||||
try {
|
||||
@@ -209,6 +218,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 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
@@ -233,9 +330,8 @@ export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
})
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId
|
||||
// jid = último segmento (formato "tenant:jid" ou "tenant:instanceId:jid").
|
||||
const chatId = ctx.extChatId.split(':').pop() ?? ctx.extChatId
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
|
||||
Reference in New Issue
Block a user