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) => {
|
||||
|
||||
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)
|
||||
*/
|
||||
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 type { PrismaClient } from '@prisma/client'
|
||||
import type { Knex } from 'knex'
|
||||
@@ -37,6 +38,46 @@ if (process.env.NODE_ENV === 'production') {
|
||||
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 {
|
||||
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 ─────────────────────────────────────────────────
|
||||
// Serve o arquivo de mídia de uma mensagem.
|
||||
// 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 ────────────────────────────────────────────────────────────
|
||||
// Lista chats da instância com snapshot da última mensagem.
|
||||
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 };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ensureAccount = ensureAccount;
|
||||
exports.provisionSatellite = provisionSatellite;
|
||||
exports.listSatellites = listSatellites;
|
||||
exports.revokeSatellite = revokeSatellite;
|
||||
@@ -10,8 +11,36 @@ exports.rotateSatelliteKey = rotateSatelliteKey;
|
||||
exports.buildSatelliteRoutes = buildSatelliteRoutes;
|
||||
const express_1 = require("express");
|
||||
const crypto_1 = __importDefault(require("crypto"));
|
||||
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||
const apikey_auth_1 = require("./apikey-auth");
|
||||
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
|
||||
// (co-localizada com os demais resolvers + cache). Aqui ficam só as 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).
|
||||
function buildSatelliteRoutes(prisma) {
|
||||
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) => {
|
||||
const key = req.headers['x-nw-key']?.trim() ?? '';
|
||||
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 { Router, type Request, type Response } from 'express'
|
||||
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, '')}`
|
||||
|
||||
// ─── 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
|
||||
// (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 {
|
||||
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) => {
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user