feat(ext-api): auto-provisionamento de conta + reconexão de sessão
- POST /satellites/accounts (ensureAccount): cria/reativa User por email, idempotente; gate aceita chave MESTRA ou chave de satélite (nwk_) válida — o satélite espelha suas próprias contas (scoreodonto) - POST /sessions/:id/connect (reconectar/re-scan) e /sessions/:id/disconnect (encerrar sem remover) — antes o satélite recebia 404 no botão Conectar - inclui proxy/re-download de mídia (/media/:id/download) da integração satélite Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,37 @@
|
||||
"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;
|
||||
};
|
||||
})();
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildExtRoutes = buildExtRoutes;
|
||||
/**
|
||||
@@ -19,6 +52,7 @@ exports.buildExtRoutes = buildExtRoutes;
|
||||
*/
|
||||
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') {
|
||||
@@ -35,6 +69,47 @@ if (process.env.NODE_ENV === 'production') {
|
||||
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;
|
||||
}
|
||||
// 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();
|
||||
@@ -326,6 +401,46 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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.
|
||||
@@ -389,6 +504,97 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
||||
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 { }
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user