f9e93647bb
Encaixe que quebra o espaçamento de 1h (ex.: 09:45): a IA usa perguntar_dentista
→ resolve o WhatsApp do dentista (bridge /dentista-contato), registra
sec_agenda_approvals (aguardando) e manda a pergunta ao dentista ("consegue
encaixar às X? SIM/NÃO", via hook ext:agenda.notify-dentist). Quando o dentista
responde, ext:message.new detecta (roda mesmo com auto_reply off), interpreta
sim/não; se SIM agenda (/book) e avisa o paciente automaticamente ("o Dr
confirmou, agendei às X"); se NÃO oferece outro horário.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2685 lines
133 KiB
JavaScript
2685 lines
133 KiB
JavaScript
"use strict";
|
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
}
|
|
Object.defineProperty(o, k2, desc);
|
|
}) : (function(o, m, k, k2) {
|
|
if (k2 === undefined) k2 = k;
|
|
o[k2] = m[k];
|
|
}));
|
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
}) : function(o, v) {
|
|
o["default"] = v;
|
|
});
|
|
var __importStar = (this && this.__importStar) || (function () {
|
|
var ownKeys = function(o) {
|
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
var ar = [];
|
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
return ar;
|
|
};
|
|
return ownKeys(o);
|
|
};
|
|
return function (mod) {
|
|
if (mod && mod.__esModule) return mod;
|
|
var result = {};
|
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
__setModuleDefault(result, mod);
|
|
return result;
|
|
};
|
|
})();
|
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
};
|
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
exports.buildExtRoutes = buildExtRoutes;
|
|
/**
|
|
* External REST API — /api/ext/v1/
|
|
*
|
|
* Endpoints implementados nesta fase:
|
|
*
|
|
* GET /api/ext/v1/sessions — lista instâncias do tenant
|
|
* POST /api/ext/v1/sessions — cria nova instância
|
|
* DELETE /api/ext/v1/sessions/:id — remove instância
|
|
* GET /api/ext/v1/sessions/:id/qr — QR base64 da instância
|
|
* GET /api/ext/v1/inbox?session=&search=&limit= — lista chats
|
|
* GET /api/ext/v1/inbox/:chatId/messages — mensagens paginadas
|
|
* POST /api/ext/v1/inbox/:chatId/send — envia mensagem de texto
|
|
*
|
|
* Envelope de resposta de erro: { error: string }
|
|
* Envelope de resposta de sucesso: dados diretos (sem wrapper extra)
|
|
*/
|
|
const fs_1 = require("fs");
|
|
const promises_1 = require("fs/promises");
|
|
const nodePath = __importStar(require("path"));
|
|
const express_1 = require("express");
|
|
let dragonfly;
|
|
if (process.env.NODE_ENV === 'production') {
|
|
dragonfly = require('../../../dist/infra/cache/dragonfly').dragonfly;
|
|
}
|
|
else {
|
|
dragonfly = require('../../../backend/src/infra/cache/dragonfly').dragonfly;
|
|
}
|
|
const brain_1 = require("../../secretaria/brain");
|
|
const child_process_1 = require("child_process");
|
|
const multer_1 = __importDefault(require("multer"));
|
|
const sharp_1 = __importDefault(require("sharp"));
|
|
// Upload de mídia (imagem/vídeo/documento/figurinha) em memória. Até 64MB — o proxy
|
|
// do satélite encaminha o multipart cru (sem base64/limite de JSON).
|
|
const mediaUpload = (0, multer_1.default)({ storage: multer_1.default.memoryStorage(), limits: { fileSize: 64 * 1024 * 1024 } });
|
|
// Converte um áudio (ex.: webm/opus do navegador) para OGG/Opus — formato de nota de
|
|
// voz do WhatsApp. Usa ffmpeg via stdin/stdout. Se falhar, o chamador usa o original.
|
|
function convertToOggOpus(input) {
|
|
return new Promise((resolve, reject) => {
|
|
const ff = (0, child_process_1.spawn)('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-c:a', 'libopus', '-b:a', '32k', '-ar', '48000', '-ac', '1', '-f', 'ogg', 'pipe:1']);
|
|
const chunks = [];
|
|
const errs = [];
|
|
ff.stdout.on('data', (d) => chunks.push(d));
|
|
ff.stderr.on('data', (d) => errs.push(d));
|
|
ff.on('error', reject);
|
|
ff.on('close', (code) => code === 0 && chunks.length
|
|
? resolve(Buffer.concat(chunks))
|
|
: reject(new Error('ffmpeg: ' + Buffer.concat(errs).toString().slice(0, 200))));
|
|
ff.stdin.on('error', () => { });
|
|
ff.stdin.write(input);
|
|
ff.stdin.end();
|
|
});
|
|
}
|
|
let sendRichMessage;
|
|
if (process.env.NODE_ENV === 'production') {
|
|
sendRichMessage = require('../../../dist/modules/whatsapp/rich-message').sendRichMessage;
|
|
}
|
|
else {
|
|
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
|
|
}
|
|
// Engine (download/getContentType) + StorageProvider (Wasabi) para re-download de
|
|
// mídia sob demanda. Mesmos módulos que a app principal usa; carregados lazy pelo
|
|
// mesmo esquema prod(dist)/dev(backend/dist) do sendRichMessage acima.
|
|
let mediaEngine;
|
|
let mediaStorage;
|
|
if (process.env.NODE_ENV === 'production') {
|
|
mediaEngine = require('../../../dist/modules/whatsapp/engine');
|
|
mediaStorage = require('../../../dist/core/StorageProvider').storageProvider;
|
|
}
|
|
else {
|
|
mediaEngine = require('../../../backend/dist/modules/whatsapp/engine');
|
|
mediaStorage = require('../../../backend/dist/core/StorageProvider').storageProvider;
|
|
}
|
|
const downloadMediaMessage = mediaEngine.downloadMediaMessage;
|
|
const getContentType = mediaEngine.getContentType;
|
|
// Extensão/mime por tipo de mensagem (espelha whatsapp.media.routes.ts).
|
|
const MEDIA_EXT_MAP = {
|
|
imageMessage: 'jpg', videoMessage: 'mp4', audioMessage: 'ogg',
|
|
documentMessage: 'bin', stickerMessage: 'webp',
|
|
};
|
|
const MEDIA_MIME_MAP = {
|
|
imageMessage: 'image/jpeg', videoMessage: 'video/mp4', audioMessage: 'audio/ogg',
|
|
documentMessage: 'application/octet-stream', stickerMessage: 'image/webp',
|
|
};
|
|
// Payload salvo em mediaPayload guarda buffers como { _b64: base64 } → Buffer.
|
|
function deserializeMediaPayload(val) {
|
|
if (Array.isArray(val))
|
|
return val.map(deserializeMediaPayload);
|
|
if (val !== null && typeof val === 'object') {
|
|
const obj = val;
|
|
if ('_b64' in obj && typeof obj._b64 === 'string')
|
|
return Buffer.from(obj._b64, 'base64');
|
|
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializeMediaPayload(v)]));
|
|
}
|
|
return val;
|
|
}
|
|
// Serializa para storage (Json): Uint8Array/Buffer → { _b64 } (reverso do deserialize
|
|
// acima). Remove thumbnails. Espelha buildMediaPayload/serializeForStorage do core.
|
|
const THUMB_KEYS = ['jpegThumbnail', 'thumbnailDirectPath', 'thumbnailSha256', 'thumbnailEncSha256'];
|
|
function serializeMediaForStorage(val) {
|
|
if (val instanceof Uint8Array || Buffer.isBuffer(val))
|
|
return { _b64: Buffer.from(val).toString('base64') };
|
|
if (Array.isArray(val))
|
|
return val.map(serializeMediaForStorage);
|
|
if (val && typeof val === 'object') {
|
|
const out = {};
|
|
for (const [k, v] of Object.entries(val)) {
|
|
if (THUMB_KEYS.includes(k))
|
|
continue;
|
|
out[k] = serializeMediaForStorage(v);
|
|
}
|
|
return out;
|
|
}
|
|
return val;
|
|
}
|
|
// mediaPayload de uma mensagem ENVIADA (WAMessage retornado por sendMessage) — assim o
|
|
// re-download do próprio envio funciona (senão /media/:id/download dá 400).
|
|
function buildSentMediaPayload(sent) {
|
|
if (!sent?.key || !sent?.message)
|
|
return null;
|
|
return {
|
|
key: { remoteJid: sent.key.remoteJid, fromMe: sent.key.fromMe, id: sent.key.id },
|
|
message: serializeMediaForStorage(sent.message),
|
|
};
|
|
}
|
|
// Logger pino-like mínimo exigido pelo downloadMediaMessage do Baileys.
|
|
const mediaLogger = {
|
|
level: 'silent', child: () => mediaLogger,
|
|
trace() { }, debug() { }, info() { }, warn() { }, error() { }, fatal() { },
|
|
};
|
|
function uuid() {
|
|
try {
|
|
return crypto.randomUUID();
|
|
}
|
|
catch {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
}
|
|
// ── Handoff timers (in-memory, por convId) ────────────────────────────────────
|
|
const HANDOFF_TIMEOUT_MS = 15 * 60 * 1000;
|
|
const handoffTimers = new Map();
|
|
function scheduleHandoffTimeout(convId, extChatId, tenantId, db, hooks) {
|
|
const existing = handoffTimers.get(convId);
|
|
if (existing)
|
|
clearTimeout(existing);
|
|
const timer = setTimeout(async () => {
|
|
handoffTimers.delete(convId);
|
|
try {
|
|
await db('sec_conversations').where({ id: convId })
|
|
.update({ handoff_mode: 'ia', handoff_human_at: null });
|
|
// 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);
|
|
handoffTimers.set(convId, timer);
|
|
}
|
|
function cancelHandoffTimeout(convId) {
|
|
const existing = handoffTimers.get(convId);
|
|
if (existing) {
|
|
clearTimeout(existing);
|
|
handoffTimers.delete(convId);
|
|
}
|
|
}
|
|
function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|
const router = (0, express_1.Router)();
|
|
// ── Helper: envia reply da IA via WA e persiste no banco principal ─────────
|
|
async function sendSecretariaReply(opts) {
|
|
const { instanceId, tenantId, chatId, jid, reply, agentName } = opts;
|
|
const sock = manager?.getSocket(instanceId);
|
|
if (!sock)
|
|
return;
|
|
// 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({
|
|
data: {
|
|
tenantId,
|
|
instanceId,
|
|
chatId,
|
|
remoteJid: jid,
|
|
messageId: sent?.key.id ?? `sec-${Date.now()}`,
|
|
fromMe: true,
|
|
type: 'TEXT',
|
|
body: text,
|
|
status: 'SENT',
|
|
timestamp: new Date(),
|
|
},
|
|
}).catch(() => null);
|
|
if (persisted && io) {
|
|
io.to(`chat:${chatId}`).emit('message:new', persisted);
|
|
}
|
|
}
|
|
// ── Helper: ecoa uma mensagem ENVIADA para os operadores em tempo real ─────
|
|
// O Baileys entrega o próprio envio como history 'append' (que NÃO emite hook),
|
|
// então um 2º operador do mesmo número não veria o envio ao vivo. Aqui persistimos
|
|
// (idempotente via @@unique[instanceId,messageId] → o append posterior é deduplicado)
|
|
// e disparamos ext:message.new (stream do satélite) + message:new (Socket.IO nativo).
|
|
async function echoSentMessage(opts) {
|
|
const { instanceId, tenantId, chatId, jid, waMessageId } = opts;
|
|
try {
|
|
const persisted = await prisma.message.upsert({
|
|
where: { instanceId_messageId: { instanceId, messageId: waMessageId } },
|
|
create: {
|
|
tenantId, instanceId, chatId, remoteJid: jid, messageId: waMessageId,
|
|
fromMe: true, type: opts.type ?? 'TEXT', body: opts.body ?? null,
|
|
status: 'SENT', timestamp: new Date(),
|
|
...(opts.mediaPayload ? { mediaPayload: opts.mediaPayload } : {}),
|
|
},
|
|
update: {}, // já existe (append/echo anterior) → não sobrescreve
|
|
});
|
|
hooks.emit('ext:message.new', {
|
|
instanceId, tenantId, chatId,
|
|
message: { ...persisted, pushName: null, senderJid: null },
|
|
timestamp: Date.now(),
|
|
}).catch(() => { });
|
|
if (io)
|
|
io.to(`chat:${chatId}`).emit('message:new', persisted);
|
|
}
|
|
catch { /* best-effort: o append do Baileys ainda persiste a mensagem */ }
|
|
}
|
|
// ── Presença do CONTATO (composing/recording) → stream do satélite ────────
|
|
// O Baileys só emite presence.update de um contato se assinarmos (presenceSubscribe).
|
|
// Anexamos o listener UMA vez por socket (WeakSet: se reconectar, o novo socket é
|
|
// outro objeto → re-anexa). Cada update vira ext:presence → ws-bridge → 'presence'.
|
|
const presenceAttached = new WeakSet();
|
|
function ensurePresenceListener(sock, instanceId, tenantId) {
|
|
if (!sock || presenceAttached.has(sock))
|
|
return;
|
|
presenceAttached.add(sock);
|
|
sock.ev.on('presence.update', ({ id, presences }) => {
|
|
// id = jid do chat; presences = { [jid]: { lastKnownPresence } }.
|
|
// Individual: 1 entrada (o contato). Grupo: se alguém digita, marca digitando.
|
|
let state = 'available';
|
|
for (const p of Object.values(presences || {})) {
|
|
const s = p?.lastKnownPresence;
|
|
if (s === 'composing' || s === 'recording') {
|
|
state = s;
|
|
break;
|
|
}
|
|
if (s)
|
|
state = s;
|
|
}
|
|
hooks.emit('ext:presence', { tenantId, instanceId, jid: id, state, timestamp: Date.now() }).catch(() => { });
|
|
});
|
|
}
|
|
// ── SEC-02+SEC-03: auto-trigger da Secretária para mensagens recebidas ────
|
|
// Só processa se NÃO houver AIBot ativo (exclusão mútua com Chatbot Rápido).
|
|
hooks.register('ext:message.new', async (data) => {
|
|
const { tenantId, instanceId, chatId, jid, text } = data;
|
|
if (!text?.trim())
|
|
return;
|
|
// Só atendimento 1:1 (@s.whatsapp.net): exclui grupos (@g.us), broadcast,
|
|
// newsletter, lid e status — a Secretária não cria conversa para esses.
|
|
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, 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
|
|
// disparo automático da Secretária (envios manuais seguem normais). Só
|
|
// bloqueia quando explicitamente false — sem a flag, comportamento inalterado.
|
|
const secCfg = (await config.get('secretaria'));
|
|
if (secCfg?.auto_reply === false)
|
|
return;
|
|
// Exclusão mútua: chatbot rápido tem prioridade se estiver ativo
|
|
const activeBot = await prisma.aIBot.findFirst({ where: { tenantId, instanceId, enabled: true } });
|
|
if (activeBot)
|
|
return;
|
|
// 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) {
|
|
// 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({
|
|
where: { jid, instanceId }, select: { name: true, verifiedName: true, notify: true },
|
|
});
|
|
const phone = jid.split('@')[0];
|
|
const displayName = contact?.name ?? contact?.verifiedName ?? contact?.notify ?? (phone ? `+${phone}` : jid);
|
|
const [newConv] = await db('sec_conversations').insert({
|
|
id: uuid(),
|
|
agent_id: agent.id,
|
|
contact_name: displayName,
|
|
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(),
|
|
status: 'active',
|
|
ext_chat_id: extKey,
|
|
handoff_mode: 'ia',
|
|
}).returning('*');
|
|
conv = newConv;
|
|
}
|
|
if (conv.handoff_mode === 'humano')
|
|
return;
|
|
// SEC-14: indicador de digitação antes de chamar a IA
|
|
const sock = manager?.getSocket(instanceId);
|
|
await sock?.sendPresenceUpdate('composing', jid).catch(() => { });
|
|
// 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, instanceId });
|
|
await sock?.sendPresenceUpdate('paused', jid).catch(() => { });
|
|
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first();
|
|
await sendSecretariaReply({ instanceId, tenantId, chatId, jid, reply, agentName: secAgent?.name });
|
|
});
|
|
// ── 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 ──────────────────────────────────────
|
|
// When escalar_humano tool fires, send a WA message to the configured admin phone.
|
|
hooks.register('ext:escalated', async (data) => {
|
|
try {
|
|
const rawPhone = await config.get('admin_notify_phone');
|
|
const adminPhone = typeof rawPhone === 'string' ? rawPhone.trim() : '';
|
|
if (!adminPhone)
|
|
return;
|
|
// Normalise to JID
|
|
const digits = adminPhone.replace(/\D/g, '');
|
|
const normalized = digits.startsWith('55') ? digits : '55' + digits;
|
|
const jid = `${normalized}@s.whatsapp.net`;
|
|
// Find first connected instance for this tenant
|
|
const instance = await prisma.instance.findFirst({
|
|
where: { tenantId: data.tenantId },
|
|
select: { id: true },
|
|
});
|
|
if (!instance)
|
|
return;
|
|
const sock = manager.getSocket(instance.id);
|
|
if (!sock)
|
|
return;
|
|
const proto = data.protocolNumber ? `#${data.protocolNumber}` : '';
|
|
const motivo = data.motivo ? ` — ${data.motivo}` : '';
|
|
const msg = `⚠️ *Escalação* ${proto}${motivo}\nUm cliente precisa de atendimento humano. Abra o painel para assumir.`;
|
|
await sock.sendMessage(jid, { text: msg });
|
|
}
|
|
catch { /* notificação é best-effort */ }
|
|
});
|
|
// ── Monitor de cota — provedor LLM sem saldo → notifica o admin ────────────
|
|
// Emitido pelo brain quando um provider retorna 429 (cota/crédito esgotado).
|
|
const PROVIDER_LABEL = {
|
|
openai: 'OpenAI', gemini: 'Google Gemini', anthropic: 'Anthropic', ollama: 'Ollama (local)',
|
|
};
|
|
hooks.register('ext:provider.exhausted', async (data) => {
|
|
try {
|
|
const prov = PROVIDER_LABEL[data.provider] ?? data.provider;
|
|
const fb = Array.isArray(data.fallbackTo) && data.fallbackTo.length
|
|
? data.fallbackTo.map((p) => PROVIDER_LABEL[p] ?? p).join(' → ')
|
|
: 'nenhum (todos esgotados)';
|
|
const when = new Date(data.at ?? Date.now()).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
|
|
const msg = [
|
|
'🔴 *Secretária IA — provedor sem saldo*',
|
|
'',
|
|
`O provedor *${prov}*${data.model ? ` (${data.model})` : ''} atingiu o limite de *cota/crédito*.`,
|
|
'▫️ Status: cota/billing esgotado (429)',
|
|
`▫️ Fallback em uso: ${fb}`,
|
|
'',
|
|
'⚠️ *Ação necessária:* recarregue o crédito da conta do provedor para manter o atendimento automático 24h.',
|
|
'',
|
|
`🕒 ${when}`,
|
|
'_NewWhats · monitor de provedores de IA_',
|
|
].join('\n');
|
|
console.warn(`[secretaria] provider sem saldo: ${prov} (${data.model ?? '—'}) — fallback: ${fb}`);
|
|
const secCfg = (await config.get('secretaria'));
|
|
const adminPhone = typeof secCfg?.admin_notify_phone === 'string' ? secCfg.admin_notify_phone.trim() : '';
|
|
if (!adminPhone)
|
|
return;
|
|
const instance = await prisma.instance.findFirst({ where: { tenantId: data.tenantId }, select: { id: true } });
|
|
if (!instance)
|
|
return;
|
|
const sock = manager?.getSocket(instance.id);
|
|
if (!sock)
|
|
return;
|
|
// Resolve o JID canônico via onWhatsApp — trata o 9º dígito (BR) e valida
|
|
// se o número está no WhatsApp; sem isso, a concatenação crua pode não
|
|
// corresponder ao JID real (mensagem "enviada" mas não entregue).
|
|
const cand = (() => { const d = adminPhone.replace(/\D/g, ''); return d.startsWith('55') ? d : '55' + d; })();
|
|
let jid = `${cand}@s.whatsapp.net`;
|
|
try {
|
|
const found = await sock.onWhatsApp(cand);
|
|
if (found?.[0]?.exists && found[0]?.jid)
|
|
jid = found[0].jid;
|
|
else {
|
|
console.warn(`[secretaria] admin ${cand} não está no WhatsApp — notificação não enviada`);
|
|
return;
|
|
}
|
|
}
|
|
catch { /* fallback: usa o jid montado */ }
|
|
await sock.sendMessage(jid, { text: msg });
|
|
}
|
|
catch { /* best-effort */ }
|
|
});
|
|
// ── GET /sessions ──────────────────────────────────────────────────────────
|
|
// Lista todas as instâncias do tenant com status e telefone.
|
|
router.get('/sessions', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
try {
|
|
const instances = await prisma.instance.findMany({
|
|
where: { tenantId },
|
|
orderBy: { createdAt: 'asc' },
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
phone: true,
|
|
status: true,
|
|
avatar: true,
|
|
createdAt: true,
|
|
},
|
|
});
|
|
res.json(instances);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /sessions ────────────────────────────────────────────────────────
|
|
// Cria uma nova instância e dispara a conexão (QR chega via WS).
|
|
router.post('/sessions', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { name } = req.body;
|
|
if (!name?.trim()) {
|
|
res.status(400).json({ error: 'name é obrigatório' });
|
|
return;
|
|
}
|
|
try {
|
|
const instance = await prisma.instance.create({
|
|
data: { tenantId, name: name.trim() },
|
|
select: { id: true, name: true, phone: true, status: true, avatar: true, createdAt: true },
|
|
});
|
|
manager.connect(instance.id, tenantId).catch((err) => {
|
|
console.error('[ext-api] Falha ao conectar instância após criação:', err);
|
|
});
|
|
res.status(201).json(instance);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── DELETE /sessions/:id ──────────────────────────────────────────────────
|
|
// Desconecta e remove a instância do tenant.
|
|
router.delete('/sessions/:id', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const instanceId = req.params['id'];
|
|
try {
|
|
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Instância não encontrada' });
|
|
return;
|
|
}
|
|
await manager.disconnect(instanceId).catch(() => { });
|
|
await prisma.instance.delete({ where: { id: instanceId } });
|
|
res.json({ message: 'Instância removida' });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── GET /sessions/:id/qr ──────────────────────────────────────────────────
|
|
// Retorna o QR code atual em base64 (poll antes de receber via WS).
|
|
router.get('/sessions/:id/qr', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const instanceId = req.params['id'];
|
|
try {
|
|
const instance = await prisma.instance.findFirst({
|
|
where: { id: instanceId, tenantId },
|
|
select: { id: true },
|
|
});
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Instância não encontrada' });
|
|
return;
|
|
}
|
|
const qrBase64 = await dragonfly.get(`instance:${instanceId}:qr`);
|
|
if (!qrBase64) {
|
|
res.status(404).json({ error: 'QR não disponível — inicie a conexão primeiro' });
|
|
return;
|
|
}
|
|
res.json({ instanceId, qrBase64 });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /sessions/:id/connect ────────────────────────────────────────────
|
|
// (Re)inicia a conexão de uma instância JÁ existente — gera novo QR se preciso.
|
|
// O POST /sessions já conecta na criação; este endpoint cobre reconectar uma
|
|
// sessão que caiu/desconectou (o "re-scan" do modelo de ownership do satélite).
|
|
router.post('/sessions/:id/connect', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const instanceId = req.params['id'];
|
|
try {
|
|
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true } });
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Instância não encontrada' });
|
|
return;
|
|
}
|
|
manager.connect(instanceId, tenantId).catch((err) => {
|
|
console.error('[ext-api] Falha ao reconectar instância:', err);
|
|
});
|
|
res.json({ message: 'Conexão iniciada', instanceId });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /sessions/:id/disconnect ─────────────────────────────────────────
|
|
// Encerra a sessão do WhatsApp SEM remover a instância (permite reconectar).
|
|
router.post('/sessions/:id/disconnect', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const instanceId = req.params['id'];
|
|
try {
|
|
const instance = await prisma.instance.findFirst({ where: { id: instanceId, tenantId }, select: { id: true } });
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Instância não encontrada' });
|
|
return;
|
|
}
|
|
await manager.disconnect(instanceId).catch(() => { });
|
|
res.json({ message: 'Sessão encerrada', instanceId });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── GET /media/:messageId ─────────────────────────────────────────────────
|
|
// Serve o arquivo de mídia de uma mensagem.
|
|
// Se mediaUrl for URL externa (Wasabi etc.) redireciona 302.
|
|
// Se for arquivo local, faz stream com suporte a Range (áudio/vídeo).
|
|
router.get('/media/:messageId', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const messageId = req.params['messageId'];
|
|
try {
|
|
const msg = await prisma.message.findFirst({
|
|
where: { id: messageId, tenantId },
|
|
select: { mediaUrl: true, mediaPath: true, mimeType: true, fileName: true },
|
|
});
|
|
if (!msg || (!msg.mediaPath && !msg.mediaUrl)) {
|
|
res.status(404).json({ error: 'Mídia não encontrada' });
|
|
return;
|
|
}
|
|
// URL externa (Wasabi / CDN) → redirect
|
|
if (msg.mediaUrl && msg.mediaUrl.startsWith('http')) {
|
|
res.redirect(302, msg.mediaUrl);
|
|
return;
|
|
}
|
|
const filePath = msg.mediaPath;
|
|
if (!filePath) {
|
|
res.status(404).json({ error: 'Arquivo não disponível' });
|
|
return;
|
|
}
|
|
const info = await (0, promises_1.stat)(filePath).catch(() => null);
|
|
if (!info) {
|
|
res.status(404).json({ error: 'Arquivo não encontrado no disco' });
|
|
return;
|
|
}
|
|
const mime = msg.mimeType || 'application/octet-stream';
|
|
const size = info.size;
|
|
// Content-Disposition: inline para imagens/áudio/vídeo, attachment para docs
|
|
const isInline = mime.startsWith('image/') || mime.startsWith('audio/') || mime.startsWith('video/');
|
|
const disposition = isInline
|
|
? 'inline'
|
|
: `attachment; filename="${msg.fileName ?? 'arquivo'}"`;
|
|
res.setHeader('Content-Disposition', disposition);
|
|
res.setHeader('Content-Type', mime);
|
|
res.setHeader('Accept-Ranges', 'bytes');
|
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
|
// Suporte a Range (essencial para <audio> e <video> no browser)
|
|
const rangeHeader = req.headers['range'];
|
|
if (rangeHeader) {
|
|
const [startStr, endStr] = rangeHeader.replace(/bytes=/, '').split('-');
|
|
const start = parseInt(startStr, 10);
|
|
const end = endStr ? parseInt(endStr, 10) : size - 1;
|
|
const chunkSize = end - start + 1;
|
|
res.status(206);
|
|
res.setHeader('Content-Range', `bytes ${start}-${end}/${size}`);
|
|
res.setHeader('Content-Length', chunkSize);
|
|
(0, fs_1.createReadStream)(filePath, { start, end }).pipe(res);
|
|
}
|
|
else {
|
|
res.setHeader('Content-Length', size);
|
|
(0, fs_1.createReadStream)(filePath).pipe(res);
|
|
}
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /media/:messageId/download ─────────────────────────────────────────
|
|
// Re-baixa a mídia sob demanda. ~95% das mídias têm mediaUrl NULL: só o
|
|
// WAMessage fica salvo em mediaPayload (lazy). Aqui re-baixamos do WhatsApp,
|
|
// persistimos (Wasabi/local) e devolvemos { mediaUrl } — é o que o "Toque para
|
|
// baixar" do satélite chama. Espelha /redownload-media da app principal.
|
|
router.post('/media/:messageId/download', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const messageId = req.params['messageId'];
|
|
try {
|
|
const msg = await prisma.message.findFirst({
|
|
where: { id: messageId, tenantId },
|
|
select: { id: true, mediaUrl: true, mediaPayload: true, chatId: true, instanceId: true, mimeType: true, fileName: true },
|
|
});
|
|
if (!msg) {
|
|
res.status(404).json({ error: 'Mensagem não encontrada' });
|
|
return;
|
|
}
|
|
if (msg.mediaUrl) {
|
|
res.json({ mediaUrl: msg.mediaUrl });
|
|
return;
|
|
}
|
|
if (!msg.mediaPayload) {
|
|
res.status(400).json({ error: 'Mídia não disponível para re-download' });
|
|
return;
|
|
}
|
|
const sock = manager?.getSocket(msg.instanceId);
|
|
if (!sock) {
|
|
res.status(503).json({ error: 'Instância não conectada' });
|
|
return;
|
|
}
|
|
const waMsg = deserializeMediaPayload(msg.mediaPayload);
|
|
const buffer = await downloadMediaMessage(waMsg, 'buffer', {}, { logger: mediaLogger, reuploadRequest: sock.updateMediaMessage });
|
|
if (!buffer || !buffer.length) {
|
|
res.status(502).json({ error: 'Sem dados de mídia do WhatsApp' });
|
|
return;
|
|
}
|
|
const contentType = getContentType(waMsg.message) ?? 'imageMessage';
|
|
let ext = MEDIA_EXT_MAP[contentType] ?? 'bin';
|
|
let mime = MEDIA_MIME_MAP[contentType] ?? 'application/octet-stream';
|
|
// Documento: preserva a extensão/mime REAIS em vez de gravar sempre .bin.
|
|
// O nome/mimetype verdadeiros vivem no WAMessage (documentMessage), não nas
|
|
// colunas do banco (quase sempre NULL). Prioridade: nome → mimetype → bin.
|
|
if (contentType === 'documentMessage' || contentType === 'documentWithCaptionMessage') {
|
|
const doc = waMsg.message?.documentMessage
|
|
?? waMsg.message?.documentWithCaptionMessage?.message?.documentMessage;
|
|
const docName = (doc?.fileName || msg.fileName || '');
|
|
const docMime = (doc?.mimetype || msg.mimeType || '');
|
|
const fromName = docName.split('.').pop()?.toLowerCase();
|
|
if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/.test(fromName)) {
|
|
ext = fromName;
|
|
}
|
|
else {
|
|
const sub = docMime.split('/').pop()?.split(';')[0]?.toLowerCase();
|
|
if (sub && /^[a-z0-9.+-]+$/.test(sub))
|
|
ext = sub;
|
|
}
|
|
if (docMime)
|
|
mime = docMime;
|
|
}
|
|
const fileName = `${messageId}.${ext}`;
|
|
let mediaUrl = `/media/${msg.instanceId}/${fileName}`;
|
|
if (mediaStorage?.isRegistered?.()) {
|
|
try {
|
|
const result = await mediaStorage.uploadFile({
|
|
category: 'whatsapp', usuarioSis: tenantId, sessionJID: msg.instanceId,
|
|
file: { originalname: fileName, buffer: buffer, mimetype: mime },
|
|
});
|
|
mediaUrl = result.path;
|
|
}
|
|
catch {
|
|
const filePath = nodePath.resolve('./media', msg.instanceId, fileName);
|
|
await (0, promises_1.mkdir)(nodePath.dirname(filePath), { recursive: true });
|
|
await (0, promises_1.writeFile)(filePath, buffer);
|
|
}
|
|
}
|
|
else {
|
|
const filePath = nodePath.resolve('./media', msg.instanceId, fileName);
|
|
await (0, promises_1.mkdir)(nodePath.dirname(filePath), { recursive: true });
|
|
await (0, promises_1.writeFile)(filePath, buffer);
|
|
}
|
|
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.
|
|
if (mediaStorage?.isRegistered?.() && !mediaUrl.startsWith('/media/') && /^image\//i.test(mime) && contentType !== 'stickerMessage') {
|
|
void (0, sharp_1.default)(buffer)
|
|
.resize(400, 400, { fit: 'inside', withoutEnlargement: true })
|
|
.webp({ quality: 60 })
|
|
.toBuffer()
|
|
.then((thumb) => mediaStorage.uploadFile({
|
|
category: 'whatsapp', customPath: `${mediaUrl}.thumb.webp`,
|
|
file: { originalname: 'thumb.webp', buffer: thumb, mimetype: 'image/webp' },
|
|
}))
|
|
.catch(() => { });
|
|
}
|
|
res.json({ mediaUrl });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err?.message ?? 'Falha no re-download de mídia' });
|
|
}
|
|
});
|
|
// ── 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'];
|
|
const limit = Math.min(parseInt(String(req.query['limit'] || '50')), 200);
|
|
if (!sessionId) {
|
|
res.status(400).json({ error: 'Query param "session" (instanceId) obrigatório' });
|
|
return;
|
|
}
|
|
try {
|
|
// Valida que a instância pertence ao tenant
|
|
const instance = await prisma.instance.findFirst({
|
|
where: { id: sessionId, tenantId },
|
|
select: { id: true },
|
|
});
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Instância não encontrada' });
|
|
return;
|
|
}
|
|
const chats = await prisma.chat.findMany({
|
|
where: {
|
|
tenantId,
|
|
instanceId: sessionId,
|
|
isArchived: false,
|
|
NOT: [
|
|
{ jid: { endsWith: '@lid' } },
|
|
{ jid: { contains: '@broadcast' } },
|
|
{ jid: { endsWith: '@newsletter' } },
|
|
{ jid: { startsWith: '0@' } },
|
|
],
|
|
...(search
|
|
? { OR: [
|
|
{ name: { contains: search, mode: 'insensitive' } },
|
|
{ jid: { contains: search } },
|
|
] }
|
|
: {}),
|
|
},
|
|
// nulls: 'last' — sem isto o Postgres ordena NULLS FIRST em DESC, e os
|
|
// chats-stub sem mensagem (lastMessageAt null) tomam o topo do inbox.
|
|
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
jid: true,
|
|
name: true,
|
|
unreadCount: true,
|
|
lastMessageAt: true,
|
|
isPinned: true,
|
|
isArchived: true,
|
|
contact: {
|
|
select: {
|
|
name: true,
|
|
verifiedName: true,
|
|
notify: true,
|
|
avatarUrl: true,
|
|
phone: true,
|
|
},
|
|
},
|
|
messages: {
|
|
orderBy: { timestamp: 'desc' },
|
|
take: 1,
|
|
select: { body: true, type: true, fromMe: true, timestamp: true, status: true },
|
|
},
|
|
},
|
|
});
|
|
// 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;
|
|
const displayName = ct?.name ?? ct?.verifiedName ?? ct?.notify ?? c.name ?? null;
|
|
const lastMsg = c.messages[0] ?? null;
|
|
return {
|
|
id: c.id,
|
|
jid: c.jid,
|
|
displayName,
|
|
avatar: ct?.avatarUrl ?? null,
|
|
phone: ct?.phone ?? c.jid.split('@')[0],
|
|
unreadCount: c.unreadCount,
|
|
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,
|
|
};
|
|
});
|
|
res.json(result);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── GET /contacts/:jid/avatar/refresh ─────────────────────────────────────
|
|
// Self-heal de avatar: a URL do CDN do WhatsApp (pps.whatsapp.net) expira
|
|
// (param oe=) e passa a 404. Aqui re-buscamos a foto ATUAL pela conexão
|
|
// Baileys viva, atualizamos o Contact.avatarUrl e devolvemos a URL nova.
|
|
// Chamado pelo componente Avatar do satélite no onError da imagem.
|
|
router.get('/contacts/:jid/avatar/refresh', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const sessionId = req.query['session'];
|
|
const jid = decodeURIComponent(req.params['jid'] ?? '');
|
|
if (!sessionId || !jid) {
|
|
res.status(400).json({ error: 'Query "session" e param "jid" obrigatórios' });
|
|
return;
|
|
}
|
|
const instance = await prisma.instance.findFirst({
|
|
where: { id: sessionId, tenantId },
|
|
select: { id: true },
|
|
});
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Instância não encontrada' });
|
|
return;
|
|
}
|
|
const sock = manager?.getSocket(sessionId);
|
|
if (!sock) {
|
|
res.status(503).json({ error: 'Sessão não conectada' });
|
|
return;
|
|
}
|
|
try {
|
|
// 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).
|
|
await prisma.contact.updateMany({
|
|
where: { tenantId, instanceId: sessionId, jid, avatarUrl: { not: null } },
|
|
data: { avatarUrl: 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({
|
|
where: { tenantId, instanceId: sessionId, jid },
|
|
data: { avatarUrl: url },
|
|
});
|
|
res.json({ avatar: url });
|
|
}
|
|
catch (err) {
|
|
res.status(502).json({ error: err.message ?? 'Falha ao buscar avatar' });
|
|
}
|
|
});
|
|
// ── GET /inbox/:chatId/messages ───────────────────────────────────────────
|
|
// Histórico paginado (cursor: before=<timestamp ISO>).
|
|
router.get('/inbox/:chatId/messages', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
const limit = Math.min(parseInt(String(req.query['limit'] || '50')), 200);
|
|
const before = req.query['before'];
|
|
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 messages = await prisma.message.findMany({
|
|
where: {
|
|
chatId,
|
|
tenantId,
|
|
...(before ? { timestamp: { lt: new Date(before) } } : {}),
|
|
},
|
|
orderBy: { timestamp: 'desc' },
|
|
take: limit,
|
|
select: {
|
|
id: true,
|
|
messageId: true,
|
|
fromMe: true,
|
|
type: true,
|
|
body: true,
|
|
caption: true,
|
|
mediaUrl: true,
|
|
mediaPath: true,
|
|
mediaPayload: true,
|
|
mimeType: true,
|
|
fileName: true,
|
|
status: true,
|
|
timestamp: true,
|
|
pushName: true,
|
|
senderJid: true,
|
|
reactions: true,
|
|
},
|
|
});
|
|
// mediaRecoverable: já tem URL, ou tem o WAMessage salvo (mediaPayload) / arquivo
|
|
// local (mediaPath) para re-baixar sob demanda. fromMe legado sem nenhum destes é
|
|
// irrecuperável — o frontend usa isto para não oferecer "Toque para baixar" (evita
|
|
// o 400 de /media/:id/download). O mediaPayload NÃO vai na resposta (só o boolean).
|
|
const serialized = messages.map(({ mediaPayload, mediaPath, ...rest }) => ({
|
|
...rest,
|
|
mediaRecoverable: Boolean(rest.mediaUrl || mediaPath || mediaPayload),
|
|
}));
|
|
res.json(serialized.reverse());
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /inbox/:chatId/read ──────────────────────────────────────────────
|
|
// Zera o contador de mensagens não lidas do chat.
|
|
router.post('/inbox/:chatId/read', 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;
|
|
}
|
|
await prisma.chat.update({ where: { id: chatId }, data: { unreadCount: 0 } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /inbox/:chatId/presence ──────────────────────────────────────────
|
|
// Assina a presença do contato (para receber composing/recording) e garante o
|
|
// listener anexado. Chamado pelo satélite ao ABRIR um chat. Sem isto o Baileys
|
|
// não emite presence.update do contato.
|
|
router.post('/inbox/:chatId/presence', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
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;
|
|
}
|
|
ensurePresenceListener(sock, chat.instanceId, tenantId);
|
|
await sock.presenceSubscribe(chat.jid).catch(() => { });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── PATCH /inbox/:chatId/archive ──────────────────────────────────────────
|
|
// Arquiva ou desarquiva um chat.
|
|
// Body: { archived: boolean }
|
|
router.patch('/inbox/:chatId/archive', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
const { archived } = req.body;
|
|
if (typeof archived !== 'boolean') {
|
|
res.status(400).json({ error: 'Campo "archived" (boolean) 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;
|
|
}
|
|
await prisma.chat.update({ where: { id: chatId }, data: { isArchived: archived } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── PATCH /inbox/:chatId/pin ──────────────────────────────────────────────
|
|
// Fixa ou desfixa um chat.
|
|
// Body: { pinned: boolean }
|
|
router.patch('/inbox/:chatId/pin', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
const { pinned } = req.body;
|
|
if (typeof pinned !== 'boolean') {
|
|
res.status(400).json({ error: 'Campo "pinned" (boolean) 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;
|
|
}
|
|
await prisma.chat.update({ where: { id: chatId }, data: { isPinned: pinned } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── DELETE /inbox/:chatId ─────────────────────────────────────────────────
|
|
// Remove o chat e todas as mensagens associadas.
|
|
router.delete('/inbox/:chatId', 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;
|
|
}
|
|
await prisma.chat.delete({ where: { id: chatId } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── DELETE /inbox/:chatId/messages/:messageId ─────────────────────────────
|
|
// Apaga a mensagem PARA TODOS (revoke no WhatsApp via Baileys). O :messageId é
|
|
// o id interno (uuid) da Message. Só funciona para mensagens ENVIADAS por você
|
|
// (fromMe) — o WhatsApp não permite revogar mensagens recebidas ("apagar para
|
|
// mim", que é local, fica para uma fase futura). A permissão (can_delete_msg)
|
|
// é validada no satélite (proxy) antes de chegar aqui.
|
|
router.delete('/inbox/:chatId/messages/:messageId', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
const messageId = req.params['messageId'];
|
|
try {
|
|
const msg = await prisma.message.findFirst({
|
|
where: { id: messageId, chatId, tenantId },
|
|
select: { id: true, instanceId: true, remoteJid: true, messageId: true, fromMe: true, senderJid: true },
|
|
});
|
|
if (!msg) {
|
|
res.status(404).json({ error: 'Mensagem não encontrada' });
|
|
return;
|
|
}
|
|
if (!msg.fromMe) {
|
|
res.status(400).json({ error: 'Só é possível apagar para todos as mensagens enviadas por você.' });
|
|
return;
|
|
}
|
|
const sock = manager.getSocket(msg.instanceId);
|
|
if (!sock) {
|
|
res.status(400).json({ error: 'Instância não conectada' });
|
|
return;
|
|
}
|
|
// Key do WhatsApp para o revoke. Em grupos, inclui o participant (remetente real).
|
|
const key = { remoteJid: msg.remoteJid, fromMe: msg.fromMe, id: msg.messageId };
|
|
if (msg.senderJid && msg.remoteJid.endsWith('@g.us'))
|
|
key.participant = msg.senderJid;
|
|
await sock.sendMessage(msg.remoteJid, { delete: key });
|
|
await prisma.message.delete({ where: { id: msg.id } }).catch(() => { });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
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, 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' });
|
|
return;
|
|
}
|
|
try {
|
|
const chat = await prisma.chat.findFirst({
|
|
where: { id: chatId, tenantId },
|
|
select: { id: true, 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;
|
|
}
|
|
// 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.
|
|
const resolvedFooter = footer || nativeList?.footer || undefined;
|
|
const cleanList = nativeList
|
|
? { buttonText: nativeList.buttonText, sections: nativeList.sections }
|
|
: undefined;
|
|
const waMessageId = await sendRichMessage(sock, chat.jid, {
|
|
text: text?.trim() || undefined,
|
|
footer: resolvedFooter,
|
|
nativeButtons: nativeButtons,
|
|
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({
|
|
instanceId: chat.instanceId, tenantId, chatId: chat.id, jid: chat.jid,
|
|
waMessageId: String(waMessageId), body: text?.trim() || null, type: 'TEXT',
|
|
});
|
|
}
|
|
res.json({ ok: true, messageId: waMessageId });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro ao enviar' });
|
|
}
|
|
});
|
|
// ── POST /inbox/:chatId/send-audio ────────────────────────────────────────
|
|
// Nota de voz. O satélite manda o áudio em base64 (JSON) porque o proxy não
|
|
// encaminha multipart. Decodifica → envia como ptt (voice note) via Baileys.
|
|
router.post('/inbox/:chatId/send-audio', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
const { audio, mimetype } = req.body;
|
|
if (!audio) {
|
|
res.status(400).json({ error: 'Áudio ausente' });
|
|
return;
|
|
}
|
|
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 raw = Buffer.from(audio, 'base64');
|
|
// Converte p/ OGG/Opus (nota de voz do WhatsApp). Se o ffmpeg falhar, envia o
|
|
// original — melhor entregar algo do que nada.
|
|
let payload = raw;
|
|
let mt = 'audio/ogg; codecs=opus';
|
|
if (!/ogg/i.test(mimetype || '')) {
|
|
try {
|
|
payload = await convertToOggOpus(raw);
|
|
}
|
|
catch {
|
|
payload = raw;
|
|
mt = mimetype || 'audio/ogg; codecs=opus';
|
|
}
|
|
}
|
|
const sent = await sock.sendMessage(chat.jid, { audio: payload, mimetype: mt, ptt: true });
|
|
const waMessageId = sent?.key?.id;
|
|
if (waMessageId) {
|
|
await echoSentMessage({
|
|
instanceId: chat.instanceId, tenantId, chatId, jid: chat.jid,
|
|
waMessageId: String(waMessageId), body: '🎤 Mensagem de voz', type: 'AUDIO',
|
|
mediaPayload: buildSentMediaPayload(sent),
|
|
});
|
|
}
|
|
res.json({ ok: true, messageId: waMessageId });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro ao enviar áudio' });
|
|
}
|
|
});
|
|
// ── POST /inbox/:chatId/send-media ────────────────────────────────────────
|
|
// Imagem / vídeo / documento / figurinha via MULTIPART (o proxy do satélite
|
|
// encaminha o stream cru — sem limite de base64). Campo 'file'; extras no body:
|
|
// caption, sticker. Tipo detectado pelo mimetype do arquivo.
|
|
router.post('/inbox/:chatId/send-media', mediaUpload.single('file'), async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = req.params['chatId'];
|
|
const up = req.file;
|
|
const caption = req.body?.caption;
|
|
const sticker = req.body?.sticker === 'true' || req.body?.sticker === true;
|
|
if (!up?.buffer) {
|
|
res.status(400).json({ error: 'Arquivo ausente' });
|
|
return;
|
|
}
|
|
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 buffer = up.buffer;
|
|
const mt = up.mimetype || 'application/octet-stream';
|
|
const filename = up.originalname;
|
|
const cap = caption?.trim() || undefined;
|
|
let content;
|
|
let echoBody = '📎 Anexo';
|
|
let echoType = 'DOCUMENT';
|
|
if (sticker || mt === 'image/webp') {
|
|
content = { sticker: buffer };
|
|
echoBody = '🎯 Figurinha';
|
|
echoType = 'STICKER';
|
|
}
|
|
else if (mt.startsWith('image/')) {
|
|
content = { image: buffer, caption: cap };
|
|
echoBody = cap || '🖼️ Imagem';
|
|
echoType = 'IMAGE';
|
|
}
|
|
else if (mt.startsWith('video/')) {
|
|
content = { video: buffer, caption: cap };
|
|
echoBody = cap || '🎬 Vídeo';
|
|
echoType = 'VIDEO';
|
|
}
|
|
else {
|
|
content = { document: buffer, mimetype: mt, fileName: filename || 'arquivo' };
|
|
echoBody = filename || '📎 Documento';
|
|
echoType = 'DOCUMENT';
|
|
}
|
|
const sent = await sock.sendMessage(chat.jid, content);
|
|
const waMessageId = sent?.key?.id;
|
|
if (waMessageId) {
|
|
await echoSentMessage({
|
|
instanceId: chat.instanceId, tenantId, chatId, jid: chat.jid,
|
|
waMessageId: String(waMessageId), body: echoBody, type: echoType,
|
|
mediaPayload: buildSentMediaPayload(sent),
|
|
});
|
|
}
|
|
res.json({ ok: true, messageId: waMessageId });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro ao enviar mídia' });
|
|
}
|
|
});
|
|
// ── POST /conversations ───────────────────────────────────────────────────
|
|
// Inicia uma nova conversa enviando a primeira mensagem para um número ainda
|
|
// sem chat existente. Body: { sessionId, phone, text }
|
|
// O phone deve estar no formato brasileiro: DDD+número (10-11 dígitos) ou
|
|
// já com código do país (55 + DDD + número = 12-13 dígitos).
|
|
router.post('/conversations', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { sessionId, phone, text } = req.body;
|
|
if (!sessionId || !phone || !text?.trim()) {
|
|
res.status(400).json({ error: 'sessionId, phone e text são obrigatórios' });
|
|
return;
|
|
}
|
|
// Normaliza para JID: remove não-dígitos e garante código do Brasil
|
|
const digits = phone.replace(/\D/g, '');
|
|
const normalized = (digits.startsWith('55') && (digits.length === 12 || digits.length === 13))
|
|
? digits
|
|
: (digits.length === 10 || digits.length === 11) ? '55' + digits : digits;
|
|
if (normalized.length < 10) {
|
|
res.status(400).json({ error: 'Número inválido — informe DDD + número (ex: 67999138794)' });
|
|
return;
|
|
}
|
|
const jid = `${normalized}@s.whatsapp.net`;
|
|
try {
|
|
const instance = await prisma.instance.findFirst({
|
|
where: { id: sessionId, tenantId },
|
|
select: { id: true },
|
|
});
|
|
if (!instance) {
|
|
res.status(404).json({ error: 'Sessão não encontrada' });
|
|
return;
|
|
}
|
|
const sock = manager.getSocket(sessionId);
|
|
if (!sock) {
|
|
res.status(400).json({ error: 'Sessão não conectada' });
|
|
return;
|
|
}
|
|
// Resolve o jid CANÔNICO no WhatsApp antes de enviar. No Brasil o mesmo número
|
|
// existe com/sem o 9º dígito (ex.: 556799591687 vs 5567999591687); enviar para a
|
|
// variante errada cria um chat DUPLICADO. onWhatsApp devolve o jid real registrado.
|
|
let realJid = jid;
|
|
try {
|
|
const [r] = await sock.onWhatsApp(normalized);
|
|
if (r?.exists && r.jid)
|
|
realJid = r.jid;
|
|
}
|
|
catch { /* fallback: usa o jid normalizado */ }
|
|
await sock.sendMessage(realJid, { text: text.trim() });
|
|
res.status(201).json({ ok: true, jid: realJid });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro ao enviar' });
|
|
}
|
|
});
|
|
// ── GET /webhooks ─────────────────────────────────────────────────────────
|
|
// Lista os webhooks registrados pelo tenant.
|
|
router.get('/webhooks', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
try {
|
|
const hooks = await prisma.extWebhook.findMany({
|
|
where: { tenantId },
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true, url: true, events: true, active: true, createdAt: true },
|
|
});
|
|
res.json(hooks);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /webhooks ────────────────────────────────────────────────────────
|
|
// Registra um novo webhook.
|
|
// Body: { url: string, events: string[], secret: string }
|
|
router.post('/webhooks', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { url, events, secret } = req.body;
|
|
if (!url || typeof url !== 'string' || !url.startsWith('http')) {
|
|
res.status(400).json({ error: 'Campo "url" obrigatório e deve ser HTTP/HTTPS' });
|
|
return;
|
|
}
|
|
if (!Array.isArray(events) || events.length === 0) {
|
|
res.status(400).json({ error: 'Campo "events" obrigatório (array não vazio)' });
|
|
return;
|
|
}
|
|
if (!secret || typeof secret !== 'string' || secret.length < 16) {
|
|
res.status(400).json({ error: 'Campo "secret" obrigatório (mínimo 16 caracteres)' });
|
|
return;
|
|
}
|
|
const VALID_EVENTS = ['message.new', 'session.status'];
|
|
const invalid = events.filter(e => !VALID_EVENTS.includes(e));
|
|
if (invalid.length > 0) {
|
|
res.status(400).json({ error: `Eventos inválidos: ${invalid.join(', ')}. Válidos: ${VALID_EVENTS.join(', ')}` });
|
|
return;
|
|
}
|
|
try {
|
|
const hook = await prisma.extWebhook.create({
|
|
data: { tenantId, url, events, secret, active: true },
|
|
select: { id: true, url: true, events: true, active: true, createdAt: true },
|
|
});
|
|
res.status(201).json(hook);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── DELETE /webhooks/:id ──────────────────────────────────────────────────
|
|
// Remove um webhook do tenant.
|
|
router.delete('/webhooks/:id', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
try {
|
|
const deleted = await prisma.extWebhook.deleteMany({ where: { id, tenantId } });
|
|
if (deleted.count === 0) {
|
|
res.status(404).json({ error: 'Webhook não encontrado' });
|
|
return;
|
|
}
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── PATCH /webhooks/:id ───────────────────────────────────────────────────
|
|
// Ativa / desativa um webhook sem removê-lo.
|
|
// Body: { active: boolean }
|
|
router.patch('/webhooks/:id', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
const { active } = req.body;
|
|
if (typeof active !== 'boolean') {
|
|
res.status(400).json({ error: 'Campo "active" (boolean) obrigatório' });
|
|
return;
|
|
}
|
|
try {
|
|
const hook = await prisma.extWebhook.findFirst({ where: { id, tenantId }, select: { id: true } });
|
|
if (!hook) {
|
|
res.status(404).json({ error: 'Webhook não encontrado' });
|
|
return;
|
|
}
|
|
const updated = await prisma.extWebhook.update({
|
|
where: { id },
|
|
data: { active },
|
|
select: { id: true, url: true, events: true, active: true, createdAt: true },
|
|
});
|
|
res.json(updated);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── POST /secretaria/ask ──────────────────────────────────────────────────
|
|
// Envia uma mensagem ao cérebro da Secretária IA em nome de um contato WA.
|
|
// O contexto é persistido por (tenantId, chatId) — cada conversa do WhatsApp
|
|
// tem seu próprio estado no ProtocolEngine.
|
|
// Body: { chatId, message, contactName?, agentId?, context?, systemExtra?, tools? }
|
|
router.post('/secretaria/ask', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { chatId, message, contactName, agentId, context: contextData, systemExtra, tools } = req.body;
|
|
if (!chatId || !message?.trim()) {
|
|
res.status(400).json({ error: 'chatId e message são obrigatórios' });
|
|
return;
|
|
}
|
|
try {
|
|
// SEC-08: valida que existe instância conectada para este tenant
|
|
const connectedInstance = await prisma.instance.findFirst({
|
|
where: { tenantId, status: 'CONNECTED' },
|
|
select: { id: true },
|
|
});
|
|
if (!connectedInstance) {
|
|
res.status(503).json({ error: 'Nenhuma instância WhatsApp conectada para este tenant.' });
|
|
return;
|
|
}
|
|
const extKey = `${tenantId}:${chatId}`;
|
|
let conv = await db('sec_conversations')
|
|
.where({ ext_chat_id: extKey, status: 'active' })
|
|
.first();
|
|
if (!conv) {
|
|
let resolvedAgentId = agentId;
|
|
if (!resolvedAgentId) {
|
|
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
|
if (!agent) {
|
|
res.status(503).json({ error: 'Nenhum agente ativo na Secretária IA. Configure em Admin → Secretária.' });
|
|
return;
|
|
}
|
|
resolvedAgentId = agent.id;
|
|
}
|
|
const [newConv] = await db('sec_conversations').insert({
|
|
id: uuid(),
|
|
agent_id: resolvedAgentId,
|
|
contact_name: contactName ?? chatId,
|
|
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(),
|
|
status: 'active',
|
|
ext_chat_id: extKey,
|
|
handoff_mode: 'ia',
|
|
}).returning('*');
|
|
conv = newConv;
|
|
}
|
|
else if (contactName && conv.contact_name !== contactName) {
|
|
await db('sec_conversations').where({ id: conv.id }).update({ contact_name: contactName });
|
|
conv.contact_name = contactName;
|
|
}
|
|
// Handoff: se humano está respondendo, não chama IA
|
|
if (conv.handoff_mode === 'humano') {
|
|
res.json({ skipped: true, reason: 'humano', conversationId: conv.id, protocolNumber: conv.protocol_number });
|
|
return;
|
|
}
|
|
const brain = new brain_1.ProtocolEngine(db, config);
|
|
const reply = await brain.chat(conv.id, message.trim(), {
|
|
contextData,
|
|
systemExtra,
|
|
tools: Array.isArray(tools) && tools.length ? tools : undefined,
|
|
hooks,
|
|
tenantId,
|
|
});
|
|
// SEC-01+SEC-11: envia reply via WA e persiste no banco principal
|
|
const chat = await prisma.chat.findFirst({
|
|
where: { tenantId, jid: chatId },
|
|
orderBy: { updatedAt: 'desc' },
|
|
select: { id: true, instanceId: true },
|
|
});
|
|
if (chat) {
|
|
const secAgent = await db('sec_agents').where({ id: conv.agent_id }).select('name').first();
|
|
await sendSecretariaReply({
|
|
instanceId: chat.instanceId,
|
|
tenantId,
|
|
chatId: chat.id,
|
|
jid: chatId,
|
|
reply,
|
|
agentName: secAgent?.name,
|
|
});
|
|
}
|
|
res.json({
|
|
reply,
|
|
conversationId: conv.id,
|
|
protocolNumber: conv.protocol_number,
|
|
});
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
// ── GET /secretaria/numbers ───────────────────────────────────────────────
|
|
// Alias do contrato com o satélite (webhook-receiver chama
|
|
// /api/ext/v1/secretaria/numbers). Espelha a rota interna /api/secretaria/numbers.
|
|
router.get('/secretaria/numbers', async (_req, res) => {
|
|
try {
|
|
const numbers = await db('sec_numbers').orderBy('priority').orderBy('label');
|
|
res.json(numbers);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Secretária — API REST de administração (/secretaria/*) do painel do satélite.
|
|
// As tabelas sec_* são GLOBAIS no motor (single-tenant); estes endpoints
|
|
// expõem o contrato completo (agents/nodes/calendar/numbers/conversations)
|
|
// esperado pelo frontend, reaproveitando a lógica dos /sec/*.
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// ── Agents ──────────────────────────────────────────────────────────────────
|
|
router.get('/secretaria/agents', async (_req, res) => {
|
|
try {
|
|
res.json(await db('sec_agents').orderBy('created_at'));
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.post('/secretaria/agents', async (req, res) => {
|
|
try {
|
|
const b = req.body ?? {};
|
|
const [a] = await db('sec_agents').insert({
|
|
id: uuid(), name: b.name ?? 'Agente', description: b.description ?? null,
|
|
model: b.model ?? 'gemini-2.0-flash', provider: b.provider ?? 'gemini',
|
|
temperature: b.temperature ?? 0.7, context_window: b.context_window ?? 10, active: true,
|
|
}).returning('*');
|
|
res.status(201).json(a);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.put('/secretaria/agents/:id', async (req, res) => {
|
|
try {
|
|
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
|
for (const k of ['name', 'description', 'model', 'provider', 'temperature', 'context_window', 'active'])
|
|
if (k in b)
|
|
patch[k] = b[k];
|
|
const [a] = await db('sec_agents').where({ id: req.params['id'] }).update(patch).returning('*');
|
|
res.json(a);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.delete('/secretaria/agents/:id', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
await db('sec_brain_nodes').where({ agent_id: id }).delete();
|
|
await db('sec_agents').where({ id }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── Brain nodes (por agente) ──────────────────────────────────────────────
|
|
router.get('/secretaria/agents/:agentId/nodes', async (req, res) => {
|
|
try {
|
|
res.json(await db('sec_brain_nodes').where({ agent_id: req.params['agentId'] }).orderBy('sort_order'));
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.post('/secretaria/agents/:agentId/nodes', async (req, res) => {
|
|
try {
|
|
const b = req.body ?? {};
|
|
const [n] = await db('sec_brain_nodes').insert({
|
|
id: uuid(), agent_id: req.params['agentId'], type: b.type, title: b.title ?? '', content: b.content ?? '',
|
|
node_model: b.node_model ?? null, active: true, sort_order: b.sort_order ?? 99,
|
|
}).returning('*');
|
|
res.status(201).json(n);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.put('/secretaria/nodes/:id', async (req, res) => {
|
|
try {
|
|
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
|
for (const k of ['type', 'title', 'content', 'node_model', 'active', 'sort_order'])
|
|
if (k in b)
|
|
patch[k] = b[k];
|
|
const [n] = await db('sec_brain_nodes').where({ id: req.params['id'] }).update(patch).returning('*');
|
|
res.json(n);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.delete('/secretaria/nodes/:id', async (req, res) => {
|
|
try {
|
|
await db('sec_brain_nodes').where({ id: req.params['id'] }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── Calendar ──────────────────────────────────────────────────────────────
|
|
router.get('/secretaria/calendar', async (req, res) => {
|
|
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']?.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 });
|
|
}
|
|
});
|
|
router.post('/secretaria/calendar', async (req, res) => {
|
|
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('*');
|
|
res.status(201).json(s);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
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', '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) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.delete('/secretaria/calendar/:id', async (req, res) => {
|
|
try {
|
|
await db('sec_calendar').where({ id: req.params['id'] }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── Numbers (GET acima) — mutações ─────────────────────────────────────────
|
|
router.post('/secretaria/numbers', async (req, res) => {
|
|
try {
|
|
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('*');
|
|
res.status(201).json(n);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
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', '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) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.delete('/secretaria/numbers/:id', async (req, res) => {
|
|
try {
|
|
await db('sec_numbers').where({ id: req.params['id'] }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── Conversations (teste do painel) ────────────────────────────────────────
|
|
router.get('/secretaria/conversations', async (req, res) => {
|
|
try {
|
|
let q = db('sec_conversations')
|
|
.select('id', 'agent_id', 'protocol_number', 'contact_name', 'status', 'handoff_mode', 'summary', 'created_at', 'updated_at')
|
|
.orderBy('updated_at', 'desc').limit(100);
|
|
const agentId = req.query['agent_id'];
|
|
if (agentId)
|
|
q = q.where({ agent_id: agentId });
|
|
res.json(await q);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.post('/secretaria/conversations', async (req, res) => {
|
|
try {
|
|
const b = req.body ?? {};
|
|
const [c] = await db('sec_conversations').insert({
|
|
id: uuid(), agent_id: b.agent_id, contact_name: b.contact_name ?? 'Teste',
|
|
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(), status: 'active', handoff_mode: 'ia',
|
|
}).returning('*');
|
|
res.status(201).json(c);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.patch('/secretaria/conversations/:id', async (req, res) => {
|
|
try {
|
|
const b = req.body ?? {}, patch = { updated_at: new Date() };
|
|
for (const k of ['status', 'contact_name', 'handoff_mode', 'summary'])
|
|
if (k in b)
|
|
patch[k] = b[k];
|
|
const [c] = await db('sec_conversations').where({ id: req.params['id'] }).update(patch).returning('*');
|
|
res.json(c);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.delete('/secretaria/conversations/:id', async (req, res) => {
|
|
try {
|
|
const id = req.params['id'];
|
|
await db('sec_messages').where({ conversation_id: id }).delete();
|
|
await db('sec_conversations').where({ id }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.get('/secretaria/conversations/:id/messages', async (req, res) => {
|
|
try {
|
|
res.json(await db('sec_messages').where({ conversation_id: req.params['id'] }).orderBy('created_at'));
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.post('/secretaria/conversations/:id/chat', async (req, res) => {
|
|
try {
|
|
const message = String((req.body ?? {}).message ?? '').trim();
|
|
if (!message) {
|
|
res.status(400).json({ error: 'message obrigatório' });
|
|
return;
|
|
}
|
|
// clínica por-requisição: workspace ativo do satélite (header x-nw-clinica).
|
|
const clinicaId = req.headers['x-nw-clinica']?.trim() || undefined;
|
|
const brain = new brain_1.ProtocolEngine(db, config);
|
|
const reply = await brain.chat(req.params['id'], message, { tenantId: req.extTenantId, hooks, clinicaId });
|
|
res.json({ reply });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
router.post('/secretaria/conversations/:id/finalize', async (req, res) => {
|
|
try {
|
|
const brain = new brain_1.ProtocolEngine(db, config);
|
|
res.json(await brain.finalizeProtocol(req.params['id']));
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /sec/handoff/:chatId ──────────────────────────────────────────────
|
|
// Retorna o modo de handoff atual para um chat específico.
|
|
router.get('/sec/handoff/:chatId', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = decodeURIComponent(req.params['chatId']);
|
|
try {
|
|
const extKey = `${tenantId}:${chatId}`;
|
|
const conv = await db('sec_conversations')
|
|
.where({ ext_chat_id: extKey, status: 'active' })
|
|
.select('id', 'handoff_mode', 'handoff_human_at')
|
|
.first();
|
|
res.json({ mode: conv?.handoff_mode ?? 'ia', conversationId: conv?.id ?? null, handoffHumanAt: conv?.handoff_human_at ?? null });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── PATCH /sec/handoff/:chatId ────────────────────────────────────────────
|
|
// Alterna entre modo 'ia' e 'humano' para um chat específico.
|
|
// Body: { mode: 'ia' | 'humano' }
|
|
router.patch('/sec/handoff/:chatId', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const chatId = decodeURIComponent(req.params['chatId']);
|
|
const { mode } = req.body;
|
|
if (mode !== 'ia' && mode !== 'humano') {
|
|
res.status(400).json({ error: 'mode deve ser "ia" ou "humano"' });
|
|
return;
|
|
}
|
|
try {
|
|
const extKey = `${tenantId}:${chatId}`;
|
|
const conv = await db('sec_conversations')
|
|
.where({ ext_chat_id: extKey, status: 'active' })
|
|
.first();
|
|
if (!conv) {
|
|
// Sem conversa ativa — retorna o modo padrão sem erro
|
|
res.json({ mode: 'ia', conversationId: null });
|
|
return;
|
|
}
|
|
const updateData = { handoff_mode: mode };
|
|
if (mode === 'humano') {
|
|
updateData.handoff_human_at = new Date();
|
|
scheduleHandoffTimeout(conv.id, extKey, tenantId, db, hooks);
|
|
}
|
|
else {
|
|
updateData.handoff_human_at = null;
|
|
cancelHandoffTimeout(conv.id);
|
|
}
|
|
await db('sec_conversations').where({ id: conv.id }).update(updateData);
|
|
// 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 });
|
|
}
|
|
});
|
|
// ── GET /sec/agent ────────────────────────────────────────────────────────
|
|
// Retorna o agente primário (primeiro criado / ativo).
|
|
router.get('/sec/agent', async (_req, res) => {
|
|
try {
|
|
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
|
res.json(agent ?? null);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /sec/agent ───────────────────────────────────────────────────────
|
|
router.post('/sec/agent', async (req, res) => {
|
|
try {
|
|
const { name, model, provider, temperature, context_window } = req.body;
|
|
const [agent] = await db('sec_agents').insert({
|
|
id: uuid(), name, model: model ?? 'gpt-4o-mini',
|
|
provider: provider ?? 'openai', temperature: temperature ?? 0.7,
|
|
context_window: context_window ?? 10, active: true,
|
|
}).returning('*');
|
|
res.status(201).json(agent);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── PUT /sec/agent/:id ────────────────────────────────────────────────────
|
|
router.put('/sec/agent/:id', async (req, res) => {
|
|
try {
|
|
const { name, model, provider, temperature, context_window } = req.body;
|
|
const [agent] = await db('sec_agents').where({ id: req.params['id'] })
|
|
.update({ name, model, provider, temperature, context_window, updated_at: new Date() })
|
|
.returning('*');
|
|
res.json(agent);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /sec/nodes ────────────────────────────────────────────────────────
|
|
// Retorna todos os brain nodes do agente primário.
|
|
router.get('/sec/nodes', async (_req, res) => {
|
|
try {
|
|
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
|
if (!agent) {
|
|
res.json([]);
|
|
return;
|
|
}
|
|
const nodes = await db('sec_brain_nodes').where({ agent_id: agent.id }).orderBy('sort_order');
|
|
res.json(nodes);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /sec/nodes ───────────────────────────────────────────────────────
|
|
router.post('/sec/nodes', async (req, res) => {
|
|
try {
|
|
const { type, title, content } = req.body;
|
|
const agent = await db('sec_agents').where({ active: true }).orderBy('created_at').first();
|
|
if (!agent) {
|
|
res.status(503).json({ error: 'Nenhum agente ativo' });
|
|
return;
|
|
}
|
|
const [node] = await db('sec_brain_nodes').insert({
|
|
id: uuid(), agent_id: agent.id, type, title: title ?? '', content: content ?? '', active: true, sort_order: 99,
|
|
}).returning('*');
|
|
res.status(201).json(node);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── PUT /sec/nodes/:id ────────────────────────────────────────────────────
|
|
router.put('/sec/nodes/:id', async (req, res) => {
|
|
try {
|
|
const { type, title, content } = req.body;
|
|
const [node] = await db('sec_brain_nodes').where({ id: req.params['id'] })
|
|
.update({ type, title: title ?? '', content, updated_at: new Date() }).returning('*');
|
|
res.json(node);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── DELETE /sec/nodes/:id ─────────────────────────────────────────────────
|
|
router.delete('/sec/nodes/:id', async (req, res) => {
|
|
try {
|
|
await db('sec_brain_nodes').where({ id: req.params['id'] }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /sec/slots ────────────────────────────────────────────────────────
|
|
router.get('/sec/slots', async (_req, res) => {
|
|
try {
|
|
const slots = await db('sec_calendar').orderBy('date').orderBy('time_start');
|
|
res.json(slots);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /sec/slots ───────────────────────────────────────────────────────
|
|
router.post('/sec/slots', async (req, res) => {
|
|
try {
|
|
const { date, time_start, time_end, attendee, notes } = req.body;
|
|
const [slot] = await db('sec_calendar').insert({
|
|
id: uuid(), date, time_start, time_end,
|
|
attendee_name: attendee ?? null, notes: notes ?? null, status: 'available',
|
|
}).returning('*');
|
|
res.status(201).json(slot);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── DELETE /sec/slots/:id ─────────────────────────────────────────────────
|
|
router.delete('/sec/slots/:id', async (req, res) => {
|
|
try {
|
|
await db('sec_calendar').where({ id: req.params['id'] }).delete();
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /sec/conversations ────────────────────────────────────────────────
|
|
router.get('/sec/conversations', async (_req, res) => {
|
|
try {
|
|
const convs = await db('sec_conversations')
|
|
.select('id', 'protocol_number', 'contact_name', 'status', 'handoff_mode', 'summary', 'created_at', 'updated_at')
|
|
.orderBy('updated_at', 'desc').limit(100);
|
|
res.json(convs);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /sec/conversations/:id/messages ───────────────────────────────────
|
|
router.get('/sec/conversations/:id/messages', async (req, res) => {
|
|
try {
|
|
const msgs = await db('sec_messages')
|
|
.where({ conversation_id: req.params['id'] })
|
|
.orderBy('created_at');
|
|
res.json(msgs);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /sec/conversations/:id/finalize ──────────────────────────────────
|
|
router.post('/sec/conversations/:id/finalize', async (req, res) => {
|
|
try {
|
|
const brain = new brain_1.ProtocolEngine(db, config);
|
|
const result = await brain.finalizeProtocol(req.params['id']);
|
|
res.json(result);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /sec/keys ─────────────────────────────────────────────────────────
|
|
// Retorna as API keys — valores reais mascarados (primeiros chars + ****)
|
|
// admin_notify_phone é retornado sem máscara (não é segredo)
|
|
router.get('/sec/keys', async (_req, res) => {
|
|
try {
|
|
// As chaves vivem DENTRO da config do plugin 'secretaria' (é o que o
|
|
// brain lê via config.get('secretaria')). config é por-plugin, não por-chave.
|
|
const cfg = ((await config.get('secretaria')) ?? {});
|
|
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url'];
|
|
const result = {};
|
|
for (const k of secretKeys) {
|
|
const raw = typeof cfg[k] === 'string' ? cfg[k] : '';
|
|
result[k] = raw.length > 0
|
|
? (raw.length > 8 ? raw.slice(0, 8) + '****' : '****')
|
|
: '';
|
|
}
|
|
// Número de notificação: retorna em claro
|
|
result['admin_notify_phone'] = typeof cfg['admin_notify_phone'] === 'string' ? cfg['admin_notify_phone'] : '';
|
|
res.json(result);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── PUT /sec/keys ─────────────────────────────────────────────────────────
|
|
// Só persiste keys que não são máscara (não terminam em ****)
|
|
router.put('/sec/keys', async (req, res) => {
|
|
try {
|
|
// Lê a config atual do plugin 'secretaria', mescla as chaves novas e
|
|
// regrava o objeto INTEIRO (config.set(pluginName, obj)) — antes gravava
|
|
// cada chave como se fosse um plugin próprio, e o brain nunca as via.
|
|
const cfg = { ...((await config.get('secretaria')) ?? {}) };
|
|
const secretKeys = ['openai_key', 'gemini_key', 'anthropic_key', 'ollama_url'];
|
|
for (const k of secretKeys) {
|
|
const v = req.body[k];
|
|
if (typeof v === 'string' && v.length > 0 && !v.endsWith('****')) {
|
|
cfg[k] = v;
|
|
}
|
|
}
|
|
// admin_notify_phone: sempre persiste (pode ser string vazia para limpar)
|
|
if (typeof req.body['admin_notify_phone'] === 'string') {
|
|
cfg['admin_notify_phone'] = req.body['admin_notify_phone'].trim();
|
|
}
|
|
await config.set('secretaria', cfg);
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /templates ────────────────────────────────────────────────────────
|
|
router.get('/templates', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { type, search } = req.query;
|
|
try {
|
|
const templates = await prisma.messageTemplate.findMany({
|
|
where: {
|
|
tenantId,
|
|
...(type && type !== 'ALL' ? { type: type } : {}),
|
|
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
res.json(templates);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /templates ───────────────────────────────────────────────────────
|
|
router.post('/templates', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { name, type, payload, tags, description } = req.body;
|
|
if (!name?.trim() || !type || !payload) {
|
|
res.status(400).json({ error: 'name, type e payload são obrigatórios' });
|
|
return;
|
|
}
|
|
try {
|
|
const tmpl = await prisma.messageTemplate.create({
|
|
data: { tenantId, name: name.trim(), type, payload: payload, tags: tags ?? [], description: description ?? null },
|
|
});
|
|
res.status(201).json(tmpl);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── DELETE /templates/:id ─────────────────────────────────────────────────
|
|
router.delete('/templates/:id', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
try {
|
|
const existing = await prisma.messageTemplate.findFirst({ where: { id, tenantId } });
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Template não encontrado' });
|
|
return;
|
|
}
|
|
await prisma.messageTemplate.delete({ where: { id } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /templates/:id/use ───────────────────────────────────────────────
|
|
router.post('/templates/:id/use', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
try {
|
|
const existing = await prisma.messageTemplate.findFirst({ where: { id, tenantId } });
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Template não encontrado' });
|
|
return;
|
|
}
|
|
await prisma.messageTemplate.update({ where: { id }, data: { usageCount: { increment: 1 } } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── GET /scheduled ────────────────────────────────────────────────────────
|
|
router.get('/scheduled', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
try {
|
|
const items = await prisma.scheduledMessage.findMany({
|
|
where: { tenantId },
|
|
orderBy: { createdAt: 'desc' },
|
|
});
|
|
res.json(items);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /scheduled ───────────────────────────────────────────────────────
|
|
router.post('/scheduled', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { instanceId, name, payload, recipients, scheduleType, cronExpr, scheduledAt, eventType, timezone } = req.body;
|
|
if (!instanceId || !name?.trim() || !payload || !Array.isArray(recipients) || !scheduleType) {
|
|
res.status(400).json({ error: 'instanceId, name, payload, recipients e scheduleType são obrigatórios' });
|
|
return;
|
|
}
|
|
try {
|
|
const nextRunAt = scheduleType === 'ONCE' && scheduledAt ? new Date(scheduledAt) : null;
|
|
const item = await prisma.scheduledMessage.create({
|
|
data: {
|
|
tenantId, instanceId, name: name.trim(),
|
|
payload: payload, recipients: recipients,
|
|
scheduleType, cronExpr: cronExpr ?? null,
|
|
scheduledAt: scheduledAt ? new Date(scheduledAt) : null,
|
|
eventType: eventType ?? null,
|
|
timezone: timezone ?? 'America/Sao_Paulo',
|
|
nextRunAt,
|
|
status: 'ACTIVE',
|
|
},
|
|
});
|
|
res.status(201).json(item);
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── DELETE /scheduled/:id ─────────────────────────────────────────────────
|
|
router.delete('/scheduled/:id', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
try {
|
|
const existing = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Agendamento não encontrado' });
|
|
return;
|
|
}
|
|
await prisma.scheduledMessage.delete({ where: { id } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /scheduled/:id/pause ─────────────────────────────────────────────
|
|
router.post('/scheduled/:id/pause', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
try {
|
|
const existing = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Agendamento não encontrado' });
|
|
return;
|
|
}
|
|
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── POST /scheduled/:id/resume ────────────────────────────────────────────
|
|
router.post('/scheduled/:id/resume', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const id = req.params['id'];
|
|
try {
|
|
const existing = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
|
|
if (!existing) {
|
|
res.status(404).json({ error: 'Agendamento não encontrado' });
|
|
return;
|
|
}
|
|
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } });
|
|
res.json({ ok: true });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
// ── PATCH /protocol/assign-agent ─────────────────────────────────────────
|
|
// Atribui um agente externo (account do sistema-nuvem) ao protocolo ativo do chat.
|
|
// Body: { chatId: string, agentId: string, agentName?: string }
|
|
// Chamado pelo sistema-nuvem quando um pedido avança de etapa e um colaborador é atribuído.
|
|
router.patch('/protocol/assign-agent', async (req, res) => {
|
|
const tenantId = req.extTenantId;
|
|
const { chatId, agentId, agentName } = req.body;
|
|
if (!chatId || !agentId) {
|
|
res.status(400).json({ error: 'chatId e agentId são obrigatórios' });
|
|
return;
|
|
}
|
|
try {
|
|
// Busca o protocolo ativo mais recente para o chat
|
|
const protocol = await prisma.protocol.findFirst({
|
|
where: {
|
|
tenantId,
|
|
chatId,
|
|
status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] },
|
|
},
|
|
orderBy: { createdAt: 'desc' },
|
|
select: { id: true },
|
|
});
|
|
if (!protocol) {
|
|
// Sem protocolo ativo — não é erro, pedido pode não ter chat vinculado
|
|
res.json({ ok: true, updated: false, reason: 'no_active_protocol' });
|
|
return;
|
|
}
|
|
await prisma.protocol.update({
|
|
where: { id: protocol.id },
|
|
data: {
|
|
agentId: String(agentId),
|
|
status: 'IN_PROGRESS',
|
|
},
|
|
});
|
|
res.json({ ok: true, updated: true, protocolId: protocol.id, agentId, agentName: agentName ?? null });
|
|
}
|
|
catch (err) {
|
|
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
|
}
|
|
});
|
|
return router;
|
|
}
|
|
//# sourceMappingURL=routes.js.map
|