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:
VPS 4 Deploy Agent
2026-07-02 23:27:35 +02:00
parent 6d9850d8d3
commit 1702235c59
6 changed files with 457 additions and 4 deletions
@@ -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) => {