feat(ext-api): storage Wasabi de mídia — thumbnails + flag de recuperabilidade
- re-download persiste a mídia no Wasabi e gera thumbnail <url>.thumb.webp (sharp) - GET /inbox/:chatId/messages expõe mediaRecoverable = url|path|payload presente - ws-bridge: encaminha hook ext:presence Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,9 @@ var __importStar = (this && this.__importStar) || (function () {
|
||||
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;
|
||||
/**
|
||||
@@ -62,6 +65,30 @@ 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;
|
||||
@@ -105,6 +132,35 @@ function deserializeMediaPayload(val) {
|
||||
}
|
||||
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,
|
||||
@@ -172,6 +228,59 @@ function buildExtRoutes(prisma, manager, db, config, hooks, 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) => {
|
||||
@@ -589,6 +698,19 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl });
|
||||
}
|
||||
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) {
|
||||
@@ -774,6 +896,8 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
body: true,
|
||||
caption: true,
|
||||
mediaUrl: true,
|
||||
mediaPath: true,
|
||||
mediaPayload: true,
|
||||
mimeType: true,
|
||||
fileName: true,
|
||||
status: true,
|
||||
@@ -782,7 +906,15 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
senderJid: true,
|
||||
},
|
||||
});
|
||||
res.json(messages.reverse());
|
||||
// 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' });
|
||||
@@ -806,6 +938,32 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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 }
|
||||
@@ -872,6 +1030,46 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
|
||||
// Envia mensagem de texto ou rica para o chat.
|
||||
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
||||
@@ -905,7 +1103,7 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
const cleanList = nativeList
|
||||
? { buttonText: nativeList.buttonText, sections: nativeList.sections }
|
||||
: undefined;
|
||||
await sendRichMessage(sock, chat.jid, {
|
||||
const waMessageId = await sendRichMessage(sock, chat.jid, {
|
||||
text: text?.trim() || undefined,
|
||||
footer: resolvedFooter,
|
||||
nativeButtons: nativeButtons,
|
||||
@@ -913,12 +1111,137 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
nativeCarousel: nativeCarousel,
|
||||
poll: poll,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
// 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 }
|
||||
@@ -955,8 +1278,18 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
res.status(400).json({ error: 'Sessão não conectada' });
|
||||
return;
|
||||
}
|
||||
await sock.sendMessage(jid, { text: text.trim() });
|
||||
res.status(201).json({ ok: true, jid });
|
||||
// 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' });
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -31,6 +31,33 @@ if (process.env.NODE_ENV === 'production') {
|
||||
dragonfly = require('../../../backend/src/infra/cache/dragonfly').dragonfly;
|
||||
}
|
||||
import { ProtocolEngine } from '../../secretaria/brain'
|
||||
import { spawn } from 'child_process'
|
||||
import multer from 'multer'
|
||||
import sharp from '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 = multer({ storage: multer.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: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ff = 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: Buffer[] = []
|
||||
const errs: Buffer[] = []
|
||||
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', () => { /* pipe fechado */ })
|
||||
ff.stdin.write(input)
|
||||
ff.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
let sendRichMessage: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
sendRichMessage = require('../../../dist/modules/whatsapp/rich-message').sendRichMessage;
|
||||
@@ -72,6 +99,32 @@ function deserializeMediaPayload(val: unknown): unknown {
|
||||
}
|
||||
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: any): any {
|
||||
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: Record<string, unknown> = {}
|
||||
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: any): Record<string, unknown> | null {
|
||||
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: any = {
|
||||
level: 'silent', child: () => mediaLogger,
|
||||
@@ -153,6 +206,58 @@ export function buildExtRoutes(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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: {
|
||||
instanceId: string; tenantId: string; chatId: string; jid: string;
|
||||
waMessageId: string; body?: string | null; type?: string;
|
||||
mediaPayload?: Record<string, unknown> | null; // p/ re-download do próprio envio
|
||||
}): Promise<void> {
|
||||
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 as any) ?? 'TEXT', body: opts.body ?? null,
|
||||
status: 'SENT', timestamp: new Date(),
|
||||
...(opts.mediaPayload ? { mediaPayload: opts.mediaPayload as any } : {}),
|
||||
},
|
||||
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(() => { /* best-effort */ })
|
||||
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<object>()
|
||||
function ensurePresenceListener(sock: any, instanceId: string, tenantId: string): void {
|
||||
if (!sock || presenceAttached.has(sock)) return
|
||||
presenceAttached.add(sock)
|
||||
sock.ev.on('presence.update', ({ id, presences }: any) => {
|
||||
// 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 || {}) as any[]) {
|
||||
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(() => { /* best-effort */ })
|
||||
})
|
||||
}
|
||||
|
||||
// ── SEC-02+SEC-03: auto-trigger da Secretária para mensagens recebidas ────
|
||||
// Só processa se NÃO houver AIBot ativo (exclusão mútua com Chatbot Rápido).
|
||||
hooks.register('ext:message.new', async (data: any) => {
|
||||
@@ -561,6 +666,20 @@ export function buildExtRoutes(
|
||||
|
||||
await prisma.message.update({ where: { id: messageId }, data: { mediaUrl } })
|
||||
try { manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', { messageId, mediaUrl }) } catch {}
|
||||
|
||||
// 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 sharp(buffer as 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(() => { /* thumb é opcional */ })
|
||||
}
|
||||
res.json({ mediaUrl })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err?.message ?? 'Falha no re-download de mídia' })
|
||||
@@ -756,6 +875,8 @@ export function buildExtRoutes(
|
||||
body: true,
|
||||
caption: true,
|
||||
mediaUrl: true,
|
||||
mediaPath: true,
|
||||
mediaPayload: true,
|
||||
mimeType: true,
|
||||
fileName: true,
|
||||
status: true,
|
||||
@@ -765,7 +886,15 @@ export function buildExtRoutes(
|
||||
},
|
||||
})
|
||||
|
||||
res.json(messages.reverse())
|
||||
// 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: any) {
|
||||
res.status(500).json({ error: err.message ?? 'Erro interno' })
|
||||
}
|
||||
@@ -786,6 +915,26 @@ export function buildExtRoutes(
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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: Request, res: Response) => {
|
||||
const tenantId = req.extTenantId!
|
||||
const chatId = req.params['chatId'] as string
|
||||
try {
|
||||
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId }, select: { 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(() => { /* best-effort */ })
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? 'Erro interno' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── PATCH /inbox/:chatId/archive ──────────────────────────────────────────
|
||||
// Arquiva ou desarquiva um chat.
|
||||
// Body: { archived: boolean }
|
||||
@@ -841,6 +990,41 @@ export function buildExtRoutes(
|
||||
}
|
||||
})
|
||||
|
||||
// ── 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: Request, res: Response) => {
|
||||
const tenantId = req.extTenantId!
|
||||
const chatId = req.params['chatId'] as string
|
||||
const messageId = req.params['messageId'] as string
|
||||
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: any = { 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(() => { /* já sumiu do banco */ })
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao apagar mensagem' })
|
||||
}
|
||||
})
|
||||
|
||||
// ── POST /inbox/:chatId/send ──────────────────────────────────────────────
|
||||
// Envia mensagem de texto ou rica para o chat.
|
||||
// Body: { text?, footer?, nativeButtons?, nativeList?, nativeCarousel?, poll? }
|
||||
@@ -882,7 +1066,7 @@ export function buildExtRoutes(
|
||||
? { buttonText: nativeList.buttonText, sections: nativeList.sections }
|
||||
: undefined
|
||||
|
||||
await sendRichMessage(sock as any, chat.jid, {
|
||||
const waMessageId = await sendRichMessage(sock as any, chat.jid, {
|
||||
text: text?.trim() || undefined,
|
||||
footer: resolvedFooter,
|
||||
nativeButtons: nativeButtons as any,
|
||||
@@ -890,12 +1074,104 @@ export function buildExtRoutes(
|
||||
nativeCarousel: nativeCarousel as any,
|
||||
poll: poll as any,
|
||||
})
|
||||
res.json({ ok: true })
|
||||
// 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: any) {
|
||||
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: Request, res: Response) => {
|
||||
const tenantId = req.extTenantId!
|
||||
const chatId = req.params['chatId'] as string
|
||||
const { audio, mimetype } = req.body as { audio?: string; mimetype?: string }
|
||||
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: any = 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 as any)?.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: any) {
|
||||
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: Request, res: Response) => {
|
||||
const tenantId = req.extTenantId!
|
||||
const chatId = req.params['chatId'] as string
|
||||
const up = (req as any).file as { buffer: Buffer; mimetype: string; originalname: string } | undefined
|
||||
const caption = (req.body?.caption as string | undefined)
|
||||
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: any
|
||||
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 as any)?.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: any) {
|
||||
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 }
|
||||
@@ -937,8 +1213,16 @@ export function buildExtRoutes(
|
||||
return
|
||||
}
|
||||
|
||||
await sock.sendMessage(jid, { text: text.trim() })
|
||||
res.status(201).json({ ok: true, jid })
|
||||
// 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: any) {
|
||||
res.status(500).json({ error: err.message ?? 'Erro ao enviar' })
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ function buildWsBridge(httpServer, prisma, hooks) {
|
||||
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
||||
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
||||
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
||||
{ hook: 'ext:presence', wsEvent: 'presence' },
|
||||
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
||||
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
||||
];
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":";;AAwCA,sCAmHC;AA3JD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2BAAoD;AAKpD,+CAA8F;AAE9F,MAAM,OAAO,GAAa,oBAAoB,CAAA;AAC9C,MAAM,gBAAgB,GAAI,KAAM,CAAA;AAQhC,SAAS,aAAa,CAAC,KAAa,EAAE,IAAa;IACjD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED,SAAgB,aAAa,CAC3B,UAAsB,EACtB,MAAoB,EACpB,KAAc;IAEd,MAAM,GAAG,GAAG,IAAI,oBAAe,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAEnD,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;QACpE,IAAI,GAAG,CAAC,GAAG,KAAK,OAAO;YAAE,OAAM,CAAE,iCAAiC;QAElE,+CAA+C;QAC/C,MAAM,MAAM,GAAI,GAAG,CAAC,OAAO,CAAC,UAAU,CAAwB,EAAE,IAAI,EAAE,CAAA;QACtE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,KAAK,CAAC,sFAAsF,CAAC,CAAA;YACpG,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,QAAuB,CAAA;QAC3B,IAAI,CAAC;YACH,4EAA4E;YAC5E,0DAA0D;YAC1D,4EAA4E;YAC5E,8DAA8D;YAC9D,mCAAmC;YACnC,MAAM,KAAK,GAAI,GAAG,CAAC,OAAO,CAAC,YAAY,CAAwB,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;YACrF,IAAI,IAAA,yBAAW,EAAC,MAAM,CAAC,EAAE,CAAC;gBACxB,IAAI,CAAC,KAAK,EAAE,CAAC;oBACX,MAAM,CAAC,KAAK,CAAC,+FAA+F,CAAC,CAAA;oBAC7G,MAAM,CAAC,OAAO,EAAE,CAAA;oBAChB,OAAM;gBACR,CAAC;gBACD,QAAQ,GAAG,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAA;YACpD,CAAC;iBAAM,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;gBACrC,MAAM,KAAK,GAAG,MAAM,IAAA,4BAAc,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;gBAClD,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;YACrF,CAAC;iBAAM,CAAC;gBACN,QAAQ,GAAG,MAAM,IAAA,2BAAa,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;YAChD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAA;YAC1D,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,CAAC,KAAK,CAAC,6GAA6G,CAAC,CAAA;YAC3H,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,0BAA0B;QAC1B,GAAG,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE;YAC1C,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAa,EAAE,IAAqB,EAAE,QAAgB,EAAE,EAAE;QAC9E,MAAM,MAAM,GAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;QAEtD,8BAA8B;QAC9B,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QAEhE,yEAAyE;QACzE,MAAM,SAAS,GAA6C;YAC1D,EAAE,IAAI,EAAE,gBAAgB,EAAM,OAAO,EAAE,YAAY,EAAW;YAC9D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,iBAAiB,EAAK,OAAO,EAAE,aAAa,EAAW;YAC/D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,aAAa,EAAS,OAAO,EAAE,sBAAsB,EAAI;YACjE,EAAE,IAAI,EAAE,eAAe,EAAM,OAAO,EAAE,wBAAwB,EAAE;SACjE,CAAA;QAED,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,SAAS,EAAE,CAAC;YAC1C,MAAM,OAAO,GAAG,KAAK,EAAE,IAAS,EAAE,EAAE;gBAClC,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ;oBAAE,OAAM,CAAQ,mBAAmB;gBACjE,IAAI,EAAE,CAAC,UAAU,KAAK,EAAE,CAAC,IAAI;oBAAI,OAAM;gBACvC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,GAAG,IAAI,CAAA,CAAG,gCAAgC;gBAC9E,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAA;YAC1C,CAAC,CAAA;YACD,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;YAC7B,iEAAiE;YACjE,iEAAiE;YACjE,sEAAsE;YACtE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;gBACtB,sEAAsE;gBACtE,0EAA0E;YAC5E,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,yEAAyE;QACzE,IAAI,OAAO,GAAG,IAAI,CAAA;QAClB,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAAC,EAAE,CAAC,SAAS,EAAE,CAAC;gBAAC,OAAM;YAAC,CAAC;YACxC,OAAO,GAAG,KAAK,CAAA;YACf,EAAE,CAAC,IAAI,EAAE,CAAA;QACX,CAAC,EAAE,gBAAgB,CAAC,CAAA;QAEpB,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,OAAO,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAAA;QAEvC,yEAAyE;QACzE,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;YACxB,sEAAsE;QACxE,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QAEF,kEAAkE;QAClE,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
||||
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":";;AAwCA,sCAoHC;AA5JD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2BAAoD;AAKpD,+CAA8F;AAE9F,MAAM,OAAO,GAAa,oBAAoB,CAAA;AAC9C,MAAM,gBAAgB,GAAI,KAAM,CAAA;AAQhC,SAAS,aAAa,CAAC,KAAa,EAAE,IAAa;IACjD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED,SAAgB,aAAa,CAC3B,UAAsB,EACtB,MAAoB,EACpB,KAAc;IAEd,MAAM,GAAG,GAAG,IAAI,oBAAe,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAEnD,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;QACpE,IAAI,GAAG,CAAC,GAAG,KAAK,OAAO;YAAE,OAAM,CAAE,iCAAiC;QAElE,+CAA+C;QAC/C,MAAM,MAAM,GAAI,GAAG,CAAC,OAAO,CAAC,UAAU,CAAwB,EAAE,IAAI,EAAE,CAAA;QACtE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,KAAK,CAAC,sFAAsF,CAAC,CAAA;YACpG,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,QAAuB,CAAA;QAC3B,IAAI,CAAC;YACH,4EAA4E;YAC5E,0DAA0D;YAC1D,4EAA4E;YAC5E,8DAA8D;YAC9D,mCAAmC;YACnC,MAAM,KAAK,GAAI,GAAG,CAAC,OAAO,CAAC,YAAY,CAAwB,EAAE,IAAI,EAAE,CAAC,WAAW,EAAE,CAAA;YACrF,IAAI,IAAA,yBAAW,EAAC,MAAM,CAAC,EAAE,CAAC;gBACxB,IAAI,CAAC,KAAK,EAAE,CAAC;oBACX,MAAM,CAAC,KAAK,CAAC,+FAA+F,CAAC,CAAA;oBAC7G,MAAM,CAAC,OAAO,EAAE,CAAA;oBAChB,OAAM;gBACR,CAAC;gBACD,QAAQ,GAAG,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAA;YACpD,CAAC;iBAAM,IAAI,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,EAAE,CAAC;gBACrC,MAAM,KAAK,GAAG,MAAM,IAAA,4BAAc,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;gBAClD,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,IAAA,gCAAkB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,CAAA;YACrF,CAAC;iBAAM,CAAC;gBACN,QAAQ,GAAG,MAAM,IAAA,2BAAa,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;YAChD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAA;YAC1D,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,CAAC,KAAK,CAAC,6GAA6G,CAAC,CAAA;YAC3H,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,0BAA0B;QAC1B,GAAG,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE;YAC1C,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAa,EAAE,IAAqB,EAAE,QAAgB,EAAE,EAAE;QAC9E,MAAM,MAAM,GAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;QAEtD,8BAA8B;QAC9B,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QAEhE,yEAAyE;QACzE,MAAM,SAAS,GAA6C;YAC1D,EAAE,IAAI,EAAE,gBAAgB,EAAM,OAAO,EAAE,YAAY,EAAW;YAC9D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,iBAAiB,EAAK,OAAO,EAAE,aAAa,EAAW;YAC/D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,cAAc,EAAQ,OAAO,EAAE,UAAU,EAAc;YAC/D,EAAE,IAAI,EAAE,aAAa,EAAS,OAAO,EAAE,sBAAsB,EAAI;YACjE,EAAE,IAAI,EAAE,eAAe,EAAM,OAAO,EAAE,wBAAwB,EAAE;SACjE,CAAA;QAED,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,SAAS,EAAE,CAAC;YAC1C,MAAM,OAAO,GAAG,KAAK,EAAE,IAAS,EAAE,EAAE;gBAClC,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ;oBAAE,OAAM,CAAQ,mBAAmB;gBACjE,IAAI,EAAE,CAAC,UAAU,KAAK,EAAE,CAAC,IAAI;oBAAI,OAAM;gBACvC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,GAAG,IAAI,CAAA,CAAG,gCAAgC;gBAC9E,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAA;YAC1C,CAAC,CAAA;YACD,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;YAC7B,iEAAiE;YACjE,iEAAiE;YACjE,sEAAsE;YACtE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;gBACtB,sEAAsE;gBACtE,0EAA0E;YAC5E,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,yEAAyE;QACzE,IAAI,OAAO,GAAG,IAAI,CAAA;QAClB,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAAC,EAAE,CAAC,SAAS,EAAE,CAAC;gBAAC,OAAM;YAAC,CAAC;YACxC,OAAO,GAAG,KAAK,CAAA;YACf,EAAE,CAAC,IAAI,EAAE,CAAA;QACX,CAAC,EAAE,gBAAgB,CAAC,CAAA;QAEpB,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,OAAO,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAAA;QAEvC,yEAAyE;QACzE,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;YACxB,sEAAsE;QACxE,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QAEF,kEAAkE;QAClE,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
||||
@@ -109,6 +109,7 @@ export function buildWsBridge(
|
||||
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
||||
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
||||
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
||||
{ hook: 'ext:presence', wsEvent: 'presence' },
|
||||
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
||||
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user