Files
clube67_newwhats.local/newwhats.clube67.com/newwhats.local/plugins/ext-api/backend/routes.js
T
VPS 4 Deploy Agent eca65bf765 feat(secretaria): roteamento por especialidade, situações, dúvida→humano, imagem, checklist do dono e desligar global
Secretária IA mais segura e contextual:
- Roteamento por especialidade: /contexto (dentistas+especialidades) injetado no
  cérebro; descobrir o motivo e priorizar clínico geral só p/ avaliação/limpeza.
- Situações da clínica: regras editáveis injetadas no prompt (siga à risca).
- Dúvida/caso incerto → tool encaminhar_humano (não agenda; pede p/ aguardar).
- Imagem/áudio: ext:message.transcription religa no fluxo ext:message.new
  (guard hadCaption evita processar 2x); a IA reage à descrição da imagem.
- Checklist do dono × banco → cautelas no /contexto: NÃO assumir clínico geral
  por padrão p/ quem já é paciente; sem certeza de quem/qual dentista → humano.
- Desligar GLOBAL da Secretária: GET/POST /secretaria/power (kill-switch
  auto_reply via config.set, efeito imediato, sem restart; visível a todos).

Inclui a plumbing da sessão (chat.upsert, ownership de conexão, ws-bridge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 15:40:26 +02:00

2733 lines
135 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 });
});
// ── ext:message.transcription → religa a Secretária p/ imagem/áudio ────────
// Imagem/áudio não geram ext:message.new (não têm `body` de texto). Quando a
// IA descreve a imagem / transcreve o áudio, reaproveitamos TODO o fluxo da
// Secretária re-emitindo ext:message.new com o texto resultante. Imagem vira
// um marcador "[Imagem: …]" (a IA identifica e, se for dúvida, encaminha ao
// humano via encaminhar_humano); áudio entra como a própria fala transcrita.
hooks.register('ext:message.transcription', async (data) => {
const { tenantId, instanceId, chatId, jid, fromMe, hadCaption, contentType, transcription } = data || {};
if (fromMe)
return; // só mensagem RECEBIDA do cliente
if (hadCaption)
return; // legenda já disparou ext:message.new — não processar 2x
if (!jid || !instanceId || !transcription?.trim())
return;
if (!String(jid).endsWith('@s.whatsapp.net'))
return; // só 1:1
const text = contentType === 'imageMessage'
? `[Imagem enviada pelo cliente] ${transcription.trim()}`
: transcription.trim();
try {
await hooks.emit('ext:message.new', { tenantId, instanceId, chatId, jid, text });
}
catch { /* best-effort */ }
});
// ── Worker: follow-ups proativos da agenda (Parte B) ──────────────────────
// A cada 30s processa os follow-ups vencidos (confirmar_depois): consulta a
// agenda perto do horário desejado e manda ao paciente "Oie, consegui!".
setInterval(async () => {
let due = [];
try {
due = await db('sec_agenda_followups').where('status', 'pendente').where('fire_at', '<=', new Date()).limit(10);
}
catch {
return;
}
for (const fu of due) {
try {
const claimed = await db('sec_agenda_followups').where({ id: fu.id, status: 'pendente' }).update({ status: 'processando' });
if (!claimed)
continue;
const dataStr = fu.data ? (fu.data instanceof Date ? fu.data.toISOString().slice(0, 10) : String(fu.data).slice(0, 10)) : null;
const u = new URL(`${String(fu.agenda_url).replace(/\/$/, '')}/slots`);
u.searchParams.set('clinica_id', fu.clinica_id);
if (dataStr)
u.searchParams.set('date', dataStr);
if (fu.periodo)
u.searchParams.set('periodo', fu.periodo);
const r = await fetch(u, { headers: { 'x-nw-agenda-secret': fu.agenda_secret || '' }, signal: AbortSignal.timeout(12000) });
const j = r.ok ? await r.json() : {};
const slots = j?.slots ?? [];
// slot mais próximo do horário desejado (senão o primeiro).
let escolhido = slots[0];
if (fu.hora_desejada && slots.length) {
const alvo = String(fu.hora_desejada).slice(0, 5);
escolhido = slots.slice().sort((a, b) => Math.abs(a.start.slice(11, 16).localeCompare(alvo)) - Math.abs(b.start.slice(11, 16).localeCompare(alvo)))[0] || slots[0];
}
const texto = escolhido
? `Oie! Consegui aqui 😊 Posso te agendar${dataStr ? ` dia ${dataStr.slice(8, 10)}/${dataStr.slice(5, 7)}` : ''} às ${escolhido.start.slice(11, 16)}. Fica bom pra você?`
: 'Oi! Verifiquei a agenda e não consegui esse horário 😕 Posso te oferecer outro dia ou período?';
// assina com o agente do número (channel) para manter o padrão.
const ch = await db('sec_numbers').where({ instance_id: fu.instance_id }).first();
const ag = ch?.agent_id ? await db('sec_agents').where({ id: ch.agent_id }).first() : await db('sec_agents').where({ active: true }).orderBy('created_at').first();
const chat = await prisma.chat.findFirst({ where: { instanceId: fu.instance_id, jid: fu.jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
await sendSecretariaReply({ instanceId: fu.instance_id, tenantId: fu.tenant_id, chatId: chat?.id ?? fu.jid, jid: fu.jid, reply: texto, agentName: ag?.name });
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'enviado' });
}
catch {
await db('sec_agenda_followups').where({ id: fu.id }).update({ status: 'falhou' }).catch(() => { });
}
}
}, 30000);
// ── Parte A: envia a pergunta de encaixe ao DENTISTA (tool perguntar_dentista) ─
hooks.register('ext:agenda.notify-dentist', async (data) => {
const { instanceId, tenantId, jid, text } = data || {};
if (!instanceId || !jid || !text)
return;
try {
const chat = await prisma.chat.findFirst({ where: { instanceId, jid }, orderBy: { updatedAt: 'desc' }, select: { id: true } });
await sendSecretariaReply({ instanceId, tenantId, chatId: chat?.id ?? jid, jid, reply: text });
}
catch { /* best-effort */ }
});
// ── Escalation notification listener ──────────────────────────────────────
// 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: ' é 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/*.
// ═══════════════════════════════════════════════════════════════════════════
// ── Liga/desliga GLOBAL da Secretária (kill-switch auto_reply) ───────────────
// Desliga o auto-reply para TODOS os chats, sem timer — fica off até religarem
// manualmente. Botão de segurança enquanto a IA amadurece; aberto a todos (por
// ora). Efeito imediato: config.set atualiza o cache que o ext:message.new lê.
router.get('/secretaria/power', async (_req, res) => {
try {
const cfg = (await config.get('secretaria'));
res.json({ enabled: cfg?.auto_reply !== false });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
router.post('/secretaria/power', async (req, res) => {
try {
const enabled = !!(req.body?.enabled);
const cfg = { ...(await config.get('secretaria')), auto_reply: enabled };
await config.set('secretaria', cfg);
res.json({ enabled });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// ── Agents ──────────────────────────────────────────────────────────────────
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