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:
VPS 4 Deploy Agent
2026-07-04 19:05:50 +02:00
parent 1702235c59
commit 2c8eb41b7d
6 changed files with 631 additions and 12 deletions
@@ -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' });