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";
|
"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 });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.buildExtRoutes = buildExtRoutes;
|
exports.buildExtRoutes = buildExtRoutes;
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +52,7 @@ exports.buildExtRoutes = buildExtRoutes;
|
|||||||
*/
|
*/
|
||||||
const fs_1 = require("fs");
|
const fs_1 = require("fs");
|
||||||
const promises_1 = require("fs/promises");
|
const promises_1 = require("fs/promises");
|
||||||
|
const nodePath = __importStar(require("path"));
|
||||||
const express_1 = require("express");
|
const express_1 = require("express");
|
||||||
let dragonfly;
|
let dragonfly;
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -35,6 +69,47 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
else {
|
else {
|
||||||
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
|
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() {
|
function uuid() {
|
||||||
try {
|
try {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
@@ -326,6 +401,46 @@ function buildExtRoutes(prisma, manager, db, config, hooks, io) {
|
|||||||
res.status(500).json({ error: err.message ?? 'Erro interno' });
|
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 ─────────────────────────────────────────────────
|
// ── GET /media/:messageId ─────────────────────────────────────────────────
|
||||||
// Serve o arquivo de mídia de uma mensagem.
|
// Serve o arquivo de mídia de uma mensagem.
|
||||||
// Se mediaUrl for URL externa (Wasabi etc.) redireciona 302.
|
// 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' });
|
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 ────────────────────────────────────────────────────────────
|
// ── GET /inbox ────────────────────────────────────────────────────────────
|
||||||
// Lista chats da instância com snapshot da última mensagem.
|
// Lista chats da instância com snapshot da última mensagem.
|
||||||
router.get('/inbox', async (req, res) => {
|
router.get('/inbox', async (req, res) => {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -15,7 +15,8 @@
|
|||||||
* Envelope de resposta de sucesso: dados diretos (sem wrapper extra)
|
* Envelope de resposta de sucesso: dados diretos (sem wrapper extra)
|
||||||
*/
|
*/
|
||||||
import { createReadStream } from 'fs'
|
import { createReadStream } from 'fs'
|
||||||
import { stat } from 'fs/promises'
|
import { stat, mkdir, writeFile } from 'fs/promises'
|
||||||
|
import * as nodePath from 'path'
|
||||||
import { Router, type Request, type Response } from 'express'
|
import { Router, type Request, type Response } from 'express'
|
||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
import type { Knex } from 'knex'
|
import type { Knex } from 'knex'
|
||||||
@@ -37,6 +38,46 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
sendRichMessage = require('../../../backend/dist/modules/whatsapp/rich-message').sendRichMessage;
|
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: any;
|
||||||
|
let mediaStorage: any;
|
||||||
|
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: any = mediaEngine.downloadMediaMessage;
|
||||||
|
const getContentType: any = mediaEngine.getContentType;
|
||||||
|
|
||||||
|
// Extensão/mime por tipo de mensagem (espelha whatsapp.media.routes.ts).
|
||||||
|
const MEDIA_EXT_MAP: Record<string, string> = {
|
||||||
|
imageMessage: 'jpg', videoMessage: 'mp4', audioMessage: 'ogg',
|
||||||
|
documentMessage: 'bin', stickerMessage: 'webp',
|
||||||
|
}
|
||||||
|
const MEDIA_MIME_MAP: Record<string, string> = {
|
||||||
|
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: unknown): unknown {
|
||||||
|
if (Array.isArray(val)) return val.map(deserializeMediaPayload)
|
||||||
|
if (val !== null && typeof val === 'object') {
|
||||||
|
const obj = val as Record<string, unknown>
|
||||||
|
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: any = {
|
||||||
|
level: 'silent', child: () => mediaLogger,
|
||||||
|
trace() {}, debug() {}, info() {}, warn() {}, error() {}, fatal() {},
|
||||||
|
}
|
||||||
|
|
||||||
function uuid(): string {
|
function uuid(): string {
|
||||||
try { return (crypto as any).randomUUID() } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
try { return (crypto as any).randomUUID() } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||||
}
|
}
|
||||||
@@ -342,6 +383,46 @@ export function buildExtRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 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: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const instanceId = req.params['id'] as string
|
||||||
|
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: unknown) => {
|
||||||
|
console.error('[ext-api] Falha ao reconectar instância:', err)
|
||||||
|
})
|
||||||
|
res.json({ message: 'Conexão iniciada', instanceId })
|
||||||
|
} catch (err: any) {
|
||||||
|
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: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const instanceId = req.params['id'] as string
|
||||||
|
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: any) {
|
||||||
|
res.status(500).json({ error: err.message ?? 'Erro interno' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── GET /media/:messageId ─────────────────────────────────────────────────
|
// ── GET /media/:messageId ─────────────────────────────────────────────────
|
||||||
// Serve o arquivo de mídia de uma mensagem.
|
// Serve o arquivo de mídia de uma mensagem.
|
||||||
// Se mediaUrl for URL externa (Wasabi etc.) redireciona 302.
|
// Se mediaUrl for URL externa (Wasabi etc.) redireciona 302.
|
||||||
@@ -410,6 +491,82 @@ export function buildExtRoutes(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── 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: Request, res: Response) => {
|
||||||
|
const tenantId = req.extTenantId!
|
||||||
|
const messageId = req.params['messageId'] as string
|
||||||
|
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 as Record<string, unknown>) as any
|
||||||
|
const buffer = await downloadMediaMessage(
|
||||||
|
waMsg, 'buffer', {},
|
||||||
|
{ logger: mediaLogger, reuploadRequest: sock.updateMediaMessage },
|
||||||
|
)
|
||||||
|
if (!buffer || !(buffer as 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 || '') as string
|
||||||
|
const docMime = (doc?.mimetype || msg.mimeType || '') as string
|
||||||
|
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 as Buffer, mimetype: mime },
|
||||||
|
})
|
||||||
|
mediaUrl = result.path
|
||||||
|
} catch {
|
||||||
|
const filePath = nodePath.resolve('./media', msg.instanceId, fileName)
|
||||||
|
await mkdir(nodePath.dirname(filePath), { recursive: true })
|
||||||
|
await writeFile(filePath, buffer as Buffer)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const filePath = nodePath.resolve('./media', msg.instanceId, fileName)
|
||||||
|
await mkdir(nodePath.dirname(filePath), { recursive: true })
|
||||||
|
await writeFile(filePath, buffer as 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: any) {
|
||||||
|
res.status(500).json({ error: err?.message ?? 'Falha no re-download de mídia' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// ── GET /inbox ────────────────────────────────────────────────────────────
|
// ── GET /inbox ────────────────────────────────────────────────────────────
|
||||||
// Lista chats da instância com snapshot da última mensagem.
|
// Lista chats da instância com snapshot da última mensagem.
|
||||||
router.get('/inbox', async (req: Request, res: Response) => {
|
router.get('/inbox', async (req: Request, res: Response) => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|||||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
};
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.ensureAccount = ensureAccount;
|
||||||
exports.provisionSatellite = provisionSatellite;
|
exports.provisionSatellite = provisionSatellite;
|
||||||
exports.listSatellites = listSatellites;
|
exports.listSatellites = listSatellites;
|
||||||
exports.revokeSatellite = revokeSatellite;
|
exports.revokeSatellite = revokeSatellite;
|
||||||
@@ -10,8 +11,36 @@ exports.rotateSatelliteKey = rotateSatelliteKey;
|
|||||||
exports.buildSatelliteRoutes = buildSatelliteRoutes;
|
exports.buildSatelliteRoutes = buildSatelliteRoutes;
|
||||||
const express_1 = require("express");
|
const express_1 = require("express");
|
||||||
const crypto_1 = __importDefault(require("crypto"));
|
const crypto_1 = __importDefault(require("crypto"));
|
||||||
|
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||||
const apikey_auth_1 = require("./apikey-auth");
|
const apikey_auth_1 = require("./apikey-auth");
|
||||||
const genKey = () => `nwk_${crypto_1.default.randomUUID().replace(/-/g, '')}`;
|
const genKey = () => `nwk_${crypto_1.default.randomUUID().replace(/-/g, '')}`;
|
||||||
|
// ─── Provisionamento de conta (auto-provisioning de satélites) ─────────────────
|
||||||
|
// Garante que exista um User no motor para um dado email. Idempotente: se a conta
|
||||||
|
// já existe, apenas a reativa (se inativa) e retorna. A conta é "headless" — o
|
||||||
|
// acesso se dá via satélite pela chave mestra + x-nw-email; a senha é aleatória
|
||||||
|
// (a pessoa nunca loga direto no motor). Chamado pelo scoreodonto ao criar a
|
||||||
|
// conta lá (eager) e como self-heal quando o motor devolve "conta não encontrada".
|
||||||
|
async function ensureAccount(prisma, input) {
|
||||||
|
const email = String(input.email ?? '').toLowerCase().trim();
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
throw Object.assign(new Error('email válido é obrigatório'), { statusCode: 400 });
|
||||||
|
}
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email }, select: { id: true, email: true, isActive: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isActive)
|
||||||
|
await prisma.user.update({ where: { id: existing.id }, data: { isActive: true } });
|
||||||
|
return { id: existing.id, email: existing.email, created: false };
|
||||||
|
}
|
||||||
|
// Senha aleatória (conta headless — nunca usada para login direto).
|
||||||
|
const passwordHash = await bcryptjs_1.default.hash(`${crypto_1.default.randomUUID()}${crypto_1.default.randomUUID()}`, 10);
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name: (input.name || email).slice(0, 120), email, passwordHash, isActive: true },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
});
|
||||||
|
return { id: user.id, email: user.email, created: true };
|
||||||
|
}
|
||||||
// A resolução da chave de satélite para o auth vive em apikey-auth.resolvePairKey
|
// A resolução da chave de satélite para o auth vive em apikey-auth.resolvePairKey
|
||||||
// (co-localizada com os demais resolvers + cache). Aqui ficam só as operações de gestão.
|
// (co-localizada com os demais resolvers + cache). Aqui ficam só as operações de gestão.
|
||||||
// ─── Operações de gestão ──────────────────────────────────────────────────────
|
// ─── Operações de gestão ──────────────────────────────────────────────────────
|
||||||
@@ -88,6 +117,24 @@ async function rotateSatelliteKey(prisma, id) {
|
|||||||
// sem exigir x-nw-email como o fluxo de operação).
|
// sem exigir x-nw-email como o fluxo de operação).
|
||||||
function buildSatelliteRoutes(prisma) {
|
function buildSatelliteRoutes(prisma) {
|
||||||
const router = (0, express_1.Router)();
|
const router = (0, express_1.Router)();
|
||||||
|
// Auto-provisiona (ou reativa) a conta de um email. Idempotente.
|
||||||
|
// Gate PRÓPRIO (definido antes do gate mestra abaixo): aceita a chave mestra OU
|
||||||
|
// uma chave de satélite válida (nwk_) — pois auto-provisionar as contas dos seus
|
||||||
|
// próprios usuários é justamente a função de um satélite confiável (ex.: scoreodonto).
|
||||||
|
router.post('/accounts', async (req, res) => {
|
||||||
|
const key = req.headers['x-nw-key']?.trim() ?? '';
|
||||||
|
const allowed = (0, apikey_auth_1.isMasterKey)(key) || (key.startsWith('nwk_') && !!(await (0, apikey_auth_1.resolvePairKey)(prisma, key)));
|
||||||
|
if (!allowed) {
|
||||||
|
res.status(403).json({ error: 'Chave inválida para provisionar conta.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
res.json(await ensureAccount(prisma, req.body ?? {}));
|
||||||
|
}
|
||||||
|
catch (e) {
|
||||||
|
res.status(e.statusCode ?? 500).json({ error: e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
const key = req.headers['x-nw-key']?.trim() ?? '';
|
const key = req.headers['x-nw-key']?.trim() ?? '';
|
||||||
if (!(0, apikey_auth_1.isMasterKey)(key)) {
|
if (!(0, apikey_auth_1.isMasterKey)(key)) {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -14,10 +14,41 @@
|
|||||||
import type { PrismaClient } from '@prisma/client'
|
import type { PrismaClient } from '@prisma/client'
|
||||||
import { Router, type Request, type Response } from 'express'
|
import { Router, type Request, type Response } from 'express'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { isMasterKey } from './apikey-auth'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import { isMasterKey, resolvePairKey } from './apikey-auth'
|
||||||
|
|
||||||
const genKey = () => `nwk_${crypto.randomUUID().replace(/-/g, '')}`
|
const genKey = () => `nwk_${crypto.randomUUID().replace(/-/g, '')}`
|
||||||
|
|
||||||
|
// ─── Provisionamento de conta (auto-provisioning de satélites) ─────────────────
|
||||||
|
// Garante que exista um User no motor para um dado email. Idempotente: se a conta
|
||||||
|
// já existe, apenas a reativa (se inativa) e retorna. A conta é "headless" — o
|
||||||
|
// acesso se dá via satélite pela chave mestra + x-nw-email; a senha é aleatória
|
||||||
|
// (a pessoa nunca loga direto no motor). Chamado pelo scoreodonto ao criar a
|
||||||
|
// conta lá (eager) e como self-heal quando o motor devolve "conta não encontrada".
|
||||||
|
export async function ensureAccount(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: { email: string; name?: string | null },
|
||||||
|
) {
|
||||||
|
const email = String(input.email ?? '').toLowerCase().trim()
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
throw Object.assign(new Error('email válido é obrigatório'), { statusCode: 400 })
|
||||||
|
}
|
||||||
|
const existing = await prisma.user.findUnique({
|
||||||
|
where: { email }, select: { id: true, email: true, isActive: true },
|
||||||
|
})
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isActive) await prisma.user.update({ where: { id: existing.id }, data: { isActive: true } })
|
||||||
|
return { id: existing.id, email: existing.email, created: false }
|
||||||
|
}
|
||||||
|
// Senha aleatória (conta headless — nunca usada para login direto).
|
||||||
|
const passwordHash = await bcrypt.hash(`${crypto.randomUUID()}${crypto.randomUUID()}`, 10)
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: { name: (input.name || email).slice(0, 120), email, passwordHash, isActive: true },
|
||||||
|
select: { id: true, email: true },
|
||||||
|
})
|
||||||
|
return { id: user.id, email: user.email, created: true }
|
||||||
|
}
|
||||||
|
|
||||||
// A resolução da chave de satélite para o auth vive em apikey-auth.resolvePairKey
|
// A resolução da chave de satélite para o auth vive em apikey-auth.resolvePairKey
|
||||||
// (co-localizada com os demais resolvers + cache). Aqui ficam só as operações de gestão.
|
// (co-localizada com os demais resolvers + cache). Aqui ficam só as operações de gestão.
|
||||||
|
|
||||||
@@ -102,6 +133,18 @@ export async function rotateSatelliteKey(prisma: PrismaClient, id: string) {
|
|||||||
export function buildSatelliteRoutes(prisma: PrismaClient): Router {
|
export function buildSatelliteRoutes(prisma: PrismaClient): Router {
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
|
// Auto-provisiona (ou reativa) a conta de um email. Idempotente.
|
||||||
|
// Gate PRÓPRIO (definido antes do gate mestra abaixo): aceita a chave mestra OU
|
||||||
|
// uma chave de satélite válida (nwk_) — pois auto-provisionar as contas dos seus
|
||||||
|
// próprios usuários é justamente a função de um satélite confiável (ex.: scoreodonto).
|
||||||
|
router.post('/accounts', async (req: Request, res: Response) => {
|
||||||
|
const key = (req.headers['x-nw-key'] as string | undefined)?.trim() ?? ''
|
||||||
|
const allowed = isMasterKey(key) || (key.startsWith('nwk_') && !!(await resolvePairKey(prisma, key)))
|
||||||
|
if (!allowed) { res.status(403).json({ error: 'Chave inválida para provisionar conta.' }); return }
|
||||||
|
try { res.json(await ensureAccount(prisma, req.body ?? {})) }
|
||||||
|
catch (e: any) { res.status(e.statusCode ?? 500).json({ error: e.message }) }
|
||||||
|
})
|
||||||
|
|
||||||
router.use((req: Request, res: Response, next) => {
|
router.use((req: Request, res: Response, next) => {
|
||||||
const key = (req.headers['x-nw-key'] as string | undefined)?.trim() ?? ''
|
const key = (req.headers['x-nw-key'] as string | undefined)?.trim() ?? ''
|
||||||
if (!isMasterKey(key)) { res.status(403).json({ error: 'Apenas a chave mestra gerencia satélites.' }); return }
|
if (!isMasterKey(key)) { res.status(403).json({ error: 'Apenas a chave mestra gerencia satélites.' }); return }
|
||||||
|
|||||||
Reference in New Issue
Block a user