chore(ops): restore all source files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ImageOptimizer = void 0;
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
class ImageOptimizer {
|
||||
/**
|
||||
* Optimizes an image: converts to WebP, strips metadata,
|
||||
* and applies a 1:1 Smart Crop if needed.
|
||||
*/
|
||||
static async optimize(buffer, category = 'media', options = {}) {
|
||||
try {
|
||||
let pipeline = (0, sharp_1.default)(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
if (!metadata.format)
|
||||
return { buffer, mimetype: 'application/octet-stream' };
|
||||
// 1. Identify Target Size
|
||||
// Default sizes based on category
|
||||
let targetSize = options.size || 1024;
|
||||
if (category === 'partners' || category === 'users')
|
||||
targetSize = 400;
|
||||
if (category === 'benefits')
|
||||
targetSize = 800;
|
||||
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
|
||||
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
|
||||
if (shouldCrop) {
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
fit: sharp_1.default.fit.cover,
|
||||
position: sharp_1.default.strategy.entropy // Smart focal point detection
|
||||
});
|
||||
}
|
||||
else {
|
||||
// Resize without cropping if it's too large
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
withoutEnlargement: true,
|
||||
fit: sharp_1.default.fit.inside
|
||||
});
|
||||
}
|
||||
// 3. Convert to WebP & Strip Metadata
|
||||
const optimizedBuffer = await pipeline
|
||||
.webp({ quality: 85, effort: 4 })
|
||||
.toBuffer();
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimetype: 'image/webp'
|
||||
};
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[SmartVision] Optimization failed:', err);
|
||||
return { buffer, mimetype: 'image/jpeg' }; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ImageOptimizer = ImageOptimizer;
|
||||
//# sourceMappingURL=Optimizer.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"Optimizer.js","sourceRoot":"","sources":["Optimizer.ts"],"names":[],"mappings":";;;;;;AAAA,kDAA0B;AAO1B,MAAa,cAAc;IACvB;;;OAGG;IACH,MAAM,CAAC,KAAK,CAAC,QAAQ,CACjB,MAAc,EACd,WAAmB,OAAO,EAC1B,UAA8C,EAAE;QAEhD,IAAI,CAAC;YACD,IAAI,QAAQ,GAAG,IAAA,eAAK,EAAC,MAAM,CAAC,CAAC;YAC7B,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,QAAQ,EAAE,CAAC;YAE3C,IAAI,CAAC,QAAQ,CAAC,MAAM;gBAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,0BAA0B,EAAE,CAAC;YAE9E,0BAA0B;YAC1B,kCAAkC;YAClC,IAAI,UAAU,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC;YACtC,IAAI,QAAQ,KAAK,UAAU,IAAI,QAAQ,KAAK,OAAO;gBAAE,UAAU,GAAG,GAAG,CAAC;YACtE,IAAI,QAAQ,KAAK,UAAU;gBAAE,UAAU,GAAG,GAAG,CAAC;YAE9C,sEAAsE;YACtE,MAAM,UAAU,GAAG,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,CAAC,UAAU,EAAE,OAAO,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC;YAExF,IAAI,UAAU,EAAE,CAAC;gBACb,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,MAAM,EAAE,UAAU;oBAClB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,KAAK;oBACpB,QAAQ,EAAE,eAAK,CAAC,QAAQ,CAAC,OAAO,CAAC,8BAA8B;iBAClE,CAAC,CAAC;YACP,CAAC;iBAAM,CAAC;gBACJ,4CAA4C;gBAC5C,QAAQ,GAAG,QAAQ,CAAC,MAAM,CAAC;oBACvB,KAAK,EAAE,UAAU;oBACjB,kBAAkB,EAAE,IAAI;oBACxB,GAAG,EAAE,eAAK,CAAC,GAAG,CAAC,MAAM;iBACxB,CAAC,CAAC;YACP,CAAC;YAED,sCAAsC;YACtC,MAAM,eAAe,GAAG,MAAM,QAAQ;iBACjC,IAAI,CAAC,EAAE,OAAO,EAAE,EAAE,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC;iBAChC,QAAQ,EAAE,CAAC;YAEhB,OAAO;gBACH,MAAM,EAAE,eAAe;gBACvB,QAAQ,EAAE,YAAY;aACzB,CAAC;QACN,CAAC;QAAC,OAAO,GAAG,EAAE,CAAC;YACX,OAAO,CAAC,KAAK,CAAC,oCAAoC,EAAE,GAAG,CAAC,CAAC;YACzD,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,YAAY,EAAE,CAAC,CAAC,WAAW;QAC1D,CAAC;IACL,CAAC;CACJ;AAvDD,wCAuDC"}
|
||||
@@ -0,0 +1,63 @@
|
||||
import sharp from 'sharp';
|
||||
|
||||
export interface OptimizationResult {
|
||||
buffer: Buffer;
|
||||
mimetype: string;
|
||||
}
|
||||
|
||||
export class ImageOptimizer {
|
||||
/**
|
||||
* Optimizes an image: converts to WebP, strips metadata,
|
||||
* and applies a 1:1 Smart Crop if needed.
|
||||
*/
|
||||
static async optimize(
|
||||
buffer: Buffer,
|
||||
category: string = 'media',
|
||||
options: { aspect?: string; size?: number } = {}
|
||||
): Promise<OptimizationResult> {
|
||||
try {
|
||||
let pipeline = sharp(buffer);
|
||||
const metadata = await pipeline.metadata();
|
||||
|
||||
if (!metadata.format) return { buffer, mimetype: 'application/octet-stream' };
|
||||
|
||||
// 1. Identify Target Size
|
||||
// Default sizes based on category
|
||||
let targetSize = options.size || 1024;
|
||||
if (category === 'partners' || category === 'users') targetSize = 400;
|
||||
if (category === 'benefits') targetSize = 800;
|
||||
|
||||
// 2. Smart Crop 1:1 if aspect=1:1 is requested or implied by category
|
||||
const shouldCrop = options.aspect === '1:1' || ['partners', 'users'].includes(category);
|
||||
|
||||
if (shouldCrop) {
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
height: targetSize,
|
||||
fit: sharp.fit.cover,
|
||||
position: sharp.strategy.entropy // Smart focal point detection
|
||||
});
|
||||
} else {
|
||||
// Resize without cropping if it's too large
|
||||
pipeline = pipeline.resize({
|
||||
width: targetSize,
|
||||
withoutEnlargement: true,
|
||||
fit: sharp.fit.inside
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Convert to WebP & Strip Metadata
|
||||
const optimizedBuffer = await pipeline
|
||||
.webp({ quality: 85, effort: 4 })
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
buffer: optimizedBuffer,
|
||||
mimetype: 'image/webp'
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[SmartVision] Optimization failed:', err);
|
||||
return { buffer, mimetype: 'image/jpeg' }; // Fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const Optimizer_1 = require("./backend/Optimizer");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
// Register for the upload:transform hook
|
||||
ctx.hooks.register('upload:transform', async (payload) => {
|
||||
const { buffer, mimetype, category } = payload;
|
||||
// Only process common image formats
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
if (!imageTypes.includes(mimetype)) {
|
||||
return null; // Skip non-images
|
||||
}
|
||||
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
|
||||
const result = await Optimizer_1.ImageOptimizer.optimize(buffer, category);
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
mimetype: result.mimetype
|
||||
};
|
||||
});
|
||||
ctx.logger.info('SmartVision Optimizer activated');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('SmartVision Optimizer deactivated');
|
||||
}
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,mDAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,yCAAyC;QACzC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,kBAAkB,EAAE,KAAK,EAAE,OAAY,EAAE,EAAE;YAC1D,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,QAAQ,EAAE,GAAG,OAAO,CAAC;YAE/C,oCAAoC;YACpC,MAAM,UAAU,GAAG,CAAC,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,YAAY,CAAC,CAAC;YAC3E,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;gBACjC,OAAO,IAAI,CAAC,CAAC,kBAAkB;YACnC,CAAC;YAED,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gDAAgD,QAAQ,EAAE,CAAC,CAAC;YAE5E,MAAM,MAAM,GAAG,MAAM,0BAAc,CAAC,QAAQ,CAAC,MAAM,EAAE,QAAQ,CAAC,CAAC;YAE/D,OAAO;gBACH,MAAM,EAAE,MAAM,CAAC,MAAM;gBACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;aAC5B,CAAC;QACN,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,iCAAiC,CAAC,CAAC;IACvD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAC;IACzD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { ImageOptimizer } from './backend/Optimizer';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
// Register for the upload:transform hook
|
||||
ctx.hooks.register('upload:transform', async (payload: any) => {
|
||||
const { buffer, mimetype, category } = payload;
|
||||
|
||||
// Only process common image formats
|
||||
const imageTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
if (!imageTypes.includes(mimetype)) {
|
||||
return null; // Skip non-images
|
||||
}
|
||||
|
||||
ctx.logger.info(`[SmartVision] Optimizing image for category: ${category}`);
|
||||
|
||||
const result = await ImageOptimizer.optimize(buffer, category);
|
||||
|
||||
return {
|
||||
buffer: result.buffer,
|
||||
mimetype: result.mimetype
|
||||
};
|
||||
});
|
||||
|
||||
ctx.logger.info('SmartVision Optimizer activated');
|
||||
},
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('SmartVision Optimizer deactivated');
|
||||
}
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "SmartVision",
|
||||
"displayName": "SmartVision Optimizer",
|
||||
"version": "1.0.0",
|
||||
"description": "Otimização inteligente de imagens com WebP e Smart Crop 1:1.",
|
||||
"author": "Clube67 Team",
|
||||
"category": "integration",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [
|
||||
"uploads"
|
||||
],
|
||||
"hooks": {
|
||||
"subscribes": [
|
||||
"upload:transform"
|
||||
],
|
||||
"emits": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const client_s3_1 = require("@aws-sdk/client-s3");
|
||||
const lib_storage_1 = require("@aws-sdk/lib-storage");
|
||||
let storageProvider;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
}
|
||||
else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
const sharp_1 = __importDefault(require("sharp"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg', doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
class UnifiedStorageProviderPlugin {
|
||||
constructor() {
|
||||
this.s3 = null;
|
||||
this.bucket = '';
|
||||
this.region = 'us-east-1';
|
||||
this.localFallbackDir = path_1.default.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
this.routesRegistered = false;
|
||||
}
|
||||
async activate(ctx) {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
if (!fs_1.default.existsSync(this.localFallbackDir)) {
|
||||
fs_1.default.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1');
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '');
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com');
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug');
|
||||
if (!accessKey || !secretKey || !this.bucket) {
|
||||
ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin');
|
||||
}
|
||||
else {
|
||||
this.s3 = new client_s3_1.S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
this.startSyncWorker();
|
||||
}
|
||||
if (!this.routesRegistered) {
|
||||
// ── Proxy: serve arquivos do Wasabi (ou fallback local) ────────────────
|
||||
ctx.app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath)
|
||||
return res.status(400).send('Path missing');
|
||||
try {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, filePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'local_fallback');
|
||||
return res.send(fs_1.default.readFileSync(localPath));
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return res.status(503).send('Storage não inicializado');
|
||||
}
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: filePath,
|
||||
}));
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'wasabi');
|
||||
if (result.Body) {
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return res.send(Buffer.from(bytes));
|
||||
}
|
||||
return res.status(404).send('Body vazio');
|
||||
}
|
||||
catch {
|
||||
return res.status(404).send('Arquivo não encontrado');
|
||||
}
|
||||
});
|
||||
// ── POST /api/storage/wasabi/list-buckets ──────────────────────────────
|
||||
// Lista buckets usando credenciais do body (sem exigir S3 pré-configurado)
|
||||
ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint } = req.body;
|
||||
if (!accessKey || !secretKey) {
|
||||
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' });
|
||||
}
|
||||
try {
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
const s3t = new client_s3_1.S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new client_s3_1.ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── POST /api/storage/wasabi/create-bucket ─────────────────────────────
|
||||
ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint, name } = req.body;
|
||||
if (!accessKey || !secretKey)
|
||||
return res.status(400).json({ error: 'Credenciais obrigatórias' });
|
||||
if (!name)
|
||||
return res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const effectiveRegion = region || 'us-east-1';
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
try {
|
||||
const s3t = new client_s3_1.S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new client_s3_1.CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
}
|
||||
catch (err) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
async deactivate(ctx) {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
async checkHealth() {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
async checkDetailedHealth() {
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new client_s3_1.HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
}
|
||||
catch (err) {
|
||||
const code = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus = err?.$metadata?.httpStatusCode ?? 0;
|
||||
if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'auth_error',
|
||||
message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403 || code === 'AccessDenied') {
|
||||
// 403 pode ser pagamento atrasado ou permissão negada
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'billing_error',
|
||||
message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 404 || code === 'NoSuchBucket') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'bucket_not_found',
|
||||
message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`,
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'network_error',
|
||||
message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'unknown',
|
||||
message: `Erro ao verificar Wasabi: ${err?.message ?? code}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
async deletePrefix(prefix) {
|
||||
let deletedCount = 0;
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken;
|
||||
while (isTruncated) {
|
||||
const listParams = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
const listResult = await this.s3.send(new client_s3_1.ListObjectsV2Command(listParams));
|
||||
const objects = listResult.Contents ?? [];
|
||||
if (objects.length > 0) {
|
||||
const deleteParams = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key })),
|
||||
Quiet: true,
|
||||
},
|
||||
};
|
||||
await this.s3.send(new client_s3_1.DeleteObjectsCommand(deleteParams));
|
||||
deletedCount += objects.length;
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`);
|
||||
}
|
||||
isTruncated = listResult.IsTruncated ?? false;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, prefix);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs_1.default.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs_1.default.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
}
|
||||
else {
|
||||
fs_1.default.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
async upload(payload) {
|
||||
const { category, file } = payload;
|
||||
if (!VALID_CATEGORIES.includes(category))
|
||||
throw new Error(`Categoria inválida: ${category}`);
|
||||
let buffer = file.buffer;
|
||||
let originalName = file.originalname;
|
||||
let mimetype = file.mimetype;
|
||||
// Otimiza imagens grandes (>150 KB) para WebP
|
||||
if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) {
|
||||
try {
|
||||
buffer = await (0, sharp_1.default)(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer();
|
||||
mimetype = 'image/webp';
|
||||
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
||||
this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`);
|
||||
}
|
||||
}
|
||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileName = `${Date.now()}-${safeBase}`;
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`);
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
try {
|
||||
await new lib_storage_1.Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
},
|
||||
}).done();
|
||||
this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`);
|
||||
return { provider: 'wasabi', path: relativePath };
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
async getBuffer(relativePath) {
|
||||
// 1. Fallback local
|
||||
const localPath = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
return fs_1.default.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket)
|
||||
return null;
|
||||
try {
|
||||
const result = await this.s3.send(new client_s3_1.GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body)
|
||||
return null;
|
||||
const bytes = await result.Body.transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
saveToLocalFallback(relativePath, buffer, originalName, _category) {
|
||||
const dest = path_1.default.join(this.localFallbackDir, relativePath);
|
||||
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
||||
fs_1.default.writeFileSync(dest, buffer);
|
||||
this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`);
|
||||
return { provider: 'local_fallback', path: relativePath };
|
||||
}
|
||||
/** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */
|
||||
startSyncWorker() {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket)
|
||||
return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120000);
|
||||
}
|
||||
async syncDir(dir) {
|
||||
if (!fs_1.default.existsSync(dir))
|
||||
return;
|
||||
for (const entry of fs_1.default.readdirSync(dir)) {
|
||||
const fullPath = path_1.default.join(dir, entry);
|
||||
const stat = fs_1.default.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
const relativePath = path_1.default.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
try {
|
||||
await new lib_storage_1.Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs_1.default.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
fs_1.default.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
}
|
||||
catch (err) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
exports.default = instance;
|
||||
//# sourceMappingURL=index.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,426 @@
|
||||
import {
|
||||
S3Client,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
ListObjectsV2Command,
|
||||
ListBucketsCommand,
|
||||
CreateBucketCommand,
|
||||
DeleteObjectsCommand,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { Upload } from '@aws-sdk/lib-storage';
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import type { StorageUploadPayload, StorageProviderInterface } from '../../backend/src/core/StorageProvider';
|
||||
|
||||
let storageProvider: any;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storageProvider = require('../../dist/core/StorageProvider').storageProvider;
|
||||
} else {
|
||||
storageProvider = require('../../backend/src/core/StorageProvider').storageProvider;
|
||||
}
|
||||
import sharp from 'sharp';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const VALID_CATEGORIES = ['documents', 'exports', 'posts', 'cards', 'partners', 'whatsapp', 'media'];
|
||||
|
||||
// Mapa de extensão → mimetype para o proxy
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
|
||||
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml',
|
||||
pdf: 'application/pdf', mp4: 'video/mp4', ogg: 'audio/ogg',
|
||||
mp3: 'audio/mpeg', doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
};
|
||||
|
||||
class UnifiedStorageProviderPlugin implements PluginInstance, StorageProviderInterface {
|
||||
manifest: any;
|
||||
private s3: S3Client | null = null;
|
||||
private bucket: string = '';
|
||||
private region: string = 'us-east-1';
|
||||
private localFallbackDir: string = path.resolve(process.cwd(), 'storage', 'local_fallback');
|
||||
private ctx!: PluginContext;
|
||||
private routesRegistered = false;
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
this.ctx = ctx;
|
||||
const cfg = ctx.config.get('UnifiedStorageProvider') || {};
|
||||
|
||||
if (!fs.existsSync(this.localFallbackDir)) {
|
||||
fs.mkdirSync(this.localFallbackDir, { recursive: true });
|
||||
}
|
||||
|
||||
const accessKey = cfg.wasabiAccessKey || process.env.WASABI_ACCESS_KEY;
|
||||
const secretKey = cfg.wasabiSecretKey || process.env.WASABI_SECRET_KEY;
|
||||
this.region = (cfg.wasabiRegion || process.env.WASABI_REGION || 'us-east-1') as string;
|
||||
this.bucket = (cfg.wasabiBucket || process.env.WASABI_BUCKET || '') as string;
|
||||
|
||||
const rawEndpoint = (cfg.wasabiEndpoint || process.env.WASABI_ENDPOINT || 's3.wasabisys.com') as string;
|
||||
const endpoint = rawEndpoint.startsWith('http') ? rawEndpoint : `https://${rawEndpoint}`;
|
||||
|
||||
ctx.logger.info({ cfgKeys: Object.keys(cfg), hasKey: !!accessKey, hasBucket: !!this.bucket }, '[UnifiedStorage] activate debug');
|
||||
|
||||
if (!accessKey || !secretKey || !this.bucket) {
|
||||
ctx.logger.warn('UnifiedStorageProvider: credenciais ou bucket não configurados — configure via admin');
|
||||
} else {
|
||||
this.s3 = new S3Client({
|
||||
region: this.region,
|
||||
endpoint,
|
||||
credentials: { accessKeyId: accessKey as string, secretAccessKey: secretKey as string },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
|
||||
storageProvider.register(this);
|
||||
ctx.logger.info(`UnifiedStorageProvider ativo — bucket: ${this.bucket} endpoint: ${endpoint}`);
|
||||
|
||||
this.startSyncWorker();
|
||||
}
|
||||
|
||||
if (!this.routesRegistered) {
|
||||
// ── Proxy: serve arquivos do Wasabi (ou fallback local) ────────────────
|
||||
ctx.app.get('/api/storage/view/*', async (req, res) => {
|
||||
const filePath = req.params[0];
|
||||
if (!filePath) return res.status(400).send('Path missing');
|
||||
|
||||
try {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, filePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'local_fallback');
|
||||
return res.send(fs.readFileSync(localPath));
|
||||
}
|
||||
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return res.status(503).send('Storage não inicializado');
|
||||
}
|
||||
|
||||
const result = await this.s3.send(new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: filePath,
|
||||
}));
|
||||
|
||||
const ext = filePath.split('.').pop()?.toLowerCase() ?? '';
|
||||
res.setHeader('Content-Type', result.ContentType || MIME_MAP[ext] || 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'public, max-age=31536000');
|
||||
res.setHeader('X-Storage-Source', 'wasabi');
|
||||
|
||||
if (result.Body) {
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return res.send(Buffer.from(bytes));
|
||||
}
|
||||
return res.status(404).send('Body vazio');
|
||||
} catch {
|
||||
return res.status(404).send('Arquivo não encontrado');
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/storage/wasabi/list-buckets ──────────────────────────────
|
||||
// Lista buckets usando credenciais do body (sem exigir S3 pré-configurado)
|
||||
ctx.app.post('/api/storage/wasabi/list-buckets', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint } = req.body as Record<string, string>;
|
||||
if (!accessKey || !secretKey) {
|
||||
return res.status(400).json({ error: 'Access Key e Secret Key são obrigatórios' });
|
||||
}
|
||||
try {
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
const s3t = new S3Client({
|
||||
region: region || 'us-east-1',
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const data = await s3t.send(new ListBucketsCommand({}));
|
||||
const buckets = (data.Buckets ?? []).map(b => ({ name: b.Name, createdAt: b.CreationDate }));
|
||||
return res.json({ buckets });
|
||||
} catch (err: any) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/storage/wasabi/create-bucket ─────────────────────────────
|
||||
ctx.app.post('/api/storage/wasabi/create-bucket', async (req, res) => {
|
||||
const { accessKey, secretKey, region, endpoint, name } = req.body as Record<string, string>;
|
||||
if (!accessKey || !secretKey) return res.status(400).json({ error: 'Credenciais obrigatórias' });
|
||||
if (!name) return res.status(400).json({ error: 'Campo "name" obrigatório' });
|
||||
|
||||
const safeName = name.trim().toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
const effectiveRegion = region || 'us-east-1';
|
||||
const ep = (endpoint || 's3.wasabisys.com').startsWith('http')
|
||||
? endpoint || 's3.wasabisys.com'
|
||||
: `https://${endpoint || 's3.wasabisys.com'}`;
|
||||
|
||||
try {
|
||||
const s3t = new S3Client({
|
||||
region: effectiveRegion,
|
||||
endpoint: ep,
|
||||
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
|
||||
forcePathStyle: true,
|
||||
});
|
||||
const params: any = { Bucket: safeName };
|
||||
if (effectiveRegion !== 'us-east-1') {
|
||||
params.CreateBucketConfiguration = { LocationConstraint: effectiveRegion };
|
||||
}
|
||||
await s3t.send(new CreateBucketCommand(params));
|
||||
ctx.logger.info(`[Wasabi] Bucket criado: ${safeName}`);
|
||||
return res.status(201).json({ ok: true, name: safeName });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
this.routesRegistered = true;
|
||||
}
|
||||
|
||||
setInterval(() => { storageProvider.updateHealth(); }, 30000);
|
||||
}
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
this.s3 = null;
|
||||
ctx.logger.info('UnifiedStorageProvider desativado');
|
||||
}
|
||||
|
||||
async checkHealth(): Promise<boolean> {
|
||||
return (await this.checkDetailedHealth()).healthy;
|
||||
}
|
||||
|
||||
async checkDetailedHealth(): Promise<import('../../backend/src/core/StorageProvider').StorageHealthStatus> {
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return {
|
||||
healthy: false,
|
||||
configured: false,
|
||||
reason: 'not_configured',
|
||||
message: 'Credenciais Wasabi não configuradas. Acesse Admin > Plugins > UnifiedStorageProvider.',
|
||||
};
|
||||
}
|
||||
try {
|
||||
await this.s3.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||
return { healthy: true, configured: true, reason: 'ok', message: `Wasabi OK — bucket: ${this.bucket}` };
|
||||
} catch (err: any) {
|
||||
const code: string = err?.Code || err?.code || err?.name || '';
|
||||
const httpStatus: number = err?.$metadata?.httpStatusCode ?? 0;
|
||||
|
||||
if (code === 'InvalidAccessKeyId' || code === 'SignatureDoesNotMatch') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'auth_error',
|
||||
message: 'Chaves de acesso inválidas. Verifique Access Key e Secret Key no painel do Wasabi.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 403 || code === 'AccessDenied') {
|
||||
// 403 pode ser pagamento atrasado ou permissão negada
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'billing_error',
|
||||
message: 'Acesso negado ao Wasabi (HTTP 403). Verifique se a conta está ativa e o pagamento em dia.',
|
||||
};
|
||||
}
|
||||
if (httpStatus === 404 || code === 'NoSuchBucket') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'bucket_not_found',
|
||||
message: `Bucket "${this.bucket}" não encontrado. Verifique o nome do bucket no painel Wasabi.`,
|
||||
};
|
||||
}
|
||||
if (code === 'ENOTFOUND' || code === 'ECONNREFUSED' || code === 'NetworkingError') {
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'network_error',
|
||||
message: 'Sem conectividade com o Wasabi. Verifique a rede e o endpoint configurado.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
healthy: false, configured: true, reason: 'unknown',
|
||||
message: `Erro ao verificar Wasabi: ${err?.message ?? code}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
|
||||
let deletedCount = 0;
|
||||
|
||||
// 1. Limpa do Wasabi
|
||||
if (this.s3 && this.bucket) {
|
||||
try {
|
||||
let isTruncated = true;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
while (isTruncated) {
|
||||
const listParams: any = {
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
};
|
||||
if (continuationToken) {
|
||||
listParams.ContinuationToken = continuationToken;
|
||||
}
|
||||
|
||||
const listResult = await this.s3.send(new ListObjectsV2Command(listParams));
|
||||
const objects = listResult.Contents ?? [];
|
||||
|
||||
if (objects.length > 0) {
|
||||
const deleteParams = {
|
||||
Bucket: this.bucket,
|
||||
Delete: {
|
||||
Objects: objects.map((obj) => ({ Key: obj.Key! })),
|
||||
Quiet: true,
|
||||
},
|
||||
};
|
||||
await this.s3.send(new DeleteObjectsCommand(deleteParams));
|
||||
deletedCount += objects.length;
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] ${objects.length} objetos deletados do Wasabi com prefixo ${prefix}`);
|
||||
}
|
||||
|
||||
isTruncated = listResult.IsTruncated ?? false;
|
||||
continuationToken = listResult.NextContinuationToken;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao deletar prefixo ${prefix} do Wasabi: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Limpa do fallback local
|
||||
const localPath = path.join(this.localFallbackDir, prefix);
|
||||
if (fs.existsSync(localPath)) {
|
||||
try {
|
||||
const stat = fs.statSync(localPath);
|
||||
if (stat.isDirectory()) {
|
||||
fs.rmSync(localPath, { recursive: true, force: true });
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Pasta local do fallback removida: ${localPath}`);
|
||||
} else {
|
||||
fs.unlinkSync(localPath);
|
||||
this.ctx.logger.info(`[UnifiedStorageProvider] Arquivo local do fallback removido: ${localPath}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[UnifiedStorageProvider] Erro ao remover fallback local para prefixo ${prefix}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { deleted: deletedCount };
|
||||
}
|
||||
|
||||
async upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
|
||||
const { category, file } = payload;
|
||||
if (!VALID_CATEGORIES.includes(category)) throw new Error(`Categoria inválida: ${category}`);
|
||||
|
||||
let buffer = file.buffer;
|
||||
let originalName = file.originalname;
|
||||
let mimetype = file.mimetype;
|
||||
|
||||
// Otimiza imagens grandes (>150 KB) para WebP
|
||||
if (mimetype.startsWith('image/') && !mimetype.includes('webp') && buffer.length > 150 * 1024) {
|
||||
try {
|
||||
buffer = await sharp(buffer).resize(1200, 1200, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer();
|
||||
mimetype = 'image/webp';
|
||||
originalName = originalName.replace(/\.[^.]+$/, '.webp');
|
||||
this.ctx.logger.debug(`[Optimizer] ${originalName} convertida para webp`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[Optimizer] falha: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const safeBase = originalName.replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const fileName = `${Date.now()}-${safeBase}`;
|
||||
|
||||
// Path no bucket: customPath (quando fornecido) > whatsapp/{t}/{i}/{file} > {cat}/{file}
|
||||
const relativePath = payload.customPath
|
||||
?? ((category === 'whatsapp' && payload.usuarioSis && payload.sessionJID)
|
||||
? `whatsapp/${payload.usuarioSis}/${payload.sessionJID}/${fileName}`
|
||||
: `${category}/${fileName}`);
|
||||
|
||||
if (!this.s3 || !this.bucket) {
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: buffer,
|
||||
ContentType: mimetype,
|
||||
},
|
||||
}).done();
|
||||
|
||||
this.ctx.logger.debug(`[Wasabi] upload ok: ${relativePath}`);
|
||||
return { provider: 'wasabi', path: relativePath };
|
||||
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.error(`[Wasabi] upload falhou (${err.message}) — salvando local`);
|
||||
return this.saveToLocalFallback(relativePath, buffer, originalName, category);
|
||||
}
|
||||
}
|
||||
|
||||
async getBuffer(relativePath: string): Promise<Buffer | null> {
|
||||
// 1. Fallback local
|
||||
const localPath = path.join(this.localFallbackDir, relativePath);
|
||||
if (fs.existsSync(localPath)) {
|
||||
return fs.readFileSync(localPath);
|
||||
}
|
||||
// 2. Wasabi
|
||||
if (!this.s3 || !this.bucket) return null;
|
||||
try {
|
||||
const result = await this.s3.send(new GetObjectCommand({ Bucket: this.bucket, Key: relativePath }));
|
||||
if (!result.Body) return null;
|
||||
const bytes = await (result.Body as any).transformToByteArray();
|
||||
return Buffer.from(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Salva buffer no diretório de fallback local, criando subpastas se necessário */
|
||||
private saveToLocalFallback(relativePath: string, buffer: Buffer, originalName: string, _category: string): { provider: string; path: string } {
|
||||
const dest = path.join(this.localFallbackDir, relativePath);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buffer);
|
||||
this.ctx.logger.debug(`[Fallback] salvo local: ${dest}`);
|
||||
return { provider: 'local_fallback', path: relativePath };
|
||||
}
|
||||
|
||||
/** Sincroniza arquivos do fallback local para o Wasabi a cada 2 minutos */
|
||||
private startSyncWorker(): void {
|
||||
setInterval(async () => {
|
||||
if (!this.s3 || !this.bucket) return;
|
||||
await this.syncDir(this.localFallbackDir);
|
||||
}, 120_000);
|
||||
}
|
||||
|
||||
private async syncDir(dir: string): Promise<void> {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
|
||||
for (const entry of fs.readdirSync(dir)) {
|
||||
const fullPath = path.join(dir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
await this.syncDir(fullPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(this.localFallbackDir, fullPath).replace(/\\/g, '/');
|
||||
|
||||
try {
|
||||
await new Upload({
|
||||
client: this.s3!,
|
||||
params: {
|
||||
Bucket: this.bucket,
|
||||
Key: relativePath,
|
||||
Body: fs.readFileSync(fullPath),
|
||||
},
|
||||
}).done();
|
||||
|
||||
fs.unlinkSync(fullPath);
|
||||
this.ctx.logger.info(`[SyncWorker] enviado e removido: ${relativePath}`);
|
||||
} catch (err: any) {
|
||||
this.ctx.logger.warn(`[SyncWorker] falha em ${relativePath}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new UnifiedStorageProviderPlugin();
|
||||
export default instance;
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "UnifiedStorageProvider",
|
||||
"displayName": "Wasabi Storage",
|
||||
"version": "1.1.0",
|
||||
"description": "Armazenamento de mídias do WhatsApp via Wasabi (S3 compatível)",
|
||||
"author": "NewWhats",
|
||||
"category": "core",
|
||||
"enabled": true,
|
||||
"canDisable": false,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/storage",
|
||||
"hasMigrations": false
|
||||
},
|
||||
"configSchema": [
|
||||
{ "key": "wasabiAccessKey", "label": "Access Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave de acesso gerada em Account → Access Keys no painel Wasabi." },
|
||||
{ "key": "wasabiSecretKey", "label": "Secret Key", "type": "password", "required": true, "group": "Credenciais Wasabi", "tooltip": "Chave secreta correspondente à Access Key — exibida só uma vez no momento da criação." },
|
||||
{ "key": "wasabiBucket", "label": "Bucket Ativo", "type": "text", "required": true, "group": "Bucket", "tooltip": "Nome do bucket onde as mídias do WhatsApp serão armazenadas." },
|
||||
{ "key": "wasabiRegion", "label": "Region", "type": "text", "required": false, "group": "Avançado", "tooltip": "Região do bucket. Padrão: us-east-1. Exemplos: us-east-2, eu-central-1." },
|
||||
{ "key": "wasabiEndpoint", "label": "Endpoint", "type": "text", "required": false, "group": "Avançado", "tooltip": "Endpoint S3. Padrão: s3.wasabisys.com. Só altere se usar região diferente." }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use strict";
|
||||
// ============================================================
|
||||
// Plugin: core-auth — Routes
|
||||
// ============================================================
|
||||
// Migrated from: backend/src/routes/auth.routes.ts
|
||||
// backend/src/controllers/auth.controller.ts
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createAuthRoutes = createAuthRoutes;
|
||||
const express_1 = require("express");
|
||||
const bcryptjs_1 = __importDefault(require("bcryptjs"));
|
||||
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
|
||||
function createAuthRoutes(ctx) {
|
||||
const router = (0, express_1.Router)();
|
||||
const { db, hooks } = ctx;
|
||||
// ── POST /login ───────────────────────────────────────
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email e senha são obrigatórios' });
|
||||
}
|
||||
const user = await db('users').where({ email }).first();
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||
}
|
||||
const isValid = await bcryptjs_1.default.compare(password, user.password_hash);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||
}
|
||||
const accessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') });
|
||||
const refreshToken = jsonwebtoken_1.default.sign({ id: user.id, type: 'refresh' }, process.env.JWT_REFRESH_SECRET, { expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') });
|
||||
// Emit hook event
|
||||
await hooks.emit('user:login', { userId: user.id, ip: req.ip });
|
||||
// Audit log
|
||||
await db('audit_log').insert({
|
||||
user_id: user.id,
|
||||
action: 'LOGIN',
|
||||
entity_type: 'user',
|
||||
entity_id: user.id,
|
||||
details: 'Usuário logou no sistema',
|
||||
ip_address: req.ip,
|
||||
}).catch(() => { }); // non-critical
|
||||
const { password_hash, ...safeUser } = user;
|
||||
res.json({ user: safeUser, accessToken, refreshToken });
|
||||
}
|
||||
catch (err) {
|
||||
ctx.logger.error(`Login error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Erro interno no servidor' });
|
||||
}
|
||||
});
|
||||
// ── POST /register ────────────────────────────────────
|
||||
router.post('/register', async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body;
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' });
|
||||
}
|
||||
const existing = await db('users').where({ email }).first();
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email já cadastrado' });
|
||||
}
|
||||
const password_hash = await bcryptjs_1.default.hash(password, 12);
|
||||
const [id] = await db('users').insert({
|
||||
name,
|
||||
email,
|
||||
password_hash,
|
||||
cpf: cpf || null,
|
||||
whatsapp: whatsapp || null,
|
||||
city: city || null,
|
||||
neighborhood: neighborhood || null,
|
||||
state: state || null,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
});
|
||||
await hooks.emit('user:register', { userId: id, email });
|
||||
res.status(201).json({ id, name, email, role: 'user' });
|
||||
}
|
||||
catch (err) {
|
||||
ctx.logger.error(`Register error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Erro interno no servidor' });
|
||||
}
|
||||
});
|
||||
// ── GET /me ───────────────────────────────────────────
|
||||
router.get('/me', async (req, res) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Token não fornecido' });
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = jsonwebtoken_1.default.verify(token, process.env.JWT_SECRET);
|
||||
const user = await db('users')
|
||||
.where({ id: payload.id })
|
||||
.select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at')
|
||||
.first();
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
}
|
||||
let partnerConsent = false;
|
||||
let partnerConsentAt = null;
|
||||
let partnerConsentIp = null;
|
||||
if (user?.partner_id) {
|
||||
const partner = await db('partners')
|
||||
.where({ id: user.partner_id })
|
||||
.select('data_consent', 'consent_at', 'consent_ip')
|
||||
.first();
|
||||
partnerConsent = Boolean(partner?.data_consent);
|
||||
partnerConsentAt = partner?.consent_at || null;
|
||||
partnerConsentIp = partner?.consent_ip || null;
|
||||
}
|
||||
res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp });
|
||||
}
|
||||
catch (err) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expirado' });
|
||||
}
|
||||
res.status(401).json({ error: 'Token inválido' });
|
||||
}
|
||||
});
|
||||
// ── POST /refresh ─────────────────────────────────────
|
||||
router.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: 'Refresh token obrigatório' });
|
||||
}
|
||||
const payload = jsonwebtoken_1.default.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
if (payload.type !== 'refresh') {
|
||||
return res.status(401).json({ error: 'Token inválido' });
|
||||
}
|
||||
const user = await db('users').where({ id: payload.id }).first();
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
}
|
||||
const newAccessToken = jsonwebtoken_1.default.sign({ id: user.id, email: user.email, role: user.role }, process.env.JWT_SECRET, { expiresIn: (process.env.JWT_EXPIRES_IN || '15m') });
|
||||
await hooks.emit('token:refresh', { userId: user.id });
|
||||
res.json({ accessToken: newAccessToken });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(401).json({ error: 'Refresh token inválido ou expirado' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,180 @@
|
||||
// ============================================================
|
||||
// Plugin: core-auth — Routes
|
||||
// ============================================================
|
||||
// Migrated from: backend/src/routes/auth.routes.ts
|
||||
// backend/src/controllers/auth.controller.ts
|
||||
|
||||
import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PluginContext } from '../../../backend/src/core/types';
|
||||
|
||||
export function createAuthRoutes(ctx: PluginContext): Router {
|
||||
const router = Router();
|
||||
const { db, hooks } = ctx;
|
||||
|
||||
// ── POST /login ───────────────────────────────────────
|
||||
router.post('/login', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({ error: 'Email e senha são obrigatórios' });
|
||||
}
|
||||
|
||||
const user = await db('users').where({ email }).first();
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||
}
|
||||
|
||||
const isValid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Credenciais inválidas' });
|
||||
}
|
||||
|
||||
const accessToken = jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any }
|
||||
);
|
||||
|
||||
const refreshToken = jwt.sign(
|
||||
{ id: user.id, type: 'refresh' },
|
||||
process.env.JWT_REFRESH_SECRET!,
|
||||
{ expiresIn: (process.env.JWT_REFRESH_EXPIRES_IN || '7d') as any }
|
||||
);
|
||||
|
||||
// Emit hook event
|
||||
await hooks.emit('user:login', { userId: user.id, ip: req.ip });
|
||||
|
||||
// Audit log
|
||||
await db('audit_log').insert({
|
||||
user_id: user.id,
|
||||
action: 'LOGIN',
|
||||
entity_type: 'user',
|
||||
entity_id: user.id,
|
||||
details: 'Usuário logou no sistema',
|
||||
ip_address: req.ip,
|
||||
}).catch(() => { }); // non-critical
|
||||
|
||||
const { password_hash, ...safeUser } = user;
|
||||
res.json({ user: safeUser, accessToken, refreshToken });
|
||||
} catch (err: any) {
|
||||
ctx.logger.error(`Login error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Erro interno no servidor' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /register ────────────────────────────────────
|
||||
router.post('/register', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, email, password, cpf, whatsapp, city, neighborhood, state } = req.body;
|
||||
|
||||
if (!name || !email || !password) {
|
||||
return res.status(400).json({ error: 'Nome, email e senha são obrigatórios' });
|
||||
}
|
||||
|
||||
const existing = await db('users').where({ email }).first();
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: 'Email já cadastrado' });
|
||||
}
|
||||
|
||||
const password_hash = await bcrypt.hash(password, 12);
|
||||
|
||||
const [id] = await db('users').insert({
|
||||
name,
|
||||
email,
|
||||
password_hash,
|
||||
cpf: cpf || null,
|
||||
whatsapp: whatsapp || null,
|
||||
city: city || null,
|
||||
neighborhood: neighborhood || null,
|
||||
state: state || null,
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
});
|
||||
|
||||
await hooks.emit('user:register', { userId: id, email });
|
||||
|
||||
res.status(201).json({ id, name, email, role: 'user' });
|
||||
} catch (err: any) {
|
||||
ctx.logger.error(`Register error: ${err.message}`);
|
||||
res.status(500).json({ error: 'Erro interno no servidor' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── GET /me ───────────────────────────────────────────
|
||||
router.get('/me', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Token não fornecido' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = jwt.verify(token, process.env.JWT_SECRET!) as any;
|
||||
|
||||
const user = await db('users')
|
||||
.where({ id: payload.id })
|
||||
.select('id', 'name', 'email', 'role', 'status', 'whatsapp', 'cpf', 'city', 'neighborhood', 'state', 'partner_id', 'created_at')
|
||||
.first();
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
}
|
||||
|
||||
let partnerConsent = false;
|
||||
let partnerConsentAt = null;
|
||||
let partnerConsentIp = null;
|
||||
if (user?.partner_id) {
|
||||
const partner = await db('partners')
|
||||
.where({ id: user.partner_id })
|
||||
.select('data_consent', 'consent_at', 'consent_ip')
|
||||
.first();
|
||||
partnerConsent = Boolean(partner?.data_consent);
|
||||
partnerConsentAt = partner?.consent_at || null;
|
||||
partnerConsentIp = partner?.consent_ip || null;
|
||||
}
|
||||
res.json({ ...user, partnerConsent, partnerConsentAt, partnerConsentIp });
|
||||
} catch (err: any) {
|
||||
if (err.name === 'TokenExpiredError') {
|
||||
return res.status(401).json({ error: 'Token expirado' });
|
||||
}
|
||||
res.status(401).json({ error: 'Token inválido' });
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /refresh ─────────────────────────────────────
|
||||
router.post('/refresh', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { refreshToken } = req.body;
|
||||
if (!refreshToken) {
|
||||
return res.status(400).json({ error: 'Refresh token obrigatório' });
|
||||
}
|
||||
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!) as any;
|
||||
if (payload.type !== 'refresh') {
|
||||
return res.status(401).json({ error: 'Token inválido' });
|
||||
}
|
||||
|
||||
const user = await db('users').where({ id: payload.id }).first();
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Usuário não encontrado' });
|
||||
}
|
||||
|
||||
const newAccessToken = jwt.sign(
|
||||
{ id: user.id, email: user.email, role: user.role },
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: (process.env.JWT_EXPIRES_IN || '15m') as any }
|
||||
);
|
||||
|
||||
await hooks.emit('token:refresh', { userId: user.id });
|
||||
|
||||
res.json({ accessToken: newAccessToken });
|
||||
} catch (err: any) {
|
||||
res.status(401).json({ error: 'Refresh token inválido ou expirado' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use strict";
|
||||
// ============================================================
|
||||
// Plugin: core-auth — Entry Point
|
||||
// ============================================================
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const routes_1 = require("./backend/routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
const router = (0, routes_1.createAuthRoutes)(ctx);
|
||||
ctx.app.use('/api/auth', router);
|
||||
ctx.logger.info('Auth routes registered at /api/auth');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('Auth plugin deactivated');
|
||||
},
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";AAAA,+DAA+D;AAC/D,kCAAkC;AAClC,+DAA+D;;;;;AAG/D,6CAAoD;AACpD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,yBAAgB,EAAC,GAAG,CAAC,CAAC;QACrC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,WAAW,EAAE,MAAM,CAAC,CAAC;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAC3D,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,23 @@
|
||||
// ============================================================
|
||||
// Plugin: core-auth — Entry Point
|
||||
// ============================================================
|
||||
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { createAuthRoutes } from './backend/routes';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
const router = createAuthRoutes(ctx);
|
||||
ctx.app.use('/api/auth', router);
|
||||
ctx.logger.info('Auth routes registered at /api/auth');
|
||||
},
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('Auth plugin deactivated');
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "core-auth",
|
||||
"displayName": "Autenticação",
|
||||
"version": "1.0.0",
|
||||
"description": "Login, JWT, refresh tokens, sessões, Google OAuth e RBAC.",
|
||||
"author": "Clube67",
|
||||
"category": "core",
|
||||
"enabled": true,
|
||||
"canDisable": false,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/auth",
|
||||
"hasMigrations": true,
|
||||
"globalMiddleware": [
|
||||
"authenticate"
|
||||
]
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": [],
|
||||
"pages": [
|
||||
{
|
||||
"path": "/login",
|
||||
"component": "LoginPage",
|
||||
"roles": [
|
||||
"*"
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "/register",
|
||||
"component": "RegisterPage",
|
||||
"roles": [
|
||||
"*"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"subscribes": [],
|
||||
"emits": [
|
||||
"user:login",
|
||||
"user:logout",
|
||||
"user:register",
|
||||
"token:refresh"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildApiKeyAuth = buildApiKeyAuth;
|
||||
exports.resolveApiKey = resolveApiKey;
|
||||
// ─── Cache de API key (evita Prisma query em cada requisição) ────────────────
|
||||
// Chaves são imutáveis durante a vida da sessão — TTL de 5 min é suficiente.
|
||||
const KEY_CACHE_TTL = 5 * 60000;
|
||||
const keyCache = new Map();
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of keyCache.entries()) {
|
||||
if (now - v.cachedAt > KEY_CACHE_TTL)
|
||||
keyCache.delete(k);
|
||||
}
|
||||
}, KEY_CACHE_TTL);
|
||||
// ─── Rate limiter simples em memória (por chave) ─────────────────────────────
|
||||
const RATE_WINDOW_MS = 60000;
|
||||
const RATE_MAX = 120;
|
||||
const rateMap = new Map();
|
||||
function checkRateLimit(key) {
|
||||
const now = Date.now();
|
||||
let entry = rateMap.get(key);
|
||||
if (!entry || now > entry.resetAt) {
|
||||
entry = { count: 1, resetAt: now + RATE_WINDOW_MS };
|
||||
rateMap.set(key, entry);
|
||||
return true;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count <= RATE_MAX;
|
||||
}
|
||||
// Limpeza periódica para não vazar memória
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of rateMap.entries()) {
|
||||
if (now > v.resetAt)
|
||||
rateMap.delete(k);
|
||||
}
|
||||
}, RATE_WINDOW_MS * 2);
|
||||
// ─── Factory ─────────────────────────────────────────────────────────────────
|
||||
function buildApiKeyAuth(prisma) {
|
||||
return async function apiKeyAuth(req, res, next) {
|
||||
const key = req.headers['x-nw-key']?.trim();
|
||||
if (!key) {
|
||||
res.status(401).json({ error: 'Header x-nw-key ausente' });
|
||||
return;
|
||||
}
|
||||
if (!checkRateLimit(key)) {
|
||||
res.status(429).json({ error: 'Rate limit excedido (120 req/min por chave)' });
|
||||
return;
|
||||
}
|
||||
// Verifica cache antes de ir ao banco
|
||||
const cached = keyCache.get(key);
|
||||
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
|
||||
if (cached.expiresAt && cached.expiresAt < new Date()) {
|
||||
res.status(401).json({ error: 'Chave expirada' });
|
||||
return;
|
||||
}
|
||||
req.extTenantId = cached.tenantId;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { key },
|
||||
select: { tenantId: true, isActive: true, expiresAt: true },
|
||||
});
|
||||
if (!apiKey || !apiKey.isActive) {
|
||||
res.status(401).json({ error: 'Chave inválida ou inativa' });
|
||||
return;
|
||||
}
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
res.status(401).json({ error: 'Chave expirada' });
|
||||
return;
|
||||
}
|
||||
keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() });
|
||||
req.extTenantId = apiKey.tenantId;
|
||||
next();
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Versão standalone que valida a chave sem passar pelo Express.
|
||||
* Usada no handshake do WS (upgrade request).
|
||||
*/
|
||||
async function resolveApiKey(prisma, key) {
|
||||
if (!key)
|
||||
return null;
|
||||
const cached = keyCache.get(key);
|
||||
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
|
||||
if (cached.expiresAt && cached.expiresAt < new Date())
|
||||
return null;
|
||||
return cached.tenantId;
|
||||
}
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { key },
|
||||
select: { tenantId: true, isActive: true, expiresAt: true },
|
||||
});
|
||||
if (!apiKey || !apiKey.isActive)
|
||||
return null;
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date())
|
||||
return null;
|
||||
keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() });
|
||||
return apiKey.tenantId;
|
||||
}
|
||||
//# sourceMappingURL=apikey-auth.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"apikey-auth.js","sourceRoot":"","sources":["apikey-auth.ts"],"names":[],"mappings":";;AA+DA,0CA6CC;AAMD,sCAkBC;AAxHD,gFAAgF;AAChF,6EAA6E;AAC7E,MAAM,aAAa,GAAG,CAAC,GAAG,KAAM,CAAA;AAEhC,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAyB,CAAA;AAEjD,WAAW,CAAC,GAAG,EAAE;IACf,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IACtB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,QAAQ,CAAC,OAAO,EAAE,EAAE,CAAC;QACxC,IAAI,GAAG,GAAG,CAAC,CAAC,QAAQ,GAAG,aAAa;YAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IAC1D,CAAC;AACH,CAAC,EAAE,aAAa,CAAC,CAAA;AAEjB,gFAAgF;AAChF,MAAM,cAAc,GAAG,KAAM,CAAA;AAC7B,MAAM,QAAQ,GAAS,GAAG,CAAA;AAG1B,MAAM,OAAO,GAAG,IAAI,GAAG,EAAqB,CAAA;AAE5C,SAAS,cAAc,CAAC,GAAW;IACjC,MAAM,GAAG,GAAI,IAAI,CAAC,GAAG,EAAE,CAAA;IACvB,IAAI,KAAK,GAAI,OAAO,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC7B,IAAI,CAAC,KAAK,IAAI,GAAG,GAAG,KAAK,CAAC,OAAO,EAAE,CAAC;QAClC,KAAK,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,EAAE,GAAG,GAAG,cAAc,EAAE,CAAA;QACnD,OAAO,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;QACvB,OAAO,IAAI,CAAA;IACb,CAAC;IACD,KAAK,CAAC,KAAK,EAAE,CAAA;IACb,OAAO,KAAK,CAAC,KAAK,IAAI,QAAQ,CAAA;AAChC,CAAC;AAED,2CAA2C;AAC3C,WAAW,CAAC,GAAG,EAAE;IACf,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAA;IACtB,KAAK,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC;QACvC,IAAI,GAAG,GAAG,CAAC,CAAC,OAAO;YAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IACxC,CAAC;AACH,CAAC,EAAE,cAAc,GAAG,CAAC,CAAC,CAAA;AAYtB,gFAAgF;AAChF,SAAgB,eAAe,CAAC,MAAoB;IAClD,OAAO,KAAK,UAAU,UAAU,CAAC,GAAY,EAAE,GAAa,EAAE,IAAkB;QAC9E,MAAM,GAAG,GAAI,GAAG,CAAC,OAAO,CAAC,UAAU,CAAwB,EAAE,IAAI,EAAE,CAAA;QAEnE,IAAI,CAAC,GAAG,EAAE,CAAC;YACT,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,yBAAyB,EAAE,CAAC,CAAA;YAC1D,OAAM;QACR,CAAC;QAED,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,EAAE,CAAC;YACzB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,6CAA6C,EAAE,CAAC,CAAA;YAC9E,OAAM;QACR,CAAC;QAED,sCAAsC;QACtC,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAChC,IAAI,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,QAAQ,GAAG,aAAa,EAAE,CAAC;YAC3D,IAAI,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE,EAAE,CAAC;gBACtD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC,CAAA;gBACjD,OAAM;YACR,CAAC;YACD,GAAG,CAAC,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAA;YACjC,IAAI,EAAE,CAAA;YACN,OAAM;QACR,CAAC;QAED,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC;YAC5C,KAAK,EAAE,EAAE,GAAG,EAAE;YACd,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;SAC5D,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC;YAChC,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,2BAA2B,EAAE,CAAC,CAAA;YAC5D,OAAM;QACR,CAAC;QAED,IAAI,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE,EAAE,CAAC;YACtD,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,gBAAgB,EAAE,CAAC,CAAA;YACjD,OAAM;QACR,CAAC;QAED,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;QACnG,GAAG,CAAC,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAA;QACjC,IAAI,EAAE,CAAA;IACR,CAAC,CAAA;AACH,CAAC;AAED;;;GAGG;AACI,KAAK,UAAU,aAAa,CACjC,MAAoB,EACpB,GAAW;IAEX,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAA;IACrB,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAChC,IAAI,MAAM,IAAI,IAAI,CAAC,GAAG,EAAE,GAAG,MAAM,CAAC,QAAQ,GAAG,aAAa,EAAE,CAAC;QAC3D,IAAI,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE;YAAE,OAAO,IAAI,CAAA;QAClE,OAAO,MAAM,CAAC,QAAQ,CAAA;IACxB,CAAC;IACD,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,UAAU,CAAC;QAC5C,KAAK,EAAE,EAAE,GAAG,EAAE;QACd,MAAM,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;KAC5D,CAAC,CAAA;IACF,IAAI,CAAC,MAAM,IAAI,CAAC,MAAM,CAAC,QAAQ;QAAE,OAAO,IAAI,CAAA;IAC5C,IAAI,MAAM,CAAC,SAAS,IAAI,MAAM,CAAC,SAAS,GAAG,IAAI,IAAI,EAAE;QAAE,OAAO,IAAI,CAAA;IAClE,QAAQ,CAAC,GAAG,CAAC,GAAG,EAAE,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,QAAQ,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IACnG,OAAO,MAAM,CAAC,QAAQ,CAAA;AACxB,CAAC"}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Middleware de autenticação para a External API.
|
||||
*
|
||||
* Aceita a integration_key via:
|
||||
* - Header: x-nw-key: nw_...
|
||||
*
|
||||
* Resolve a chave para tenantId e injeta em req.extTenantId.
|
||||
* Rate-limiting básico: max 120 req/min por chave (janela deslizante simples em memória).
|
||||
*/
|
||||
import type { Request, Response, NextFunction } from 'express'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
|
||||
// ─── Cache de API key (evita Prisma query em cada requisição) ────────────────
|
||||
// Chaves são imutáveis durante a vida da sessão — TTL de 5 min é suficiente.
|
||||
const KEY_CACHE_TTL = 5 * 60_000
|
||||
interface KeyCacheEntry { tenantId: string; expiresAt: Date | null; cachedAt: number }
|
||||
const keyCache = new Map<string, KeyCacheEntry>()
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [k, v] of keyCache.entries()) {
|
||||
if (now - v.cachedAt > KEY_CACHE_TTL) keyCache.delete(k)
|
||||
}
|
||||
}, KEY_CACHE_TTL)
|
||||
|
||||
// ─── Rate limiter simples em memória (por chave) ─────────────────────────────
|
||||
const RATE_WINDOW_MS = 60_000
|
||||
const RATE_MAX = 120
|
||||
|
||||
interface RateEntry { count: number; resetAt: number }
|
||||
const rateMap = new Map<string, RateEntry>()
|
||||
|
||||
function checkRateLimit(key: string): boolean {
|
||||
const now = Date.now()
|
||||
let entry = rateMap.get(key)
|
||||
if (!entry || now > entry.resetAt) {
|
||||
entry = { count: 1, resetAt: now + RATE_WINDOW_MS }
|
||||
rateMap.set(key, entry)
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= RATE_MAX
|
||||
}
|
||||
|
||||
// Limpeza periódica para não vazar memória
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [k, v] of rateMap.entries()) {
|
||||
if (now > v.resetAt) rateMap.delete(k)
|
||||
}
|
||||
}, RATE_WINDOW_MS * 2)
|
||||
|
||||
// ─── Extend Express Request ───────────────────────────────────────────────────
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
extTenantId?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Factory ─────────────────────────────────────────────────────────────────
|
||||
export function buildApiKeyAuth(prisma: PrismaClient) {
|
||||
return async function apiKeyAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const key = (req.headers['x-nw-key'] as string | undefined)?.trim()
|
||||
|
||||
if (!key) {
|
||||
res.status(401).json({ error: 'Header x-nw-key ausente' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!checkRateLimit(key)) {
|
||||
res.status(429).json({ error: 'Rate limit excedido (120 req/min por chave)' })
|
||||
return
|
||||
}
|
||||
|
||||
// Verifica cache antes de ir ao banco
|
||||
const cached = keyCache.get(key)
|
||||
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
|
||||
if (cached.expiresAt && cached.expiresAt < new Date()) {
|
||||
res.status(401).json({ error: 'Chave expirada' })
|
||||
return
|
||||
}
|
||||
req.extTenantId = cached.tenantId
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { key },
|
||||
select: { tenantId: true, isActive: true, expiresAt: true },
|
||||
})
|
||||
|
||||
if (!apiKey || !apiKey.isActive) {
|
||||
res.status(401).json({ error: 'Chave inválida ou inativa' })
|
||||
return
|
||||
}
|
||||
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) {
|
||||
res.status(401).json({ error: 'Chave expirada' })
|
||||
return
|
||||
}
|
||||
|
||||
keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() })
|
||||
req.extTenantId = apiKey.tenantId
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Versão standalone que valida a chave sem passar pelo Express.
|
||||
* Usada no handshake do WS (upgrade request).
|
||||
*/
|
||||
export async function resolveApiKey(
|
||||
prisma: PrismaClient,
|
||||
key: string,
|
||||
): Promise<string | null> {
|
||||
if (!key) return null
|
||||
const cached = keyCache.get(key)
|
||||
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
|
||||
if (cached.expiresAt && cached.expiresAt < new Date()) return null
|
||||
return cached.tenantId
|
||||
}
|
||||
const apiKey = await prisma.apiKey.findUnique({
|
||||
where: { key },
|
||||
select: { tenantId: true, isActive: true, expiresAt: true },
|
||||
})
|
||||
if (!apiKey || !apiKey.isActive) return null
|
||||
if (apiKey.expiresAt && apiKey.expiresAt < new Date()) return null
|
||||
keyCache.set(key, { tenantId: apiKey.tenantId, expiresAt: apiKey.expiresAt, cachedAt: Date.now() })
|
||||
return apiKey.tenantId
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.env = void 0;
|
||||
const zod_1 = require("zod");
|
||||
require("dotenv/config");
|
||||
const envSchema = zod_1.z.object({
|
||||
PORT: zod_1.z.string().default('8008'),
|
||||
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
|
||||
JWT_SECRET: zod_1.z.string().min(16),
|
||||
DATABASE_URL: zod_1.z.string().url(),
|
||||
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
|
||||
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
|
||||
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
|
||||
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
|
||||
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
|
||||
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
|
||||
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
|
||||
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
|
||||
});
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('❌ Variáveis de ambiente inválidas:');
|
||||
console.error(parsed.error.format());
|
||||
process.exit(1);
|
||||
}
|
||||
exports.env = parsed.data;
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.logger = void 0;
|
||||
const pino_1 = __importDefault(require("pino"));
|
||||
const env_1 = require("./env");
|
||||
exports.logger = (0, pino_1.default)({
|
||||
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
transport: env_1.env.NODE_ENV !== 'production'
|
||||
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
|
||||
: undefined,
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.storageProvider = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Singleton registry para o storage provider ativo.
|
||||
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
|
||||
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
|
||||
*/
|
||||
class StorageProviderRegistry {
|
||||
constructor() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
}
|
||||
register(provider) {
|
||||
this.provider = provider;
|
||||
logger_1.logger.info('[StorageProvider] Provider registrado');
|
||||
}
|
||||
isRegistered() {
|
||||
return this.provider !== null;
|
||||
}
|
||||
isHealthy() {
|
||||
return this.healthy;
|
||||
}
|
||||
async uploadFile(payload) {
|
||||
if (!this.provider) {
|
||||
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
|
||||
}
|
||||
return this.provider.upload(payload);
|
||||
}
|
||||
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
|
||||
async updateHealth() {
|
||||
if (!this.provider) {
|
||||
this.healthy = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.healthy = await this.provider.checkHealth();
|
||||
}
|
||||
catch {
|
||||
this.healthy = false;
|
||||
}
|
||||
}
|
||||
unregister() {
|
||||
this.provider = null;
|
||||
this.healthy = false;
|
||||
logger_1.logger.info('[StorageProvider] Provider removido');
|
||||
}
|
||||
}
|
||||
exports.storageProvider = new StorageProviderRegistry();
|
||||
@@ -0,0 +1,49 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.hookBus = exports.HookBus = void 0;
|
||||
const logger_1 = require("../config/logger");
|
||||
/**
|
||||
* Event bus entre plugins.
|
||||
* emit() executa todos os handlers registrados e retorna array de resultados.
|
||||
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
|
||||
*/
|
||||
class HookBus {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
}
|
||||
register(event, handler) {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, []);
|
||||
}
|
||||
this.handlers.get(event).push(handler);
|
||||
logger_1.logger.debug({ event }, '[HookBus] Handler registrado');
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async emit(event, data) {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (!handlers || handlers.length === 0)
|
||||
return [];
|
||||
const results = [];
|
||||
for (const handler of handlers) {
|
||||
try {
|
||||
const result = await handler(data);
|
||||
results.push(result);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, event }, '[HookBus] Erro em handler');
|
||||
results.push(null);
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
|
||||
removeAll(event) {
|
||||
this.handlers.delete(event);
|
||||
}
|
||||
/** Lista eventos registrados — útil para debug */
|
||||
listEvents() {
|
||||
return Array.from(this.handlers.keys());
|
||||
}
|
||||
}
|
||||
exports.HookBus = HookBus;
|
||||
exports.hookBus = new HookBus();
|
||||
@@ -0,0 +1,51 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.pluginConfig = exports.PluginConfigStore = void 0;
|
||||
const dragonfly_1 = require("../infra/cache/dragonfly");
|
||||
const logger_1 = require("../config/logger");
|
||||
const KEY_PREFIX = 'plugin:config:';
|
||||
// Config de plugins não expira — TTL longo (1 ano)
|
||||
const TTL_SECONDS = 365 * 24 * 3600;
|
||||
/**
|
||||
* Config store para plugins, backed por DragonflyDB.
|
||||
* get() usa cache in-memory para evitar round-trips no hot-path.
|
||||
* set() persiste no Dragonfly e invalida o cache local.
|
||||
*/
|
||||
class PluginConfigStore {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
/** Lê config de um plugin. Retorna {} se não houver config salva. */
|
||||
get(pluginName) {
|
||||
return this.cache.get(pluginName) ?? {};
|
||||
}
|
||||
/** Persiste config no Dragonfly e atualiza cache local. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async set(pluginName, config) {
|
||||
this.cache.set(pluginName, config);
|
||||
await dragonfly_1.dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS);
|
||||
logger_1.logger.debug({ pluginName }, '[PluginConfig] Config salva');
|
||||
}
|
||||
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
|
||||
async loadAll(pluginNames) {
|
||||
await Promise.all(pluginNames.map(async (name) => {
|
||||
const stored = await dragonfly_1.dragonfly.getJson(`${KEY_PREFIX}${name}`);
|
||||
if (stored) {
|
||||
this.cache.set(name, stored);
|
||||
logger_1.logger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly');
|
||||
}
|
||||
}));
|
||||
}
|
||||
/** Retorna config de todos os plugins (para a API admin). */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
getAllCached() {
|
||||
const result = {};
|
||||
for (const [name, config] of this.cache.entries()) {
|
||||
result[name] = config;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
exports.PluginConfigStore = PluginConfigStore;
|
||||
exports.pluginConfig = new PluginConfigStore();
|
||||
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.dragonfly = void 0;
|
||||
const ioredis_1 = __importDefault(require("ioredis"));
|
||||
const env_1 = require("../../config/env");
|
||||
const logger_1 = require("../../config/logger");
|
||||
class DragonflyClient {
|
||||
constructor() {
|
||||
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
|
||||
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
enableReadyCheck: true,
|
||||
lazyConnect: true,
|
||||
});
|
||||
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
|
||||
this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro'));
|
||||
}
|
||||
async connect() {
|
||||
await this.client.connect();
|
||||
}
|
||||
async get(key) {
|
||||
return this.client.get(key);
|
||||
}
|
||||
async getJson(key) {
|
||||
const raw = await this.client.get(key);
|
||||
if (!raw)
|
||||
return null;
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
async set(key, value, ttlSeconds) {
|
||||
await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl);
|
||||
}
|
||||
async setJson(key, value, ttlSeconds) {
|
||||
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl);
|
||||
}
|
||||
async del(key) {
|
||||
await this.client.del(key);
|
||||
}
|
||||
async exists(key) {
|
||||
const count = await this.client.exists(key);
|
||||
return count > 0;
|
||||
}
|
||||
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
|
||||
async publish(channel, message) {
|
||||
await this.client.publish(channel, message);
|
||||
}
|
||||
raw() {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
exports.dragonfly = new DragonflyClient();
|
||||
@@ -0,0 +1,13 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.prisma = void 0;
|
||||
const client_1 = require("@prisma/client");
|
||||
exports.prisma = global.__prisma ??
|
||||
new client_1.PrismaClient({
|
||||
log: process.env.NODE_ENV !== 'production'
|
||||
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
|
||||
: [],
|
||||
});
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
global.__prisma = exports.prisma;
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0;
|
||||
/**
|
||||
* chatbot.repository.ts — CRUD para AICredential e AIBot.
|
||||
*/
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
// ─── Credentials ─────────────────────────────────────────────────────────────
|
||||
exports.credentialRepo = {
|
||||
findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
create: (data) => prisma_1.prisma.aICredential.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
name: data.name,
|
||||
provider: data.provider ?? 'GEMINI',
|
||||
apiKey: data.apiKey,
|
||||
},
|
||||
select: { id: true, name: true, provider: true, createdAt: true },
|
||||
}),
|
||||
delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }),
|
||||
};
|
||||
// ─── Bots ─────────────────────────────────────────────────────────────────────
|
||||
exports.botRepo = {
|
||||
findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({
|
||||
where: { tenantId, instanceId },
|
||||
include: { credential: { select: { id: true, name: true, provider: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({
|
||||
where: { tenantId, instanceId, enabled: true },
|
||||
include: { credential: true },
|
||||
}),
|
||||
findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({
|
||||
where: { id, tenantId },
|
||||
include: { credential: true },
|
||||
}),
|
||||
create: (data) => prisma_1.prisma.aIBot.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
instanceId: data.instanceId,
|
||||
credentialId: data.credentialId,
|
||||
name: data.name,
|
||||
systemPrompt: data.systemPrompt,
|
||||
model: data.model ?? 'gemini-1.5-flash',
|
||||
enabled: data.enabled ?? false,
|
||||
triggerMode: data.triggerMode ?? 'ALL',
|
||||
keywords: data.keywords ?? [],
|
||||
},
|
||||
}),
|
||||
update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
|
||||
delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }),
|
||||
};
|
||||
// ─── Chat bot state ───────────────────────────────────────────────────────────
|
||||
exports.chatBotState = {
|
||||
pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
|
||||
resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
|
||||
updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
|
||||
getState: (chatId) => prisma_1.prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
select: { botPaused: true, botSummary: true },
|
||||
}),
|
||||
};
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ChatbotService = void 0;
|
||||
/**
|
||||
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
|
||||
*
|
||||
* Padrão Cérebro (instrucoes.md §5):
|
||||
* - Não envia histórico completo para economizar tokens
|
||||
* - Mantém resumo de 2 frases por chat (botSummary no banco)
|
||||
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
|
||||
*/
|
||||
const generative_ai_1 = require("@google/generative-ai");
|
||||
const prisma_1 = require("../../infra/database/prisma");
|
||||
const logger_1 = require("../../config/logger");
|
||||
const chatbot_repository_1 = require("./chatbot.repository");
|
||||
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
|
||||
const geminiClients = new Map();
|
||||
function getGeminiClient(apiKey) {
|
||||
if (!geminiClients.has(apiKey)) {
|
||||
geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey));
|
||||
}
|
||||
return geminiClients.get(apiKey);
|
||||
}
|
||||
function getModel(apiKey, modelName) {
|
||||
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName });
|
||||
}
|
||||
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
|
||||
async function classifyIntent(userMsg, summary, systemPrompt, model) {
|
||||
const prompt = [
|
||||
`Você é um classificador de intenção para um chatbot de atendimento.`,
|
||||
`Prompt do atendente: ${systemPrompt}`,
|
||||
summary ? `Contexto da conversa até agora: ${summary}` : '',
|
||||
`Nova mensagem do cliente: "${userMsg}"`,
|
||||
`Responda APENAS com o número:`,
|
||||
`1 = Consigo responder essa pergunta dentro do meu papel`,
|
||||
`2 = Precisa de um agente humano`,
|
||||
].filter(Boolean).join('\n');
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 2, temperature: 0 },
|
||||
});
|
||||
const text = result.response.text().trim();
|
||||
return text.startsWith('2') ? 'escalate' : 'resolve';
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve');
|
||||
return 'resolve';
|
||||
}
|
||||
}
|
||||
// ─── Geração de resposta ──────────────────────────────────────────────────────
|
||||
async function generateResponse(userMsg, summary, systemPrompt, model) {
|
||||
const prompt = [
|
||||
systemPrompt,
|
||||
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
|
||||
`\nMensagem do cliente: "${userMsg}"`,
|
||||
`\nResponda de forma natural, breve e direta. Não use markdown.`,
|
||||
].filter(Boolean).join('\n');
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
|
||||
});
|
||||
return result.response.text().trim();
|
||||
}
|
||||
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
|
||||
async function updateSummary(currentSummary, userMsg, botMsg, model) {
|
||||
const prompt = [
|
||||
currentSummary
|
||||
? `Resumo atual da conversa: ${currentSummary}`
|
||||
: 'Esta é a primeira troca da conversa.',
|
||||
`Nova troca:`,
|
||||
`Cliente: "${userMsg}"`,
|
||||
`Assistente: "${botMsg}"`,
|
||||
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
|
||||
`Responda apenas com o resumo, sem introdução.`,
|
||||
].join('\n');
|
||||
try {
|
||||
const result = await model.generateContent({
|
||||
contents: [{ role: 'user', parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
|
||||
});
|
||||
return result.response.text().trim();
|
||||
}
|
||||
catch {
|
||||
// Em caso de erro, mantém o resumo anterior
|
||||
return currentSummary ?? '';
|
||||
}
|
||||
}
|
||||
// ─── Interface pública ────────────────────────────────────────────────────────
|
||||
class ChatbotService {
|
||||
constructor(io) {
|
||||
this.io = io;
|
||||
}
|
||||
/**
|
||||
* Ponto de entrada para cada mensagem recebida.
|
||||
* Chamado pelo MessageHandler após persistir a mensagem.
|
||||
*/
|
||||
async handleIncoming(opts) {
|
||||
const { tenantId, instanceId, chatId, jid, text, sock } = opts;
|
||||
// 1. Verifica se há bot ativo para esta instância
|
||||
const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId);
|
||||
if (!bot)
|
||||
return;
|
||||
// 2. Verifica o estado do chat (human takeover)
|
||||
const chatState = await chatbot_repository_1.chatBotState.getState(chatId);
|
||||
if (!chatState || chatState.botPaused)
|
||||
return;
|
||||
// 3. Aplica modo de gatilho
|
||||
if (bot.triggerMode === 'KEYWORD') {
|
||||
const lowerText = text.toLowerCase();
|
||||
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
|
||||
if (!hasKeyword)
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const model = getModel(bot.credential.apiKey, bot.model);
|
||||
const summary = chatState.botSummary ?? null;
|
||||
// 4. Micro-prompt: intenção
|
||||
const intent = await classifyIntent(text, summary, bot.systemPrompt, model);
|
||||
if (intent === 'escalate') {
|
||||
// Pausa o bot e notifica via Socket.IO
|
||||
await chatbot_repository_1.chatBotState.pauseBot(chatId);
|
||||
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
|
||||
chatId,
|
||||
message: 'Bot pausado — atendente humano necessário',
|
||||
});
|
||||
logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano');
|
||||
return;
|
||||
}
|
||||
// 5. Gera resposta
|
||||
const responseText = await generateResponse(text, summary, bot.systemPrompt, model);
|
||||
// 6. Envia via Baileys
|
||||
const sent = await sock.sendMessage(jid, { text: responseText });
|
||||
// 7. Persiste mensagem do bot no banco
|
||||
const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } });
|
||||
if (chat) {
|
||||
const botMsg = await prisma_1.prisma.message.create({
|
||||
data: {
|
||||
tenantId,
|
||||
instanceId,
|
||||
chatId,
|
||||
remoteJid: jid,
|
||||
messageId: sent?.key.id ?? `bot-${Date.now()}`,
|
||||
fromMe: true,
|
||||
type: 'TEXT',
|
||||
body: responseText,
|
||||
status: 'SENT',
|
||||
timestamp: new Date(),
|
||||
},
|
||||
});
|
||||
// Notifica frontend
|
||||
this.io.to(`chat:${chatId}`).emit('message:new', botMsg);
|
||||
}
|
||||
// 8. Atualiza Cérebro
|
||||
const newSummary = await updateSummary(summary, text, responseText, model);
|
||||
await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary);
|
||||
logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ChatbotService = ChatbotService;
|
||||
+1113
File diff suppressed because it is too large
Load Diff
+494
@@ -0,0 +1,494 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ContactHandler = void 0;
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
|
||||
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
|
||||
// forem feitas em curto período. Estas constantes controlam o throttling.
|
||||
/** Quantos avatares buscar em paralelo por lote */
|
||||
const AVATAR_BATCH_SIZE = 5;
|
||||
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
|
||||
const AVATAR_DELAY_MS = 900;
|
||||
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
|
||||
const MAX_MISSING_AVATAR_SYNC = 80;
|
||||
class ContactHandler {
|
||||
constructor(
|
||||
/** UUID da instância WhatsApp */
|
||||
instanceId,
|
||||
/** UUID do tenant (isolamento multi-tenant) */
|
||||
tenantId,
|
||||
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
|
||||
sock,
|
||||
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
|
||||
io) {
|
||||
this.instanceId = instanceId;
|
||||
this.tenantId = tenantId;
|
||||
this.sock = sock;
|
||||
this.io = io;
|
||||
/** Fila de JIDs aguardando busca de avatar */
|
||||
this.avatarQueue = [];
|
||||
/** Flag de mutex simples — evita processamento concorrente da fila */
|
||||
this.processingAvatars = false;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RESOLUÇÃO DE NOME
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Regra de ouro para resolução de nome de contato:
|
||||
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
|
||||
*
|
||||
* Retorna undefined se nenhum campo estiver disponível.
|
||||
*/
|
||||
resolveName(c) {
|
||||
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Cria ou atualiza contatos em massa.
|
||||
*
|
||||
* Dispara em dois cenários:
|
||||
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
|
||||
* 2. Campo contacts[] do messaging-history.set (history sync)
|
||||
*
|
||||
* Para cada contato:
|
||||
* - Cria ou atualiza no banco com nome resolvido
|
||||
* - Vincula chats órfãos (Chat sem contactId) ao contato
|
||||
* - Enfileira busca de avatar se imgUrl não disponível
|
||||
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
|
||||
*
|
||||
* TRATAMENTO DE LID:
|
||||
* O Baileys pode enviar contatos com c.id no formato @lid.
|
||||
* Quando o contato do history sync inclui campo `jid` (telefone real),
|
||||
* usamos esse JID como chave primária e salvamos o LID em lidJid.
|
||||
* Quando não há campo `jid`, o contato é criado com o LID como JID
|
||||
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
|
||||
*/
|
||||
async upsert(contacts) {
|
||||
/** JIDs que precisam buscar avatar via profilePictureUrl */
|
||||
const needFetchAvatar = [];
|
||||
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
|
||||
let directAvatarCount = 0;
|
||||
for (const c of contacts) {
|
||||
if (!c.id)
|
||||
continue;
|
||||
const isLid = c.id.endsWith('@lid');
|
||||
const isBroadcast = c.id.includes('@broadcast');
|
||||
if (isBroadcast)
|
||||
continue;
|
||||
// imgUrl pode vir do Baileys com valores especiais:
|
||||
// - URL string: avatar disponível (salva direto)
|
||||
// - 'changed': avatar foi alterado (precisa buscar novamente)
|
||||
// - null/undefined: não disponível (precisa buscar via API)
|
||||
const imgUrl = c.imgUrl;
|
||||
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
|
||||
// que associa o LID ao contato com JID real (@s.whatsapp.net)
|
||||
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
|
||||
const lidFromContact = c.lid;
|
||||
try {
|
||||
// Para contatos @lid que possuem campo `jid` (vem do history sync),
|
||||
// usa o JID real como chave primária. Caso contrário, usa c.id como está.
|
||||
// Isso evita criar contatos duplicados (um com LID, outro com phone).
|
||||
const contactJid = isLid && c.jid ? c.jid : c.id;
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: {
|
||||
tenantId_instanceId_jid: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: contactJid,
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
// Salva mapeamento LID:
|
||||
// Se o contato É um LID (c.id termina em @lid): salva c.id como lidJid
|
||||
// Se o contato TEM campo lid (history sync): salva lid como lidJid
|
||||
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
|
||||
},
|
||||
update: {
|
||||
name: this.resolveName(c) ?? null,
|
||||
verifiedName: c.verifiedName ?? null,
|
||||
notify: c.notify ?? null,
|
||||
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
|
||||
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
|
||||
},
|
||||
});
|
||||
// Garante vínculo Chat ↔ Contact para chats órfãos
|
||||
// (chats criados antes do contato existir ficam com contactId=null)
|
||||
await prisma_1.prisma.chat.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
contactId: null,
|
||||
},
|
||||
data: { contactId: contact.id },
|
||||
});
|
||||
// ── Lógica de avatar ──────────────────────────────────────────────
|
||||
// JIDs @lid não suportam profilePictureUrl (retorna 404 no WA)
|
||||
if (!isLid) {
|
||||
if (imgUrl && imgUrl !== 'changed') {
|
||||
// Avatar veio pronto do Baileys — notifica frontend imediatamente
|
||||
directAvatarCount++;
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
avatarUrl: imgUrl,
|
||||
});
|
||||
}
|
||||
else if (imgUrl === 'changed' || imgUrl === undefined) {
|
||||
// Precisa buscar via API — adiciona à fila com rate-limit
|
||||
needFetchAvatar.push(c.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
|
||||
}
|
||||
}
|
||||
logger_1.logger.debug({
|
||||
instanceId: this.instanceId,
|
||||
direct: directAvatarCount,
|
||||
toFetch: needFetchAvatar.length,
|
||||
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
|
||||
// Enfileira busca de avatares com cap de 50 por batch
|
||||
// (randomiza para priorizar contatos diferentes a cada sync)
|
||||
if (this.sock && needFetchAvatar.length > 0) {
|
||||
const toQueue = needFetchAvatar.length > 50
|
||||
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
|
||||
: needFetchAvatar;
|
||||
this.enqueueAvatars(toQueue);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UPDATE PARCIAL (contacts.update)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Atualiza campos parciais de contatos existentes.
|
||||
*
|
||||
* Dispara no evento contacts.update do Baileys — diferente do upsert,
|
||||
* aqui só atualizamos os campos que foram explicitamente alterados.
|
||||
* Não cria contatos novos (se não existir, ignora silenciosamente).
|
||||
*/
|
||||
async update(contacts) {
|
||||
for (const c of contacts) {
|
||||
if (!c.id)
|
||||
continue;
|
||||
const resolvedName = this.resolveName(c);
|
||||
// Se nenhum campo relevante mudou, pula
|
||||
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
|
||||
continue;
|
||||
try {
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: c.id,
|
||||
},
|
||||
data: {
|
||||
...(resolvedName !== undefined && { name: resolvedName }),
|
||||
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
|
||||
...(c.notify !== undefined && { notify: c.notify }),
|
||||
},
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
|
||||
}
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Busca e salva a foto de perfil do número WhatsApp da instância.
|
||||
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
|
||||
*/
|
||||
async syncInstanceAvatar(instanceId) {
|
||||
if (!this.sock?.user?.id)
|
||||
return null;
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
|
||||
if (url) {
|
||||
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
|
||||
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Avatar privado ou não disponível — normal para contas com privacidade restrita
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Reconcilia nomes de contatos após sync do histórico.
|
||||
*
|
||||
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
|
||||
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
|
||||
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
|
||||
*
|
||||
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
|
||||
* name=null E notify=null, e preenche usando o pushName da mensagem
|
||||
* recebida mais recente para cada JID.
|
||||
*
|
||||
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
|
||||
*
|
||||
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
|
||||
*/
|
||||
async reconcileNamesFromMessages() {
|
||||
try {
|
||||
// Busca contatos "sem nome" — candidatos à reconciliação
|
||||
const unnamed = await prisma_1.prisma.contact.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
name: null,
|
||||
notify: null,
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
{ jid: { endsWith: '@newsletter' } },
|
||||
{ jid: { endsWith: '@g.us' } },
|
||||
],
|
||||
},
|
||||
select: { id: true, jid: true },
|
||||
});
|
||||
if (unnamed.length === 0) {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
|
||||
return;
|
||||
}
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
|
||||
let updated = 0;
|
||||
for (const contact of unnamed) {
|
||||
// Busca a mensagem recebida mais recente com pushName para este JID
|
||||
const msg = await prisma_1.prisma.message.findFirst({
|
||||
where: {
|
||||
instanceId: this.instanceId,
|
||||
remoteJid: contact.jid,
|
||||
fromMe: false,
|
||||
pushName: { not: null },
|
||||
},
|
||||
orderBy: { timestamp: 'desc' },
|
||||
select: { pushName: true },
|
||||
});
|
||||
if (msg?.pushName) {
|
||||
await prisma_1.prisma.contact.update({
|
||||
where: { id: contact.id },
|
||||
data: { name: msg.pushName, notify: msg.pushName },
|
||||
});
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
|
||||
// Notifica o frontend para atualizar a lista de chats
|
||||
if (updated > 0) {
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
|
||||
instanceId: this.instanceId,
|
||||
count: updated,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FORCE CONTACT SYNC (resyncAppState)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Força re-sync dos contatos via app-state do Baileys.
|
||||
*
|
||||
* NOTA: Este método existe mas NÃO é chamado automaticamente.
|
||||
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
|
||||
* força o resync das app-state collections que contêm contatos.
|
||||
*
|
||||
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
|
||||
* "tried remove, but no previous op" — erro de estado corrompido
|
||||
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
|
||||
*
|
||||
* Pode ser chamado manualmente via API se necessário.
|
||||
*/
|
||||
async forceContactSync() {
|
||||
if (!this.sock)
|
||||
return;
|
||||
try {
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
|
||||
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Busca avatares ausentes para chats ativos após reconexão.
|
||||
*
|
||||
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
|
||||
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
|
||||
* ou contatos vieram sem imgUrl.
|
||||
*
|
||||
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
|
||||
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
|
||||
*/
|
||||
async syncMissingAvatars() {
|
||||
try {
|
||||
const missing = await prisma_1.prisma.chat.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
isArchived: false,
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
],
|
||||
contact: { avatarUrl: null },
|
||||
},
|
||||
select: { jid: true },
|
||||
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
|
||||
take: MAX_MISSING_AVATAR_SYNC,
|
||||
});
|
||||
if (missing.length === 0)
|
||||
return;
|
||||
const jids = missing.map((c) => c.jid);
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
|
||||
this.enqueueAvatars(jids);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
|
||||
* em menos de 48 horas ou já expirou.
|
||||
*
|
||||
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
|
||||
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
|
||||
*/
|
||||
async syncExpiredAvatars() {
|
||||
try {
|
||||
const contacts = await prisma_1.prisma.contact.findMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
avatarUrl: { not: null },
|
||||
NOT: [
|
||||
{ jid: { endsWith: '@lid' } },
|
||||
{ jid: { contains: '@broadcast' } },
|
||||
],
|
||||
},
|
||||
select: { jid: true, avatarUrl: true },
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: 200,
|
||||
});
|
||||
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
|
||||
const expiring = contacts
|
||||
.filter(c => {
|
||||
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
|
||||
if (!m)
|
||||
return false;
|
||||
return parseInt(m[1], 16) < threshold;
|
||||
})
|
||||
.map(c => c.jid);
|
||||
if (expiring.length === 0)
|
||||
return;
|
||||
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
|
||||
// Limpa as URLs expiradas antes de re-buscar, para que o browser
|
||||
// não use URLs inválidas enquanto a fila processa
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: { in: expiring },
|
||||
},
|
||||
data: { avatarUrl: null },
|
||||
});
|
||||
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// FILA DE AVATARES COM RATE-LIMIT
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
|
||||
*
|
||||
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
|
||||
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
|
||||
*
|
||||
* Se a fila já está sendo processada, os novos JIDs são adicionados
|
||||
* e serão processados na próxima iteração do loop.
|
||||
*/
|
||||
enqueueAvatars(jids) {
|
||||
const inQueue = new Set(this.avatarQueue);
|
||||
for (const jid of jids) {
|
||||
if (!inQueue.has(jid))
|
||||
this.avatarQueue.push(jid);
|
||||
}
|
||||
this.processAvatarQueue();
|
||||
}
|
||||
/**
|
||||
* Processa a fila de avatares com rate-limit.
|
||||
*
|
||||
* Usa Promise.allSettled() para que falhas individuais (foto privada,
|
||||
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
|
||||
* 1. Salvo no campo avatarUrl do contato no banco
|
||||
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
|
||||
*/
|
||||
async processAvatarQueue() {
|
||||
// Mutex simples: apenas uma execução por vez
|
||||
if (this.processingAvatars || this.avatarQueue.length === 0)
|
||||
return;
|
||||
this.processingAvatars = true;
|
||||
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
|
||||
while (this.avatarQueue.length > 0 && this.sock) {
|
||||
// Pega o próximo lote (remove da fila)
|
||||
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
|
||||
await Promise.allSettled(batch.map(async (jid) => {
|
||||
try {
|
||||
const url = await this.sock.profilePictureUrl(jid, 'image');
|
||||
if (url) {
|
||||
// Persiste no banco
|
||||
await prisma_1.prisma.contact.updateMany({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
|
||||
data: { avatarUrl: url },
|
||||
});
|
||||
// Notifica o frontend para atualizar o avatar na UI
|
||||
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
avatarUrl: url,
|
||||
});
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Foto privada ou JID inválido — ignora silenciosamente
|
||||
// (não loga para evitar spam com milhares de contatos)
|
||||
}
|
||||
}));
|
||||
// Delay entre lotes para respeitar rate-limit do WhatsApp
|
||||
if (this.avatarQueue.length > 0) {
|
||||
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
|
||||
}
|
||||
}
|
||||
this.processingAvatars = false;
|
||||
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
|
||||
}
|
||||
}
|
||||
exports.ContactHandler = ContactHandler;
|
||||
+758
@@ -0,0 +1,758 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.MessageHandler = void 0;
|
||||
/**
|
||||
* MessageHandler — Processa mensagens recebidas do Baileys (tempo real e histórico).
|
||||
*
|
||||
* Responsabilidades:
|
||||
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
|
||||
* 2. Resolver JIDs LID → telefone (@lid → @s.whatsapp.net)
|
||||
* 3. Criar/atualizar Contact, Chat e Message no banco
|
||||
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
|
||||
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
|
||||
* 6. Disparar chatbot para mensagens recebidas com texto
|
||||
*
|
||||
* CONTEXTO LID (Linked ID):
|
||||
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
|
||||
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
|
||||
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
|
||||
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
|
||||
* - key.participantPn: idem, usado em contextos de grupo
|
||||
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
|
||||
*
|
||||
* @see WhatsAppConnectionManager — registra os event listeners do Baileys
|
||||
* @see ContactHandler — gerencia contatos e avatares
|
||||
*/
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const promises_1 = __importDefault(require("fs/promises"));
|
||||
const baileys_1 = require("@whiskeysockets/baileys");
|
||||
const client_1 = require("@prisma/client");
|
||||
const prisma_1 = require("../../../infra/database/prisma");
|
||||
const logger_1 = require("../../../config/logger");
|
||||
const whatsapp_1 = require("../../../shared/utils/whatsapp");
|
||||
const StorageProvider_1 = require("../../../core/StorageProvider");
|
||||
const hook_bus_1 = require("../../../core/hook-bus");
|
||||
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
|
||||
const MEDIA_DIR = path_1.default.resolve('./media');
|
||||
/**
|
||||
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
|
||||
* Mensagens com estes contentTypes são descartadas silenciosamente — sem criar Chat nem Message.
|
||||
*/
|
||||
const PROTOCOL_CONTENT_TYPES = new Set([
|
||||
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
|
||||
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
|
||||
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
|
||||
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
|
||||
'messageContextInfo', // só metadados, sem conteúdo
|
||||
'appStateSyncKeyShare', // sync de estado do app (protocolo)
|
||||
'appStateFatalExceptionNotification', // notificação interna (protocolo)
|
||||
'keepInChatMessage', // manter no chat (protocolo)
|
||||
]);
|
||||
class MessageHandler {
|
||||
constructor(
|
||||
/** Socket Baileys ativo — usado para download de mídia e reupload */
|
||||
sock,
|
||||
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
|
||||
instanceId,
|
||||
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
|
||||
tenantId,
|
||||
/** Socket.IO server — emite eventos em tempo real para o frontend */
|
||||
io,
|
||||
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
|
||||
chatbotService) {
|
||||
this.sock = sock;
|
||||
this.instanceId = instanceId;
|
||||
this.tenantId = tenantId;
|
||||
this.io = io;
|
||||
this.chatbotService = chatbotService;
|
||||
/**
|
||||
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
|
||||
*
|
||||
* Mapa: msgId → phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
|
||||
*
|
||||
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
|
||||
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
|
||||
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
|
||||
* sessão (não o destinatário) — inútil pra resolução. A ÚNICA pista do
|
||||
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
|
||||
* Baileys NÃO expõe em `decode-wa-message.js`.
|
||||
*
|
||||
* Por isso, o WhatsAppConnectionManager registra um listener extra em
|
||||
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
|
||||
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
|
||||
* consome a dica e remove a entrada (mapa efêmero, não cresce).
|
||||
*/
|
||||
this.peerRecipientHints = new Map();
|
||||
}
|
||||
/**
|
||||
* Registra uma dica de destinatário extraída da stanza bruta.
|
||||
*
|
||||
* Chamado pelo listener `sock.ws.on('CB:message')` no
|
||||
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
|
||||
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
|
||||
*/
|
||||
setPeerRecipientHint(msgId, peerRecipientPn) {
|
||||
this.peerRecipientHints.set(msgId, peerRecipientPn);
|
||||
setTimeout(() => this.peerRecipientHints.delete(msgId), 60000).unref();
|
||||
}
|
||||
/**
|
||||
* Ponto de entrada principal — chamado pelo event listener `messages.upsert`.
|
||||
*
|
||||
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
|
||||
*
|
||||
* Diferença entre os modos:
|
||||
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
|
||||
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
|
||||
*/
|
||||
async handle({ messages, type }) {
|
||||
if (type === 'notify') {
|
||||
for (const msg of messages) {
|
||||
try {
|
||||
await this.processMessage(msg);
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem');
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (type === 'append') {
|
||||
await this.handleHistory(messages);
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Processa mensagens históricas (history sync / append).
|
||||
*
|
||||
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
|
||||
* por grupo, minimizando round-trips ao banco.
|
||||
*
|
||||
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
|
||||
* Se não for possível resolver, a mensagem é descartada silenciosamente
|
||||
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
|
||||
*/
|
||||
async handleHistory(messages) {
|
||||
if (messages.length === 0)
|
||||
return;
|
||||
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
|
||||
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
|
||||
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
|
||||
const byJid = new Map();
|
||||
for (const msg of messages) {
|
||||
let jidRaw = msg.key.remoteJid;
|
||||
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message)
|
||||
continue;
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
|
||||
const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw);
|
||||
if (ownJid && normalizedRaw === ownJid)
|
||||
continue;
|
||||
// ── Resolução LID → phone JID para mensagens históricas ──────────
|
||||
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
|
||||
// Precisamos converter para @s.whatsapp.net antes de persistir,
|
||||
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
|
||||
if (jidRaw.endsWith('@lid')) {
|
||||
// Tenta key.senderPn (disponível quando o protocolo WA inclui sender_pn)
|
||||
const senderPn = msg.key.senderPn || msg.key.participantPn;
|
||||
if (senderPn) {
|
||||
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
if (!jidRaw.endsWith('@s.whatsapp.net'))
|
||||
continue;
|
||||
}
|
||||
else {
|
||||
// Fallback: busca mapeamento LID → phone já salvo no banco
|
||||
// (populado por phoneNumberShare ou processMessage anterior)
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
|
||||
select: { jid: true },
|
||||
});
|
||||
if (contact?.jid && !contact.jid.endsWith('@lid')) {
|
||||
jidRaw = contact.jid;
|
||||
}
|
||||
else {
|
||||
// Sem resolução possível, ou contato ainda armazenado com JID @lid
|
||||
// (o mapeamento phone ainda não chegou via phoneNumberShare / senderPn).
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
|
||||
const list = byJid.get(jid) ?? [];
|
||||
list.push(msg);
|
||||
byJid.set(jid, list);
|
||||
}
|
||||
let totalSaved = 0;
|
||||
for (const [jid, msgs] of byJid) {
|
||||
try {
|
||||
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
|
||||
const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0);
|
||||
const lastAt = latestTs ? new Date(latestTs * 1000) : null;
|
||||
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
// 2. Garantir que o Chat existe e está vinculado ao Contact
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid,
|
||||
contactId: contact.id,
|
||||
lastMessageAt: lastAt,
|
||||
unreadCount: 0
|
||||
},
|
||||
update: {
|
||||
contactId: contact.id,
|
||||
...(lastAt && { lastMessageAt: lastAt })
|
||||
},
|
||||
});
|
||||
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
|
||||
const rows = msgs.flatMap((msg) => {
|
||||
const { key, message, messageTimestamp } = msg;
|
||||
if (!message || !key.id)
|
||||
return [];
|
||||
const contentType = (0, baileys_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return [];
|
||||
// Descarta tipos de protocolo/sistema que não representam conteúdo real
|
||||
if (PROTOCOL_CONTENT_TYPES.has(contentType))
|
||||
return [];
|
||||
const msgType = this.mapContentType(contentType);
|
||||
if (msgType === client_1.MessageType.UNSUPPORTED)
|
||||
return [];
|
||||
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date();
|
||||
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
|
||||
const body = message.conversation ??
|
||||
message.extendedTextMessage?.text ??
|
||||
message.imageMessage?.caption ??
|
||||
message.videoMessage?.caption ??
|
||||
message.documentMessage?.caption ??
|
||||
null;
|
||||
const isGroup = jid.endsWith('@g.us');
|
||||
return [{
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid: jid,
|
||||
messageId: key.id,
|
||||
fromMe: key.fromMe ?? false,
|
||||
type: msgType,
|
||||
body,
|
||||
// pushName: nome público do remetente (só confiável em msgs recebidas)
|
||||
pushName: (key.fromMe ? null : msg.pushName) ?? null,
|
||||
// senderJid: em grupos, identifica quem enviou (JID do participante)
|
||||
senderJid: isGroup ? (key.participant ?? null) : null,
|
||||
status: 'READ', // históricas são consideradas já lidas
|
||||
timestamp: ts,
|
||||
}];
|
||||
});
|
||||
if (rows.length > 0) {
|
||||
const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true });
|
||||
totalSaved += count;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat');
|
||||
}
|
||||
}
|
||||
if (totalSaved > 0) {
|
||||
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, '[History] Mensagens históricas salvas');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// TEMPO REAL — Processa uma única mensagem (notify)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Processa uma mensagem recebida em tempo real.
|
||||
*
|
||||
* Fluxo completo:
|
||||
* 1. Filtra broadcasts, newsletters
|
||||
* 2. Resolve LID → telefone (se @lid)
|
||||
* 3. Cria/atualiza Contact com pushName (se aplicável)
|
||||
* 4. Cria/atualiza Chat vinculado ao Contact
|
||||
* 5. Persiste a Message no banco
|
||||
* 6. Emite socket events (message:new, chat:upsert)
|
||||
* 7. Agenda download de mídia (assíncrono)
|
||||
* 8. Dispara chatbot (assíncrono)
|
||||
*/
|
||||
async processMessage(msg) {
|
||||
const { key, message, messageTimestamp, pushName } = msg;
|
||||
if (!message || !key.remoteJid)
|
||||
return;
|
||||
// Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
|
||||
if (key.remoteJid === 'status@broadcast')
|
||||
return;
|
||||
if (key.remoteJid.endsWith('@newsletter'))
|
||||
return;
|
||||
if (key.remoteJid.startsWith('0@'))
|
||||
return;
|
||||
// ── Resolução LID → telefone ──────────────────────────────────────────
|
||||
// WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
|
||||
// O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
|
||||
//
|
||||
// SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
|
||||
// e o chat apareceria com a foto/número errado no frontend (bug crítico).
|
||||
//
|
||||
// Prioridade de resolução:
|
||||
// 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
|
||||
// 2. key.participantPn — idem, usado em contextos de grupo
|
||||
// 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
|
||||
/**
|
||||
* Guarda o JID @lid original quando a resolução é bem-sucedida, para
|
||||
* persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
|
||||
* Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
|
||||
* contatos novos — bug silencioso que fazia o mapeamento ser perdido.
|
||||
*/
|
||||
let originalLidJid = null;
|
||||
if (key.remoteJid.endsWith('@lid')) {
|
||||
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key);
|
||||
if (!phoneJid) {
|
||||
// Sem resolução: não há como saber o número real do contato.
|
||||
// A mensagem será perdida, mas o mapeamento será capturado no futuro
|
||||
// via chats.phoneNumberShare e as próximas mensagens serão processadas.
|
||||
logger_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando');
|
||||
return;
|
||||
}
|
||||
originalLidJid = key.remoteJid;
|
||||
key.remoteJid = phoneJid;
|
||||
}
|
||||
const contentType = (0, baileys_1.getContentType)(message);
|
||||
if (!contentType)
|
||||
return;
|
||||
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
|
||||
if (PROTOCOL_CONTENT_TYPES.has(contentType))
|
||||
return;
|
||||
const msgType = this.mapContentType(contentType);
|
||||
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
|
||||
if (msgType === client_1.MessageType.UNSUPPORTED)
|
||||
return;
|
||||
const fromMe = key.fromMe ?? false;
|
||||
const remoteJidRaw = key.remoteJid;
|
||||
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
|
||||
const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw);
|
||||
const messageId = key.id;
|
||||
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
|
||||
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
|
||||
if (ownJid && remoteJid === ownJid)
|
||||
return;
|
||||
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
|
||||
// REGRA IMPORTANTE sobre pushName:
|
||||
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
|
||||
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
|
||||
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
|
||||
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
|
||||
// - Para GRUPOS: pushName é do remetente individual, não do grupo
|
||||
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
|
||||
const isGroupMessage = remoteJid.endsWith('@g.us');
|
||||
const participantJid = key.participant ?? msg.participant ?? null;
|
||||
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null;
|
||||
const contact = await prisma_1.prisma.contact.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: remoteJid,
|
||||
name: safePushName,
|
||||
notify: safePushName,
|
||||
phone: isGroupMessage ? null : remoteJid.split('@')[0],
|
||||
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
|
||||
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
|
||||
...(originalLidJid && { lidJid: originalLidJid }),
|
||||
},
|
||||
update: {
|
||||
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
|
||||
...(safePushName && { notify: safePushName }),
|
||||
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
|
||||
// que ainda não tinham o mapeamento (ex: criados via history sync).
|
||||
...(originalLidJid && { lidJid: originalLidJid }),
|
||||
},
|
||||
});
|
||||
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
|
||||
const chat = await prisma_1.prisma.chat.upsert({
|
||||
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
jid: remoteJid,
|
||||
contactId: contact.id,
|
||||
unreadCount: fromMe ? 0 : 1,
|
||||
lastMessageAt: new Date(messageTimestamp * 1000),
|
||||
},
|
||||
update: {
|
||||
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
|
||||
lastMessageAt: new Date(messageTimestamp * 1000),
|
||||
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
|
||||
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
|
||||
},
|
||||
});
|
||||
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
|
||||
// nativeFlowResponseMessage: resposta ao clique de botão/lista
|
||||
let nativeFlowBody = null;
|
||||
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
|
||||
try {
|
||||
const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}');
|
||||
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null;
|
||||
}
|
||||
catch { /* ignora JSON inválido */ }
|
||||
}
|
||||
const body = message.conversation ??
|
||||
message.extendedTextMessage?.text ??
|
||||
message.imageMessage?.caption ??
|
||||
message.videoMessage?.caption ??
|
||||
message.documentMessage?.caption ??
|
||||
nativeFlowBody ??
|
||||
null;
|
||||
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
|
||||
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null;
|
||||
let replyToId = null;
|
||||
if (replyToWaId) {
|
||||
const replyMsg = await prisma_1.prisma.message.findUnique({
|
||||
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
|
||||
});
|
||||
replyToId = replyMsg?.id ?? null;
|
||||
}
|
||||
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
|
||||
const saved = await prisma_1.prisma.message.upsert({
|
||||
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
|
||||
create: {
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
remoteJid,
|
||||
messageId,
|
||||
fromMe,
|
||||
type: msgType,
|
||||
body,
|
||||
pushName: fromMe ? null : (pushName ?? null),
|
||||
senderJid: isGroupMessage ? participantJid : null,
|
||||
replyToId,
|
||||
status: fromMe ? 'SENT' : 'PENDING',
|
||||
timestamp: new Date(messageTimestamp * 1000),
|
||||
},
|
||||
update: {}, // Se já existe (duplicata), não sobrescreve
|
||||
});
|
||||
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
|
||||
// 6a. Para quem está com o chat aberto (sala chat:{id})
|
||||
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
|
||||
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
|
||||
const emitMsg = replyToId
|
||||
? await prisma_1.prisma.message.findUnique({
|
||||
where: { id: saved.id },
|
||||
include: {
|
||||
replyTo: {
|
||||
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
|
||||
},
|
||||
},
|
||||
}) ?? saved
|
||||
: saved;
|
||||
this.io.to(`chat:${chat.id}`).emit('message:new', {
|
||||
...emitMsg,
|
||||
pushName,
|
||||
senderJid: isGroupMessage ? participantJid : null,
|
||||
});
|
||||
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
|
||||
hook_bus_1.hookBus.emit('ext:message.new', {
|
||||
instanceId: this.instanceId,
|
||||
tenantId: this.tenantId,
|
||||
chatId: chat.id,
|
||||
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
|
||||
timestamp: Date.now(),
|
||||
}).catch(() => { });
|
||||
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
|
||||
// Necessário para: novos chats, chats não abertos, badge de unread
|
||||
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp });
|
||||
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
|
||||
if (this.isMediaMessage(contentType)) {
|
||||
setImmediate(() => this.downloadAndPersistMedia(msg, saved.id, chat.id, contentType));
|
||||
}
|
||||
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
|
||||
if (!fromMe && body && this.chatbotService) {
|
||||
setImmediate(() => this.chatbotService.handleIncoming({
|
||||
tenantId: this.tenantId,
|
||||
instanceId: this.instanceId,
|
||||
chatId: chat.id,
|
||||
jid: remoteJid,
|
||||
text: body,
|
||||
sock: this.sock,
|
||||
}).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono')));
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// RESOLUÇÃO LID → TELEFONE
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
|
||||
* (ex: "5511999999999@s.whatsapp.net").
|
||||
*
|
||||
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
|
||||
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
|
||||
*
|
||||
* Fontes de resolução (em ordem de prioridade):
|
||||
* 1. key.senderPn / key.participantPn — campo do protocolo WA.
|
||||
* Útil para mensagens RECEBIDAS e contextos de grupo.
|
||||
* 2. peerRecipientHints[msgId] — dica extraída da stanza bruta
|
||||
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
|
||||
* Única fonte confiável para mensagens fromMe=true enviadas pelo
|
||||
* celular pra um contato @lid que ainda não temos mapeado.
|
||||
* 3. DB lookup — contacts.lidJid (mapeamento persistido anteriormente).
|
||||
* Populado por: processamento anterior, chats.phoneNumberShare,
|
||||
* ou contacts.upsert do history sync.
|
||||
*
|
||||
* A persistência do mapeamento LID→phone foi movida para processMessage
|
||||
* (após o upsert do Contact), para evitar o bug anterior onde o
|
||||
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
|
||||
*
|
||||
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
|
||||
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
|
||||
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
|
||||
*/
|
||||
async resolveLidToPhoneJid(lidJid, key) {
|
||||
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
|
||||
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
|
||||
// descreve o DONO da sessão, não o destinatário — inútil aqui.
|
||||
const senderPn = key.senderPn || key.participantPn;
|
||||
if (senderPn && !key.fromMe) {
|
||||
const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
|
||||
return phoneJid;
|
||||
}
|
||||
}
|
||||
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
|
||||
// mensagens fromMe=true que chegam via sync de outro dispositivo.
|
||||
if (key.fromMe && key.id) {
|
||||
const hint = this.peerRecipientHints.get(key.id);
|
||||
this.peerRecipientHints.delete(key.id); // uso único
|
||||
if (hint) {
|
||||
const phoneJid = (0, whatsapp_1.normalizeJid)(hint);
|
||||
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
|
||||
return phoneJid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Busca no DB por mapeamento LID salvo anteriormente
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
|
||||
select: { jid: true },
|
||||
});
|
||||
if (contact?.jid)
|
||||
return contact.jid;
|
||||
return null;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EMISSÃO DE EVENTOS SOCKET.IO
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Emite evento `chat:upsert` para todos os clientes do tenant.
|
||||
*
|
||||
* Usado para atualizar a lista de chats no frontend em tempo real:
|
||||
* - Novos chats aparecem instantaneamente
|
||||
* - Chats existentes atualizam lastMessage, unreadCount, etc.
|
||||
* - Chats @lid são filtrados (não devem aparecer no frontend)
|
||||
*
|
||||
* O frontend usa este evento para reordenar a lista por lastMessageAt
|
||||
* e exibir o preview da última mensagem.
|
||||
*/
|
||||
async emitChatUpsert(chatId, lastMessage) {
|
||||
try {
|
||||
const chat = await prisma_1.prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
name: true,
|
||||
verifiedName: true,
|
||||
notify: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!chat)
|
||||
return;
|
||||
// Chats @lid são artefatos internos — nunca expor ao frontend
|
||||
if (chat.jid.endsWith('@lid'))
|
||||
return;
|
||||
const c = chat.contact;
|
||||
const isGroup = chat.jid.endsWith('@g.us');
|
||||
// Regra de resolução de nome:
|
||||
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
|
||||
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
|
||||
// NUNCA usar pushName do remetente como nome de grupo
|
||||
const displayName = isGroup
|
||||
? (chat.name ?? null)
|
||||
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
|
||||
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
|
||||
id: chat.id,
|
||||
tenantId: chat.tenantId,
|
||||
instanceId: chat.instanceId,
|
||||
jid: chat.jid,
|
||||
name: chat.name ?? null,
|
||||
unreadCount: chat.unreadCount,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
isPinned: chat.isPinned,
|
||||
isArchived: chat.isArchived,
|
||||
botPaused: chat.botPaused,
|
||||
contact: c
|
||||
? {
|
||||
name: displayName,
|
||||
phone: c.phone,
|
||||
avatarUrl: c.avatarUrl,
|
||||
scoreReputacao: c.scoreReputacao,
|
||||
flagRestricao: c.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// DOWNLOAD DE MÍDIA
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
|
||||
*
|
||||
* Executado via setImmediate() para não bloquear o event loop.
|
||||
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
|
||||
* e emite `message:media_ready` para o frontend renderizar o preview.
|
||||
*
|
||||
* Organização: /media/{instanceId}/{messageId}.{ext}
|
||||
*/
|
||||
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
|
||||
try {
|
||||
const buffer = await (0, baileys_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
|
||||
if (!buffer)
|
||||
return;
|
||||
// Para documentos: preserva nome e extensão originais do arquivo
|
||||
const docMsg = msg.message?.documentMessage;
|
||||
const originalFileName = docMsg?.fileName?.trim() || null;
|
||||
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType);
|
||||
let ext;
|
||||
if (contentType === 'documentMessage' && originalFileName) {
|
||||
ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin';
|
||||
}
|
||||
else {
|
||||
ext = this.extensionForType(contentType);
|
||||
}
|
||||
const fileName = `${savedMsgId}.${ext}`;
|
||||
const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName);
|
||||
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
|
||||
await promises_1.default.writeFile(filePath, buffer);
|
||||
// Tenta upload para Wasabi; se falhar usa path local como fallback
|
||||
let mediaUrl = `/media/${this.instanceId}/${fileName}`;
|
||||
if (StorageProvider_1.storageProvider.isRegistered()) {
|
||||
try {
|
||||
const result = await StorageProvider_1.storageProvider.uploadFile({
|
||||
category: 'whatsapp',
|
||||
usuarioSis: this.tenantId,
|
||||
sessionJID: this.instanceId,
|
||||
file: {
|
||||
originalname: originalFileName ?? fileName,
|
||||
buffer: buffer,
|
||||
mimetype: originalMimetype,
|
||||
},
|
||||
});
|
||||
mediaUrl = result.path;
|
||||
logger_1.logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage');
|
||||
}
|
||||
catch (uploadErr) {
|
||||
logger_1.logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local');
|
||||
}
|
||||
}
|
||||
await prisma_1.prisma.message.update({
|
||||
where: { id: savedMsgId },
|
||||
data: {
|
||||
mediaPath: filePath,
|
||||
mediaUrl,
|
||||
// Preserva o nome original do documento para exibição no frontend
|
||||
...(contentType === 'documentMessage' && originalFileName
|
||||
? { fileName: originalFileName }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
// Notifica frontend que a mídia está pronta para exibição
|
||||
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
|
||||
messageId: savedMsgId,
|
||||
mediaUrl,
|
||||
});
|
||||
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
|
||||
}
|
||||
catch (err) {
|
||||
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
|
||||
}
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// UTILITÁRIOS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/** Verifica se o contentType é de mídia (precisa de download) */
|
||||
isMediaMessage(contentType) {
|
||||
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType);
|
||||
}
|
||||
/**
|
||||
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
|
||||
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
|
||||
*/
|
||||
mapContentType(contentType) {
|
||||
const map = {
|
||||
conversation: client_1.MessageType.TEXT,
|
||||
extendedTextMessage: client_1.MessageType.TEXT,
|
||||
imageMessage: client_1.MessageType.IMAGE,
|
||||
videoMessage: client_1.MessageType.VIDEO,
|
||||
audioMessage: client_1.MessageType.AUDIO,
|
||||
documentMessage: client_1.MessageType.DOCUMENT,
|
||||
stickerMessage: client_1.MessageType.STICKER,
|
||||
locationMessage: client_1.MessageType.LOCATION,
|
||||
contactMessage: client_1.MessageType.CONTACT,
|
||||
reactionMessage: client_1.MessageType.REACTION,
|
||||
pollCreationMessage: client_1.MessageType.POLL,
|
||||
pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos
|
||||
pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select
|
||||
// Resposta ao clique de botão / seleção de item de lista
|
||||
interactiveResponseMessage: client_1.MessageType.INTERACTIVE,
|
||||
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
|
||||
interactiveMessage: client_1.MessageType.BUTTONS,
|
||||
};
|
||||
return map[contentType] ?? client_1.MessageType.UNSUPPORTED;
|
||||
}
|
||||
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
|
||||
extensionForType(contentType) {
|
||||
const map = {
|
||||
imageMessage: 'jpg',
|
||||
videoMessage: 'mp4',
|
||||
audioMessage: 'ogg',
|
||||
documentMessage: 'bin',
|
||||
stickerMessage: 'webp',
|
||||
};
|
||||
return map[contentType] ?? 'bin';
|
||||
}
|
||||
/** Retorna o mimetype adequado para o tipo de mídia */
|
||||
mimetypeForType(contentType) {
|
||||
const map = {
|
||||
imageMessage: 'image/jpeg',
|
||||
videoMessage: 'video/mp4',
|
||||
audioMessage: 'audio/ogg',
|
||||
documentMessage: 'application/octet-stream',
|
||||
stickerMessage: 'image/webp',
|
||||
};
|
||||
return map[contentType] ?? 'application/octet-stream';
|
||||
}
|
||||
}
|
||||
exports.MessageHandler = MessageHandler;
|
||||
@@ -0,0 +1,68 @@
|
||||
"use strict";
|
||||
/**
|
||||
* Utilitários para manipulação de JIDs (Jabber IDs) do WhatsApp.
|
||||
*
|
||||
* O WhatsApp usa JIDs como identificadores únicos para contatos, grupos e canais:
|
||||
* - Contatos individuais: "5511999999999@s.whatsapp.net"
|
||||
* - Grupos: "120363012345678@g.us"
|
||||
* - Canais/newsletters: "120363012345678@newsletter"
|
||||
* - LID (Linked ID): "123456789012345@lid" (identificador interno moderno)
|
||||
* - Broadcast: "status@broadcast"
|
||||
*
|
||||
* JIDs de dispositivo incluem sufixo ":N" (ex: "5511999999999:1@s.whatsapp.net")
|
||||
* que identifica o dispositivo específico (multi-device). Para armazenamento
|
||||
* e comparação, normalizamos removendo esse sufixo.
|
||||
*
|
||||
* @see MessageHandler — usa normalizeJid para persistir mensagens
|
||||
* @see ContactHandler — usa para vincular contatos a chats
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.normalizeJid = normalizeJid;
|
||||
exports.extractPhone = extractPhone;
|
||||
/**
|
||||
* Normaliza um JID do WhatsApp removendo sufixos de dispositivo (:1, :2, etc).
|
||||
*
|
||||
* Exemplos:
|
||||
* "556199999999:1@s.whatsapp.net" → "556199999999@s.whatsapp.net"
|
||||
* "556199999999@s.whatsapp.net" → "556199999999@s.whatsapp.net" (sem mudança)
|
||||
* "120363012345@g.us" → "120363012345@g.us" (grupos não têm sufixo)
|
||||
* null / undefined → "" (string vazia como fallback seguro)
|
||||
*
|
||||
* IMPORTANTE: Não confundir com resolução LID. Este método apenas remove
|
||||
* o sufixo de dispositivo — NÃO converte @lid para @s.whatsapp.net.
|
||||
* Para resolução LID, veja MessageHandler.resolveLidToPhoneJid().
|
||||
*
|
||||
* @param jid - JID bruto do Baileys (pode conter sufixo de dispositivo)
|
||||
* @returns JID normalizado sem sufixo de dispositivo
|
||||
*/
|
||||
function normalizeJid(jid) {
|
||||
if (!jid)
|
||||
return '';
|
||||
const [userPart, domain] = jid.split('@');
|
||||
if (!domain)
|
||||
return jid; // Não é um JID válido (sem @), retorna como está
|
||||
// Remove sufixo de dispositivo: "556199999999:1" → "556199999999"
|
||||
const [cleanUser] = userPart.split(':');
|
||||
return `${cleanUser}@${domain}`;
|
||||
}
|
||||
/**
|
||||
* Extrai o número de telefone puro de um JID.
|
||||
*
|
||||
* Exemplos:
|
||||
* "5511999999999@s.whatsapp.net" → "5511999999999"
|
||||
* "5511999999999:1@s.whatsapp.net" → "5511999999999"
|
||||
* "120363012345@g.us" → null (grupos não têm telefone único)
|
||||
* null / undefined → null
|
||||
*
|
||||
* @param jid - JID do WhatsApp
|
||||
* @returns Número de telefone (apenas dígitos + código do país) ou null
|
||||
*/
|
||||
function extractPhone(jid) {
|
||||
if (!jid)
|
||||
return null;
|
||||
if (jid.endsWith('@g.us'))
|
||||
return null; // Grupos não têm "telefone" único no JID
|
||||
const [userPart] = jid.split('@');
|
||||
const [cleanUser] = userPart.split(':');
|
||||
return cleanUser;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,721 @@
|
||||
"use strict";
|
||||
/**
|
||||
* ProtocolEngine — Cérebro Stateful da Secretária IA
|
||||
*
|
||||
* Princípios de economia de tokens:
|
||||
* 1. Lê o ESTADO atual do protocolo (summary), não o histórico completo
|
||||
* 2. Carrega apenas as últimas N mensagens (context_window)
|
||||
* 3. Sumarização ativa a cada 10 trocas para manter o resumo atualizado
|
||||
* 4. Nós do cérebro são compostos apenas com os ativos
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ProtocolEngine = void 0;
|
||||
const tools_1 = require("./tools");
|
||||
class ProtocolEngine {
|
||||
constructor(db, config) {
|
||||
this.db = db;
|
||||
this.config = config;
|
||||
}
|
||||
// ── Chat ─────────────────────────────────────────────────────────────────
|
||||
async chat(conversationId, userMessage, opts) {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||
if (!conversation)
|
||||
throw new Error('Conversa não encontrada');
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first();
|
||||
if (!agent)
|
||||
throw new Error('Agente não encontrado');
|
||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
||||
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts);
|
||||
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||
const contextWindow = agent.context_window ?? 8;
|
||||
const recentMessages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(contextWindow)
|
||||
.then((rows) => rows.reverse());
|
||||
// Salva a mensagem do usuário
|
||||
await this.db('sec_messages').insert({
|
||||
id: this.uuid(),
|
||||
conversation_id: conversationId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
created_at: new Date(),
|
||||
});
|
||||
// Chama a IA — usa node_model do nó persona se definido (sobrepõe o agente)
|
||||
const personaNode = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: conversation.agent_id, type: 'persona', active: true })
|
||||
.orderBy('sort_order')
|
||||
.first();
|
||||
const agentOverride = personaNode?.node_model
|
||||
? { ...agent, model: personaNode.node_model }
|
||||
: agent;
|
||||
const messages = [
|
||||
...recentMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
// Resolve tools: usa lista passada por opts, ou todas as builtins por padrão
|
||||
const toolNames = opts?.tools ?? tools_1.ALL_TOOL_NAMES;
|
||||
const toolDefs = (0, tools_1.resolveTools)(toolNames);
|
||||
const toolCtx = {
|
||||
db: this.db,
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
hooks: opts?.hooks,
|
||||
};
|
||||
let response;
|
||||
let usageInfo = null;
|
||||
let providerUsed = null;
|
||||
let modelUsed = null;
|
||||
try {
|
||||
if (toolDefs.length > 0) {
|
||||
response = await this.callAIWithTools(agentOverride, systemPrompt, messages, toolDefs, toolCtx);
|
||||
// Telemetria escrita pelos tool loops via side channel (toolCtx._telemetry)
|
||||
if (toolCtx._telemetry) {
|
||||
usageInfo = toolCtx._telemetry.usage;
|
||||
providerUsed = toolCtx._telemetry.provider;
|
||||
modelUsed = toolCtx._telemetry.model;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const result = await this.callAI(agentOverride, systemPrompt, messages);
|
||||
response = result.text;
|
||||
usageInfo = result.usage;
|
||||
providerUsed = result.provider;
|
||||
modelUsed = result.model;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
response = `[Erro ao chamar IA: ${err.message}. Verifique a API Key nas configurações do plugin.]`;
|
||||
}
|
||||
// Salva resposta da IA com telemetria de tokens
|
||||
await this.db('sec_messages').insert({
|
||||
id: this.uuid(),
|
||||
conversation_id: conversationId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
usage_tokens: usageInfo ? JSON.stringify(usageInfo) : null,
|
||||
provider_used: providerUsed,
|
||||
model_used: modelUsed,
|
||||
created_at: new Date(),
|
||||
});
|
||||
// Atualiza conversa + sumariza a cada 10 trocas
|
||||
const totalMsgs = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.count('id as c')
|
||||
.first()
|
||||
.then((r) => Number(r?.c ?? 0));
|
||||
let summary = conversation.summary;
|
||||
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
||||
summary = await this.summarize(agent, recentMessages, userMessage, response);
|
||||
}
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ updated_at: new Date(), summary });
|
||||
return response;
|
||||
}
|
||||
// ── Protocol Number ───────────────────────────────────────────────────────
|
||||
static generateProtocolNumber() {
|
||||
const now = new Date();
|
||||
const p = (n, d = 2) => String(n).padStart(d, '0');
|
||||
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
||||
}
|
||||
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||
async buildSystemPrompt(agent, conversation, opts) {
|
||||
const nodes = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: agent.id, active: true })
|
||||
.orderBy('sort_order');
|
||||
let prompt = '';
|
||||
// Data/hora real — impede o modelo de alucinar a data
|
||||
const nowReal = new Date();
|
||||
const dtStr = nowReal.toLocaleString('pt-BR', {
|
||||
timeZone: 'America/Sao_Paulo',
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
prompt += `=== DATA E HORA ATUAL ===\n${dtStr} (horário de Brasília)\n\n`;
|
||||
// Cabeçalho do protocolo — sempre presente, leve (3 linhas)
|
||||
const protocolHeader = [
|
||||
`=== PROTOCOLO ATIVO ===`,
|
||||
`Número: ${conversation.protocol_number || '—'}`,
|
||||
`Contato: ${conversation.contact_name}`,
|
||||
`Status: ${conversation.status}`,
|
||||
``,
|
||||
].join('\n');
|
||||
prompt += protocolHeader;
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case 'persona':
|
||||
prompt += `${node.content}\n\n`;
|
||||
break;
|
||||
case 'knowledge':
|
||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`;
|
||||
break;
|
||||
case 'rules':
|
||||
prompt += `=== REGRAS ===\n${node.content}\n\n`;
|
||||
break;
|
||||
case 'calendar': {
|
||||
const calCtx = await this.getCalendarContext();
|
||||
prompt += `=== AGENDA DISPONÍVEL (próximos 7 dias) ===\n${calCtx}\n\nInstruções: ${node.content}\n\n`;
|
||||
break;
|
||||
}
|
||||
case 'escalation':
|
||||
prompt += `=== REGRAS DE ESCALADA ===\n${node.content}\n\n`;
|
||||
break;
|
||||
default:
|
||||
prompt += `${node.content}\n\n`;
|
||||
}
|
||||
}
|
||||
// Injeta contexto local do projeto (enviado pelo plugin satélite)
|
||||
if (opts?.contextData && Object.keys(opts.contextData).length > 0) {
|
||||
const ctx = JSON.stringify(opts.contextData, null, 2);
|
||||
prompt += `=== CONTEXTO DO CLIENTE (dados reais do projeto) ===\n${ctx}\n\n`;
|
||||
}
|
||||
// Prompt extra do plugin (instruções específicas da chamada)
|
||||
if (opts?.systemExtra?.trim()) {
|
||||
prompt += `=== INSTRUÇÕES ADICIONAIS ===\n${opts.systemExtra.trim()}\n\n`;
|
||||
}
|
||||
// Injeta resumo do estado atual (economia de tokens — evita reler o histórico)
|
||||
if (conversation.summary) {
|
||||
prompt += `=== ESTADO ATUAL DA CONVERSA ===\n${conversation.summary}\n\n`;
|
||||
}
|
||||
return prompt.trim();
|
||||
}
|
||||
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||
async finalizeProtocol(conversationId) {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||
if (!conversation)
|
||||
throw new Error('Conversa não encontrada');
|
||||
if (conversation.status === 'closed') {
|
||||
return { summary: conversation.summary ?? '', protocol_number: conversation.protocol_number };
|
||||
}
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first();
|
||||
if (!agent)
|
||||
throw new Error('Agente não encontrado');
|
||||
// Carrega todas as mensagens para gerar resumo completo
|
||||
const messages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at');
|
||||
let summary = conversation.summary ?? '';
|
||||
if (messages.length > 0) {
|
||||
const transcript = messages
|
||||
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Ana'}: ${m.content}`)
|
||||
.join('\n');
|
||||
const summaryPrompt = `Gere um resumo estruturado desta conversa de atendimento para uso futuro como contexto rápido.\nInclua: motivo do contato, o que foi resolvido, próximos passos pendentes (se houver).\nMáximo 5 linhas. Seja objetivo.\n\nProtocolo: ${conversation.protocol_number}\nContato: ${conversation.contact_name}\n\n${transcript}`;
|
||||
const cheapModel = {
|
||||
openai: 'gpt-4o-mini', anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash', ollama: agent.model ?? 'llama3',
|
||||
};
|
||||
const finalAgent = {
|
||||
...agent, temperature: 0.2, max_tokens: 200,
|
||||
model: cheapModel[agent.provider] ?? agent.model,
|
||||
};
|
||||
try {
|
||||
const result = await this.callAI(finalAgent, '', [{ role: 'user', content: summaryPrompt }]);
|
||||
summary = result.text;
|
||||
}
|
||||
catch {
|
||||
summary = conversation.summary ?? `Protocolo ${conversation.protocol_number} encerrado.`;
|
||||
}
|
||||
}
|
||||
// Apaga mensagens — contexto comprimido no resumo (economia de tokens)
|
||||
await this.db('sec_messages').where({ conversation_id: conversationId }).delete();
|
||||
// Fecha o protocolo com resumo persistido
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ status: 'closed', summary, updated_at: new Date() });
|
||||
return { summary, protocol_number: conversation.protocol_number };
|
||||
}
|
||||
// ── AI Call ───────────────────────────────────────────────────────────────
|
||||
buildFallbackChain(agent, cfg) {
|
||||
const chainStr = cfg.fallback_chain ?? 'openai,gemini,anthropic,ollama';
|
||||
const order = chainStr.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const defaults = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: 'llama3',
|
||||
};
|
||||
const hasKey = (p) => {
|
||||
if (p === 'openai')
|
||||
return !!cfg.openai_key;
|
||||
if (p === 'anthropic')
|
||||
return !!cfg.anthropic_key;
|
||||
if (p === 'gemini')
|
||||
return !!cfg.gemini_key;
|
||||
if (p === 'ollama')
|
||||
return true; // local, sempre disponível
|
||||
return false;
|
||||
};
|
||||
const agentProvider = agent.provider ?? 'openai';
|
||||
const agentModel = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini';
|
||||
const chain = [{ provider: agentProvider, model: agentModel }];
|
||||
for (const p of order) {
|
||||
if (p === agentProvider)
|
||||
continue;
|
||||
if (!hasKey(p))
|
||||
continue;
|
||||
chain.push({ provider: p, model: defaults[p] ?? p });
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
isRecoverableError(err) {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (msg.includes('quota') ||
|
||||
msg.includes('rate limit') ||
|
||||
msg.includes('limite da api') ||
|
||||
msg.includes('exceeded') ||
|
||||
msg.includes('billing') ||
|
||||
msg.includes('insufficient') ||
|
||||
msg.includes('invalid_api_key') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('enotfound') ||
|
||||
msg.includes('não configurada'));
|
||||
}
|
||||
async callAI(agent, systemPrompt, messages) {
|
||||
const cfg = await this.config.get('secretaria');
|
||||
const chain = this.buildFallbackChain(agent, cfg);
|
||||
let lastError = new Error('Nenhum provider disponível');
|
||||
for (const entry of chain) {
|
||||
try {
|
||||
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages);
|
||||
}
|
||||
catch (err) {
|
||||
lastError = err;
|
||||
if (this.isRecoverableError(err))
|
||||
continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`);
|
||||
}
|
||||
/**
|
||||
* Chama o provider e retorna { text, usage, provider, model }.
|
||||
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
||||
* Reads agent.max_tokens (default 250 — adequado a WhatsApp).
|
||||
*/
|
||||
async callProvider(provider, model, agent, cfg, systemPrompt, messages) {
|
||||
const maxTokens = agent.max_tokens ?? 250;
|
||||
const temperature = agent.temperature ?? 0.7;
|
||||
// ── OpenAI ────────────────────────────────────────────────────────────────
|
||||
if (provider === 'openai') {
|
||||
const apiKey = cfg.openai_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('OpenAI API Key não configurada. Acesse Admin → Plugins → Secretária IA.');
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `OpenAI ${res.status}`);
|
||||
const text = data.choices[0].message.content;
|
||||
const usage = {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
total: data.usage?.total_tokens ?? 0,
|
||||
};
|
||||
return { text, usage, provider, model };
|
||||
}
|
||||
// ── Anthropic (com prompt caching ephemeral no system) ───────────────────
|
||||
if (provider === 'anthropic') {
|
||||
const apiKey = cfg.anthropic_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Anthropic API Key não configurada. Acesse Admin → Plugins → Secretária IA.');
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
// Header necessário até GA do prompt caching
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
// System como array com cache_control: trecho fica em cache 5min
|
||||
// Próximas chamadas com mesmo systemPrompt pagam ~10% pelo trecho cacheado.
|
||||
system: [
|
||||
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `Anthropic ${res.status}`);
|
||||
const text = data.content[0].text;
|
||||
const usage = {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_create: data.usage?.cache_creation_input_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
total: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
|
||||
};
|
||||
return { text, usage, provider, model };
|
||||
}
|
||||
// ── Google Gemini ─────────────────────────────────────────────────────────
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = cfg.gemini_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Google Gemini API Key não configurada. Acesse Admin → Plugins → Secretária IA.');
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash';
|
||||
const geminiMessages = messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
}));
|
||||
const geminiBody = JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents: geminiMessages,
|
||||
generationConfig: { temperature, maxOutputTokens: maxTokens },
|
||||
});
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`;
|
||||
const doGeminiCall = async () => fetch(geminiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: geminiBody,
|
||||
});
|
||||
let res = await doGeminiCall();
|
||||
let data = (await res.json());
|
||||
if (!res.ok && (res.status === 429 || String(data.error?.message ?? '').toLowerCase().includes('quota'))) {
|
||||
const msg = data.error?.message ?? '';
|
||||
const match = msg.match(/retry in ([\d.]+)s/i);
|
||||
const waitMs = match ? Math.min(Math.ceil(parseFloat(match[1])) * 1000, 30000) : 5000;
|
||||
await new Promise((r) => setTimeout(r, waitMs));
|
||||
res = await doGeminiCall();
|
||||
data = (await res.json());
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errMsg = data.error?.message ?? `Gemini ${res.status}`;
|
||||
if (errMsg.toLowerCase().includes('quota') || res.status === 429) {
|
||||
throw new Error('Limite da API Gemini atingido. Aguarde alguns instantes e tente novamente.');
|
||||
}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
const text = data.candidates[0].content.parts[0].text;
|
||||
const usage = {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
total: data.usageMetadata?.totalTokenCount ?? 0,
|
||||
};
|
||||
return { text, usage, provider, model: geminiModel };
|
||||
}
|
||||
// ── Ollama (local) ────────────────────────────────────────────────────────
|
||||
if (provider === 'ollama') {
|
||||
const baseUrl = cfg.ollama_url ?? 'http://localhost:11434';
|
||||
const ollamaModel = model || 'llama3';
|
||||
const res = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: ollamaModel,
|
||||
stream: false,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||
options: { temperature, num_predict: maxTokens },
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error ?? `Ollama ${res.status}`);
|
||||
const text = data.message.content;
|
||||
const usage = {
|
||||
input: data.prompt_eval_count ?? 0,
|
||||
output: data.eval_count ?? 0,
|
||||
total: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
|
||||
};
|
||||
return { text, usage, provider, model: ollamaModel };
|
||||
}
|
||||
throw new Error(`Provider "${provider}" não suportado. Use: openai, anthropic, gemini, ollama`);
|
||||
}
|
||||
// ── Calendar Context ──────────────────────────────────────────────────────
|
||||
async getCalendarContext() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
const nextWeekStr = nextWeek.toISOString().split('T')[0];
|
||||
const slots = await this.db('sec_calendar')
|
||||
.whereIn('status', ['available', 'booked'])
|
||||
.whereBetween('date', [today, nextWeekStr])
|
||||
.orderBy('date')
|
||||
.orderBy('time_start')
|
||||
.limit(30);
|
||||
if (slots.length === 0)
|
||||
return 'Nenhum horário nos próximos 7 dias.';
|
||||
const lines = slots.map((s) => {
|
||||
const time = `${s.date} ${s.time_start.slice(0, 5)}–${s.time_end.slice(0, 5)}`;
|
||||
if (s.status === 'booked') {
|
||||
const who = s.attendee_name ? ` | Paciente: ${s.attendee_name}` : '';
|
||||
const phone = s.attendee_phone ? ` (${s.attendee_phone})` : '';
|
||||
return `• [AGENDADO] ${time}: ${s.title}${who}${phone}`;
|
||||
}
|
||||
return `• [DISPONÍVEL] ${time}: ${s.title}`;
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
// ── Summarization (token economy) ────────────────────────────────────────
|
||||
/**
|
||||
* Sumarização com modelo barato (M1.5).
|
||||
* Força modelo "mini/haiku/flash" mesmo que o agente principal use modelo caro.
|
||||
* Sumário é tarefa simples — não precisa do modelo de produção.
|
||||
*/
|
||||
async summarize(agent, recentMsgs, lastUser, lastAssistant) {
|
||||
const excerpt = [
|
||||
...recentMsgs.slice(-6).map((m) => `${m.role}: ${m.content}`),
|
||||
`user: ${lastUser}`,
|
||||
`assistant: ${lastAssistant}`,
|
||||
].join('\n');
|
||||
const prompt = `Resuma em no máximo 2 frases curtas o estado atual desta conversa de atendimento, focando no tema e próximo passo:\n\n${excerpt}`;
|
||||
// Modelo barato por provider
|
||||
const cheapModel = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: agent.model ?? 'llama3',
|
||||
};
|
||||
const summaryAgent = {
|
||||
...agent,
|
||||
temperature: 0.3,
|
||||
max_tokens: 120,
|
||||
model: cheapModel[agent.provider] ?? agent.model,
|
||||
};
|
||||
try {
|
||||
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }]);
|
||||
return result.text;
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
// ── Tool Calling ──────────────────────────────────────────────────────────
|
||||
async callAIWithTools(agent, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const cfg = await this.config.get('secretaria');
|
||||
const chain = this.buildFallbackChain(agent, cfg);
|
||||
// Prefere provider com suporte a tool calling; Ollama cai em modo texto
|
||||
const TOOL_PROVIDERS = ['openai', 'anthropic', 'gemini'];
|
||||
const entry = chain.find(e => TOOL_PROVIDERS.includes(e.provider));
|
||||
if (!entry) {
|
||||
// Nenhum provider com tool calling disponível — usa modo texto normal
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text;
|
||||
}
|
||||
try {
|
||||
switch (entry.provider) {
|
||||
case 'openai':
|
||||
return await this.openAIToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx);
|
||||
case 'anthropic':
|
||||
return await this.anthropicToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx);
|
||||
case 'gemini':
|
||||
return await this.geminiToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx);
|
||||
default:
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.isRecoverableError(err)) {
|
||||
// Provider com tools falhou — tenta sem tools no próximo da chain
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async executeTool(name, rawArgs, tools, toolCtx) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
if (!tool)
|
||||
return { error: `Tool "${name}" não encontrada.` };
|
||||
const args = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs;
|
||||
try {
|
||||
return await tool.execute(args, toolCtx);
|
||||
}
|
||||
catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
// ── Telemetria helper para tool loops ─────────────────────────────────────
|
||||
accumTelemetry(toolCtx, provider, model, incremental) {
|
||||
if (!toolCtx._telemetry) {
|
||||
toolCtx._telemetry = {
|
||||
usage: { input: 0, output: 0, total: 0, cache_read: 0, cached: 0 },
|
||||
provider, model, iterations: 0,
|
||||
};
|
||||
}
|
||||
toolCtx._telemetry.iterations += 1;
|
||||
toolCtx._telemetry.usage.input += incremental.input;
|
||||
toolCtx._telemetry.usage.output += incremental.output;
|
||||
toolCtx._telemetry.usage.total += incremental.input + incremental.output;
|
||||
if (incremental.cache_read)
|
||||
toolCtx._telemetry.usage.cache_read = (toolCtx._telemetry.usage.cache_read ?? 0) + incremental.cache_read;
|
||||
if (incremental.cached)
|
||||
toolCtx._telemetry.usage.cached = (toolCtx._telemetry.usage.cached ?? 0) + incremental.cached;
|
||||
}
|
||||
// ── OpenAI tool loop ───────────────────────────────────────────────────────
|
||||
async openAIToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const apiKey = cfg.openai_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('OpenAI API Key não configurada');
|
||||
const oaiTools = tools.map(t => ({
|
||||
type: 'function',
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters },
|
||||
}));
|
||||
let msgs = [...inputMessages];
|
||||
const MAX_ITER = 5;
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: agent.temperature ?? 0.7,
|
||||
max_tokens: agent.max_tokens ?? 250,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...msgs],
|
||||
tools: oaiTools,
|
||||
tool_choice: 'auto',
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `OpenAI ${res.status}`);
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'openai', model, {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
});
|
||||
const choice = data.choices[0];
|
||||
const assistantMsg = choice.message;
|
||||
if (choice.finish_reason !== 'tool_calls' || !assistantMsg.tool_calls?.length) {
|
||||
return (assistantMsg.content ?? '');
|
||||
}
|
||||
// Execute tools in parallel
|
||||
msgs.push(assistantMsg);
|
||||
const toolResults = await Promise.all(assistantMsg.tool_calls.map(async (tc) => {
|
||||
const result = await this.executeTool(tc.function.name, tc.function.arguments, tools, toolCtx);
|
||||
return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) };
|
||||
}));
|
||||
msgs.push(...toolResults);
|
||||
}
|
||||
throw new Error('Tool calling: limite de iterações atingido');
|
||||
}
|
||||
// ── Anthropic tool loop ────────────────────────────────────────────────────
|
||||
async anthropicToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const apiKey = cfg.anthropic_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Anthropic API Key não configurada');
|
||||
const anthropicTools = tools.map(t => ({
|
||||
name: t.name, description: t.description, input_schema: t.parameters,
|
||||
}));
|
||||
let msgs = [...inputMessages];
|
||||
const MAX_ITER = 5;
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
|
||||
messages: msgs, tools: anthropicTools,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `Anthropic ${res.status}`);
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'anthropic', model, {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
});
|
||||
// Texto puro
|
||||
if (data.stop_reason !== 'tool_use') {
|
||||
const textBlock = data.content.find(b => b.type === 'text');
|
||||
return (textBlock?.text ?? '');
|
||||
}
|
||||
// Tool calls
|
||||
msgs.push({ role: 'assistant', content: data.content });
|
||||
const toolResults = await Promise.all(data.content
|
||||
.filter(b => b.type === 'tool_use')
|
||||
.map(async (b) => {
|
||||
const result = await this.executeTool(b.name, b.input, tools, toolCtx);
|
||||
return { type: 'tool_result', tool_use_id: b.id, content: JSON.stringify(result) };
|
||||
}));
|
||||
msgs.push({ role: 'user', content: toolResults });
|
||||
}
|
||||
throw new Error('Tool calling (Anthropic): limite de iterações atingido');
|
||||
}
|
||||
// ── Gemini tool loop ───────────────────────────────────────────────────────
|
||||
async geminiToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const apiKey = cfg.gemini_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Gemini API Key não configurada');
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash';
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`;
|
||||
const geminiTools = [{
|
||||
functionDeclarations: tools.map(t => ({
|
||||
name: t.name, description: t.description, parameters: t.parameters,
|
||||
})),
|
||||
}];
|
||||
// Converte msgs para formato Gemini
|
||||
let contents = inputMessages.map(m => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
}));
|
||||
const MAX_ITER = 5;
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents,
|
||||
tools: geminiTools,
|
||||
generationConfig: { temperature: agent.temperature ?? 0.7, maxOutputTokens: agent.max_tokens ?? 250 },
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `Gemini ${res.status}`);
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'gemini', geminiModel, {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
});
|
||||
const candidate = data.candidates?.[0];
|
||||
const parts = candidate?.content?.parts ?? [];
|
||||
// Verifica se há function calls
|
||||
const fnCalls = parts.filter(p => p.functionCall);
|
||||
if (!fnCalls.length) {
|
||||
const textPart = parts.find(p => p.text);
|
||||
return (textPart?.text ?? '');
|
||||
}
|
||||
// Adiciona resposta do modelo ao histórico
|
||||
contents.push({ role: 'model', parts });
|
||||
// Executa tools e injeta resultados
|
||||
const resultParts = await Promise.all(fnCalls.map(async (p) => {
|
||||
const result = await this.executeTool(p.functionCall.name, p.functionCall.args ?? {}, tools, toolCtx);
|
||||
return { functionResponse: { name: p.functionCall.name, response: result } };
|
||||
}));
|
||||
contents.push({ role: 'user', parts: resultParts });
|
||||
}
|
||||
throw new Error('Tool calling (Gemini): limite de iterações atingido');
|
||||
}
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||
uuid() {
|
||||
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ProtocolEngine = ProtocolEngine;
|
||||
@@ -0,0 +1,160 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS = void 0;
|
||||
exports.resolveTools = resolveTools;
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function fmtDate(d) {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
// ── Tools ──────────────────────────────────────────────────────────────────────
|
||||
exports.BUILTIN_TOOLS = [
|
||||
// ── listar_horarios ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'listar_horarios',
|
||||
description: 'Lista os horários disponíveis na agenda para agendamento. ' +
|
||||
'Use antes de propor datas ao cliente. ' +
|
||||
'Se não informar a data, retorna os próximos 7 dias.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'string',
|
||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const from = args['data'] ?? fmtDate(new Date());
|
||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.whereBetween('date', [from, to])
|
||||
.orderBy('date').orderBy('time_start')
|
||||
.limit(20);
|
||||
if (!slots.length) {
|
||||
return { disponivel: false, mensagem: 'Nenhum horário disponível no período informado.' };
|
||||
}
|
||||
return {
|
||||
disponivel: true,
|
||||
horarios: slots.map(s => ({
|
||||
id: s.id,
|
||||
data: s.date,
|
||||
inicio: String(s.time_start).slice(0, 5),
|
||||
fim: String(s.time_end).slice(0, 5),
|
||||
titulo: s.title,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── agendar_horario ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'agendar_horario',
|
||||
description: 'Reserva um horário disponível na agenda para o cliente. ' +
|
||||
'Use listar_horarios primeiro para obter o slot_id correto.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slot_id: { type: 'string', description: 'ID do horário retornado por listar_horarios.' },
|
||||
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['slot_id', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const slot = await ctx.db('sec_calendar')
|
||||
.where({ id: args['slot_id'], status: 'available' })
|
||||
.first();
|
||||
if (!slot) {
|
||||
return { ok: false, erro: 'Horário não encontrado ou já reservado. Chame listar_horarios novamente.' };
|
||||
}
|
||||
await ctx.db('sec_calendar').where({ id: args['slot_id'] }).update({
|
||||
status: 'booked',
|
||||
attendee_name: args['nome_cliente'],
|
||||
attendee_phone: args['telefone_cliente'] ?? null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
confirmacao: {
|
||||
data: slot.date,
|
||||
inicio: String(slot.time_start).slice(0, 5),
|
||||
fim: String(slot.time_end).slice(0, 5),
|
||||
titulo: slot.title,
|
||||
nome: args['nome_cliente'],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
description: 'Transfere o atendimento para um atendente humano quando a situação exige. ' +
|
||||
'Use quando: cliente pede explicitamente, problema financeiro sensível, raiva intensa, ' +
|
||||
'ou quando você não consegue resolver após tentativas honestas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
motivo: {
|
||||
type: 'string',
|
||||
description: 'Motivo da transferência (ex: "cliente insatisfeito com cobrança").',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
handoff_mode: 'humano',
|
||||
handoff_human_at: new Date(),
|
||||
status: 'escalated',
|
||||
});
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId;
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
chatId,
|
||||
protocolNumber: conv?.protocol_number ?? '',
|
||||
motivo: args['motivo'] ?? '',
|
||||
};
|
||||
ctx.hooks.emit('ext:handoff', { ...payload, mode: 'humano', reason: 'escalation' }).catch(() => { });
|
||||
ctx.hooks.emit('ext:escalated', payload).catch(() => { });
|
||||
}
|
||||
return { ok: true, escalado: true, motivo: args['motivo'] ?? 'Solicitado pelo sistema' };
|
||||
},
|
||||
},
|
||||
// ── encerrar_protocolo ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'encerrar_protocolo',
|
||||
description: 'Encerra o protocolo de atendimento após resolver o problema do cliente. ' +
|
||||
'Use apenas quando tiver certeza de que tudo foi resolvido.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resumo: {
|
||||
type: 'string',
|
||||
description: 'Breve resumo do que foi tratado e resolvido neste atendimento.',
|
||||
},
|
||||
},
|
||||
required: ['resumo'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const summary = args['resumo'] ?? 'Atendimento encerrado.';
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
status: 'closed',
|
||||
summary,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await ctx.db('sec_messages').where({ conversation_id: ctx.conversationId }).delete();
|
||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary };
|
||||
},
|
||||
},
|
||||
];
|
||||
function resolveTools(names) {
|
||||
return names
|
||||
.map(n => exports.BUILTIN_TOOLS.find(t => t.name === n))
|
||||
.filter((t) => t !== undefined);
|
||||
}
|
||||
exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS.map(t => t.name);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildWebhookDispatcher = buildWebhookDispatcher;
|
||||
/**
|
||||
* Webhook Dispatcher — envia eventos para URLs registradas na tabela ext_webhooks.
|
||||
*
|
||||
* Fluxo:
|
||||
* hookBus emite ext:message.new ou ext:session.status
|
||||
* → dispatcher consulta ext_webhooks do tenant
|
||||
* → para cada webhook ativo que subscreve o evento, faz POST com envelope + assinatura HMAC
|
||||
*
|
||||
* Envelope enviado ao receptor:
|
||||
* { event, data, timestamp }
|
||||
*
|
||||
* Header de assinatura:
|
||||
* x-nw-signature: sha256=<hmac-hex>
|
||||
* HMAC-SHA256(secret, JSON.stringify(body))
|
||||
*
|
||||
* Retry: sem retry automático nesta fase (Fase 5 scope mínimo).
|
||||
* Timeout: 10s por requisição para não bloquear o event loop.
|
||||
*/
|
||||
const crypto_1 = require("crypto");
|
||||
let rootLogger;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
rootLogger = require('../../../dist/config/logger').logger;
|
||||
}
|
||||
else {
|
||||
rootLogger = require('../../../backend/src/config/logger').logger;
|
||||
}
|
||||
const logger = rootLogger.child({ module: 'webhook-dispatcher' });
|
||||
const DISPATCH_TIMEOUT_MS = 10000;
|
||||
// Eventos hookBus → nome de evento no payload enviado ao receptor
|
||||
const HOOK_TO_EVENT = {
|
||||
'ext:message.new': 'message.new',
|
||||
'ext:session.status': 'session.status',
|
||||
};
|
||||
function sign(secret, body) {
|
||||
return 'sha256=' + (0, crypto_1.createHmac)('sha256', secret).update(body).digest('hex');
|
||||
}
|
||||
async function dispatch(url, secret, event, data) {
|
||||
const body = JSON.stringify({ event, data, timestamp: Date.now() });
|
||||
const sig = sign(secret, body);
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), DISPATCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-nw-signature': sig,
|
||||
},
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (!res.ok) {
|
||||
logger.warn({ url, event, status: res.status }, '[webhook] Receptor retornou erro');
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
logger.warn({ url, event, err: err.message }, '[webhook] Falha ao entregar');
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
function buildWebhookDispatcher(prisma, hooks) {
|
||||
for (const [hookEvent, wsEvent] of Object.entries(HOOK_TO_EVENT)) {
|
||||
hooks.register(hookEvent, async (raw) => {
|
||||
const tenantId = raw.tenantId;
|
||||
if (!tenantId)
|
||||
return;
|
||||
// Carregar apenas webhooks ativos que subscrevem este evento
|
||||
let webhooks;
|
||||
try {
|
||||
webhooks = await prisma.extWebhook.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
active: true,
|
||||
events: { has: wsEvent },
|
||||
},
|
||||
select: { url: true, secret: true },
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger.error({ err, tenantId, event: wsEvent }, '[webhook] Erro ao buscar webhooks');
|
||||
return;
|
||||
}
|
||||
if (webhooks.length === 0)
|
||||
return;
|
||||
// Retira tenantId do payload antes de enviar ao receptor
|
||||
const { tenantId: _tid, ...data } = raw;
|
||||
// Dispara em paralelo sem bloquear o hookBus
|
||||
Promise.allSettled(webhooks.map(wh => dispatch(wh.url, wh.secret, wsEvent, data))).catch(() => { });
|
||||
});
|
||||
}
|
||||
logger.info('[webhook] Dispatcher ativo para eventos: ' + Object.values(HOOK_TO_EVENT).join(', '));
|
||||
}
|
||||
//# sourceMappingURL=webhook-dispatcher.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"webhook-dispatcher.js","sourceRoot":"","sources":["webhook-dispatcher.ts"],"names":[],"mappings":";;AA0EA,wDAmCC;AA7GD;;;;;;;;;;;;;;;;;GAiBG;AACH,mCAAmC;AAGnC,IAAI,UAAe,CAAA;AACnB,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IACxC,UAAU,GAAG,OAAO,CAAC,6BAA6B,CAAC,CAAC,MAAM,CAAA;AAC9D,CAAC;KAAM,CAAC;IACJ,UAAU,GAAG,OAAO,CAAC,oCAAoC,CAAC,CAAC,MAAM,CAAA;AACrE,CAAC;AAED,MAAM,MAAM,GAAG,UAAU,CAAC,KAAK,CAAC,EAAE,MAAM,EAAE,oBAAoB,EAAE,CAAC,CAAA;AAEjE,MAAM,mBAAmB,GAAG,KAAM,CAAA;AAElC,kEAAkE;AAClE,MAAM,aAAa,GAA2B;IAC5C,iBAAiB,EAAK,aAAa;IACnC,oBAAoB,EAAE,gBAAgB;CACvC,CAAA;AAED,SAAS,IAAI,CAAC,MAAc,EAAE,IAAY;IACxC,OAAO,SAAS,GAAG,IAAA,mBAAU,EAAC,QAAQ,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC5E,CAAC;AAED,KAAK,UAAU,QAAQ,CACrB,GAAW,EACX,MAAc,EACd,KAAa,EACb,IAA6B;IAE7B,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;IACnE,MAAM,GAAG,GAAI,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,CAAA;IAE/B,MAAM,IAAI,GAAG,IAAI,eAAe,EAAE,CAAA;IAClC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,EAAE,mBAAmB,CAAC,CAAA;IAEjE,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,KAAK,CAAC,GAAG,EAAE;YAC3B,MAAM,EAAG,MAAM;YACf,OAAO,EAAE;gBACP,cAAc,EAAI,kBAAkB;gBACpC,gBAAgB,EAAE,GAAG;aACtB;YACD,IAAI;YACJ,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB,CAAC,CAAA;QACF,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,EAAE,kCAAkC,CAAC,CAAA;QACrF,CAAC;IACH,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,GAAG,CAAC,OAAO,EAAE,EAAE,6BAA6B,CAAC,CAAA;IAC9E,CAAC;YAAS,CAAC;QACT,YAAY,CAAC,KAAK,CAAC,CAAA;IACrB,CAAC;AACH,CAAC;AAED,SAAgB,sBAAsB,CAAC,MAAoB,EAAE,KAAc;IACzE,KAAK,MAAM,CAAC,SAAS,EAAE,OAAO,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,aAAa,CAAC,EAAE,CAAC;QACjE,KAAK,CAAC,QAAQ,CAAC,SAAS,EAAE,KAAK,EAAE,GAAQ,EAAE,EAAE;YAC3C,MAAM,QAAQ,GAAuB,GAAG,CAAC,QAAQ,CAAA;YACjD,IAAI,CAAC,QAAQ;gBAAE,OAAM;YAErB,6DAA6D;YAC7D,IAAI,QAAgD,CAAA;YACpD,IAAI,CAAC;gBACH,QAAQ,GAAG,MAAM,MAAM,CAAC,UAAU,CAAC,QAAQ,CAAC;oBAC1C,KAAK,EAAE;wBACL,QAAQ;wBACR,MAAM,EAAE,IAAI;wBACZ,MAAM,EAAE,EAAE,GAAG,EAAE,OAAO,EAAE;qBACzB;oBACD,MAAM,EAAE,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE;iBACpC,CAAC,CAAA;YACJ,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,MAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,KAAK,EAAE,OAAO,EAAE,EAAE,mCAAmC,CAAC,CAAA;gBACpF,OAAM;YACR,CAAC;YAED,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;gBAAE,OAAM;YAEjC,yDAAyD;YACzD,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,IAAI,EAAE,GAAG,GAAG,CAAA;YAEvC,6CAA6C;YAC7C,OAAO,CAAC,UAAU,CAChB,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,QAAQ,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,CAAC,CAC/D,CAAC,KAAK,CAAC,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;QACnB,CAAC,CAAC,CAAA;IACJ,CAAC;IAED,MAAM,CAAC,IAAI,CAAC,2CAA2C,GAAG,MAAM,CAAC,MAAM,CAAC,aAAa,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAA;AACpG,CAAC"}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Webhook Dispatcher — envia eventos para URLs registradas na tabela ext_webhooks.
|
||||
*
|
||||
* Fluxo:
|
||||
* hookBus emite ext:message.new ou ext:session.status
|
||||
* → dispatcher consulta ext_webhooks do tenant
|
||||
* → para cada webhook ativo que subscreve o evento, faz POST com envelope + assinatura HMAC
|
||||
*
|
||||
* Envelope enviado ao receptor:
|
||||
* { event, data, timestamp }
|
||||
*
|
||||
* Header de assinatura:
|
||||
* x-nw-signature: sha256=<hmac-hex>
|
||||
* HMAC-SHA256(secret, JSON.stringify(body))
|
||||
*
|
||||
* Retry: sem retry automático nesta fase (Fase 5 scope mínimo).
|
||||
* Timeout: 10s por requisição para não bloquear o event loop.
|
||||
*/
|
||||
import { createHmac } from 'crypto'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
||||
let rootLogger: any
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
rootLogger = require('../../../dist/config/logger').logger
|
||||
} else {
|
||||
rootLogger = require('../../../backend/src/config/logger').logger
|
||||
}
|
||||
|
||||
const logger = rootLogger.child({ module: 'webhook-dispatcher' })
|
||||
|
||||
const DISPATCH_TIMEOUT_MS = 10_000
|
||||
|
||||
// Eventos hookBus → nome de evento no payload enviado ao receptor
|
||||
const HOOK_TO_EVENT: Record<string, string> = {
|
||||
'ext:message.new': 'message.new',
|
||||
'ext:session.status': 'session.status',
|
||||
}
|
||||
|
||||
function sign(secret: string, body: string): string {
|
||||
return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
|
||||
}
|
||||
|
||||
async function dispatch(
|
||||
url: string,
|
||||
secret: string,
|
||||
event: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const body = JSON.stringify({ event, data, timestamp: Date.now() })
|
||||
const sig = sign(secret, body)
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), DISPATCH_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-nw-signature': sig,
|
||||
},
|
||||
body,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
logger.warn({ url, event, status: res.status }, '[webhook] Receptor retornou erro')
|
||||
}
|
||||
} catch (err: any) {
|
||||
logger.warn({ url, event, err: err.message }, '[webhook] Falha ao entregar')
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWebhookDispatcher(prisma: PrismaClient, hooks: HookBus): void {
|
||||
for (const [hookEvent, wsEvent] of Object.entries(HOOK_TO_EVENT)) {
|
||||
hooks.register(hookEvent, async (raw: any) => {
|
||||
const tenantId: string | undefined = raw.tenantId
|
||||
if (!tenantId) return
|
||||
|
||||
// Carregar apenas webhooks ativos que subscrevem este evento
|
||||
let webhooks: Array<{ url: string; secret: string }>
|
||||
try {
|
||||
webhooks = await prisma.extWebhook.findMany({
|
||||
where: {
|
||||
tenantId,
|
||||
active: true,
|
||||
events: { has: wsEvent },
|
||||
},
|
||||
select: { url: true, secret: true },
|
||||
})
|
||||
} catch (err: any) {
|
||||
logger.error({ err, tenantId, event: wsEvent }, '[webhook] Erro ao buscar webhooks')
|
||||
return
|
||||
}
|
||||
|
||||
if (webhooks.length === 0) return
|
||||
|
||||
// Retira tenantId do payload antes de enviar ao receptor
|
||||
const { tenantId: _tid, ...data } = raw
|
||||
|
||||
// Dispara em paralelo sem bloquear o hookBus
|
||||
Promise.allSettled(
|
||||
webhooks.map(wh => dispatch(wh.url, wh.secret, wsEvent, data))
|
||||
).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
logger.info('[webhook] Dispatcher ativo para eventos: ' + Object.values(HOOK_TO_EVENT).join(', '))
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.buildWsBridge = buildWsBridge;
|
||||
/**
|
||||
* WS Bridge — /api/ext/v1/stream
|
||||
*
|
||||
* Servidor WebSocket nativo (biblioteca `ws`) que:
|
||||
* 1. Intercepta HTTP upgrades no path /api/ext/v1/stream
|
||||
* 2. Autentica via header x-nw-key → resolve tenantId
|
||||
* 3. Subscreve ao hook-bus (ext:*) e encaminha apenas eventos do tenant
|
||||
* 4. Mantém heartbeat (ping/pong a cada 25s) para detectar conexões mortas
|
||||
* 5. Remove subscrições quando o cliente desconecta (evitar leak)
|
||||
*
|
||||
* Envelope de evento (contrato imutável v1):
|
||||
* { event: string, data: object, timestamp: number }
|
||||
*
|
||||
* Eventos emitidos:
|
||||
* session.qr — novo QR gerado
|
||||
* session.status — instância conectou / desconectou
|
||||
* message.new — nova mensagem recebida
|
||||
* message.update — status de mensagem atualizado
|
||||
* error — erro de autenticação ou protocolo
|
||||
*/
|
||||
const ws_1 = require("ws");
|
||||
const apikey_auth_1 = require("./apikey-auth");
|
||||
const WS_PATH = '/api/ext/v1/stream';
|
||||
const PING_INTERVAL_MS = 25000;
|
||||
function buildEnvelope(event, data) {
|
||||
return JSON.stringify({ event, data, timestamp: Date.now() });
|
||||
}
|
||||
function buildWsBridge(httpServer, prisma, hooks) {
|
||||
const wss = new ws_1.WebSocketServer({ noServer: true });
|
||||
// ── Intercepta upgrade apenas no path correto ─────────────────────────────
|
||||
httpServer.on('upgrade', async (req, socket, head) => {
|
||||
if (req.url !== WS_PATH)
|
||||
return; // deixa outros handlers tratarem
|
||||
// Auth: extrai x-nw-key do header do handshake
|
||||
const apiKey = req.headers['x-nw-key']?.trim();
|
||||
if (!apiKey) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nHeader x-nw-key ausente');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
let tenantId;
|
||||
try {
|
||||
tenantId = await (0, apikey_auth_1.resolveApiKey)(prisma, apiKey);
|
||||
}
|
||||
catch {
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (!tenantId) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nChave inválida, inativa ou expirada');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// Completa o handshake WS
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req, tenantId);
|
||||
});
|
||||
});
|
||||
// ── Conexão estabelecida ──────────────────────────────────────────────────
|
||||
wss.on('connection', (ws, _req, tenantId) => {
|
||||
const client = { ws, tenantId, unsubs: [] };
|
||||
// Confirma conexão ao cliente
|
||||
ws.send(buildEnvelope('connected', { tenantId, version: 'v1' }));
|
||||
// ── Subscrevemos aos eventos do hook-bus para este tenant ─────────────
|
||||
const extEvents = [
|
||||
{ hook: 'ext:session.qr', wsEvent: 'session.qr' },
|
||||
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
||||
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
||||
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
||||
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
||||
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
||||
];
|
||||
for (const { hook, wsEvent } of extEvents) {
|
||||
const handler = async (data) => {
|
||||
if (data.tenantId !== tenantId)
|
||||
return; // isola por tenant
|
||||
if (ws.readyState !== ws.OPEN)
|
||||
return;
|
||||
const { tenantId: _tid, ...payload } = data; // não expõe tenantId no payload
|
||||
ws.send(buildEnvelope(wsEvent, payload));
|
||||
};
|
||||
hooks.register(hook, handler);
|
||||
// Para remover o handler no disconnect, guardamos uma referência
|
||||
// O hookBus atual não tem removeSpecific, então usamos removeAll
|
||||
// no deactivate do plugin — para conexão individual guardamos a lista
|
||||
client.unsubs.push(() => {
|
||||
// Não há API de remove por handler no hookBus — ao desconectar apenas
|
||||
// o handler fica inerte (verifica ws.readyState === OPEN antes de enviar)
|
||||
});
|
||||
}
|
||||
// ── Heartbeat: ping a cada 25s ────────────────────────────────────────
|
||||
let isAlive = true;
|
||||
const pingTimer = setInterval(() => {
|
||||
if (!isAlive) {
|
||||
ws.terminate();
|
||||
return;
|
||||
}
|
||||
isAlive = false;
|
||||
ws.ping();
|
||||
}, PING_INTERVAL_MS);
|
||||
ws.on('pong', () => { isAlive = true; });
|
||||
// ── Graceful close ────────────────────────────────────────────────────
|
||||
ws.on('close', () => {
|
||||
clearInterval(pingTimer);
|
||||
// handlers ficam registrados mas são no-ops pois verificam readyState
|
||||
});
|
||||
ws.on('error', () => {
|
||||
clearInterval(pingTimer);
|
||||
});
|
||||
// Ignora mensagens do cliente (WS ext/v1 é só leitura nesta fase)
|
||||
ws.on('message', () => { });
|
||||
});
|
||||
}
|
||||
//# sourceMappingURL=ws-bridge.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"ws-bridge.js","sourceRoot":"","sources":["ws-bridge.ts"],"names":[],"mappings":";;AAwCA,sCAiGC;AAzID;;;;;;;;;;;;;;;;;;;GAmBG;AACH,2BAAoD;AAKpD,+CAA6C;AAE7C,MAAM,OAAO,GAAa,oBAAoB,CAAA;AAC9C,MAAM,gBAAgB,GAAI,KAAM,CAAA;AAQhC,SAAS,aAAa,CAAC,KAAa,EAAE,IAAa;IACjD,OAAO,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAA;AAC/D,CAAC;AAED,SAAgB,aAAa,CAC3B,UAAsB,EACtB,MAAoB,EACpB,KAAc;IAEd,MAAM,GAAG,GAAG,IAAI,oBAAe,CAAC,EAAE,QAAQ,EAAE,IAAI,EAAE,CAAC,CAAA;IAEnD,6EAA6E;IAC7E,UAAU,CAAC,EAAE,CAAC,SAAS,EAAE,KAAK,EAAE,GAAoB,EAAE,MAAM,EAAE,IAAI,EAAE,EAAE;QACpE,IAAI,GAAG,CAAC,GAAG,KAAK,OAAO;YAAE,OAAM,CAAE,iCAAiC;QAElE,+CAA+C;QAC/C,MAAM,MAAM,GAAI,GAAG,CAAC,OAAO,CAAC,UAAU,CAAwB,EAAE,IAAI,EAAE,CAAA;QACtE,IAAI,CAAC,MAAM,EAAE,CAAC;YACZ,MAAM,CAAC,KAAK,CAAC,sFAAsF,CAAC,CAAA;YACpG,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,QAAuB,CAAA;QAC3B,IAAI,CAAC;YACH,QAAQ,GAAG,MAAM,IAAA,2BAAa,EAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAChD,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,CAAC,KAAK,CAAC,4CAA4C,CAAC,CAAA;YAC1D,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,IAAI,CAAC,QAAQ,EAAE,CAAC;YACd,MAAM,CAAC,KAAK,CAAC,kGAAkG,CAAC,CAAA;YAChH,MAAM,CAAC,OAAO,EAAE,CAAA;YAChB,OAAM;QACR,CAAC;QAED,0BAA0B;QAC1B,GAAG,CAAC,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,EAAE;YAC1C,GAAG,CAAC,IAAI,CAAC,YAAY,EAAE,EAAE,EAAE,GAAG,EAAE,QAAQ,CAAC,CAAA;QAC3C,CAAC,CAAC,CAAA;IACJ,CAAC,CAAC,CAAA;IAEF,6EAA6E;IAC7E,GAAG,CAAC,EAAE,CAAC,YAAY,EAAE,CAAC,EAAa,EAAE,IAAqB,EAAE,QAAgB,EAAE,EAAE;QAC9E,MAAM,MAAM,GAAc,EAAE,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,CAAA;QAEtD,8BAA8B;QAC9B,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,WAAW,EAAE,EAAE,QAAQ,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC,CAAC,CAAA;QAEhE,yEAAyE;QACzE,MAAM,SAAS,GAA6C;YAC1D,EAAE,IAAI,EAAE,gBAAgB,EAAM,OAAO,EAAE,YAAY,EAAW;YAC9D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,iBAAiB,EAAK,OAAO,EAAE,aAAa,EAAW;YAC/D,EAAE,IAAI,EAAE,oBAAoB,EAAE,OAAO,EAAE,gBAAgB,EAAQ;YAC/D,EAAE,IAAI,EAAE,aAAa,EAAS,OAAO,EAAE,sBAAsB,EAAI;YACjE,EAAE,IAAI,EAAE,eAAe,EAAM,OAAO,EAAE,wBAAwB,EAAE;SACjE,CAAA;QAED,KAAK,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,SAAS,EAAE,CAAC;YAC1C,MAAM,OAAO,GAAG,KAAK,EAAE,IAAS,EAAE,EAAE;gBAClC,IAAI,IAAI,CAAC,QAAQ,KAAK,QAAQ;oBAAE,OAAM,CAAQ,mBAAmB;gBACjE,IAAI,EAAE,CAAC,UAAU,KAAK,EAAE,CAAC,IAAI;oBAAI,OAAM;gBACvC,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,GAAG,OAAO,EAAE,GAAG,IAAI,CAAA,CAAG,gCAAgC;gBAC9E,EAAE,CAAC,IAAI,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC,CAAA;YAC1C,CAAC,CAAA;YACD,KAAK,CAAC,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAA;YAC7B,iEAAiE;YACjE,iEAAiE;YACjE,sEAAsE;YACtE,MAAM,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,EAAE;gBACtB,sEAAsE;gBACtE,0EAA0E;YAC5E,CAAC,CAAC,CAAA;QACJ,CAAC;QAED,yEAAyE;QACzE,IAAI,OAAO,GAAG,IAAI,CAAA;QAClB,MAAM,SAAS,GAAG,WAAW,CAAC,GAAG,EAAE;YACjC,IAAI,CAAC,OAAO,EAAE,CAAC;gBAAC,EAAE,CAAC,SAAS,EAAE,CAAC;gBAAC,OAAM;YAAC,CAAC;YACxC,OAAO,GAAG,KAAK,CAAA;YACf,EAAE,CAAC,IAAI,EAAE,CAAA;QACX,CAAC,EAAE,gBAAgB,CAAC,CAAA;QAEpB,EAAE,CAAC,EAAE,CAAC,MAAM,EAAE,GAAG,EAAE,GAAG,OAAO,GAAG,IAAI,CAAA,CAAC,CAAC,CAAC,CAAA;QAEvC,yEAAyE;QACzE,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;YACxB,sEAAsE;QACxE,CAAC,CAAC,CAAA;QAEF,EAAE,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;YAClB,aAAa,CAAC,SAAS,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QAEF,kEAAkE;QAClE,EAAE,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,GAAE,CAAC,CAAC,CAAA;IAC5B,CAAC,CAAC,CAAA;AACJ,CAAC"}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* WS Bridge — /api/ext/v1/stream
|
||||
*
|
||||
* Servidor WebSocket nativo (biblioteca `ws`) que:
|
||||
* 1. Intercepta HTTP upgrades no path /api/ext/v1/stream
|
||||
* 2. Autentica via header x-nw-key → resolve tenantId
|
||||
* 3. Subscreve ao hook-bus (ext:*) e encaminha apenas eventos do tenant
|
||||
* 4. Mantém heartbeat (ping/pong a cada 25s) para detectar conexões mortas
|
||||
* 5. Remove subscrições quando o cliente desconecta (evitar leak)
|
||||
*
|
||||
* Envelope de evento (contrato imutável v1):
|
||||
* { event: string, data: object, timestamp: number }
|
||||
*
|
||||
* Eventos emitidos:
|
||||
* session.qr — novo QR gerado
|
||||
* session.status — instância conectou / desconectou
|
||||
* message.new — nova mensagem recebida
|
||||
* message.update — status de mensagem atualizado
|
||||
* error — erro de autenticação ou protocolo
|
||||
*/
|
||||
import { WebSocketServer, type WebSocket } from 'ws'
|
||||
import type { Server as HttpServer } from 'http'
|
||||
import type { IncomingMessage } from 'http'
|
||||
import type { PrismaClient } from '@prisma/client'
|
||||
import type { HookBus } from '../../../backend/src/core/hook-bus'
|
||||
import { resolveApiKey } from './apikey-auth'
|
||||
|
||||
const WS_PATH = '/api/ext/v1/stream'
|
||||
const PING_INTERVAL_MS = 25_000
|
||||
|
||||
interface ExtClient {
|
||||
ws: WebSocket
|
||||
tenantId: string
|
||||
unsubs: Array<() => void>
|
||||
}
|
||||
|
||||
function buildEnvelope(event: string, data: unknown): string {
|
||||
return JSON.stringify({ event, data, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
export function buildWsBridge(
|
||||
httpServer: HttpServer,
|
||||
prisma: PrismaClient,
|
||||
hooks: HookBus,
|
||||
): void {
|
||||
const wss = new WebSocketServer({ noServer: true })
|
||||
|
||||
// ── Intercepta upgrade apenas no path correto ─────────────────────────────
|
||||
httpServer.on('upgrade', async (req: IncomingMessage, socket, head) => {
|
||||
if (req.url !== WS_PATH) return // deixa outros handlers tratarem
|
||||
|
||||
// Auth: extrai x-nw-key do header do handshake
|
||||
const apiKey = (req.headers['x-nw-key'] as string | undefined)?.trim()
|
||||
if (!apiKey) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nHeader x-nw-key ausente')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
let tenantId: string | null
|
||||
try {
|
||||
tenantId = await resolveApiKey(prisma, apiKey)
|
||||
} catch {
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
if (!tenantId) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\nContent-Type: text/plain\r\n\r\nChave inválida, inativa ou expirada')
|
||||
socket.destroy()
|
||||
return
|
||||
}
|
||||
|
||||
// Completa o handshake WS
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req, tenantId)
|
||||
})
|
||||
})
|
||||
|
||||
// ── Conexão estabelecida ──────────────────────────────────────────────────
|
||||
wss.on('connection', (ws: WebSocket, _req: IncomingMessage, tenantId: string) => {
|
||||
const client: ExtClient = { ws, tenantId, unsubs: [] }
|
||||
|
||||
// Confirma conexão ao cliente
|
||||
ws.send(buildEnvelope('connected', { tenantId, version: 'v1' }))
|
||||
|
||||
// ── Subscrevemos aos eventos do hook-bus para este tenant ─────────────
|
||||
const extEvents: Array<{ hook: string; wsEvent: string }> = [
|
||||
{ hook: 'ext:session.qr', wsEvent: 'session.qr' },
|
||||
{ hook: 'ext:session.status', wsEvent: 'session.status' },
|
||||
{ hook: 'ext:message.new', wsEvent: 'message.new' },
|
||||
{ hook: 'ext:message.update', wsEvent: 'message.update' },
|
||||
{ hook: 'ext:handoff', wsEvent: 'conversation.handoff' },
|
||||
{ hook: 'ext:escalated', wsEvent: 'conversation.escalated' },
|
||||
]
|
||||
|
||||
for (const { hook, wsEvent } of extEvents) {
|
||||
const handler = async (data: any) => {
|
||||
if (data.tenantId !== tenantId) return // isola por tenant
|
||||
if (ws.readyState !== ws.OPEN) return
|
||||
const { tenantId: _tid, ...payload } = data // não expõe tenantId no payload
|
||||
ws.send(buildEnvelope(wsEvent, payload))
|
||||
}
|
||||
hooks.register(hook, handler)
|
||||
// Para remover o handler no disconnect, guardamos uma referência
|
||||
// O hookBus atual não tem removeSpecific, então usamos removeAll
|
||||
// no deactivate do plugin — para conexão individual guardamos a lista
|
||||
client.unsubs.push(() => {
|
||||
// Não há API de remove por handler no hookBus — ao desconectar apenas
|
||||
// o handler fica inerte (verifica ws.readyState === OPEN antes de enviar)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Heartbeat: ping a cada 25s ────────────────────────────────────────
|
||||
let isAlive = true
|
||||
const pingTimer = setInterval(() => {
|
||||
if (!isAlive) { ws.terminate(); return }
|
||||
isAlive = false
|
||||
ws.ping()
|
||||
}, PING_INTERVAL_MS)
|
||||
|
||||
ws.on('pong', () => { isAlive = true })
|
||||
|
||||
// ── Graceful close ────────────────────────────────────────────────────
|
||||
ws.on('close', () => {
|
||||
clearInterval(pingTimer)
|
||||
// handlers ficam registrados mas são no-ops pois verificam readyState
|
||||
})
|
||||
|
||||
ws.on('error', () => {
|
||||
clearInterval(pingTimer)
|
||||
})
|
||||
|
||||
// Ignora mensagens do cliente (WS ext/v1 é só leitura nesta fase)
|
||||
ws.on('message', () => {})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const apikey_auth_1 = require("./backend/apikey-auth");
|
||||
const routes_1 = require("./backend/routes");
|
||||
const ws_bridge_1 = require("./backend/ws-bridge");
|
||||
const webhook_dispatcher_1 = require("./backend/webhook-dispatcher");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
const { app, prisma, db, config, hooks, logger, httpServer } = ctx;
|
||||
// ── REST ────────────────────────────────────────────────────────────────
|
||||
const manager = globalThis.__whatsAppManager;
|
||||
if (!manager) {
|
||||
logger.warn('[ext-api] WhatsAppConnectionManager não disponível via globalThis.__whatsAppManager — endpoints de send desativados');
|
||||
}
|
||||
const authMiddleware = (0, apikey_auth_1.buildApiKeyAuth)(prisma);
|
||||
const extRouter = (0, routes_1.buildExtRoutes)(prisma, manager, db, config, hooks);
|
||||
app.use('/api/ext/v1', authMiddleware, extRouter);
|
||||
logger.info('[ext-api] Rotas REST registradas em /api/ext/v1');
|
||||
// ── WS Bridge ───────────────────────────────────────────────────────────
|
||||
(0, ws_bridge_1.buildWsBridge)(httpServer, prisma, hooks);
|
||||
logger.info('[ext-api] WS bridge ativo em /api/ext/v1/stream (x-nw-key auth)');
|
||||
// ── Webhook Dispatcher ──────────────────────────────────────────────────
|
||||
(0, webhook_dispatcher_1.buildWebhookDispatcher)(prisma, hooks);
|
||||
logger.info('[ext-api] Webhook dispatcher ativo');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.warn('[ext-api] Desativado. Reinicie o servidor para remover as rotas.');
|
||||
},
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AAKA,uDAAuD;AACvD,6CAAiD;AACjD,mDAAmD;AACnD,qEAAqE;AAErE,oEAAsC;AAWtC,MAAM,MAAM,GAAmB;IAC7B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC/B,MAAM,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,GAAG,GAAG,CAAA;QAElE,2EAA2E;QAC3E,MAAM,OAAO,GAAG,UAAU,CAAC,iBAAiB,CAAA;QAC5C,IAAI,CAAC,OAAO,EAAE,CAAC;YACb,MAAM,CAAC,IAAI,CAAC,qHAAqH,CAAC,CAAA;QACpI,CAAC;QAED,MAAM,cAAc,GAAG,IAAA,6BAAe,EAAC,MAAM,CAAC,CAAA;QAC9C,MAAM,SAAS,GAAQ,IAAA,uBAAc,EAAC,MAAM,EAAE,OAAoC,EAAE,EAAE,EAAE,MAAM,EAAE,KAAK,CAAC,CAAA;QAEtG,GAAG,CAAC,GAAG,CAAC,aAAa,EAAE,cAAc,EAAE,SAAS,CAAC,CAAA;QACjD,MAAM,CAAC,IAAI,CAAC,iDAAiD,CAAC,CAAA;QAE9D,2EAA2E;QAC3E,IAAA,yBAAa,EAAC,UAAU,EAAE,MAAM,EAAE,KAAK,CAAC,CAAA;QACxC,MAAM,CAAC,IAAI,CAAC,iEAAiE,CAAC,CAAA;QAE9E,2EAA2E;QAC3E,IAAA,2CAAsB,EAAC,MAAM,EAAE,KAAK,CAAC,CAAA;QACrC,MAAM,CAAC,IAAI,CAAC,oCAAoC,CAAC,CAAA;IACnD,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,kEAAkE,CAAC,CAAA;IACrF,CAAC;CACF,CAAA;AAED,kBAAe,MAAM,CAAA"}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Plugin: ext-api
|
||||
* Entry point — registra rotas REST e WS bridge ao activar.
|
||||
*/
|
||||
import type { PluginInstance, PluginContext } from '../../backend/src/core/types'
|
||||
import { buildApiKeyAuth } from './backend/apikey-auth'
|
||||
import { buildExtRoutes } from './backend/routes'
|
||||
import { buildWsBridge } from './backend/ws-bridge'
|
||||
import { buildWebhookDispatcher } from './backend/webhook-dispatcher'
|
||||
import { WhatsAppConnectionManager } from '../../backend/src/modules/whatsapp/connection/WhatsAppConnectionManager'
|
||||
import manifest from './manifest.json'
|
||||
|
||||
// O WhatsAppConnectionManager é um singleton instanciado no server.ts.
|
||||
// O plugin precisa de uma referência a ele para enviar mensagens.
|
||||
// Injectamos via globalThis como bridge temporária (sem modificar o server.ts).
|
||||
// Em revisão futura pode ser passado no PluginContext.
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __whatsAppManager: WhatsAppConnectionManager | undefined
|
||||
}
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
const { app, prisma, db, config, hooks, logger, httpServer, io } = ctx
|
||||
|
||||
// ── REST ────────────────────────────────────────────────────────────────
|
||||
const manager = globalThis.__whatsAppManager
|
||||
if (!manager) {
|
||||
logger.warn('[ext-api] WhatsAppConnectionManager não disponível via globalThis.__whatsAppManager — endpoints de send desativados')
|
||||
}
|
||||
|
||||
const authMiddleware = buildApiKeyAuth(prisma)
|
||||
const extRouter = buildExtRoutes(prisma, manager as WhatsAppConnectionManager, db, config, hooks, io)
|
||||
|
||||
app.use('/api/ext/v1', authMiddleware, extRouter)
|
||||
logger.info('[ext-api] Rotas REST registradas em /api/ext/v1')
|
||||
|
||||
// ── WS Bridge ───────────────────────────────────────────────────────────
|
||||
buildWsBridge(httpServer, prisma, hooks)
|
||||
logger.info('[ext-api] WS bridge ativo em /api/ext/v1/stream (x-nw-key auth)')
|
||||
|
||||
// ── Webhook Dispatcher ──────────────────────────────────────────────────
|
||||
buildWebhookDispatcher(prisma, hooks)
|
||||
logger.info('[ext-api] Webhook dispatcher ativo')
|
||||
},
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.warn('[ext-api] Desativado. Reinicie o servidor para remover as rotas.')
|
||||
},
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "ext-api",
|
||||
"displayName": "External REST + WS API (v1)",
|
||||
"version": "1.0.0",
|
||||
"description": "Expõe /api/ext/v1/ para satélites externos (instâncias, inbox, envio, stream WS). Auth via x-nw-key.",
|
||||
"author": "Clube67",
|
||||
"category": "integration",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/ext/v1",
|
||||
"hasMigrations": false
|
||||
},
|
||||
"hooks": {
|
||||
"subscribes": [
|
||||
"ext:session.qr",
|
||||
"ext:session.status",
|
||||
"ext:message.new",
|
||||
"ext:message.update"
|
||||
],
|
||||
"emits": []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createFinanceRoutes = createFinanceRoutes;
|
||||
const express_1 = require("express");
|
||||
function createFinanceRoutes(ctx) {
|
||||
const router = (0, express_1.Router)();
|
||||
const { db, hooks } = ctx;
|
||||
// GET /plans — List subscription plans
|
||||
router.get('/plans', async (_req, res) => {
|
||||
try {
|
||||
const plans = await db('subscription_plans').orderBy('monthly_price', 'asc');
|
||||
res.json({ plans });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar planos' });
|
||||
}
|
||||
});
|
||||
// GET /invoices — List invoices (optional partner filter)
|
||||
router.get('/invoices', async (req, res) => {
|
||||
try {
|
||||
const partnerId = req.query.partner_id;
|
||||
const status = req.query.status;
|
||||
let query = db('invoices')
|
||||
.join('partners', 'invoices.partner_id', 'partners.id')
|
||||
.select('invoices.*', 'partners.company_name');
|
||||
if (partnerId)
|
||||
query = query.where({ 'invoices.partner_id': partnerId });
|
||||
if (status)
|
||||
query = query.where({ 'invoices.status': status });
|
||||
const invoices = await query.orderBy('invoices.due_date', 'desc');
|
||||
res.json({ invoices });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar faturas' });
|
||||
}
|
||||
});
|
||||
// POST /invoices — Create invoice
|
||||
router.post('/invoices', async (req, res) => {
|
||||
try {
|
||||
const { partner_id, plan_id, amount, due_date } = req.body;
|
||||
const [id] = await db('invoices').insert({
|
||||
partner_id, plan_id, amount,
|
||||
issue_date: db.fn.now(),
|
||||
due_date,
|
||||
status: 'Pendente',
|
||||
});
|
||||
await hooks.emit('invoice:created', { invoiceId: id, partnerId: partner_id });
|
||||
const invoice = await db('invoices').where({ id }).first();
|
||||
res.status(201).json(invoice);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao criar fatura' });
|
||||
}
|
||||
});
|
||||
// PUT /invoices/:id/pay — Mark invoice as paid
|
||||
router.put('/invoices/:id/pay', async (req, res) => {
|
||||
try {
|
||||
await db('invoices').where({ id: req.params.id }).update({
|
||||
status: 'Paga',
|
||||
paid_at: db.fn.now(),
|
||||
});
|
||||
await hooks.emit('invoice:paid', { invoiceId: req.params.id });
|
||||
res.json({ message: 'Fatura paga com sucesso' });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao pagar fatura' });
|
||||
}
|
||||
});
|
||||
// GET /dashboard — Financial overview
|
||||
router.get('/dashboard', async (_req, res) => {
|
||||
try {
|
||||
const [totalRevenue] = await db('invoices')
|
||||
.where({ status: 'Paga' })
|
||||
.sum('amount as total');
|
||||
const [pendingRevenue] = await db('invoices')
|
||||
.where({ status: 'Pendente' })
|
||||
.sum('amount as total');
|
||||
const [overdueRevenue] = await db('invoices')
|
||||
.where({ status: 'Atrasada' })
|
||||
.sum('amount as total');
|
||||
const [activeSubscriptions] = await db('partners')
|
||||
.where({ status: 'active' })
|
||||
.count('id as count');
|
||||
const planDistribution = await db('partners')
|
||||
.select('subscription_plan_id as plan')
|
||||
.count('id as count')
|
||||
.where({ status: 'active' })
|
||||
.groupBy('subscription_plan_id');
|
||||
res.json({
|
||||
totalRevenue: Number(totalRevenue.total || 0),
|
||||
pendingRevenue: Number(pendingRevenue.total || 0),
|
||||
overdueRevenue: Number(overdueRevenue.total || 0),
|
||||
activeSubscriptions: Number(activeSubscriptions.count),
|
||||
planDistribution,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar dashboard financeiro' });
|
||||
}
|
||||
});
|
||||
// GET /commissions — List commissions
|
||||
router.get('/commissions', async (req, res) => {
|
||||
try {
|
||||
const partnerId = req.query.partner_id;
|
||||
let query = db('commissions');
|
||||
if (partnerId)
|
||||
query = query.where({ partner_id: partnerId });
|
||||
const commissions = await query.orderBy('transaction_date', 'desc');
|
||||
res.json({ commissions });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar comissões' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"routes.js","sourceRoot":"","sources":["routes.ts"],"names":[],"mappings":";;AAGA,kDAuHC;AA1HD,qCAAoD;AAGpD,SAAgB,mBAAmB,CAAC,GAAkB;IAClD,MAAM,MAAM,GAAG,IAAA,gBAAM,GAAE,CAAC;IACxB,MAAM,EAAE,EAAE,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC;IAE1B,uCAAuC;IACvC,MAAM,CAAC,GAAG,CAAC,QAAQ,EAAE,KAAK,EAAE,IAAa,EAAE,GAAa,EAAE,EAAE;QACxD,IAAI,CAAC;YACD,MAAM,KAAK,GAAG,MAAM,EAAE,CAAC,oBAAoB,CAAC,CAAC,OAAO,CAAC,eAAe,EAAE,KAAK,CAAC,CAAC;YAC7E,GAAG,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC;QACxB,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,uBAAuB,EAAE,CAAC,CAAC;QAC7D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,0DAA0D;IAC1D,MAAM,CAAC,GAAG,CAAC,WAAW,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAC1D,IAAI,CAAC;YACD,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,UAAoB,CAAC;YACjD,MAAM,MAAM,GAAG,GAAG,CAAC,KAAK,CAAC,MAAgB,CAAC;YAE1C,IAAI,KAAK,GAAG,EAAE,CAAC,UAAU,CAAC;iBACrB,IAAI,CAAC,UAAU,EAAE,qBAAqB,EAAE,aAAa,CAAC;iBACtD,MAAM,CAAC,YAAY,EAAE,uBAAuB,CAAC,CAAC;YAEnD,IAAI,SAAS;gBAAE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,qBAAqB,EAAE,SAAS,EAAE,CAAC,CAAC;YACzE,IAAI,MAAM;gBAAE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,iBAAiB,EAAE,MAAM,EAAE,CAAC,CAAC;YAE/D,MAAM,QAAQ,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,mBAAmB,EAAE,MAAM,CAAC,CAAC;YAClE,GAAG,CAAC,IAAI,CAAC,EAAE,QAAQ,EAAE,CAAC,CAAC;QAC3B,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,wBAAwB,EAAE,CAAC,CAAC;QAC9D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,kCAAkC;IAClC,MAAM,CAAC,IAAI,CAAC,WAAW,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAC3D,IAAI,CAAC;YACD,MAAM,EAAE,UAAU,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,GAAG,CAAC,IAAI,CAAC;YAE3D,MAAM,CAAC,EAAE,CAAC,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,CAAC,MAAM,CAAC;gBACrC,UAAU,EAAE,OAAO,EAAE,MAAM;gBAC3B,UAAU,EAAE,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE;gBACvB,QAAQ;gBACR,MAAM,EAAE,UAAU;aACrB,CAAC,CAAC;YAEH,MAAM,KAAK,CAAC,IAAI,CAAC,iBAAiB,EAAE,EAAE,SAAS,EAAE,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC,CAAC;YAC9E,MAAM,OAAO,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,KAAK,EAAE,CAAC;YAC3D,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClC,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,sBAAsB,EAAE,CAAC,CAAC;QAC5D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,+CAA+C;IAC/C,MAAM,CAAC,GAAG,CAAC,mBAAmB,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAClE,IAAI,CAAC;YACD,MAAM,EAAE,CAAC,UAAU,CAAC,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC;gBACrD,MAAM,EAAE,MAAM;gBACd,OAAO,EAAE,EAAE,CAAC,EAAE,CAAC,GAAG,EAAE;aACvB,CAAC,CAAC;YACH,MAAM,KAAK,CAAC,IAAI,CAAC,cAAc,EAAE,EAAE,SAAS,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,EAAE,CAAC,CAAC;YAC/D,GAAG,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,yBAAyB,EAAE,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,sBAAsB,EAAE,CAAC,CAAC;QAC5D,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,sCAAsC;IACtC,MAAM,CAAC,GAAG,CAAC,YAAY,EAAE,KAAK,EAAE,IAAa,EAAE,GAAa,EAAE,EAAE;QAC5D,IAAI,CAAC;YACD,MAAM,CAAC,YAAY,CAAC,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC;iBACtC,KAAK,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,CAAC;iBACzB,GAAG,CAAC,iBAAiB,CAAC,CAAC;YAE5B,MAAM,CAAC,cAAc,CAAC,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC;iBACxC,KAAK,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;iBAC7B,GAAG,CAAC,iBAAiB,CAAC,CAAC;YAE5B,MAAM,CAAC,cAAc,CAAC,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC;iBACxC,KAAK,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,CAAC;iBAC7B,GAAG,CAAC,iBAAiB,CAAC,CAAC;YAE5B,MAAM,CAAC,mBAAmB,CAAC,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC;iBAC7C,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;iBAC3B,KAAK,CAAC,aAAa,CAAC,CAAC;YAE1B,MAAM,gBAAgB,GAAG,MAAM,EAAE,CAAC,UAAU,CAAC;iBACxC,MAAM,CAAC,8BAA8B,CAAC;iBACtC,KAAK,CAAC,aAAa,CAAC;iBACpB,KAAK,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC;iBAC3B,OAAO,CAAC,sBAAsB,CAAC,CAAC;YAErC,GAAG,CAAC,IAAI,CAAC;gBACL,YAAY,EAAE,MAAM,CAAC,YAAY,CAAC,KAAK,IAAI,CAAC,CAAC;gBAC7C,cAAc,EAAE,MAAM,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC,CAAC;gBACjD,cAAc,EAAE,MAAM,CAAC,cAAc,CAAC,KAAK,IAAI,CAAC,CAAC;gBACjD,mBAAmB,EAAE,MAAM,CAAC,mBAAmB,CAAC,KAAK,CAAC;gBACtD,gBAAgB;aACnB,CAAC,CAAC;QACP,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qCAAqC,EAAE,CAAC,CAAC;QAC3E,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,sCAAsC;IACtC,MAAM,CAAC,GAAG,CAAC,cAAc,EAAE,KAAK,EAAE,GAAY,EAAE,GAAa,EAAE,EAAE;QAC7D,IAAI,CAAC;YACD,MAAM,SAAS,GAAG,GAAG,CAAC,KAAK,CAAC,UAAoB,CAAC;YACjD,IAAI,KAAK,GAAG,EAAE,CAAC,aAAa,CAAC,CAAC;YAC9B,IAAI,SAAS;gBAAE,KAAK,GAAG,KAAK,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,SAAS,EAAE,CAAC,CAAC;YAC9D,MAAM,WAAW,GAAG,MAAM,KAAK,CAAC,OAAO,CAAC,kBAAkB,EAAE,MAAM,CAAC,CAAC;YACpE,GAAG,CAAC,IAAI,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC;QAC9B,CAAC;QAAC,OAAO,GAAQ,EAAE,CAAC;YAChB,GAAG,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,0BAA0B,EAAE,CAAC,CAAC;QAChE,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,OAAO,MAAM,CAAC;AAClB,CAAC"}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { PluginContext } from '../../../backend/src/core/types';
|
||||
|
||||
export function createFinanceRoutes(ctx: PluginContext): Router {
|
||||
const router = Router();
|
||||
const { db, hooks } = ctx;
|
||||
|
||||
// GET /plans — List subscription plans
|
||||
router.get('/plans', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const plans = await db('subscription_plans').orderBy('monthly_price', 'asc');
|
||||
res.json({ plans });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao listar planos' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /invoices — List invoices (optional partner filter)
|
||||
router.get('/invoices', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const partnerId = req.query.partner_id as string;
|
||||
const status = req.query.status as string;
|
||||
|
||||
let query = db('invoices')
|
||||
.join('partners', 'invoices.partner_id', 'partners.id')
|
||||
.select('invoices.*', 'partners.company_name');
|
||||
|
||||
if (partnerId) query = query.where({ 'invoices.partner_id': partnerId });
|
||||
if (status) query = query.where({ 'invoices.status': status });
|
||||
|
||||
const invoices = await query.orderBy('invoices.due_date', 'desc');
|
||||
res.json({ invoices });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao listar faturas' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /invoices — Create invoice
|
||||
router.post('/invoices', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { partner_id, plan_id, amount, due_date } = req.body;
|
||||
|
||||
const [id] = await db('invoices').insert({
|
||||
partner_id, plan_id, amount,
|
||||
issue_date: db.fn.now(),
|
||||
due_date,
|
||||
status: 'Pendente',
|
||||
});
|
||||
|
||||
await hooks.emit('invoice:created', { invoiceId: id, partnerId: partner_id });
|
||||
const invoice = await db('invoices').where({ id }).first();
|
||||
res.status(201).json(invoice);
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao criar fatura' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /invoices/:id/pay — Mark invoice as paid
|
||||
router.put('/invoices/:id/pay', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('invoices').where({ id: req.params.id }).update({
|
||||
status: 'Paga',
|
||||
paid_at: db.fn.now(),
|
||||
});
|
||||
await hooks.emit('invoice:paid', { invoiceId: req.params.id });
|
||||
res.json({ message: 'Fatura paga com sucesso' });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao pagar fatura' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /dashboard — Financial overview
|
||||
router.get('/dashboard', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const [totalRevenue] = await db('invoices')
|
||||
.where({ status: 'Paga' })
|
||||
.sum('amount as total');
|
||||
|
||||
const [pendingRevenue] = await db('invoices')
|
||||
.where({ status: 'Pendente' })
|
||||
.sum('amount as total');
|
||||
|
||||
const [overdueRevenue] = await db('invoices')
|
||||
.where({ status: 'Atrasada' })
|
||||
.sum('amount as total');
|
||||
|
||||
const [activeSubscriptions] = await db('partners')
|
||||
.where({ status: 'active' })
|
||||
.count('id as count');
|
||||
|
||||
const planDistribution = await db('partners')
|
||||
.select('subscription_plan_id as plan')
|
||||
.count('id as count')
|
||||
.where({ status: 'active' })
|
||||
.groupBy('subscription_plan_id');
|
||||
|
||||
res.json({
|
||||
totalRevenue: Number(totalRevenue.total || 0),
|
||||
pendingRevenue: Number(pendingRevenue.total || 0),
|
||||
overdueRevenue: Number(overdueRevenue.total || 0),
|
||||
activeSubscriptions: Number(activeSubscriptions.count),
|
||||
planDistribution,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao buscar dashboard financeiro' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /commissions — List commissions
|
||||
router.get('/commissions', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const partnerId = req.query.partner_id as string;
|
||||
let query = db('commissions');
|
||||
if (partnerId) query = query.where({ partner_id: partnerId });
|
||||
const commissions = await query.orderBy('transaction_date', 'desc');
|
||||
res.json({ commissions });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao listar comissões' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const routes_1 = require("./backend/routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
const router = (0, routes_1.createFinanceRoutes)(ctx);
|
||||
ctx.app.use('/api/finance', router);
|
||||
// Listen for new partners to create initial invoice
|
||||
ctx.hooks.register('partner:created', async (data) => {
|
||||
ctx.logger.info(`Creating initial invoice for partner ${data.partnerId}`);
|
||||
});
|
||||
ctx.logger.info('Finance routes registered at /api/finance');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('Financeiro plugin deactivated');
|
||||
},
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,6CAAuD;AACvD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,4BAAmB,EAAC,GAAG,CAAC,CAAC;QACxC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,MAAM,CAAC,CAAC;QAEpC,oDAAoD;QACpD,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,iBAAiB,EAAE,KAAK,EAAE,IAA2B,EAAE,EAAE;YACxE,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,wCAAwC,IAAI,CAAC,SAAS,EAAE,CAAC,CAAC;QAC9E,CAAC,CAAC,CAAC;QAEH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,2CAA2C,CAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,+BAA+B,CAAC,CAAC;IACrD,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { createFinanceRoutes } from './backend/routes';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
const router = createFinanceRoutes(ctx);
|
||||
ctx.app.use('/api/finance', router);
|
||||
|
||||
// Listen for new partners to create initial invoice
|
||||
ctx.hooks.register('partner:created', async (data: { partnerId: string }) => {
|
||||
ctx.logger.info(`Creating initial invoice for partner ${data.partnerId}`);
|
||||
});
|
||||
|
||||
ctx.logger.info('Finance routes registered at /api/finance');
|
||||
},
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('Financeiro plugin deactivated');
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "financeiro",
|
||||
"displayName": "Financeiro",
|
||||
"version": "1.0.0",
|
||||
"description": "Planos de assinatura, faturas, comissões e controle financeiro de parceiros.",
|
||||
"author": "Clube67",
|
||||
"category": "business",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [
|
||||
"core-auth",
|
||||
"partners"
|
||||
],
|
||||
"backend": {
|
||||
"routePrefix": "/api/finance",
|
||||
"hasMigrations": true
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": [
|
||||
{
|
||||
"id": "finance-dashboard",
|
||||
"label": "Financeiro",
|
||||
"icon": "DollarSign",
|
||||
"href": "/admin/finance",
|
||||
"roles": [
|
||||
"super_admin",
|
||||
"finance"
|
||||
],
|
||||
"order": 50
|
||||
},
|
||||
{
|
||||
"id": "finance-invoices",
|
||||
"label": "Faturas",
|
||||
"icon": "FileText",
|
||||
"href": "/partner/invoices",
|
||||
"roles": [
|
||||
"partner_admin"
|
||||
],
|
||||
"order": 55
|
||||
}
|
||||
],
|
||||
"widgets": [
|
||||
{
|
||||
"id": "finance-mrr",
|
||||
"component": "MRRWidget",
|
||||
"span": 2,
|
||||
"roles": [
|
||||
"super_admin",
|
||||
"finance"
|
||||
],
|
||||
"order": 30
|
||||
}
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"subscribes": [
|
||||
"partner:created",
|
||||
"partner:deactivated"
|
||||
],
|
||||
"emits": [
|
||||
"invoice:created",
|
||||
"invoice:paid",
|
||||
"invoice:overdue"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createLeadsRoutes = createLeadsRoutes;
|
||||
const express_1 = require("express");
|
||||
let authenticate;
|
||||
let authorize;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
authenticate = require('../../../dist/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../dist/middleware/rbac').adminMiddleware;
|
||||
}
|
||||
else {
|
||||
authenticate = require('../../../backend/src/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../backend/src/middleware/rbac').adminMiddleware;
|
||||
}
|
||||
const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost'];
|
||||
const LEAD_TYPE_VALUES = ['cold', 'member'];
|
||||
function translateLead(lead) {
|
||||
if (!lead)
|
||||
return lead;
|
||||
return {
|
||||
...lead,
|
||||
source: lead.source || 'manual',
|
||||
partner_id: lead.partner_id || null,
|
||||
benefit_id: lead.benefit_id || null,
|
||||
user_id: lead.user_id || null,
|
||||
lead_type: lead.lead_type || 'cold',
|
||||
};
|
||||
}
|
||||
function createLeadsRoutes(ctx) {
|
||||
const router = (0, express_1.Router)();
|
||||
const { db } = ctx;
|
||||
const protectedRoutes = [authenticate, authorize(['super_admin', 'admin'])];
|
||||
// GET /api/leads/metrics — Global metrics for super admin
|
||||
router.get('/metrics', ...protectedRoutes, async (req, res) => {
|
||||
try {
|
||||
const [totalRow] = await db('leads').count('id as total');
|
||||
const total = Number(totalRow.total);
|
||||
// Today
|
||||
const [todayRow] = await db('leads')
|
||||
.count('id as total')
|
||||
.whereRaw('DATE(created_at) = CURDATE()');
|
||||
const today = Number(todayRow.total);
|
||||
// By status
|
||||
const byStatus = await db('leads')
|
||||
.select('status')
|
||||
.count('id as total')
|
||||
.groupBy('status');
|
||||
// By lead type
|
||||
const byType = await db('leads')
|
||||
.select('lead_type')
|
||||
.count('id as total')
|
||||
.groupBy('lead_type');
|
||||
// Converted count for conversion rate
|
||||
const converted = byStatus.find((r) => r.status === 'converted');
|
||||
const conversionRate = total > 0 ? ((Number(converted?.total || 0) / total) * 100).toFixed(1) : '0.0';
|
||||
// By city (via users join)
|
||||
const byCity = await db('leads')
|
||||
.join('users', 'leads.user_id', 'users.id')
|
||||
.select('users.city', 'users.state')
|
||||
.count('leads.id as total')
|
||||
.whereNotNull('users.city')
|
||||
.groupBy('users.city', 'users.state')
|
||||
.orderBy('total', 'desc')
|
||||
.limit(10);
|
||||
// By neighborhood (via users join)
|
||||
const byNeighborhood = await db('leads')
|
||||
.join('users', 'leads.user_id', 'users.id')
|
||||
.select('users.neighborhood', 'users.city', 'users.state')
|
||||
.count('leads.id as total')
|
||||
.whereNotNull('users.neighborhood')
|
||||
.groupBy('users.neighborhood', 'users.city', 'users.state')
|
||||
.orderBy('total', 'desc')
|
||||
.limit(10);
|
||||
// By partner
|
||||
const byPartner = await db('leads')
|
||||
.join('partners', 'leads.partner_id', 'partners.id')
|
||||
.select('partners.id as partner_id', 'partners.company_name')
|
||||
.count('leads.id as total')
|
||||
.groupBy('partners.id', 'partners.company_name')
|
||||
.orderBy('total', 'desc')
|
||||
.limit(10);
|
||||
// Recent leads
|
||||
const recent = await db('leads')
|
||||
.leftJoin('users', 'leads.user_id', 'users.id')
|
||||
.leftJoin('partners', 'leads.partner_id', 'partners.id')
|
||||
.select('leads.id', 'leads.name', 'leads.email', 'leads.status', 'leads.lead_type', 'leads.source', 'leads.created_at', 'partners.company_name as partner_name', 'users.city', 'users.neighborhood')
|
||||
.orderBy('leads.created_at', 'desc')
|
||||
.limit(15);
|
||||
res.json({
|
||||
total,
|
||||
today,
|
||||
conversion_rate: conversionRate,
|
||||
by_status: byStatus,
|
||||
by_type: byType,
|
||||
by_city: byCity,
|
||||
by_neighborhood: byNeighborhood,
|
||||
by_partner: byPartner,
|
||||
recent,
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
console.error('Error fetching lead metrics:', err);
|
||||
res.status(500).json({ error: 'Erro ao buscar métricas' });
|
||||
}
|
||||
});
|
||||
router.get('/', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { partner_id, status, type, page = '1', limit = '20' } = req.query;
|
||||
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||
const userRole = req.user?.role;
|
||||
const userPartner = req.user?.partner_id;
|
||||
let query = db('leads').orderBy('created_at', 'desc');
|
||||
if (partner_id)
|
||||
query = query.where({ partner_id });
|
||||
if (userRole === 'partner' && userPartner) {
|
||||
query = query.where({ partner_id: userPartner });
|
||||
}
|
||||
if (type && LEAD_TYPE_VALUES.includes(type)) {
|
||||
query = query.where({ lead_type: type });
|
||||
}
|
||||
if (status && STATUS_VALUES.includes(status)) {
|
||||
query = query.where({ status: status });
|
||||
}
|
||||
else {
|
||||
query = query.where({ status: 'new' });
|
||||
}
|
||||
const countResult = await query.clone().count('id as count');
|
||||
const leads = await query.limit(parseInt(limit)).offset(offset);
|
||||
res.json({ leads: leads.map(translateLead), total: Number(countResult[0].count), page: parseInt(page) });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao listar leads' });
|
||||
}
|
||||
});
|
||||
router.get('/:id', authenticate, async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
if (!lead)
|
||||
return res.status(404).json({ error: 'Lead não encontrado' });
|
||||
if (req.user?.role === 'partner' && req.user.partner_id && lead.partner_id !== req.user.partner_id) {
|
||||
return res.status(403).json({ error: 'Acesso negado' });
|
||||
}
|
||||
const history = await db('lead_history').where({ lead_id: id }).orderBy('created_at', 'desc');
|
||||
res.json({ lead: translateLead(lead), history });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao buscar lead' });
|
||||
}
|
||||
});
|
||||
router.post('/', ...protectedRoutes, async (req, res) => {
|
||||
try {
|
||||
const { name, email, phone, partner_id, source, notes, benefit_id, assigned_to, user_id } = req.body;
|
||||
const numericUserId = user_id ? Number(user_id) : null;
|
||||
const parsedUserId = Number.isInteger(numericUserId) ? numericUserId : null;
|
||||
const leadType = parsedUserId ? 'member' : 'cold';
|
||||
const [id] = await db('leads').insert({
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
partner_id: partner_id || null,
|
||||
benefit_id: benefit_id || null,
|
||||
user_id: parsedUserId,
|
||||
lead_type: leadType,
|
||||
source: source || 'manual',
|
||||
notes: notes || null,
|
||||
assigned_to: assigned_to || null,
|
||||
status: 'new',
|
||||
owner: 'clube67',
|
||||
});
|
||||
await db('lead_history').insert({
|
||||
lead_id: id,
|
||||
action: 'created',
|
||||
new_value: 'new',
|
||||
created_by: req.user?.id || null,
|
||||
});
|
||||
await ctx.hooks.emit('lead:created', { id, email, partner_id });
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
res.status(201).json(translateLead(lead));
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao criar lead' });
|
||||
}
|
||||
});
|
||||
router.patch('/:id/status', ...protectedRoutes, async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { status } = req.body;
|
||||
if (!STATUS_VALUES.includes(status))
|
||||
return res.status(400).json({ error: 'Status inválido' });
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
if (!lead)
|
||||
return res.status(404).json({ error: 'Lead não encontrado' });
|
||||
await db('leads').where({ id }).update({ status, updated_at: db.fn.now() });
|
||||
await db('lead_history').insert({
|
||||
lead_id: id,
|
||||
action: 'status',
|
||||
old_value: lead.status,
|
||||
new_value: status,
|
||||
created_by: req.user?.id || null,
|
||||
});
|
||||
if (status === 'converted')
|
||||
await ctx.hooks.emit('lead:converted', { id });
|
||||
if (status === 'lost')
|
||||
await ctx.hooks.emit('lead:lost', { id });
|
||||
const updated = await db('leads').where({ id }).first();
|
||||
res.json(translateLead(updated));
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar status' });
|
||||
}
|
||||
});
|
||||
router.post('/:id/notes', ...protectedRoutes, async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { note } = req.body;
|
||||
if (!note)
|
||||
return res.status(400).json({ error: 'Nota obrigatória' });
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
if (!lead)
|
||||
return res.status(404).json({ error: 'Lead não encontrado' });
|
||||
await db('lead_history').insert({
|
||||
lead_id: id,
|
||||
action: 'note',
|
||||
old_value: lead.notes,
|
||||
new_value: lead.notes ? `${lead.notes}\n${note}` : note,
|
||||
note,
|
||||
created_by: req.user?.id || null,
|
||||
});
|
||||
await db('leads').where({ id }).update({
|
||||
notes: db.raw('CONCAT(COALESCE(notes,""), "\n", ?)', [note]),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const updated = await db('leads').where({ id }).first();
|
||||
res.json(translateLead(updated));
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao salvar nota' });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,256 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { PluginContext } from '../../../backend/src/core/types';
|
||||
|
||||
let authenticate: any;
|
||||
let authorize: any;
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
authenticate = require('../../../dist/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../dist/middleware/rbac').adminMiddleware;
|
||||
} else {
|
||||
authenticate = require('../../../backend/src/middleware/auth').authMiddleware;
|
||||
authorize = require('../../../backend/src/middleware/rbac').adminMiddleware;
|
||||
}
|
||||
|
||||
const STATUS_VALUES = ['new', 'contacted', 'qualified', 'converted', 'lost'];
|
||||
const LEAD_TYPE_VALUES = ['cold', 'member'];
|
||||
|
||||
function translateLead(lead: any) {
|
||||
if (!lead) return lead;
|
||||
return {
|
||||
...lead,
|
||||
source: lead.source || 'manual',
|
||||
partner_id: lead.partner_id || null,
|
||||
benefit_id: lead.benefit_id || null,
|
||||
user_id: lead.user_id || null,
|
||||
lead_type: lead.lead_type || 'cold',
|
||||
};
|
||||
}
|
||||
|
||||
export function createLeadsRoutes(ctx: PluginContext): Router {
|
||||
const router = Router();
|
||||
const { db } = ctx;
|
||||
const protectedRoutes = [authenticate, authorize(['super_admin', 'admin'])];
|
||||
|
||||
// GET /api/leads/metrics — Global metrics for super admin
|
||||
router.get('/metrics', ...protectedRoutes, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const [totalRow] = await db('leads').count('id as total');
|
||||
const total = Number(totalRow.total);
|
||||
|
||||
// Today
|
||||
const [todayRow] = await db('leads')
|
||||
.count('id as total')
|
||||
.whereRaw('DATE(created_at) = CURDATE()');
|
||||
const today = Number(todayRow.total);
|
||||
|
||||
// By status
|
||||
const byStatus = await db('leads')
|
||||
.select('status')
|
||||
.count('id as total')
|
||||
.groupBy('status');
|
||||
|
||||
// By lead type
|
||||
const byType = await db('leads')
|
||||
.select('lead_type')
|
||||
.count('id as total')
|
||||
.groupBy('lead_type');
|
||||
|
||||
// Converted count for conversion rate
|
||||
const converted = byStatus.find((r: any) => r.status === 'converted');
|
||||
const conversionRate = total > 0 ? ((Number(converted?.total || 0) / total) * 100).toFixed(1) : '0.0';
|
||||
|
||||
// By city (via users join)
|
||||
const byCity = await db('leads')
|
||||
.join('users', 'leads.user_id', 'users.id')
|
||||
.select('users.city', 'users.state')
|
||||
.count('leads.id as total')
|
||||
.whereNotNull('users.city')
|
||||
.groupBy('users.city', 'users.state')
|
||||
.orderBy('total', 'desc')
|
||||
.limit(10);
|
||||
|
||||
// By neighborhood (via users join)
|
||||
const byNeighborhood = await db('leads')
|
||||
.join('users', 'leads.user_id', 'users.id')
|
||||
.select('users.neighborhood', 'users.city', 'users.state')
|
||||
.count('leads.id as total')
|
||||
.whereNotNull('users.neighborhood')
|
||||
.groupBy('users.neighborhood', 'users.city', 'users.state')
|
||||
.orderBy('total', 'desc')
|
||||
.limit(10);
|
||||
|
||||
// By partner
|
||||
const byPartner = await db('leads')
|
||||
.join('partners', 'leads.partner_id', 'partners.id')
|
||||
.select('partners.id as partner_id', 'partners.company_name')
|
||||
.count('leads.id as total')
|
||||
.groupBy('partners.id', 'partners.company_name')
|
||||
.orderBy('total', 'desc')
|
||||
.limit(10);
|
||||
|
||||
// Recent leads
|
||||
const recent = await db('leads')
|
||||
.leftJoin('users', 'leads.user_id', 'users.id')
|
||||
.leftJoin('partners', 'leads.partner_id', 'partners.id')
|
||||
.select(
|
||||
'leads.id',
|
||||
'leads.name',
|
||||
'leads.email',
|
||||
'leads.status',
|
||||
'leads.lead_type',
|
||||
'leads.source',
|
||||
'leads.created_at',
|
||||
'partners.company_name as partner_name',
|
||||
'users.city',
|
||||
'users.neighborhood',
|
||||
)
|
||||
.orderBy('leads.created_at', 'desc')
|
||||
.limit(15);
|
||||
|
||||
res.json({
|
||||
total,
|
||||
today,
|
||||
conversion_rate: conversionRate,
|
||||
by_status: byStatus,
|
||||
by_type: byType,
|
||||
by_city: byCity,
|
||||
by_neighborhood: byNeighborhood,
|
||||
by_partner: byPartner,
|
||||
recent,
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching lead metrics:', err);
|
||||
res.status(500).json({ error: 'Erro ao buscar métricas' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/', authenticate, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { partner_id, status, type, page = '1', limit = '20' } = req.query;
|
||||
const offset = (parseInt(page as string) - 1) * parseInt(limit as string);
|
||||
const userRole = req.user?.role;
|
||||
const userPartner = req.user?.partner_id;
|
||||
let query = db('leads').orderBy('created_at', 'desc');
|
||||
if (partner_id) query = query.where({ partner_id });
|
||||
if (userRole === 'partner' && userPartner) {
|
||||
query = query.where({ partner_id: userPartner });
|
||||
}
|
||||
if (type && LEAD_TYPE_VALUES.includes(type as string)) {
|
||||
query = query.where({ lead_type: type as string });
|
||||
}
|
||||
if (status && STATUS_VALUES.includes(status as string)) {
|
||||
query = query.where({ status: status as string });
|
||||
} else {
|
||||
query = query.where({ status: 'new' });
|
||||
}
|
||||
|
||||
const countResult = await query.clone().count('id as count');
|
||||
const leads = await query.limit(parseInt(limit as string)).offset(offset);
|
||||
res.json({ leads: leads.map(translateLead), total: Number(countResult[0].count), page: parseInt(page as string) });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao listar leads' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', authenticate, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
if (!lead) return res.status(404).json({ error: 'Lead não encontrado' });
|
||||
if (req.user?.role === 'partner' && req.user.partner_id && lead.partner_id !== req.user.partner_id) {
|
||||
return res.status(403).json({ error: 'Acesso negado' });
|
||||
}
|
||||
const history = await db('lead_history').where({ lead_id: id }).orderBy('created_at', 'desc');
|
||||
res.json({ lead: translateLead(lead), history });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao buscar lead' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', ...protectedRoutes, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, email, phone, partner_id, source, notes, benefit_id, assigned_to, user_id } = req.body;
|
||||
const numericUserId = user_id ? Number(user_id) : null;
|
||||
const parsedUserId = Number.isInteger(numericUserId) ? numericUserId : null;
|
||||
const leadType = parsedUserId ? 'member' : 'cold';
|
||||
const [id] = await db('leads').insert({
|
||||
name,
|
||||
email,
|
||||
phone,
|
||||
partner_id: partner_id || null,
|
||||
benefit_id: benefit_id || null,
|
||||
user_id: parsedUserId,
|
||||
lead_type: leadType,
|
||||
source: source || 'manual',
|
||||
notes: notes || null,
|
||||
assigned_to: assigned_to || null,
|
||||
status: 'new',
|
||||
owner: 'clube67',
|
||||
});
|
||||
await db('lead_history').insert({
|
||||
lead_id: id,
|
||||
action: 'created',
|
||||
new_value: 'new',
|
||||
created_by: (req as any).user?.id || null,
|
||||
});
|
||||
await ctx.hooks.emit('lead:created', { id, email, partner_id });
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
res.status(201).json(translateLead(lead));
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao criar lead' });
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:id/status', ...protectedRoutes, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { status } = req.body;
|
||||
if (!STATUS_VALUES.includes(status)) return res.status(400).json({ error: 'Status inválido' });
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
if (!lead) return res.status(404).json({ error: 'Lead não encontrado' });
|
||||
await db('leads').where({ id }).update({ status, updated_at: db.fn.now() });
|
||||
await db('lead_history').insert({
|
||||
lead_id: id,
|
||||
action: 'status',
|
||||
old_value: lead.status,
|
||||
new_value: status,
|
||||
created_by: (req as any).user?.id || null,
|
||||
});
|
||||
if (status === 'converted') await ctx.hooks.emit('lead:converted', { id });
|
||||
if (status === 'lost') await ctx.hooks.emit('lead:lost', { id });
|
||||
const updated = await db('leads').where({ id }).first();
|
||||
res.json(translateLead(updated));
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao atualizar status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/:id/notes', ...protectedRoutes, async (req: Request, res: Response) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
const { note } = req.body;
|
||||
if (!note) return res.status(400).json({ error: 'Nota obrigatória' });
|
||||
const lead = await db('leads').where({ id }).first();
|
||||
if (!lead) return res.status(404).json({ error: 'Lead não encontrado' });
|
||||
await db('lead_history').insert({
|
||||
lead_id: id,
|
||||
action: 'note',
|
||||
old_value: lead.notes,
|
||||
new_value: lead.notes ? `${lead.notes}\n${note}` : note,
|
||||
note,
|
||||
created_by: (req as any).user?.id || null,
|
||||
});
|
||||
await db('leads').where({ id }).update({
|
||||
notes: db.raw('CONCAT(COALESCE(notes,""), "\n", ?)', [note]),
|
||||
updated_at: db.fn.now(),
|
||||
});
|
||||
const updated = await db('leads').where({ id }).first();
|
||||
res.json(translateLead(updated));
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: 'Erro ao salvar nota' });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const routes_1 = require("./backend/routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
const router = (0, routes_1.createLeadsRoutes)(ctx);
|
||||
ctx.app.use('/api/leads', router);
|
||||
ctx.hooks.register('user:register', async (data) => {
|
||||
await ctx.db('leads').insert({
|
||||
user_id: data.userId,
|
||||
source: 'registration',
|
||||
status: 'new',
|
||||
lead_type: 'member',
|
||||
}).catch(() => { });
|
||||
});
|
||||
ctx.logger.info('Leads routes registered');
|
||||
},
|
||||
async deactivate(ctx) { ctx.logger.info('Leads deactivated'); },
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,6CAAqD;AACrD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,MAAM,MAAM,GAAG,IAAA,0BAAiB,EAAC,GAAG,CAAC,CAAC;QACtC,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,YAAY,EAAE,MAAM,CAAC,CAAC;QAClC,GAAG,CAAC,KAAK,CAAC,QAAQ,CAAC,eAAe,EAAE,KAAK,EAAE,IAAS,EAAE,EAAE;YACpD,MAAM,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,CAAC,MAAM,CAAC;gBACzB,OAAO,EAAE,IAAI,CAAC,MAAM;gBACpB,MAAM,EAAE,cAAc;gBACtB,MAAM,EAAE,KAAK;gBACb,SAAS,EAAE,QAAQ;aACtB,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;QACxB,CAAC,CAAC,CAAC;QACH,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAC;IAC/C,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC,CAAC,CAAC,CAAC;CAChG,CAAC;AACF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { createLeadsRoutes } from './backend/routes';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
const router = createLeadsRoutes(ctx);
|
||||
ctx.app.use('/api/leads', router);
|
||||
ctx.hooks.register('user:register', async (data: any) => {
|
||||
await ctx.db('leads').insert({
|
||||
user_id: data.userId,
|
||||
source: 'registration',
|
||||
status: 'new',
|
||||
lead_type: 'member',
|
||||
}).catch(() => { });
|
||||
});
|
||||
ctx.logger.info('Leads routes registered');
|
||||
},
|
||||
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('Leads deactivated'); },
|
||||
};
|
||||
export default plugin;
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "leads",
|
||||
"displayName": "Leads & CRM",
|
||||
"version": "1.0.0",
|
||||
"description": "Captura, qualificação e acompanhamento de leads.",
|
||||
"author": "Clube67",
|
||||
"category": "business",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [
|
||||
"core-auth",
|
||||
"partners"
|
||||
],
|
||||
"backend": {
|
||||
"routePrefix": "/api/leads",
|
||||
"hasMigrations": true
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": [
|
||||
{
|
||||
"id": "leads-list",
|
||||
"label": "Leads",
|
||||
"icon": "UserPlus",
|
||||
"href": "/admin/leads",
|
||||
"roles": [
|
||||
"super_admin"
|
||||
],
|
||||
"order": 35
|
||||
},
|
||||
{
|
||||
"id": "leads-partner",
|
||||
"label": "Meus Leads",
|
||||
"icon": "UserPlus",
|
||||
"href": "/partner/leads",
|
||||
"roles": [
|
||||
"partner_admin"
|
||||
],
|
||||
"order": 35
|
||||
}
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"subscribes": [
|
||||
"user:register"
|
||||
],
|
||||
"emits": [
|
||||
"lead:created",
|
||||
"lead:converted",
|
||||
"lead:lost"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createRoutes = createRoutes;
|
||||
const express_1 = require("express");
|
||||
const openai_1 = __importDefault(require("openai"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const https_1 = __importDefault(require("https"));
|
||||
const uuid_1 = require("uuid");
|
||||
function createRoutes(ctx) {
|
||||
const router = (0, express_1.Router)();
|
||||
const uploadDir = process.env.UPLOAD_DIR || path_1.default.join(process.cwd(), 'uploads');
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: 'Nanobana plugin active',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Clube67 System'
|
||||
});
|
||||
});
|
||||
router.post('/generate-image', async (req, res) => {
|
||||
try {
|
||||
const { type, prompt, partnerName, city, description, partnerId } = req.body;
|
||||
if (!partnerId) {
|
||||
return res.status(400).json({ error: 'Partner ID is required' });
|
||||
}
|
||||
// Check Usage Limit (1 per type)
|
||||
// FUTURE UPDATE: Add monetization logic here to allow more generations
|
||||
// Check for Unlimited User (ruibto@gmail.com)
|
||||
const user = await ctx.db('users').where({ partner_id: partnerId }).first();
|
||||
const isUnlimited = user && (user.email === 'ruibto@gmail.com');
|
||||
if (!isUnlimited) {
|
||||
const usageCount = await ctx.db('nanobana_usage')
|
||||
.where({ partner_id: partnerId, type })
|
||||
.count('id as count')
|
||||
.first();
|
||||
const count = usageCount ? Number(usageCount.count) : 0;
|
||||
if (count >= 1) {
|
||||
return res.status(403).json({ error: `Limite gratuito atingido para ${type === 'logo' ? 'Logo' : 'Banner'}. (Máximo: 1)` });
|
||||
}
|
||||
}
|
||||
// Get Config
|
||||
const nanobanaConfig = ctx.config.get('nanobana');
|
||||
const apiKey = nanobanaConfig?.apiKey;
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' });
|
||||
}
|
||||
const openai = new openai_1.default({ apiKey });
|
||||
// Vision Analysis (if enabled)
|
||||
let visionDescription = '';
|
||||
if (req.body.useImageRef && req.body.referenceImageUrl) {
|
||||
try {
|
||||
const refUrl = req.body.referenceImageUrl;
|
||||
let imagePath = '';
|
||||
// Resolve local path
|
||||
if (refUrl.includes('/uploads/')) {
|
||||
const filename = path_1.default.basename(refUrl);
|
||||
imagePath = path_1.default.join(uploadDir, filename);
|
||||
}
|
||||
if (imagePath && fs_1.default.existsSync(imagePath)) {
|
||||
const imageBuffer = fs_1.default.readFileSync(imagePath);
|
||||
const base64Image = imageBuffer.toString('base64');
|
||||
const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const dataUrl = `data:${mimeType};base64,${base64Image}`;
|
||||
const visionResponse = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Describe this image in detail. Focus on the main subject, colors, style, artistic technique, and composition. The description will be used to recreate a similar image." },
|
||||
{ type: "image_url", image_url: { url: dataUrl } }
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 300,
|
||||
});
|
||||
visionDescription = visionResponse.choices[0].message.content || '';
|
||||
console.log('[Nanobana] Vision Description:', visionDescription);
|
||||
}
|
||||
else {
|
||||
console.warn('[Nanobana] Reference image not found locally:', refUrl);
|
||||
}
|
||||
}
|
||||
catch (visionErr) {
|
||||
console.error('[Nanobana] Vision Error:', visionErr);
|
||||
// Continue without vision
|
||||
}
|
||||
}
|
||||
// Construct Prompt
|
||||
let finalPrompt = '';
|
||||
if (visionDescription) {
|
||||
finalPrompt = `Create an image based on this visual description: "${visionDescription}".\n\nCONTEXT/MODIFICATIONS REQUESTED: ${prompt || 'Keep the style similar.'}.\n\nIdentity: ${partnerName} (${city}).`;
|
||||
}
|
||||
else if (prompt) {
|
||||
finalPrompt = prompt;
|
||||
}
|
||||
else {
|
||||
if (type === 'logo') {
|
||||
finalPrompt = `A professional, minimalist, and modern logo for a company named "${partnerName}". Context: ${description}. Location style: ${city}. High quality, vector style, white background.`;
|
||||
}
|
||||
else {
|
||||
finalPrompt = `A stunning, high-quality banner image for a company named "${partnerName}". Context: ${description}. The image should be wide (landscape), professional photography style, inviting, and related to the business topic.`;
|
||||
}
|
||||
}
|
||||
// Generate Image
|
||||
const response = await openai.images.generate({
|
||||
model: "dall-e-3",
|
||||
prompt: finalPrompt,
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
quality: "standard",
|
||||
response_format: "url",
|
||||
});
|
||||
const imageUrl = response.data[0].url;
|
||||
if (!imageUrl)
|
||||
throw new Error('Falha ao gerar imagem na OpenAI');
|
||||
// Download Image to Local Storage
|
||||
const ext = '.png'; // DALL-E 3 usually png
|
||||
const filename = `${(0, uuid_1.v4)()}${ext}`;
|
||||
const localPath = path_1.default.join(uploadDir, filename);
|
||||
const publicUrl = `/uploads/${filename}`;
|
||||
const file = fs_1.default.createWriteStream(localPath);
|
||||
await new Promise((resolve, reject) => {
|
||||
https_1.default.get(imageUrl, function (response) {
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve(true);
|
||||
});
|
||||
}).on('error', function (err) {
|
||||
fs_1.default.unlink(localPath, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
// Record Usage
|
||||
await ctx.db('nanobana_usage').insert({
|
||||
partner_id: partnerId,
|
||||
type
|
||||
});
|
||||
res.json({
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
originalPrompt: finalPrompt
|
||||
});
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Nanobana Error:', error);
|
||||
let errorMessage = error.message || 'Erro ao gerar imagem';
|
||||
// Translate Safety Error
|
||||
if (errorMessage.includes('safety system') || errorMessage.includes('rejected')) {
|
||||
errorMessage = '⚠️ A imagem não pôde ser gerada pois a descrição ou o contexto viola as políticas de segurança da IA (conteúdo impróprio, marcas protegidas ou termos sensíveis). Tente ajustar o texto para ser mais genérico.';
|
||||
}
|
||||
else if (errorMessage.includes('billing') || errorMessage.includes('quota')) {
|
||||
errorMessage = 'Erro de faturamento na IA. Contate o suporte.';
|
||||
}
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,182 @@
|
||||
import { Router } from 'express';
|
||||
import { PluginContext } from '../../../backend/src/core/types';
|
||||
|
||||
import OpenAI from 'openai';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import https from 'https';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export function createRoutes(ctx: PluginContext): Router {
|
||||
const router = Router();
|
||||
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: 'Nanobana plugin active',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'Clube67 System'
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/generate-image', async (req, res) => {
|
||||
try {
|
||||
const { type, prompt, partnerName, city, description, partnerId } = req.body;
|
||||
|
||||
if (!partnerId) {
|
||||
return res.status(400).json({ error: 'Partner ID is required' });
|
||||
}
|
||||
|
||||
// Check Usage Limit (1 per type)
|
||||
// FUTURE UPDATE: Add monetization logic here to allow more generations
|
||||
|
||||
// Check for Unlimited User (ruibto@gmail.com)
|
||||
const user = await ctx.db('users').where({ partner_id: partnerId }).first();
|
||||
const isUnlimited = user && (user.email === 'ruibto@gmail.com');
|
||||
|
||||
if (!isUnlimited) {
|
||||
const usageCount = await ctx.db('nanobana_usage')
|
||||
.where({ partner_id: partnerId, type })
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
const count = usageCount ? Number(usageCount.count) : 0;
|
||||
if (count >= 1) {
|
||||
return res.status(403).json({ error: `Limite gratuito atingido para ${type === 'logo' ? 'Logo' : 'Banner'}. (Máximo: 1)` });
|
||||
}
|
||||
}
|
||||
|
||||
// Get Config
|
||||
const nanobanaConfig = ctx.config.get('nanobana');
|
||||
const apiKey = nanobanaConfig?.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
return res.status(400).json({ error: 'OpenAI API Key não configurada no plugin Nanobana.' });
|
||||
}
|
||||
|
||||
const openai = new OpenAI({ apiKey });
|
||||
|
||||
// Vision Analysis (if enabled)
|
||||
let visionDescription = '';
|
||||
if (req.body.useImageRef && req.body.referenceImageUrl) {
|
||||
try {
|
||||
const refUrl = req.body.referenceImageUrl;
|
||||
let imagePath = '';
|
||||
|
||||
// Resolve local path
|
||||
if (refUrl.includes('/uploads/')) {
|
||||
const filename = path.basename(refUrl);
|
||||
imagePath = path.join(uploadDir, filename);
|
||||
}
|
||||
|
||||
if (imagePath && fs.existsSync(imagePath)) {
|
||||
const imageBuffer = fs.readFileSync(imagePath);
|
||||
const base64Image = imageBuffer.toString('base64');
|
||||
const mimeType = imagePath.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
||||
const dataUrl = `data:${mimeType};base64,${base64Image}`;
|
||||
|
||||
const visionResponse = await openai.chat.completions.create({
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Describe this image in detail. Focus on the main subject, colors, style, artistic technique, and composition. The description will be used to recreate a similar image." },
|
||||
{ type: "image_url", image_url: { url: dataUrl } }
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 300,
|
||||
});
|
||||
|
||||
visionDescription = visionResponse.choices[0].message.content || '';
|
||||
console.log('[Nanobana] Vision Description:', visionDescription);
|
||||
} else {
|
||||
console.warn('[Nanobana] Reference image not found locally:', refUrl);
|
||||
}
|
||||
} catch (visionErr) {
|
||||
console.error('[Nanobana] Vision Error:', visionErr);
|
||||
// Continue without vision
|
||||
}
|
||||
}
|
||||
|
||||
// Construct Prompt
|
||||
let finalPrompt = '';
|
||||
|
||||
if (visionDescription) {
|
||||
finalPrompt = `Create an image based on this visual description: "${visionDescription}".\n\nCONTEXT/MODIFICATIONS REQUESTED: ${prompt || 'Keep the style similar.'}.\n\nIdentity: ${partnerName} (${city}).`;
|
||||
} else if (prompt) {
|
||||
finalPrompt = prompt;
|
||||
} else {
|
||||
if (type === 'logo') {
|
||||
finalPrompt = `A professional, minimalist, and modern logo for a company named "${partnerName}". Context: ${description}. Location style: ${city}. High quality, vector style, white background.`;
|
||||
} else {
|
||||
finalPrompt = `A stunning, high-quality banner image for a company named "${partnerName}". Context: ${description}. The image should be wide (landscape), professional photography style, inviting, and related to the business topic.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Image
|
||||
const response = await openai.images.generate({
|
||||
model: "dall-e-3",
|
||||
prompt: finalPrompt,
|
||||
n: 1,
|
||||
size: "1024x1024",
|
||||
quality: "standard",
|
||||
response_format: "url",
|
||||
});
|
||||
|
||||
const imageUrl = response.data[0].url;
|
||||
|
||||
if (!imageUrl) throw new Error('Falha ao gerar imagem na OpenAI');
|
||||
|
||||
// Download Image to Local Storage
|
||||
const ext = '.png'; // DALL-E 3 usually png
|
||||
const filename = `${uuidv4()}${ext}`;
|
||||
const localPath = path.join(uploadDir, filename);
|
||||
const publicUrl = `/uploads/${filename}`;
|
||||
|
||||
const file = fs.createWriteStream(localPath);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
https.get(imageUrl, function (response) {
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve(true);
|
||||
});
|
||||
}).on('error', function (err) {
|
||||
fs.unlink(localPath, () => { });
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
// Record Usage
|
||||
await ctx.db('nanobana_usage').insert({
|
||||
partner_id: partnerId,
|
||||
type
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
url: publicUrl,
|
||||
originalPrompt: finalPrompt
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Nanobana Error:', error);
|
||||
|
||||
let errorMessage = error.message || 'Erro ao gerar imagem';
|
||||
|
||||
// Translate Safety Error
|
||||
if (errorMessage.includes('safety system') || errorMessage.includes('rejected')) {
|
||||
errorMessage = '⚠️ A imagem não pôde ser gerada pois a descrição ou o contexto viola as políticas de segurança da IA (conteúdo impróprio, marcas protegidas ou termos sensíveis). Tente ajustar o texto para ser mais genérico.';
|
||||
} else if (errorMessage.includes('billing') || errorMessage.includes('quota')) {
|
||||
errorMessage = 'Erro de faturamento na IA. Contate o suporte.';
|
||||
}
|
||||
|
||||
res.status(500).json({ error: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const routes_1 = require("./backend/routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
ctx.logger.info('Registering Nanobana routes...');
|
||||
ctx.app.use(manifest_json_1.default.backend.routePrefix, (0, routes_1.createRoutes)(ctx));
|
||||
ctx.logger.info('Nanobana activated!');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('Nanobana deactivated');
|
||||
},
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,6CAAgD;AAChD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,gCAAgC,CAAC,CAAC;QAElD,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,uBAAQ,CAAC,OAAO,CAAC,WAAW,EAAE,IAAA,qBAAY,EAAC,GAAG,CAAC,CAAC,CAAC;QAE7D,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC;IAC3C,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB;QAC/B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,sBAAsB,CAAC,CAAC;IAC5C,CAAC;CACJ,CAAC;AAEF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { createRoutes } from './backend/routes';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('Registering Nanobana routes...');
|
||||
|
||||
ctx.app.use(manifest.backend.routePrefix, createRoutes(ctx));
|
||||
|
||||
ctx.logger.info('Nanobana activated!');
|
||||
},
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('Nanobana deactivated');
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "nanobana",
|
||||
"displayName": "Nanobana",
|
||||
"version": "1.0.0",
|
||||
"description": "Exposes a global API endpoint for system consumption.",
|
||||
"author": "Clube67",
|
||||
"category": "utility",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/plugins/nanobana",
|
||||
"hasMigrations": true
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": []
|
||||
},
|
||||
"configSchema": [
|
||||
{ "key": "apiKey", "label": "OpenAI API Key", "type": "password", "placeholder": "sk-...", "required": true, "group": "Integração OpenAI" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
# Plugin Empresa — Arquitetura de Integração
|
||||
|
||||
## Visão Geral
|
||||
|
||||
O sistema NewWhats roda em servidor próprio e nunca acessa diretamente o banco da empresa cliente.
|
||||
A comunicação acontece por dois plugins que se reconhecem via chave compartilhada:
|
||||
|
||||
```
|
||||
[ Servidor NewWhats ] [ Servidor da Empresa ]
|
||||
Plugin: secretaria ←→ Plugin: newwhats-client
|
||||
- Secretária IA - CRUD nos bancos
|
||||
- WhatsApp / Baileys - Agente de consulta
|
||||
- Inbox / Sessions - Frontend embarcado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Plugin da Empresa (`newwhats-client`)
|
||||
|
||||
### Responsabilidades
|
||||
|
||||
- Conecta ao banco de dados interno da empresa (leitura e escrita)
|
||||
- Expõe endpoints controlados para o NewWhats consumir
|
||||
- Recebe webhooks do NewWhats (eventos, notificações)
|
||||
- Serve o frontend embarcado (inbox, sessions, secretária)
|
||||
- Gerencia a chave de integração e autenticação cruzada
|
||||
|
||||
### Instalação
|
||||
|
||||
O plugin é distribuído como pacote independente.
|
||||
A empresa instala no próprio servidor — Node.js, PHP, Python ou via Docker.
|
||||
Após instalação, gera-se uma `integration_key` que é inserida nos dois lados.
|
||||
|
||||
```
|
||||
1. Empresa instala o plugin
|
||||
2. Plugin gera integration_key + registra URL do servidor NewWhats
|
||||
3. Admin copia a key e cola nas configurações do NewWhats
|
||||
4. Os dois sistemas se reconhecem e começam a conversar
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Comunicação entre os Plugins
|
||||
|
||||
### Direções
|
||||
|
||||
| Direção | Quando | Exemplo |
|
||||
|---|---|---|
|
||||
| NewWhats → Empresa (pull) | Secretária precisa de dados em tempo real | Consultar agenda do Dr. |
|
||||
| Empresa → NewWhats (push) | Evento proativo no sistema da empresa | Dr. cancelou consulta, avisar paciente |
|
||||
|
||||
### Autenticação
|
||||
|
||||
- Chave JWT gerada no NewWhats, compartilhada com o plugin da empresa
|
||||
- Rotacionável pelo admin a qualquer momento
|
||||
- Cada requisição carrega o JWT no header `Authorization: Bearer <key>`
|
||||
- Expiração configurável (padrão: 90 dias)
|
||||
|
||||
---
|
||||
|
||||
## Contrato de API (Endpoints Mínimos)
|
||||
|
||||
O plugin da empresa **obrigatoriamente** expõe estes endpoints para a secretária funcionar:
|
||||
|
||||
### Agenda
|
||||
```
|
||||
GET /nw/agenda?data=YYYY-MM-DD&profissional_id= → horários disponíveis
|
||||
POST /nw/agenda/agendar → confirmar agendamento
|
||||
PUT /nw/agenda/:id/cancelar → cancelar agendamento
|
||||
GET /nw/agenda/:id → detalhes de um agendamento
|
||||
```
|
||||
|
||||
### Profissionais / Responsáveis
|
||||
```
|
||||
GET /nw/profissionais → lista com nome, área, número WhatsApp
|
||||
GET /nw/profissionais/:id → dados de um profissional
|
||||
```
|
||||
|
||||
### Clientes / Pacientes / Contatos
|
||||
```
|
||||
GET /nw/cliente?telefone=5511999999999 → dados pelo número de WhatsApp
|
||||
GET /nw/cliente/:id → dados por ID
|
||||
POST /nw/cliente → cadastrar novo cliente
|
||||
```
|
||||
|
||||
### Webhook
|
||||
```
|
||||
POST /nw/webhook/registrar → registrar URL de callback no NewWhats
|
||||
```
|
||||
|
||||
### Endpoints opcionais (dependem do ramo)
|
||||
```
|
||||
GET /nw/produtos → catálogo
|
||||
GET /nw/pedidos?cliente_id= → histórico de pedidos
|
||||
GET /nw/financeiro?cliente_id= → situação financeira
|
||||
GET /nw/historico?cliente_id= → histórico de atendimentos
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Agente de Consulta (dentro do plugin da empresa)
|
||||
|
||||
O plugin não expõe o banco diretamente — ele tem um **agente interno** que:
|
||||
|
||||
1. Recebe a query da secretária (ex: `agenda?data=2026-04-15&profissional=Dr. Carlos`)
|
||||
2. Traduz para uma query SQL ou chamada interna do sistema da empresa
|
||||
3. Filtra apenas os campos permitidos (sem dados sensíveis)
|
||||
4. Devolve JSON padronizado
|
||||
|
||||
```
|
||||
Secretária IA (NewWhats)
|
||||
↓ GET /nw/agenda?data=15/04
|
||||
Plugin da Empresa
|
||||
↓ SELECT * FROM agenda WHERE data = '2026-04-15' AND ativo = true
|
||||
Banco da Empresa
|
||||
↓ rows
|
||||
Plugin filtra e formata
|
||||
↓ JSON limpo
|
||||
Secretária injeta no contexto e responde o cliente
|
||||
```
|
||||
|
||||
### Permissões por endpoint
|
||||
|
||||
Cada endpoint tem uma flag de permissão configurável pelo admin da empresa:
|
||||
|
||||
```
|
||||
agenda.read = true ✓
|
||||
agenda.write = true ✓
|
||||
cliente.read = true ✓
|
||||
cliente.write = false ✗ (somente leitura por enquanto)
|
||||
financeiro.read = false ✗ (dado sensível, bloqueado)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Embarcado
|
||||
|
||||
### O que é
|
||||
|
||||
As três telas principais do NewWhats servidas **dentro do sistema da empresa**,
|
||||
sem precisar abrir outro sistema ou fazer login separado.
|
||||
|
||||
```
|
||||
Sistema da Empresa (ERP / CRM)
|
||||
└── Menu
|
||||
├── [módulos da empresa]
|
||||
└── NewWhats ← plugin embarcado
|
||||
├── Inbox (conversas WhatsApp em tempo real)
|
||||
├── Sessions (instâncias Baileys, status de conexão)
|
||||
└── Secretária IA (configuração + chat de teste)
|
||||
```
|
||||
|
||||
### Opções de implementação
|
||||
|
||||
**Opção 1 — iFrame com token SSO** (mais simples)
|
||||
- Plugin gera um token de sessão temporário (15min, renovável)
|
||||
- Abre `https://newwhats-server/embed?token=<sso_token>` em iFrame
|
||||
- NewWhats valida o token e serve a interface sem tela de login
|
||||
|
||||
**Opção 2 — Micro-frontend (componentes standalone)** (mais integrado)
|
||||
- Plugin distribui componentes React/Vue que consomem a API do NewWhats
|
||||
- Renderizam dentro do layout da empresa sem iFrame
|
||||
- Requerem que a empresa use React/Vue no frontend
|
||||
|
||||
**Recomendação:** começar com iFrame SSO — funciona em qualquer stack,
|
||||
entrega valor rápido. Migrar para micro-frontend quando houver demanda.
|
||||
|
||||
### Autenticação cruzada (SSO)
|
||||
|
||||
```
|
||||
1. Usuário logado no sistema da empresa clica em "NewWhats"
|
||||
2. Sistema da empresa chama POST /nw/auth/sso no NewWhats com a integration_key
|
||||
3. NewWhats retorna um token de sessão temporário
|
||||
4. Plugin abre o iFrame com esse token
|
||||
5. Usuário entra direto, sem segunda tela de login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Números e Papéis
|
||||
|
||||
O plugin da empresa cadastra os números WhatsApp relevantes e seus papéis:
|
||||
|
||||
| Papel | Descrição | Exemplo |
|
||||
|---|---|---|
|
||||
| `secretary_virtual` | Número principal da secretária IA | Atende clientes |
|
||||
| `clinic` | Número geral da empresa | Recebe notificações |
|
||||
| `doctor` / `specialist` | Profissional responsável por área | Recebe dúvidas específicas |
|
||||
| `manager` | Fallback universal | Recebe quando ninguém mais responde |
|
||||
| `reserve` | Backup da secretária | Entra quando principal cai ou é banido |
|
||||
| `human_secretary` | Secretária humana | Pode assumir conversa da IA |
|
||||
|
||||
O NewWhats consulta esses papéis via `GET /nw/profissionais` e usa para roteamento interno.
|
||||
|
||||
---
|
||||
|
||||
## Fluxo Completo — Exemplo Real
|
||||
|
||||
**Cenário:** Paciente pergunta se pode agendar com o Dr. Carlos na quinta-feira.
|
||||
|
||||
```
|
||||
1. Paciente envia mensagem no WhatsApp
|
||||
2. Baileys recebe → NewWhats aciona Secretária IA
|
||||
3. Nó de intenção classifica: "agendamento"
|
||||
4. Nó de calendário chama GET /nw/agenda?data=2026-04-16&profissional=Dr.Carlos
|
||||
5. Plugin da empresa consulta banco → retorna horários livres
|
||||
6. Secretária oferece opções ao paciente
|
||||
7. Paciente confirma horário das 14h
|
||||
8. Secretária chama POST /nw/agenda/agendar com os dados
|
||||
9. Plugin grava no banco da empresa
|
||||
10. Secretária confirma para o paciente via WhatsApp
|
||||
11. Sistema da empresa exibe o agendamento normalmente no próprio sistema
|
||||
```
|
||||
|
||||
**Tudo isso sem o paciente saber que são dois sistemas.**
|
||||
|
||||
---
|
||||
|
||||
## Roadmap de Implementação
|
||||
|
||||
### Fase 1 — Conexão básica
|
||||
- [ ] Geração e troca de `integration_key` entre os dois plugins
|
||||
- [ ] Endpoints mínimos no plugin da empresa (agenda, profissionais, cliente)
|
||||
- [ ] Nó do tipo `api_query` no cérebro da secretária
|
||||
- [ ] Teste end-to-end: secretária consulta agenda real
|
||||
|
||||
### Fase 2 — Agente de consulta
|
||||
- [ ] Agente interno no plugin da empresa com mapeamento de permissões
|
||||
- [ ] Push de eventos (webhook): cancelamentos, atualizações
|
||||
- [ ] Roteamento por papel: secretária sabe para qual número perguntar
|
||||
|
||||
### Fase 3 — Frontend embarcado
|
||||
- [ ] SSO por token temporário
|
||||
- [ ] iFrame com Inbox + Sessions + Secretária IA
|
||||
- [ ] Personalização básica (logo, cores da empresa)
|
||||
|
||||
### Fase 4 — Expansão
|
||||
- [ ] SDK para empresas implementarem o plugin em qualquer stack
|
||||
- [ ] Marketplace de conectores prontos (ex: conector para sistemas populares do setor)
|
||||
- [ ] Painel de auditoria cruzada (o que a secretária consultou e quando)
|
||||
@@ -0,0 +1,730 @@
|
||||
"use strict";
|
||||
/**
|
||||
* ProtocolEngine — Cérebro Stateful da Secretária IA
|
||||
*
|
||||
* Princípios de economia de tokens:
|
||||
* 1. Lê o ESTADO atual do protocolo (summary), não o histórico completo
|
||||
* 2. Carrega apenas as últimas N mensagens (context_window)
|
||||
* 3. Sumarização ativa a cada 10 trocas para manter o resumo atualizado
|
||||
* 4. Nós do cérebro são compostos apenas com os ativos
|
||||
*/
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ProtocolEngine = void 0;
|
||||
const tools_1 = require("./tools");
|
||||
class ProtocolEngine {
|
||||
constructor(db, config) {
|
||||
this.db = db;
|
||||
this.config = config;
|
||||
}
|
||||
// ── Chat ─────────────────────────────────────────────────────────────────
|
||||
async chat(conversationId, userMessage, opts) {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||
if (!conversation)
|
||||
throw new Error('Conversa não encontrada');
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first();
|
||||
if (!agent)
|
||||
throw new Error('Agente não encontrado');
|
||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
||||
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts);
|
||||
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||
const contextWindow = agent.context_window ?? 8;
|
||||
const recentMessages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(contextWindow)
|
||||
.then((rows) => rows.reverse());
|
||||
// Salva a mensagem do usuário
|
||||
await this.db('sec_messages').insert({
|
||||
id: this.uuid(),
|
||||
conversation_id: conversationId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
created_at: new Date(),
|
||||
});
|
||||
// Chama a IA — usa node_model do nó persona se definido (sobrepõe o agente)
|
||||
const personaNode = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: conversation.agent_id, type: 'persona', active: true })
|
||||
.orderBy('sort_order')
|
||||
.first();
|
||||
const agentOverride = personaNode?.node_model
|
||||
? { ...agent, model: personaNode.node_model }
|
||||
: agent;
|
||||
const messages = [
|
||||
...recentMessages.map((m) => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: userMessage },
|
||||
];
|
||||
// Resolve tools: usa lista passada por opts, ou todas as builtins por padrão
|
||||
const toolNames = opts?.tools ?? tools_1.ALL_TOOL_NAMES;
|
||||
const toolDefs = (0, tools_1.resolveTools)(toolNames);
|
||||
const toolCtx = {
|
||||
db: this.db,
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
hooks: opts?.hooks,
|
||||
};
|
||||
let response;
|
||||
let usageInfo = null;
|
||||
let providerUsed = null;
|
||||
let modelUsed = null;
|
||||
try {
|
||||
if (toolDefs.length > 0) {
|
||||
response = await this.callAIWithTools(agentOverride, systemPrompt, messages, toolDefs, toolCtx);
|
||||
// Telemetria escrita pelos tool loops via side channel (toolCtx._telemetry)
|
||||
if (toolCtx._telemetry) {
|
||||
usageInfo = toolCtx._telemetry.usage;
|
||||
providerUsed = toolCtx._telemetry.provider;
|
||||
modelUsed = toolCtx._telemetry.model;
|
||||
}
|
||||
}
|
||||
else {
|
||||
const result = await this.callAI(agentOverride, systemPrompt, messages);
|
||||
response = result.text;
|
||||
usageInfo = result.usage;
|
||||
providerUsed = result.provider;
|
||||
modelUsed = result.model;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
response = `[Erro ao chamar IA: ${err.message}. Verifique a API Key nas configurações do plugin.]`;
|
||||
}
|
||||
// Salva resposta da IA com telemetria de tokens
|
||||
await this.db('sec_messages').insert({
|
||||
id: this.uuid(),
|
||||
conversation_id: conversationId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
usage_tokens: usageInfo ? JSON.stringify(usageInfo) : null,
|
||||
provider_used: providerUsed,
|
||||
model_used: modelUsed,
|
||||
created_at: new Date(),
|
||||
});
|
||||
// Atualiza conversa + sumariza a cada 10 trocas
|
||||
const totalMsgs = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.count('id as c')
|
||||
.first()
|
||||
.then((r) => Number(r?.c ?? 0));
|
||||
let summary = conversation.summary;
|
||||
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
||||
summary = await this.summarize(agent, recentMessages, userMessage, response);
|
||||
}
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ updated_at: new Date(), summary });
|
||||
return response;
|
||||
}
|
||||
// ── Protocol Number ───────────────────────────────────────────────────────
|
||||
static generateProtocolNumber() {
|
||||
const now = new Date();
|
||||
const p = (n, d = 2) => String(n).padStart(d, '0');
|
||||
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
||||
}
|
||||
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||
async buildSystemPrompt(agent, conversation, opts) {
|
||||
const nodes = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: agent.id, active: true })
|
||||
.orderBy('sort_order');
|
||||
let prompt = '';
|
||||
// Data/hora real — impede o modelo de alucinar a data
|
||||
const nowReal = new Date();
|
||||
const dtStr = nowReal.toLocaleString('pt-BR', {
|
||||
timeZone: 'America/Sao_Paulo',
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
});
|
||||
prompt += `=== DATA E HORA ATUAL ===\n${dtStr} (horário de Brasília)\n\n`;
|
||||
// Cabeçalho do protocolo — sempre presente, leve (3 linhas)
|
||||
const protocolHeader = [
|
||||
`=== PROTOCOLO ATIVO ===`,
|
||||
`Número: ${conversation.protocol_number || '—'}`,
|
||||
`Contato: ${conversation.contact_name}`,
|
||||
`Status: ${conversation.status}`,
|
||||
``,
|
||||
].join('\n');
|
||||
prompt += protocolHeader;
|
||||
for (const node of nodes) {
|
||||
switch (node.type) {
|
||||
case 'persona':
|
||||
prompt += `${node.content}\n\n`;
|
||||
break;
|
||||
case 'knowledge':
|
||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`;
|
||||
break;
|
||||
case 'rules':
|
||||
prompt += `=== REGRAS ===\n${node.content}\n\n`;
|
||||
break;
|
||||
case 'calendar': {
|
||||
const calCtx = await this.getCalendarContext();
|
||||
prompt += `=== AGENDA DISPONÍVEL (próximos 7 dias) ===\n${calCtx}\n\nInstruções: ${node.content}\n\n`;
|
||||
break;
|
||||
}
|
||||
case 'escalation':
|
||||
prompt += `=== REGRAS DE ESCALADA ===\n${node.content}\n\n`;
|
||||
break;
|
||||
default:
|
||||
prompt += `${node.content}\n\n`;
|
||||
}
|
||||
}
|
||||
// Injeta contexto local do projeto (enviado pelo plugin satélite)
|
||||
if (opts?.contextData && Object.keys(opts.contextData).length > 0) {
|
||||
const ctx = JSON.stringify(opts.contextData, null, 2);
|
||||
prompt += `=== CONTEXTO DO CLIENTE (dados reais do projeto) ===\n${ctx}\n\n`;
|
||||
}
|
||||
// Prompt extra do plugin (instruções específicas da chamada)
|
||||
if (opts?.systemExtra?.trim()) {
|
||||
prompt += `=== INSTRUÇÕES ADICIONAIS ===\n${opts.systemExtra.trim()}\n\n`;
|
||||
}
|
||||
// Injeta resumo do estado atual (economia de tokens — evita reler o histórico)
|
||||
if (conversation.summary) {
|
||||
prompt += `=== ESTADO ATUAL DA CONVERSA ===\n${conversation.summary}\n\n`;
|
||||
}
|
||||
return prompt.trim();
|
||||
}
|
||||
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||
async finalizeProtocol(conversationId) {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first();
|
||||
if (!conversation)
|
||||
throw new Error('Conversa não encontrada');
|
||||
if (conversation.status === 'closed') {
|
||||
return { summary: conversation.summary ?? '', protocol_number: conversation.protocol_number };
|
||||
}
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first();
|
||||
if (!agent)
|
||||
throw new Error('Agente não encontrado');
|
||||
// Carrega todas as mensagens para gerar resumo completo
|
||||
const messages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at');
|
||||
let summary = conversation.summary ?? '';
|
||||
if (messages.length > 0) {
|
||||
const transcript = messages
|
||||
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Ana'}: ${m.content}`)
|
||||
.join('\n');
|
||||
const summaryPrompt = `Gere um resumo estruturado desta conversa de atendimento para uso futuro como contexto rápido.\nInclua: motivo do contato, o que foi resolvido, próximos passos pendentes (se houver).\nMáximo 5 linhas. Seja objetivo.\n\nProtocolo: ${conversation.protocol_number}\nContato: ${conversation.contact_name}\n\n${transcript}`;
|
||||
const cheapModel = {
|
||||
openai: 'gpt-4o-mini', anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash', ollama: agent.model ?? 'llama3',
|
||||
};
|
||||
const finalAgent = {
|
||||
...agent, temperature: 0.2, max_tokens: 200,
|
||||
model: cheapModel[agent.provider] ?? agent.model,
|
||||
};
|
||||
try {
|
||||
const result = await this.callAI(finalAgent, '', [{ role: 'user', content: summaryPrompt }]);
|
||||
summary = result.text;
|
||||
}
|
||||
catch {
|
||||
summary = conversation.summary ?? `Protocolo ${conversation.protocol_number} encerrado.`;
|
||||
}
|
||||
}
|
||||
// Apaga mensagens — contexto comprimido no resumo (economia de tokens)
|
||||
await this.db('sec_messages').where({ conversation_id: conversationId }).delete();
|
||||
// Fecha o protocolo com resumo persistido
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ status: 'closed', summary, updated_at: new Date() });
|
||||
return { summary, protocol_number: conversation.protocol_number };
|
||||
}
|
||||
// ── AI Call ───────────────────────────────────────────────────────────────
|
||||
buildFallbackChain(agent, cfg) {
|
||||
const chainStr = cfg.fallback_chain ?? 'openai,gemini,anthropic,ollama';
|
||||
const order = chainStr.split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const defaults = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: 'llama3',
|
||||
};
|
||||
const hasKey = (p) => {
|
||||
if (p === 'openai')
|
||||
return !!cfg.openai_key;
|
||||
if (p === 'anthropic')
|
||||
return !!cfg.anthropic_key;
|
||||
if (p === 'gemini')
|
||||
return !!cfg.gemini_key;
|
||||
if (p === 'ollama')
|
||||
return true; // local, sempre disponível
|
||||
return false;
|
||||
};
|
||||
const agentProvider = agent.provider ?? 'openai';
|
||||
const agentModel = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini';
|
||||
const chain = [{ provider: agentProvider, model: agentModel }];
|
||||
for (const p of order) {
|
||||
if (p === agentProvider)
|
||||
continue;
|
||||
if (!hasKey(p))
|
||||
continue;
|
||||
chain.push({ provider: p, model: defaults[p] ?? p });
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
isRecoverableError(err) {
|
||||
const msg = err.message.toLowerCase();
|
||||
return (msg.includes('quota') ||
|
||||
msg.includes('rate limit') ||
|
||||
msg.includes('limite da api') ||
|
||||
msg.includes('exceeded') ||
|
||||
msg.includes('billing') ||
|
||||
msg.includes('insufficient') ||
|
||||
msg.includes('invalid_api_key') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('enotfound') ||
|
||||
msg.includes('não configurada'));
|
||||
}
|
||||
async callAI(agent, systemPrompt, messages) {
|
||||
const cfg = await this.config.get('secretaria');
|
||||
const chain = this.buildFallbackChain(agent, cfg);
|
||||
let lastError = new Error('Nenhum provider disponível');
|
||||
for (const entry of chain) {
|
||||
try {
|
||||
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages);
|
||||
}
|
||||
catch (err) {
|
||||
lastError = err;
|
||||
if (this.isRecoverableError(err))
|
||||
continue;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`);
|
||||
}
|
||||
/**
|
||||
* Chama o provider e retorna { text, usage, provider, model }.
|
||||
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
||||
* Reads agent.max_tokens (default 250 — adequado a WhatsApp).
|
||||
*/
|
||||
async callProvider(provider, model, agent, cfg, systemPrompt, messages) {
|
||||
const maxTokens = agent.max_tokens ?? 250;
|
||||
const temperature = agent.temperature ?? 0.7;
|
||||
// ── OpenAI ────────────────────────────────────────────────────────────────
|
||||
if (provider === 'openai') {
|
||||
const apiKey = cfg.openai_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('OpenAI API Key não configurada. Acesse Admin → Plugins → Secretária IA.');
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `OpenAI ${res.status}`);
|
||||
const text = data.choices[0].message.content;
|
||||
const usage = {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
total: data.usage?.total_tokens ?? 0,
|
||||
};
|
||||
return { text, usage, provider, model };
|
||||
}
|
||||
// ── Anthropic (com prompt caching ephemeral no system) ───────────────────
|
||||
if (provider === 'anthropic') {
|
||||
const apiKey = cfg.anthropic_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Anthropic API Key não configurada. Acesse Admin → Plugins → Secretária IA.');
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
// Header necessário até GA do prompt caching
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
// System como array com cache_control: trecho fica em cache 5min
|
||||
// Próximas chamadas com mesmo systemPrompt pagam ~10% pelo trecho cacheado.
|
||||
system: [
|
||||
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
messages,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `Anthropic ${res.status}`);
|
||||
const text = data.content[0].text;
|
||||
const usage = {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_create: data.usage?.cache_creation_input_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
total: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
|
||||
};
|
||||
return { text, usage, provider, model };
|
||||
}
|
||||
// ── Google Gemini ─────────────────────────────────────────────────────────
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = cfg.gemini_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Google Gemini API Key não configurada. Acesse Admin → Plugins → Secretária IA.');
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash';
|
||||
const geminiMessages = messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
}));
|
||||
const geminiBody = JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents: geminiMessages,
|
||||
generationConfig: { temperature, maxOutputTokens: maxTokens },
|
||||
});
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`;
|
||||
const doGeminiCall = async () => fetch(geminiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: geminiBody,
|
||||
});
|
||||
let res = await doGeminiCall();
|
||||
let data = (await res.json());
|
||||
if (!res.ok && (res.status === 429 || String(data.error?.message ?? '').toLowerCase().includes('quota'))) {
|
||||
const msg = data.error?.message ?? '';
|
||||
const match = msg.match(/retry in ([\d.]+)s/i);
|
||||
// Espera no máximo 8s (era 30s) e mínimo 1.5s (era 5s) — limita impacto de quota no tempo total
|
||||
const waitMs = match ? Math.min(Math.ceil(parseFloat(match[1])) * 1000, 8000) : 1500;
|
||||
await new Promise((r) => setTimeout(r, waitMs));
|
||||
res = await doGeminiCall();
|
||||
data = (await res.json());
|
||||
}
|
||||
if (!res.ok) {
|
||||
const errMsg = data.error?.message ?? `Gemini ${res.status}`;
|
||||
if (errMsg.toLowerCase().includes('quota') || res.status === 429) {
|
||||
throw new Error('Limite da API Gemini atingido. Aguarde alguns instantes e tente novamente.');
|
||||
}
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
const text = data.candidates[0].content.parts[0].text;
|
||||
const usage = {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
total: data.usageMetadata?.totalTokenCount ?? 0,
|
||||
};
|
||||
return { text, usage, provider, model: geminiModel };
|
||||
}
|
||||
// ── Ollama (local) ────────────────────────────────────────────────────────
|
||||
if (provider === 'ollama') {
|
||||
const baseUrl = cfg.ollama_url ?? 'http://localhost:11434';
|
||||
const ollamaModel = model || 'llama3';
|
||||
const res = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: JSON.stringify({
|
||||
model: ollamaModel,
|
||||
stream: false,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||
options: { temperature, num_predict: maxTokens },
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error ?? `Ollama ${res.status}`);
|
||||
const text = data.message.content;
|
||||
const usage = {
|
||||
input: data.prompt_eval_count ?? 0,
|
||||
output: data.eval_count ?? 0,
|
||||
total: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
|
||||
};
|
||||
return { text, usage, provider, model: ollamaModel };
|
||||
}
|
||||
throw new Error(`Provider "${provider}" não suportado. Use: openai, anthropic, gemini, ollama`);
|
||||
}
|
||||
// ── Calendar Context ──────────────────────────────────────────────────────
|
||||
async getCalendarContext() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const nextWeek = new Date();
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
const nextWeekStr = nextWeek.toISOString().split('T')[0];
|
||||
const slots = await this.db('sec_calendar')
|
||||
.whereIn('status', ['available', 'booked'])
|
||||
.whereBetween('date', [today, nextWeekStr])
|
||||
.orderBy('date')
|
||||
.orderBy('time_start')
|
||||
.limit(30);
|
||||
if (slots.length === 0)
|
||||
return 'Nenhum horário nos próximos 7 dias.';
|
||||
const lines = slots.map((s) => {
|
||||
const time = `${s.date} ${s.time_start.slice(0, 5)}–${s.time_end.slice(0, 5)}`;
|
||||
if (s.status === 'booked') {
|
||||
const who = s.attendee_name ? ` | Paciente: ${s.attendee_name}` : '';
|
||||
const phone = s.attendee_phone ? ` (${s.attendee_phone})` : '';
|
||||
return `• [AGENDADO] ${time}: ${s.title}${who}${phone}`;
|
||||
}
|
||||
return `• [DISPONÍVEL] ${time}: ${s.title}`;
|
||||
});
|
||||
return lines.join('\n');
|
||||
}
|
||||
// ── Summarization (token economy) ────────────────────────────────────────
|
||||
/**
|
||||
* Sumarização com modelo barato (M1.5).
|
||||
* Força modelo "mini/haiku/flash" mesmo que o agente principal use modelo caro.
|
||||
* Sumário é tarefa simples — não precisa do modelo de produção.
|
||||
*/
|
||||
async summarize(agent, recentMsgs, lastUser, lastAssistant) {
|
||||
const excerpt = [
|
||||
...recentMsgs.slice(-6).map((m) => `${m.role}: ${m.content}`),
|
||||
`user: ${lastUser}`,
|
||||
`assistant: ${lastAssistant}`,
|
||||
].join('\n');
|
||||
const prompt = `Resuma em no máximo 2 frases curtas o estado atual desta conversa de atendimento, focando no tema e próximo passo:\n\n${excerpt}`;
|
||||
// Modelo barato por provider
|
||||
const cheapModel = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: agent.model ?? 'llama3',
|
||||
};
|
||||
const summaryAgent = {
|
||||
...agent,
|
||||
temperature: 0.3,
|
||||
max_tokens: 120,
|
||||
model: cheapModel[agent.provider] ?? agent.model,
|
||||
};
|
||||
try {
|
||||
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }]);
|
||||
return result.text;
|
||||
}
|
||||
catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
// ── Tool Calling ──────────────────────────────────────────────────────────
|
||||
async callAIWithTools(agent, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const cfg = await this.config.get('secretaria');
|
||||
const chain = this.buildFallbackChain(agent, cfg);
|
||||
// Prefere provider com suporte a tool calling; Ollama cai em modo texto
|
||||
const TOOL_PROVIDERS = ['openai', 'anthropic', 'gemini'];
|
||||
const entry = chain.find(e => TOOL_PROVIDERS.includes(e.provider));
|
||||
if (!entry) {
|
||||
// Nenhum provider com tool calling disponível — usa modo texto normal
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text;
|
||||
}
|
||||
try {
|
||||
switch (entry.provider) {
|
||||
case 'openai':
|
||||
return await this.openAIToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx);
|
||||
case 'anthropic':
|
||||
return await this.anthropicToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx);
|
||||
case 'gemini':
|
||||
return await this.geminiToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx);
|
||||
default:
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text;
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
if (this.isRecoverableError(err)) {
|
||||
// Provider com tools falhou — tenta sem tools no próximo da chain
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async executeTool(name, rawArgs, tools, toolCtx) {
|
||||
const tool = tools.find(t => t.name === name);
|
||||
if (!tool)
|
||||
return { error: `Tool "${name}" não encontrada.` };
|
||||
const args = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs;
|
||||
try {
|
||||
return await tool.execute(args, toolCtx);
|
||||
}
|
||||
catch (e) {
|
||||
return { error: e.message };
|
||||
}
|
||||
}
|
||||
// ── Telemetria helper para tool loops ─────────────────────────────────────
|
||||
accumTelemetry(toolCtx, provider, model, incremental) {
|
||||
if (!toolCtx._telemetry) {
|
||||
toolCtx._telemetry = {
|
||||
usage: { input: 0, output: 0, total: 0, cache_read: 0, cached: 0 },
|
||||
provider, model, iterations: 0,
|
||||
};
|
||||
}
|
||||
toolCtx._telemetry.iterations += 1;
|
||||
toolCtx._telemetry.usage.input += incremental.input;
|
||||
toolCtx._telemetry.usage.output += incremental.output;
|
||||
toolCtx._telemetry.usage.total += incremental.input + incremental.output;
|
||||
if (incremental.cache_read)
|
||||
toolCtx._telemetry.usage.cache_read = (toolCtx._telemetry.usage.cache_read ?? 0) + incremental.cache_read;
|
||||
if (incremental.cached)
|
||||
toolCtx._telemetry.usage.cached = (toolCtx._telemetry.usage.cached ?? 0) + incremental.cached;
|
||||
}
|
||||
// ── OpenAI tool loop ───────────────────────────────────────────────────────
|
||||
async openAIToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const apiKey = cfg.openai_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('OpenAI API Key não configurada');
|
||||
const oaiTools = tools.map(t => ({
|
||||
type: 'function',
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters },
|
||||
}));
|
||||
let msgs = [...inputMessages];
|
||||
const MAX_ITER = 5;
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: agent.temperature ?? 0.7,
|
||||
max_tokens: agent.max_tokens ?? 250,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...msgs],
|
||||
tools: oaiTools,
|
||||
tool_choice: 'auto',
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `OpenAI ${res.status}`);
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'openai', model, {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
});
|
||||
const choice = data.choices[0];
|
||||
const assistantMsg = choice.message;
|
||||
if (choice.finish_reason !== 'tool_calls' || !assistantMsg.tool_calls?.length) {
|
||||
return (assistantMsg.content ?? '');
|
||||
}
|
||||
// Execute tools in parallel
|
||||
msgs.push(assistantMsg);
|
||||
const toolResults = await Promise.all(assistantMsg.tool_calls.map(async (tc) => {
|
||||
const result = await this.executeTool(tc.function.name, tc.function.arguments, tools, toolCtx);
|
||||
return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) };
|
||||
}));
|
||||
msgs.push(...toolResults);
|
||||
}
|
||||
throw new Error('Tool calling: limite de iterações atingido');
|
||||
}
|
||||
// ── Anthropic tool loop ────────────────────────────────────────────────────
|
||||
async anthropicToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const apiKey = cfg.anthropic_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Anthropic API Key não configurada');
|
||||
const anthropicTools = tools.map(t => ({
|
||||
name: t.name, description: t.description, input_schema: t.parameters,
|
||||
}));
|
||||
let msgs = [...inputMessages];
|
||||
const MAX_ITER = 5;
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: JSON.stringify({
|
||||
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
|
||||
messages: msgs, tools: anthropicTools,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `Anthropic ${res.status}`);
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'anthropic', model, {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
});
|
||||
// Texto puro
|
||||
if (data.stop_reason !== 'tool_use') {
|
||||
const textBlock = data.content.find(b => b.type === 'text');
|
||||
return (textBlock?.text ?? '');
|
||||
}
|
||||
// Tool calls
|
||||
msgs.push({ role: 'assistant', content: data.content });
|
||||
const toolResults = await Promise.all(data.content
|
||||
.filter(b => b.type === 'tool_use')
|
||||
.map(async (b) => {
|
||||
const result = await this.executeTool(b.name, b.input, tools, toolCtx);
|
||||
return { type: 'tool_result', tool_use_id: b.id, content: JSON.stringify(result) };
|
||||
}));
|
||||
msgs.push({ role: 'user', content: toolResults });
|
||||
}
|
||||
throw new Error('Tool calling (Anthropic): limite de iterações atingido');
|
||||
}
|
||||
// ── Gemini tool loop ───────────────────────────────────────────────────────
|
||||
async geminiToolLoop(model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx) {
|
||||
const apiKey = cfg.gemini_key ?? '';
|
||||
if (!apiKey)
|
||||
throw new Error('Gemini API Key não configurada');
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash';
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`;
|
||||
const geminiTools = [{
|
||||
functionDeclarations: tools.map(t => ({
|
||||
name: t.name, description: t.description, parameters: t.parameters,
|
||||
})),
|
||||
}];
|
||||
// Converte msgs para formato Gemini
|
||||
let contents = inputMessages.map(m => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
}));
|
||||
const MAX_ITER = 5;
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25000),
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents,
|
||||
tools: geminiTools,
|
||||
generationConfig: { temperature: agent.temperature ?? 0.7, maxOutputTokens: agent.max_tokens ?? 250 },
|
||||
}),
|
||||
});
|
||||
const data = (await res.json());
|
||||
if (!res.ok)
|
||||
throw new Error(data.error?.message ?? `Gemini ${res.status}`);
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'gemini', geminiModel, {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
});
|
||||
const candidate = data.candidates?.[0];
|
||||
const parts = candidate?.content?.parts ?? [];
|
||||
// Verifica se há function calls
|
||||
const fnCalls = parts.filter(p => p.functionCall);
|
||||
if (!fnCalls.length) {
|
||||
const textPart = parts.find(p => p.text);
|
||||
return (textPart?.text ?? '');
|
||||
}
|
||||
// Adiciona resposta do modelo ao histórico
|
||||
contents.push({ role: 'model', parts });
|
||||
// Executa tools e injeta resultados
|
||||
const resultParts = await Promise.all(fnCalls.map(async (p) => {
|
||||
const result = await this.executeTool(p.functionCall.name, p.functionCall.args ?? {}, tools, toolCtx);
|
||||
return { functionResponse: { name: p.functionCall.name, response: result } };
|
||||
}));
|
||||
contents.push({ role: 'user', parts: resultParts });
|
||||
}
|
||||
throw new Error('Tool calling (Gemini): limite de iterações atingido');
|
||||
}
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||
uuid() {
|
||||
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
exports.ProtocolEngine = ProtocolEngine;
|
||||
//# sourceMappingURL=brain.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,869 @@
|
||||
/**
|
||||
* ProtocolEngine — Cérebro Stateful da Secretária IA
|
||||
*
|
||||
* Princípios de economia de tokens:
|
||||
* 1. Lê o ESTADO atual do protocolo (summary), não o histórico completo
|
||||
* 2. Carrega apenas as últimas N mensagens (context_window)
|
||||
* 3. Sumarização ativa a cada 10 trocas para manter o resumo atualizado
|
||||
* 4. Nós do cérebro são compostos apenas com os ativos
|
||||
*/
|
||||
|
||||
import { Knex } from 'knex'
|
||||
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
|
||||
import type { HookBus } from '../../backend/src/core/hook-bus'
|
||||
import { type ToolDef, type ToolContext, resolveTools, ALL_TOOL_NAMES } from './tools'
|
||||
|
||||
export class ProtocolEngine {
|
||||
constructor(
|
||||
private readonly db: Knex,
|
||||
private readonly config: PluginConfigStore,
|
||||
) {}
|
||||
|
||||
// ── Chat ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async chat(
|
||||
conversationId: string,
|
||||
userMessage: string,
|
||||
opts?: {
|
||||
contextData?: Record<string, unknown>
|
||||
systemExtra?: string
|
||||
tools?: string[] // nomes das tools a habilitar (padrão: todas)
|
||||
hooks?: HookBus
|
||||
tenantId?: string
|
||||
},
|
||||
): Promise<string> {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
|
||||
if (!conversation) throw new Error('Conversa não encontrada')
|
||||
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
|
||||
if (!agent) throw new Error('Agente não encontrado')
|
||||
|
||||
// Monta system prompt a partir dos nós ativos + contexto externo
|
||||
const systemPrompt = await this.buildSystemPrompt(agent, conversation, opts)
|
||||
|
||||
// Carrega apenas as últimas N mensagens (não o histórico completo)
|
||||
const contextWindow: number = agent.context_window ?? 8
|
||||
const recentMessages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at', 'desc')
|
||||
.limit(contextWindow)
|
||||
.then((rows: any[]) => rows.reverse())
|
||||
|
||||
// Salva a mensagem do usuário
|
||||
await this.db('sec_messages').insert({
|
||||
id: this.uuid(),
|
||||
conversation_id: conversationId,
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
created_at: new Date(),
|
||||
})
|
||||
|
||||
// Chama a IA — usa node_model do nó persona se definido (sobrepõe o agente)
|
||||
const personaNode = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: conversation.agent_id, type: 'persona', active: true })
|
||||
.orderBy('sort_order')
|
||||
.first()
|
||||
|
||||
const agentOverride = personaNode?.node_model
|
||||
? { ...agent, model: personaNode.node_model }
|
||||
: agent
|
||||
|
||||
const messages = [
|
||||
...recentMessages.map((m: any) => ({ role: m.role, content: m.content })),
|
||||
{ role: 'user', content: userMessage },
|
||||
]
|
||||
|
||||
// Resolve tools: usa lista passada por opts, ou todas as builtins por padrão
|
||||
const toolNames = opts?.tools ?? ALL_TOOL_NAMES
|
||||
const toolDefs = resolveTools(toolNames)
|
||||
|
||||
const toolCtx: ToolContext = {
|
||||
db: this.db,
|
||||
conversationId,
|
||||
extChatId: conversation.ext_chat_id ?? undefined,
|
||||
tenantId: opts?.tenantId,
|
||||
hooks: opts?.hooks,
|
||||
}
|
||||
|
||||
let response: string
|
||||
let usageInfo: any = null
|
||||
let providerUsed: string | null = null
|
||||
let modelUsed: string | null = null
|
||||
try {
|
||||
if (toolDefs.length > 0) {
|
||||
response = await this.callAIWithTools(agentOverride, systemPrompt, messages, toolDefs, toolCtx)
|
||||
// Telemetria escrita pelos tool loops via side channel (toolCtx._telemetry)
|
||||
if (toolCtx._telemetry) {
|
||||
usageInfo = toolCtx._telemetry.usage
|
||||
providerUsed = toolCtx._telemetry.provider
|
||||
modelUsed = toolCtx._telemetry.model
|
||||
}
|
||||
} else {
|
||||
const result = await this.callAI(agentOverride, systemPrompt, messages)
|
||||
response = result.text
|
||||
usageInfo = result.usage
|
||||
providerUsed = result.provider
|
||||
modelUsed = result.model
|
||||
}
|
||||
} catch (err: any) {
|
||||
response = `[Erro ao chamar IA: ${err.message}. Verifique a API Key nas configurações do plugin.]`
|
||||
}
|
||||
|
||||
// Salva resposta da IA com telemetria de tokens
|
||||
await this.db('sec_messages').insert({
|
||||
id: this.uuid(),
|
||||
conversation_id: conversationId,
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
usage_tokens: usageInfo ? JSON.stringify(usageInfo) : null,
|
||||
provider_used: providerUsed,
|
||||
model_used: modelUsed,
|
||||
created_at: new Date(),
|
||||
})
|
||||
|
||||
// Atualiza conversa + sumariza a cada 10 trocas
|
||||
const totalMsgs = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.count('id as c')
|
||||
.first()
|
||||
.then((r: any) => Number(r?.c ?? 0))
|
||||
|
||||
let summary = conversation.summary
|
||||
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
|
||||
summary = await this.summarize(agent, recentMessages, userMessage, response)
|
||||
}
|
||||
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ updated_at: new Date(), summary })
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
// ── Protocol Number ───────────────────────────────────────────────────────
|
||||
|
||||
static generateProtocolNumber(): string {
|
||||
const now = new Date()
|
||||
const p = (n: number, d = 2) => String(n).padStart(d, '0')
|
||||
return `${p(now.getDate())}${p(now.getMonth() + 1)}${String(now.getFullYear()).slice(-2)}${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`
|
||||
}
|
||||
|
||||
// ── System Prompt Builder ─────────────────────────────────────────────────
|
||||
|
||||
private async buildSystemPrompt(
|
||||
agent: any,
|
||||
conversation: any,
|
||||
opts?: { contextData?: Record<string, unknown>; systemExtra?: string },
|
||||
): Promise<string> {
|
||||
const nodes = await this.db('sec_brain_nodes')
|
||||
.where({ agent_id: agent.id, active: true })
|
||||
.orderBy('sort_order')
|
||||
|
||||
let prompt = ''
|
||||
|
||||
// Data/hora real — impede o modelo de alucinar a data
|
||||
const nowReal = new Date()
|
||||
const dtStr = nowReal.toLocaleString('pt-BR', {
|
||||
timeZone: 'America/Sao_Paulo',
|
||||
weekday: 'long', day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
prompt += `=== DATA E HORA ATUAL ===\n${dtStr} (horário de Brasília)\n\n`
|
||||
|
||||
// Cabeçalho do protocolo — sempre presente, leve (3 linhas)
|
||||
const protocolHeader = [
|
||||
`=== PROTOCOLO ATIVO ===`,
|
||||
`Número: ${conversation.protocol_number || '—'}`,
|
||||
`Contato: ${conversation.contact_name}`,
|
||||
`Status: ${conversation.status}`,
|
||||
``,
|
||||
].join('\n')
|
||||
prompt += protocolHeader
|
||||
|
||||
for (const node of nodes as any[]) {
|
||||
switch (node.type) {
|
||||
case 'persona':
|
||||
prompt += `${node.content}\n\n`
|
||||
break
|
||||
case 'knowledge':
|
||||
prompt += `=== BASE DE CONHECIMENTO ===\n${node.content}\n\n`
|
||||
break
|
||||
case 'rules':
|
||||
prompt += `=== REGRAS ===\n${node.content}\n\n`
|
||||
break
|
||||
case 'calendar': {
|
||||
const calCtx = await this.getCalendarContext()
|
||||
prompt += `=== AGENDA DISPONÍVEL (próximos 7 dias) ===\n${calCtx}\n\nInstruções: ${node.content}\n\n`
|
||||
break
|
||||
}
|
||||
case 'escalation':
|
||||
prompt += `=== REGRAS DE ESCALADA ===\n${node.content}\n\n`
|
||||
break
|
||||
default:
|
||||
prompt += `${node.content}\n\n`
|
||||
}
|
||||
}
|
||||
|
||||
// Injeta contexto local do projeto (enviado pelo plugin satélite)
|
||||
if (opts?.contextData && Object.keys(opts.contextData).length > 0) {
|
||||
const ctx = JSON.stringify(opts.contextData, null, 2)
|
||||
prompt += `=== CONTEXTO DO CLIENTE (dados reais do projeto) ===\n${ctx}\n\n`
|
||||
}
|
||||
|
||||
// Prompt extra do plugin (instruções específicas da chamada)
|
||||
if (opts?.systemExtra?.trim()) {
|
||||
prompt += `=== INSTRUÇÕES ADICIONAIS ===\n${opts.systemExtra.trim()}\n\n`
|
||||
}
|
||||
|
||||
// Injeta resumo do estado atual (economia de tokens — evita reler o histórico)
|
||||
if (conversation.summary) {
|
||||
prompt += `=== ESTADO ATUAL DA CONVERSA ===\n${conversation.summary}\n\n`
|
||||
}
|
||||
|
||||
return prompt.trim()
|
||||
}
|
||||
|
||||
// ── Finalize Protocol ─────────────────────────────────────────────────────
|
||||
|
||||
async finalizeProtocol(conversationId: string): Promise<{ summary: string; protocol_number: string }> {
|
||||
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
|
||||
if (!conversation) throw new Error('Conversa não encontrada')
|
||||
if (conversation.status === 'closed') {
|
||||
return { summary: conversation.summary ?? '', protocol_number: conversation.protocol_number }
|
||||
}
|
||||
|
||||
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
|
||||
if (!agent) throw new Error('Agente não encontrado')
|
||||
|
||||
// Carrega todas as mensagens para gerar resumo completo
|
||||
const messages = await this.db('sec_messages')
|
||||
.where({ conversation_id: conversationId })
|
||||
.orderBy('created_at')
|
||||
|
||||
let summary = conversation.summary ?? ''
|
||||
|
||||
if (messages.length > 0) {
|
||||
const transcript = (messages as any[])
|
||||
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Ana'}: ${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
const summaryPrompt = `Gere um resumo estruturado desta conversa de atendimento para uso futuro como contexto rápido.\nInclua: motivo do contato, o que foi resolvido, próximos passos pendentes (se houver).\nMáximo 5 linhas. Seja objetivo.\n\nProtocolo: ${conversation.protocol_number}\nContato: ${conversation.contact_name}\n\n${transcript}`
|
||||
|
||||
const cheapModel: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini', anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash', ollama: agent.model ?? 'llama3',
|
||||
}
|
||||
const finalAgent = {
|
||||
...agent, temperature: 0.2, max_tokens: 200,
|
||||
model: cheapModel[agent.provider as string] ?? agent.model,
|
||||
}
|
||||
try {
|
||||
const result = await this.callAI(finalAgent, '', [{ role: 'user', content: summaryPrompt }])
|
||||
summary = result.text
|
||||
} catch {
|
||||
summary = conversation.summary ?? `Protocolo ${conversation.protocol_number} encerrado.`
|
||||
}
|
||||
}
|
||||
|
||||
// Apaga mensagens — contexto comprimido no resumo (economia de tokens)
|
||||
await this.db('sec_messages').where({ conversation_id: conversationId }).delete()
|
||||
|
||||
// Fecha o protocolo com resumo persistido
|
||||
await this.db('sec_conversations')
|
||||
.where({ id: conversationId })
|
||||
.update({ status: 'closed', summary, updated_at: new Date() })
|
||||
|
||||
return { summary, protocol_number: conversation.protocol_number }
|
||||
}
|
||||
|
||||
// ── AI Call ───────────────────────────────────────────────────────────────
|
||||
|
||||
private buildFallbackChain(agent: any, cfg: any): { provider: string; model: string }[] {
|
||||
const chainStr: string = (cfg.fallback_chain as string | undefined) ?? 'openai,gemini,anthropic,ollama'
|
||||
const order = chainStr.split(',').map((s: string) => s.trim()).filter(Boolean)
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: 'llama3',
|
||||
}
|
||||
|
||||
const hasKey = (p: string): boolean => {
|
||||
if (p === 'openai') return !!(cfg.openai_key as string | undefined)
|
||||
if (p === 'anthropic') return !!(cfg.anthropic_key as string | undefined)
|
||||
if (p === 'gemini') return !!(cfg.gemini_key as string | undefined)
|
||||
if (p === 'ollama') return true // local, sempre disponível
|
||||
return false
|
||||
}
|
||||
|
||||
const agentProvider: string = agent.provider ?? 'openai'
|
||||
const agentModel: string = agent.model ?? defaults[agentProvider] ?? 'gpt-4o-mini'
|
||||
|
||||
const chain: { provider: string; model: string }[] = [{ provider: agentProvider, model: agentModel }]
|
||||
|
||||
for (const p of order) {
|
||||
if (p === agentProvider) continue
|
||||
if (!hasKey(p)) continue
|
||||
chain.push({ provider: p, model: defaults[p] ?? p })
|
||||
}
|
||||
|
||||
return chain
|
||||
}
|
||||
|
||||
private isRecoverableError(err: Error): boolean {
|
||||
const msg = err.message.toLowerCase()
|
||||
return (
|
||||
msg.includes('quota') ||
|
||||
msg.includes('rate limit') ||
|
||||
msg.includes('limite da api') ||
|
||||
msg.includes('exceeded') ||
|
||||
msg.includes('billing') ||
|
||||
msg.includes('insufficient') ||
|
||||
msg.includes('invalid_api_key') ||
|
||||
msg.includes('econnrefused') ||
|
||||
msg.includes('enotfound') ||
|
||||
msg.includes('não configurada')
|
||||
)
|
||||
}
|
||||
|
||||
private async callAI(
|
||||
agent: any, systemPrompt: string, messages: any[],
|
||||
): Promise<{ text: string; usage: any; provider: string; model: string }> {
|
||||
const cfg = await this.config.get('secretaria')
|
||||
const chain = this.buildFallbackChain(agent, cfg)
|
||||
|
||||
let lastError: Error = new Error('Nenhum provider disponível')
|
||||
|
||||
for (const entry of chain) {
|
||||
try {
|
||||
return await this.callProvider(entry.provider, entry.model, agent, cfg, systemPrompt, messages)
|
||||
} catch (err: any) {
|
||||
lastError = err
|
||||
if (this.isRecoverableError(err)) continue
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Todos os providers falharam. Último erro: ${lastError.message}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Chama o provider e retorna { text, usage, provider, model }.
|
||||
* usage: { input, output, cached?, total } — chars/tokens consumidos.
|
||||
* Reads agent.max_tokens (default 250 — adequado a WhatsApp).
|
||||
*/
|
||||
private async callProvider(
|
||||
provider: string, model: string, agent: any, cfg: any, systemPrompt: string, messages: any[],
|
||||
): Promise<{ text: string; usage: any; provider: string; model: string }> {
|
||||
const maxTokens = agent.max_tokens ?? 250
|
||||
const temperature = agent.temperature ?? 0.7
|
||||
|
||||
// ── OpenAI ────────────────────────────────────────────────────────────────
|
||||
if (provider === 'openai') {
|
||||
const apiKey = (cfg.openai_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('OpenAI API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
|
||||
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
|
||||
const text = data.choices[0].message.content as string
|
||||
const usage = {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
total: data.usage?.total_tokens ?? 0,
|
||||
}
|
||||
return { text, usage, provider, model }
|
||||
}
|
||||
|
||||
// ── Anthropic (com prompt caching ephemeral no system) ───────────────────
|
||||
if (provider === 'anthropic') {
|
||||
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('Anthropic API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
|
||||
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
// Header necessário até GA do prompt caching
|
||||
'anthropic-beta': 'prompt-caching-2024-07-31',
|
||||
},
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
max_tokens: maxTokens,
|
||||
// System como array com cache_control: trecho fica em cache 5min
|
||||
// Próximas chamadas com mesmo systemPrompt pagam ~10% pelo trecho cacheado.
|
||||
system: [
|
||||
{ type: 'text', text: systemPrompt, cache_control: { type: 'ephemeral' } },
|
||||
],
|
||||
messages,
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
|
||||
const text = data.content[0].text as string
|
||||
const usage = {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_create: data.usage?.cache_creation_input_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
total: (data.usage?.input_tokens ?? 0) + (data.usage?.output_tokens ?? 0),
|
||||
}
|
||||
return { text, usage, provider, model }
|
||||
}
|
||||
|
||||
// ── Google Gemini ─────────────────────────────────────────────────────────
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('Google Gemini API Key não configurada. Acesse Admin → Plugins → Secretária IA.')
|
||||
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
|
||||
|
||||
const geminiMessages = messages.map((m) => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content }],
|
||||
}))
|
||||
|
||||
const geminiBody = JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents: geminiMessages,
|
||||
generationConfig: { temperature, maxOutputTokens: maxTokens },
|
||||
})
|
||||
const geminiUrl = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
|
||||
|
||||
const doGeminiCall = async () => fetch(geminiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: geminiBody,
|
||||
})
|
||||
|
||||
let res = await doGeminiCall()
|
||||
let data = (await res.json()) as any
|
||||
|
||||
if (!res.ok && (res.status === 429 || String(data.error?.message ?? '').toLowerCase().includes('quota'))) {
|
||||
const msg: string = data.error?.message ?? ''
|
||||
const match = msg.match(/retry in ([\d.]+)s/i)
|
||||
// Espera no máximo 8s (era 30s) e mínimo 1.5s (era 5s) — limita impacto de quota no tempo total
|
||||
const waitMs = match ? Math.min(Math.ceil(parseFloat(match[1])) * 1000, 8_000) : 1_500
|
||||
await new Promise((r) => setTimeout(r, waitMs))
|
||||
res = await doGeminiCall()
|
||||
data = (await res.json()) as any
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const errMsg: string = data.error?.message ?? `Gemini ${res.status}`
|
||||
if (errMsg.toLowerCase().includes('quota') || res.status === 429) {
|
||||
throw new Error('Limite da API Gemini atingido. Aguarde alguns instantes e tente novamente.')
|
||||
}
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
const text = data.candidates[0].content.parts[0].text as string
|
||||
const usage = {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
total: data.usageMetadata?.totalTokenCount ?? 0,
|
||||
}
|
||||
return { text, usage, provider, model: geminiModel }
|
||||
}
|
||||
|
||||
// ── Ollama (local) ────────────────────────────────────────────────────────
|
||||
if (provider === 'ollama') {
|
||||
const baseUrl = (cfg.ollama_url as string | undefined) ?? 'http://localhost:11434'
|
||||
const ollamaModel = model || 'llama3'
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model: ollamaModel,
|
||||
stream: false,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...messages],
|
||||
options: { temperature, num_predict: maxTokens },
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error ?? `Ollama ${res.status}`)
|
||||
const text = data.message.content as string
|
||||
const usage = {
|
||||
input: data.prompt_eval_count ?? 0,
|
||||
output: data.eval_count ?? 0,
|
||||
total: (data.prompt_eval_count ?? 0) + (data.eval_count ?? 0),
|
||||
}
|
||||
return { text, usage, provider, model: ollamaModel }
|
||||
}
|
||||
|
||||
throw new Error(`Provider "${provider}" não suportado. Use: openai, anthropic, gemini, ollama`)
|
||||
}
|
||||
|
||||
// ── Calendar Context ──────────────────────────────────────────────────────
|
||||
|
||||
private async getCalendarContext(): Promise<string> {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const nextWeek = new Date()
|
||||
nextWeek.setDate(nextWeek.getDate() + 7)
|
||||
const nextWeekStr = nextWeek.toISOString().split('T')[0]
|
||||
|
||||
const slots = await this.db('sec_calendar')
|
||||
.whereIn('status', ['available', 'booked'])
|
||||
.whereBetween('date', [today, nextWeekStr])
|
||||
.orderBy('date')
|
||||
.orderBy('time_start')
|
||||
.limit(30)
|
||||
|
||||
if (slots.length === 0) return 'Nenhum horário nos próximos 7 dias.'
|
||||
|
||||
const lines = (slots as any[]).map((s) => {
|
||||
const time = `${s.date} ${s.time_start.slice(0, 5)}–${s.time_end.slice(0, 5)}`
|
||||
if (s.status === 'booked') {
|
||||
const who = s.attendee_name ? ` | Paciente: ${s.attendee_name}` : ''
|
||||
const phone = s.attendee_phone ? ` (${s.attendee_phone})` : ''
|
||||
return `• [AGENDADO] ${time}: ${s.title}${who}${phone}`
|
||||
}
|
||||
return `• [DISPONÍVEL] ${time}: ${s.title}`
|
||||
})
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// ── Summarization (token economy) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sumarização com modelo barato (M1.5).
|
||||
* Força modelo "mini/haiku/flash" mesmo que o agente principal use modelo caro.
|
||||
* Sumário é tarefa simples — não precisa do modelo de produção.
|
||||
*/
|
||||
private async summarize(
|
||||
agent: any,
|
||||
recentMsgs: any[],
|
||||
lastUser: string,
|
||||
lastAssistant: string,
|
||||
): Promise<string> {
|
||||
const excerpt = [
|
||||
...recentMsgs.slice(-6).map((m: any) => `${m.role}: ${m.content}`),
|
||||
`user: ${lastUser}`,
|
||||
`assistant: ${lastAssistant}`,
|
||||
].join('\n')
|
||||
|
||||
const prompt = `Resuma em no máximo 2 frases curtas o estado atual desta conversa de atendimento, focando no tema e próximo passo:\n\n${excerpt}`
|
||||
|
||||
// Modelo barato por provider
|
||||
const cheapModel: Record<string, string> = {
|
||||
openai: 'gpt-4o-mini',
|
||||
anthropic: 'claude-3-5-haiku-20241022',
|
||||
gemini: 'gemini-2.0-flash',
|
||||
ollama: agent.model ?? 'llama3',
|
||||
}
|
||||
const summaryAgent = {
|
||||
...agent,
|
||||
temperature: 0.3,
|
||||
max_tokens: 120,
|
||||
model: cheapModel[agent.provider as string] ?? agent.model,
|
||||
}
|
||||
try {
|
||||
const result = await this.callAI(summaryAgent, '', [{ role: 'user', content: prompt }])
|
||||
return result.text
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool Calling ──────────────────────────────────────────────────────────
|
||||
|
||||
private async callAIWithTools(
|
||||
agent: any,
|
||||
systemPrompt: string,
|
||||
inputMessages: any[],
|
||||
tools: ToolDef[],
|
||||
toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const cfg = await this.config.get('secretaria')
|
||||
const chain = this.buildFallbackChain(agent, cfg)
|
||||
|
||||
// Prefere provider com suporte a tool calling; Ollama cai em modo texto
|
||||
const TOOL_PROVIDERS = ['openai', 'anthropic', 'gemini']
|
||||
const entry = chain.find(e => TOOL_PROVIDERS.includes(e.provider))
|
||||
|
||||
if (!entry) {
|
||||
// Nenhum provider com tool calling disponível — usa modo texto normal
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text
|
||||
}
|
||||
|
||||
try {
|
||||
switch (entry.provider) {
|
||||
case 'openai':
|
||||
return await this.openAIToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
|
||||
case 'anthropic':
|
||||
return await this.anthropicToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
|
||||
case 'gemini':
|
||||
return await this.geminiToolLoop(entry.model, agent, cfg, systemPrompt, inputMessages, tools, toolCtx)
|
||||
default:
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (this.isRecoverableError(err)) {
|
||||
// Provider com tools falhou — tenta sem tools no próximo da chain
|
||||
return (await this.callAI(agent, systemPrompt, inputMessages)).text
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
name: string, rawArgs: string | Record<string, any>, tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<any> {
|
||||
const tool = tools.find(t => t.name === name)
|
||||
if (!tool) return { error: `Tool "${name}" não encontrada.` }
|
||||
const args = typeof rawArgs === 'string' ? JSON.parse(rawArgs || '{}') : rawArgs
|
||||
try {
|
||||
return await tool.execute(args, toolCtx)
|
||||
} catch (e: any) {
|
||||
return { error: e.message }
|
||||
}
|
||||
}
|
||||
|
||||
// ── Telemetria helper para tool loops ─────────────────────────────────────
|
||||
private accumTelemetry(
|
||||
toolCtx: ToolContext, provider: string, model: string,
|
||||
incremental: { input: number; output: number; cache_read?: number; cached?: number },
|
||||
): void {
|
||||
if (!toolCtx._telemetry) {
|
||||
toolCtx._telemetry = {
|
||||
usage: { input: 0, output: 0, total: 0, cache_read: 0, cached: 0 },
|
||||
provider, model, iterations: 0,
|
||||
}
|
||||
}
|
||||
toolCtx._telemetry.iterations += 1
|
||||
toolCtx._telemetry.usage.input += incremental.input
|
||||
toolCtx._telemetry.usage.output += incremental.output
|
||||
toolCtx._telemetry.usage.total += incremental.input + incremental.output
|
||||
if (incremental.cache_read) toolCtx._telemetry.usage.cache_read = (toolCtx._telemetry.usage.cache_read ?? 0) + incremental.cache_read
|
||||
if (incremental.cached) toolCtx._telemetry.usage.cached = (toolCtx._telemetry.usage.cached ?? 0) + incremental.cached
|
||||
}
|
||||
|
||||
// ── OpenAI tool loop ───────────────────────────────────────────────────────
|
||||
|
||||
private async openAIToolLoop(
|
||||
model: string, agent: any, cfg: any,
|
||||
systemPrompt: string, inputMessages: any[],
|
||||
tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const apiKey = (cfg.openai_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('OpenAI API Key não configurada')
|
||||
|
||||
const oaiTools = tools.map(t => ({
|
||||
type: 'function',
|
||||
function: { name: t.name, description: t.description, parameters: t.parameters },
|
||||
}))
|
||||
|
||||
let msgs = [...inputMessages]
|
||||
const MAX_ITER = 5
|
||||
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.openai.com/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: agent.temperature ?? 0.7,
|
||||
max_tokens: agent.max_tokens ?? 250,
|
||||
messages: [{ role: 'system', content: systemPrompt }, ...msgs],
|
||||
tools: oaiTools,
|
||||
tool_choice: 'auto',
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
|
||||
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'openai', model, {
|
||||
input: data.usage?.prompt_tokens ?? 0,
|
||||
output: data.usage?.completion_tokens ?? 0,
|
||||
cached: data.usage?.prompt_tokens_details?.cached_tokens ?? 0,
|
||||
})
|
||||
|
||||
const choice = data.choices[0]
|
||||
const assistantMsg = choice.message
|
||||
|
||||
if (choice.finish_reason !== 'tool_calls' || !assistantMsg.tool_calls?.length) {
|
||||
return (assistantMsg.content ?? '') as string
|
||||
}
|
||||
|
||||
// Execute tools in parallel
|
||||
msgs.push(assistantMsg)
|
||||
const toolResults = await Promise.all(
|
||||
(assistantMsg.tool_calls as any[]).map(async (tc) => {
|
||||
const result = await this.executeTool(tc.function.name, tc.function.arguments, tools, toolCtx)
|
||||
return { role: 'tool', tool_call_id: tc.id, content: JSON.stringify(result) }
|
||||
}),
|
||||
)
|
||||
msgs.push(...toolResults)
|
||||
}
|
||||
|
||||
throw new Error('Tool calling: limite de iterações atingido')
|
||||
}
|
||||
|
||||
// ── Anthropic tool loop ────────────────────────────────────────────────────
|
||||
|
||||
private async anthropicToolLoop(
|
||||
model: string, agent: any, cfg: any,
|
||||
systemPrompt: string, inputMessages: any[],
|
||||
tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const apiKey = (cfg.anthropic_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('Anthropic API Key não configurada')
|
||||
|
||||
const anthropicTools = tools.map(t => ({
|
||||
name: t.name, description: t.description, input_schema: t.parameters,
|
||||
}))
|
||||
|
||||
let msgs = [...inputMessages]
|
||||
const MAX_ITER = 5
|
||||
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
model, max_tokens: agent.max_tokens ?? 250, system: systemPrompt,
|
||||
messages: msgs, tools: anthropicTools,
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `Anthropic ${res.status}`)
|
||||
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'anthropic', model, {
|
||||
input: data.usage?.input_tokens ?? 0,
|
||||
output: data.usage?.output_tokens ?? 0,
|
||||
cache_read: data.usage?.cache_read_input_tokens ?? 0,
|
||||
})
|
||||
|
||||
// Texto puro
|
||||
if (data.stop_reason !== 'tool_use') {
|
||||
const textBlock = (data.content as any[]).find(b => b.type === 'text')
|
||||
return (textBlock?.text ?? '') as string
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
msgs.push({ role: 'assistant', content: data.content })
|
||||
|
||||
const toolResults = await Promise.all(
|
||||
(data.content as any[])
|
||||
.filter(b => b.type === 'tool_use')
|
||||
.map(async (b) => {
|
||||
const result = await this.executeTool(b.name, b.input, tools, toolCtx)
|
||||
return { type: 'tool_result', tool_use_id: b.id, content: JSON.stringify(result) }
|
||||
}),
|
||||
)
|
||||
msgs.push({ role: 'user', content: toolResults })
|
||||
}
|
||||
|
||||
throw new Error('Tool calling (Anthropic): limite de iterações atingido')
|
||||
}
|
||||
|
||||
// ── Gemini tool loop ───────────────────────────────────────────────────────
|
||||
|
||||
private async geminiToolLoop(
|
||||
model: string, agent: any, cfg: any,
|
||||
systemPrompt: string, inputMessages: any[],
|
||||
tools: ToolDef[], toolCtx: ToolContext,
|
||||
): Promise<string> {
|
||||
const apiKey = (cfg.gemini_key as string | undefined) ?? ''
|
||||
if (!apiKey) throw new Error('Gemini API Key não configurada')
|
||||
|
||||
const geminiModel = model.startsWith('gemini') ? model : 'gemini-2.0-flash'
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`
|
||||
|
||||
const geminiTools = [{
|
||||
functionDeclarations: tools.map(t => ({
|
||||
name: t.name, description: t.description, parameters: t.parameters,
|
||||
})),
|
||||
}]
|
||||
|
||||
// Converte msgs para formato Gemini
|
||||
let contents: any[] = inputMessages.map(m => ({
|
||||
role: m.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: m.content as string }],
|
||||
}))
|
||||
|
||||
const MAX_ITER = 5
|
||||
|
||||
for (let i = 0; i < MAX_ITER; i++) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: AbortSignal.timeout(25_000),
|
||||
body: JSON.stringify({
|
||||
systemInstruction: { parts: [{ text: systemPrompt }] },
|
||||
contents,
|
||||
tools: geminiTools,
|
||||
generationConfig: { temperature: agent.temperature ?? 0.7, maxOutputTokens: agent.max_tokens ?? 250 },
|
||||
}),
|
||||
})
|
||||
const data = (await res.json()) as any
|
||||
if (!res.ok) throw new Error(data.error?.message ?? `Gemini ${res.status}`)
|
||||
|
||||
// Telemetria (M1.4)
|
||||
this.accumTelemetry(toolCtx, 'gemini', geminiModel, {
|
||||
input: data.usageMetadata?.promptTokenCount ?? 0,
|
||||
output: data.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
})
|
||||
|
||||
const candidate = data.candidates?.[0]
|
||||
const parts: any[] = candidate?.content?.parts ?? []
|
||||
|
||||
// Verifica se há function calls
|
||||
const fnCalls = parts.filter(p => p.functionCall)
|
||||
if (!fnCalls.length) {
|
||||
const textPart = parts.find(p => p.text)
|
||||
return (textPart?.text ?? '') as string
|
||||
}
|
||||
|
||||
// Adiciona resposta do modelo ao histórico
|
||||
contents.push({ role: 'model', parts })
|
||||
|
||||
// Executa tools e injeta resultados
|
||||
const resultParts = await Promise.all(
|
||||
fnCalls.map(async (p) => {
|
||||
const result = await this.executeTool(p.functionCall.name, p.functionCall.args ?? {}, tools, toolCtx)
|
||||
return { functionResponse: { name: p.functionCall.name, response: result } }
|
||||
}),
|
||||
)
|
||||
contents.push({ role: 'user', parts: resultParts })
|
||||
}
|
||||
|
||||
throw new Error('Tool calling (Gemini): limite de iterações atingido')
|
||||
}
|
||||
|
||||
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||
|
||||
private uuid(): string {
|
||||
// Node 14.17+ tem crypto.randomUUID globalmente; fallback para Date-based
|
||||
try {
|
||||
return (crypto as any).randomUUID()
|
||||
} catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const migrate_1 = require("./migrate");
|
||||
const routes_1 = require("./routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
// 1. Cria tabelas e seed inicial
|
||||
await (0, migrate_1.runMigrations)(ctx.db);
|
||||
ctx.logger.info('[secretaria] Tabelas criadas/verificadas');
|
||||
// 2. Registra rotas REST
|
||||
const router = (0, routes_1.createSecretariaRoutes)(ctx.db, ctx.config);
|
||||
ctx.app.use('/api/secretaria', router);
|
||||
ctx.logger.info('[secretaria] Rotas registradas em /api/secretaria');
|
||||
},
|
||||
async deactivate(ctx) {
|
||||
ctx.logger.info('[secretaria] Desativado');
|
||||
},
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AAIA,uCAAyC;AACzC,qCAAiD;AACjD,oEAAsC;AAEtC,MAAM,MAAM,GAAmB;IAC7B,QAAQ,EAAE,uBAAe;IAEzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC/B,iCAAiC;QACjC,MAAM,IAAA,uBAAa,EAAC,GAAG,CAAC,EAAE,CAAC,CAAA;QAC3B,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0CAA0C,CAAC,CAAA;QAE3D,yBAAyB;QACzB,MAAM,MAAM,GAAG,IAAA,+BAAsB,EAAC,GAAG,CAAC,EAAE,EAAE,GAAG,CAAC,MAAM,CAAC,CAAA;QACzD,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,CAAA;QACtC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,mDAAmD,CAAC,CAAA;IACtE,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,GAAkB;QACjC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,yBAAyB,CAAC,CAAA;IAC5C,CAAC;CACF,CAAA;AAED,kBAAe,MAAM,CAAA"}
|
||||
@@ -0,0 +1,28 @@
|
||||
// ============================================================
|
||||
// Plugin: secretaria — Secretária IA Multi-Nó
|
||||
// ============================================================
|
||||
import type { PluginInstance, PluginContext } from '../../backend/src/core/types'
|
||||
import { runMigrations } from './migrate'
|
||||
import { createSecretariaRoutes } from './routes'
|
||||
import manifest from './manifest.json'
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
// 1. Cria tabelas e seed inicial
|
||||
await runMigrations(ctx.db)
|
||||
ctx.logger.info('[secretaria] Tabelas criadas/verificadas')
|
||||
|
||||
// 2. Registra rotas REST
|
||||
const router = createSecretariaRoutes(ctx.db, ctx.config)
|
||||
ctx.app.use('/api/secretaria', router)
|
||||
ctx.logger.info('[secretaria] Rotas registradas em /api/secretaria')
|
||||
},
|
||||
|
||||
async deactivate(ctx: PluginContext): Promise<void> {
|
||||
ctx.logger.info('[secretaria] Desativado')
|
||||
},
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"name": "secretaria",
|
||||
"displayName": "Secretária IA",
|
||||
"version": "1.0.0",
|
||||
"description": "Atendente virtual autônoma com cérebro stateful, multi-nós e economia de tokens.",
|
||||
"author": "NewWhats",
|
||||
"category": "utility",
|
||||
"enabled": true,
|
||||
"canDisable": true,
|
||||
"dependencies": [],
|
||||
"backend": {
|
||||
"routePrefix": "/api/secretaria",
|
||||
"hasMigrations": true
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": [
|
||||
{ "label": "Secretária", "path": "/secretaria", "icon": "BrainCircuit" }
|
||||
]
|
||||
},
|
||||
"configSchema": [
|
||||
{
|
||||
"key": "default_provider",
|
||||
"label": "Provider Padrão",
|
||||
"type": "select",
|
||||
"options": ["openai", "anthropic", "gemini", "ollama"],
|
||||
"group": "IA"
|
||||
},
|
||||
{
|
||||
"key": "default_model",
|
||||
"label": "Modelo Padrão",
|
||||
"type": "model_select",
|
||||
"group": "IA"
|
||||
},
|
||||
{
|
||||
"key": "openai_key",
|
||||
"label": "OpenAI API Key",
|
||||
"type": "password",
|
||||
"placeholder": "sk-...",
|
||||
"required": false,
|
||||
"group": "OpenAI"
|
||||
},
|
||||
{
|
||||
"key": "anthropic_key",
|
||||
"label": "Anthropic API Key",
|
||||
"type": "password",
|
||||
"placeholder": "sk-ant-...",
|
||||
"required": false,
|
||||
"group": "Anthropic"
|
||||
},
|
||||
{
|
||||
"key": "gemini_key",
|
||||
"label": "Google Gemini API Key",
|
||||
"type": "password",
|
||||
"placeholder": "AIza...",
|
||||
"required": false,
|
||||
"group": "Google Gemini (grátis)"
|
||||
},
|
||||
{
|
||||
"key": "ollama_url",
|
||||
"label": "Ollama Base URL (local)",
|
||||
"type": "text",
|
||||
"placeholder": "http://localhost:11434",
|
||||
"required": false,
|
||||
"group": "Ollama (local)"
|
||||
},
|
||||
{
|
||||
"key": "fallback_chain",
|
||||
"label": "Ordem de Fallback dos Providers",
|
||||
"type": "provider_chain",
|
||||
"description": "Se o provider principal falhar (sem saldo, quota, erro), o sistema tenta o próximo da lista automaticamente. Apenas providers com chave configurada são usados.",
|
||||
"default": "openai,gemini,anthropic,ollama",
|
||||
"group": "Fallback"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.runMigrations = runMigrations;
|
||||
async function runMigrations(db) {
|
||||
// ── sec_agents ────────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_agents'))) {
|
||||
await db.schema.createTable('sec_agents', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('name', 100).notNullable();
|
||||
t.text('description').nullable();
|
||||
t.string('model', 60).defaultTo('gpt-4o-mini');
|
||||
t.string('provider', 20).defaultTo('openai');
|
||||
t.float('temperature').defaultTo(0.7);
|
||||
t.integer('context_window').defaultTo(4);
|
||||
t.boolean('active').defaultTo(true);
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_brain_nodes ───────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_brain_nodes'))) {
|
||||
await db.schema.createTable('sec_brain_nodes', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE');
|
||||
t.string('type', 30).notNullable(); // persona | knowledge | rules | calendar | escalation
|
||||
t.string('title', 100).notNullable();
|
||||
t.text('content').notNullable();
|
||||
t.boolean('active').defaultTo(true);
|
||||
t.integer('sort_order').defaultTo(0);
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_conversations ─────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_conversations'))) {
|
||||
await db.schema.createTable('sec_conversations', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE');
|
||||
t.string('contact_name', 100).defaultTo('Usuário');
|
||||
t.string('protocol_number', 20).notNullable().defaultTo(''); // DDMMYYHHmmSS ex: 120426224935
|
||||
t.string('status', 20).defaultTo('active'); // active | closed | escalated
|
||||
t.text('summary').nullable(); // resumo compacto (economia de tokens)
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_conversations — adiciona protocol_number se ainda não existe ───────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'protocol_number'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('protocol_number', 20).notNullable().defaultTo('');
|
||||
});
|
||||
}
|
||||
}
|
||||
// ── sec_messages ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_messages'))) {
|
||||
await db.schema.createTable('sec_messages', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.uuid('conversation_id').notNullable().references('id').inTable('sec_conversations').onDelete('CASCADE');
|
||||
t.string('role', 20).notNullable(); // user | assistant | system
|
||||
t.text('content').notNullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_brain_nodes — adiciona node_model se ainda não existe ────────────
|
||||
if (await db.schema.hasTable('sec_brain_nodes')) {
|
||||
if (!(await db.schema.hasColumn('sec_brain_nodes', 'node_model'))) {
|
||||
await db.schema.alterTable('sec_brain_nodes', (t) => {
|
||||
t.string('node_model', 80).nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
// ── sec_conversations — adiciona ext_chat_id para integração ext-api ─────
|
||||
// Chave: "<tenantId>:<chatId>" — identifica a conversa por canal externo
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'ext_chat_id'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('ext_chat_id', 300).nullable();
|
||||
});
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_conv_ext_chat ON sec_conversations (ext_chat_id)');
|
||||
}
|
||||
}
|
||||
// ── sec_conversations — handoff mode (ia | humano) ────────────────────────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_mode'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('handoff_mode', 20).notNullable().defaultTo('ia');
|
||||
});
|
||||
}
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_human_at'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.timestamp('handoff_human_at').nullable();
|
||||
});
|
||||
}
|
||||
}
|
||||
// ── sec_calendar ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_calendar'))) {
|
||||
await db.schema.createTable('sec_calendar', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('title', 200).notNullable();
|
||||
t.date('date').notNullable();
|
||||
t.time('time_start').notNullable();
|
||||
t.time('time_end').notNullable();
|
||||
t.string('attendee_name', 100).nullable();
|
||||
t.string('attendee_phone', 30).nullable();
|
||||
t.string('status', 20).defaultTo('available'); // available | booked | cancelled
|
||||
t.text('notes').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── sec_numbers ───────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||
await db.schema.createTable('sec_numbers', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'));
|
||||
t.string('instance_id', 100).nullable(); // ID da instância Baileys existente
|
||||
t.string('phone', 30).nullable(); // número do WhatsApp (preenchido após conexão)
|
||||
t.string('label', 100).notNullable(); // apelido (pode vir do nome da instância)
|
||||
t.string('role', 30).notNullable().defaultTo('clinic'); // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary
|
||||
t.string('area', 100).nullable(); // área de responsabilidade
|
||||
t.integer('priority').defaultTo(10); // menor = maior prioridade no fallback
|
||||
t.boolean('active').defaultTo(true);
|
||||
t.text('notes').nullable();
|
||||
t.timestamps(true, true);
|
||||
});
|
||||
}
|
||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||
const agentCount = await db('sec_agents').count('id as c').first();
|
||||
if (Number(agentCount?.c ?? 0) === 0) {
|
||||
await seedDefaults(db);
|
||||
}
|
||||
}
|
||||
async function seedDefaults(db) {
|
||||
// Agente padrão
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
name: 'Ana — Atendente Virtual',
|
||||
description: 'Atendente geral para suporte, financeiro, dúvidas e agendamentos',
|
||||
model: 'gemini-2.0-flash',
|
||||
provider: 'gemini',
|
||||
temperature: 0.7,
|
||||
context_window: 10,
|
||||
active: true,
|
||||
}).returning('id');
|
||||
const agentId = typeof agent === 'object' ? agent.id : agent;
|
||||
// Nós do cérebro
|
||||
await db('sec_brain_nodes').insert([
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'persona',
|
||||
title: 'Identidade — Ana',
|
||||
content: `Você é Ana, atendente da empresa. Seu jeito de ser: calorosa, paciente e direta — nunca robótica.
|
||||
|
||||
Adapte seu tom ao do cliente: se ele for informal e descontraído, seja assim também; se for mais sério e objetivo, acompanhe. Leia o ritmo dele.
|
||||
|
||||
Ao longo da conversa, use o nome do cliente de forma natural — não em toda mensagem, só quando fizer sentido, como uma pessoa real faria.
|
||||
|
||||
Quando o cliente estiver frustrado ou irritado, reconheça o sentimento antes de tentar resolver. Dizer "Entendo, isso é realmente chato" antes de dar a solução faz toda a diferença.
|
||||
|
||||
Varie suas respostas. Nunca use a mesma frase de confirmação duas vezes seguidas. Nunca use linguagem corporativa: esqueça "atenciosamente", "prezado cliente", "informamos que" e afins.
|
||||
|
||||
Quando não souber algo, seja honesta: "Deixa eu verificar isso com mais calma pra te dar uma resposta certa."`,
|
||||
active: true,
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Regras de Comportamento',
|
||||
content: `1. Cumprimente o cliente pelo nome quando já souber — mas de forma natural, não mecânica.
|
||||
2. Nunca revele que é uma IA a menos que perguntado diretamente. Se perguntado, responda com honestidade e leveza.
|
||||
3. Para questões financeiras que você não consegue resolver, ofereça encaminhar para um especialista.
|
||||
4. Respostas curtas e diretas em geral. Só detalhe mais quando o assunto exigir — não encha de texto desnecessário.
|
||||
5. Antes de confirmar um agendamento, valide: data, horário e nome completo. Faça isso de forma conversacional, não como checklist.
|
||||
6. Nunca repita a mesma frase de confirmação em sequência. Varie: "Faz sentido!", "Entendi sim.", "Tudo certo.", "Combinado.", etc.
|
||||
7. Se o cliente mandar uma mensagem muito longa ou confusa, foque no ponto principal e pergunte apenas o que for essencial.`,
|
||||
active: true,
|
||||
sort_order: 1,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Inteligência Emocional',
|
||||
content: `Quando o cliente demonstrar frustração ou raiva:
|
||||
- Primeiro reconheça: "Entendo, isso é realmente frustrante." ou "Faz todo sentido ficar chateado com isso."
|
||||
- Só depois vá para a solução. Nunca pule direto para a resposta técnica quando o cliente está emotivo.
|
||||
|
||||
Quando o cliente estiver com urgência:
|
||||
- Responda de forma objetiva e sem enrolação. Priorize resolver.
|
||||
|
||||
Quando o cliente agradecer ou elogiar:
|
||||
- Responda de forma genuína e breve. Evite "Disponha! Qualquer coisa é só chamar." — prefira algo como "Fico feliz que resolveu!" ou "Que bom, até mais!"
|
||||
|
||||
Quando o cliente disser que vai cancelar ou está insatisfeito:
|
||||
- Não entre em modo de venda forçada. Ouça o motivo, reconheça, e só então — se fizer sentido — apresente alternativas.`,
|
||||
active: true,
|
||||
sort_order: 2,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'knowledge',
|
||||
title: 'Base de Conhecimento',
|
||||
content: `Horário de atendimento: Segunda a Sexta, 08h às 18h. Suporte emergencial 24h.
|
||||
Contatos: WhatsApp (11) 99999-9999 | Email: suporte@empresa.com
|
||||
|
||||
⚠️ Atenção: substitua estas informações pelas reais da sua empresa antes de usar em produção.
|
||||
|
||||
Planos disponíveis:
|
||||
• Básico: R$ 99/mês — até 2 usuários, 1 instância WhatsApp
|
||||
• Pro: R$ 199/mês — até 10 usuários, 5 instâncias
|
||||
• Enterprise: sob consulta com a equipe comercial
|
||||
|
||||
SLA de suporte: Crítico 2h | Alta 8h | Normal 24h
|
||||
Política de cancelamento: aviso prévio de 30 dias por email.`,
|
||||
active: true,
|
||||
sort_order: 3,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'calendar',
|
||||
title: 'Acesso à Agenda',
|
||||
content: `Você tem acesso à agenda da empresa e pode consultar horários disponíveis.
|
||||
Quando o cliente mencionar agendamento, consulte os horários e ofereça opções concretas — não peça que ele escolha sem saber o que está disponível.
|
||||
Ao confirmar um agendamento, repita o resumo de forma natural: "Então ficou marcado para [data] às [hora], certo? Vou registrar aqui."`,
|
||||
active: true,
|
||||
sort_order: 4,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'escalation',
|
||||
title: 'Quando Escalar para Humano',
|
||||
content: `Transfira o atendimento para um humano quando:
|
||||
- O cliente pedir explicitamente falar com uma pessoa
|
||||
- O problema envolver contestação de cobrança, estorno ou situação financeira sensível
|
||||
- O cliente demonstrar raiva intensa por mais de 2 mensagens seguidas, sem que você consiga ajudar
|
||||
- Você não souber responder após 2 tentativas honestas
|
||||
|
||||
Como escalar de forma natural:
|
||||
Não diga "vou transferir você". Prefira: "Vou chamar a [nome/equipe] que consegue resolver isso melhor pra você. Um momento?" — e aguarde confirmação antes de encerrar.
|
||||
|
||||
Ao escalar, registre brevemente o contexto para quem vai assumir: o nome do cliente, o problema e o que já foi tentado.`,
|
||||
active: true,
|
||||
sort_order: 5,
|
||||
},
|
||||
]);
|
||||
// Calendário — próximos 7 dias com slots de teste
|
||||
const slots = [];
|
||||
const types = ['Consulta Técnica', 'Reunião de Onboarding', 'Demonstração do Produto', 'Suporte Premium'];
|
||||
const times = [
|
||||
{ s: '09:00', e: '10:00' },
|
||||
{ s: '10:00', e: '11:00' },
|
||||
{ s: '11:00', e: '12:00' },
|
||||
{ s: '14:00', e: '15:00' },
|
||||
{ s: '15:00', e: '16:00' },
|
||||
{ s: '16:00', e: '17:00' },
|
||||
];
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + d);
|
||||
// Skip weekends
|
||||
if (date.getDay() === 0 || date.getDay() === 6)
|
||||
continue;
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
times.forEach((t, idx) => {
|
||||
const isBooked = (d === 1 && idx === 2) || (d === 2 && idx === 4) || (d === 4 && idx === 1);
|
||||
slots.push({
|
||||
title: types[idx % types.length],
|
||||
date: dateStr,
|
||||
time_start: t.s,
|
||||
time_end: t.e,
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
attendee_name: isBooked ? ['João Silva', 'Maria Santos', 'Carlos Lima'][d % 3] : null,
|
||||
attendee_phone: isBooked ? `119${String(d * 1111 + idx * 100).padStart(8, '0')}` : null,
|
||||
});
|
||||
});
|
||||
}
|
||||
if (slots.length > 0) {
|
||||
await db('sec_calendar').insert(slots);
|
||||
}
|
||||
}
|
||||
//# sourceMappingURL=migrate.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,290 @@
|
||||
import { Knex } from 'knex'
|
||||
|
||||
export async function runMigrations(db: Knex): Promise<void> {
|
||||
// ── sec_agents ────────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_agents'))) {
|
||||
await db.schema.createTable('sec_agents', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('name', 100).notNullable()
|
||||
t.text('description').nullable()
|
||||
t.string('model', 60).defaultTo('gpt-4o-mini')
|
||||
t.string('provider', 20).defaultTo('openai')
|
||||
t.float('temperature').defaultTo(0.7)
|
||||
t.integer('context_window').defaultTo(4)
|
||||
t.boolean('active').defaultTo(true)
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_brain_nodes ───────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_brain_nodes'))) {
|
||||
await db.schema.createTable('sec_brain_nodes', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE')
|
||||
t.string('type', 30).notNullable() // persona | knowledge | rules | calendar | escalation
|
||||
t.string('title', 100).notNullable()
|
||||
t.text('content').notNullable()
|
||||
t.boolean('active').defaultTo(true)
|
||||
t.integer('sort_order').defaultTo(0)
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_conversations ─────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_conversations'))) {
|
||||
await db.schema.createTable('sec_conversations', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.uuid('agent_id').notNullable().references('id').inTable('sec_agents').onDelete('CASCADE')
|
||||
t.string('contact_name', 100).defaultTo('Usuário')
|
||||
t.string('protocol_number', 20).notNullable().defaultTo('') // DDMMYYHHmmSS ex: 120426224935
|
||||
t.string('status', 20).defaultTo('active') // active | closed | escalated
|
||||
t.text('summary').nullable() // resumo compacto (economia de tokens)
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_conversations — adiciona protocol_number se ainda não existe ───────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'protocol_number'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('protocol_number', 20).notNullable().defaultTo('')
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_messages ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_messages'))) {
|
||||
await db.schema.createTable('sec_messages', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.uuid('conversation_id').notNullable().references('id').inTable('sec_conversations').onDelete('CASCADE')
|
||||
t.string('role', 20).notNullable() // user | assistant | system
|
||||
t.text('content').notNullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_brain_nodes — adiciona node_model se ainda não existe ────────────
|
||||
if (await db.schema.hasTable('sec_brain_nodes')) {
|
||||
if (!(await db.schema.hasColumn('sec_brain_nodes', 'node_model'))) {
|
||||
await db.schema.alterTable('sec_brain_nodes', (t) => {
|
||||
t.string('node_model', 80).nullable()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_conversations — adiciona ext_chat_id para integração ext-api ─────
|
||||
// Chave: "<tenantId>:<chatId>" — identifica a conversa por canal externo
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'ext_chat_id'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('ext_chat_id', 300).nullable()
|
||||
})
|
||||
await db.raw('CREATE INDEX IF NOT EXISTS idx_sec_conv_ext_chat ON sec_conversations (ext_chat_id)')
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_conversations — handoff mode (ia | humano) ────────────────────────
|
||||
if (await db.schema.hasTable('sec_conversations')) {
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_mode'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.string('handoff_mode', 20).notNullable().defaultTo('ia')
|
||||
})
|
||||
}
|
||||
if (!(await db.schema.hasColumn('sec_conversations', 'handoff_human_at'))) {
|
||||
await db.schema.alterTable('sec_conversations', (t) => {
|
||||
t.timestamp('handoff_human_at').nullable()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── sec_calendar ──────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_calendar'))) {
|
||||
await db.schema.createTable('sec_calendar', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('title', 200).notNullable()
|
||||
t.date('date').notNullable()
|
||||
t.time('time_start').notNullable()
|
||||
t.time('time_end').notNullable()
|
||||
t.string('attendee_name', 100).nullable()
|
||||
t.string('attendee_phone', 30).nullable()
|
||||
t.string('status', 20).defaultTo('available') // available | booked | cancelled
|
||||
t.text('notes').nullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── sec_numbers ───────────────────────────────────────────────────────────
|
||||
if (!(await db.schema.hasTable('sec_numbers'))) {
|
||||
await db.schema.createTable('sec_numbers', (t) => {
|
||||
t.uuid('id').primary().defaultTo(db.raw('gen_random_uuid()'))
|
||||
t.string('instance_id', 100).nullable() // ID da instância Baileys existente
|
||||
t.string('phone', 30).nullable() // número do WhatsApp (preenchido após conexão)
|
||||
t.string('label', 100).notNullable() // apelido (pode vir do nome da instância)
|
||||
t.string('role', 30).notNullable().defaultTo('clinic') // secretary_virtual | clinic | doctor | specialist | manager | reserve | human_secretary
|
||||
t.string('area', 100).nullable() // área de responsabilidade
|
||||
t.integer('priority').defaultTo(10) // menor = maior prioridade no fallback
|
||||
t.boolean('active').defaultTo(true)
|
||||
t.text('notes').nullable()
|
||||
t.timestamps(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
// ── Seeds: agente padrão + nós + calendário ───────────────────────────────
|
||||
const agentCount = await db('sec_agents').count('id as c').first()
|
||||
if (Number(agentCount?.c ?? 0) === 0) {
|
||||
await seedDefaults(db)
|
||||
}
|
||||
}
|
||||
|
||||
async function seedDefaults(db: Knex): Promise<void> {
|
||||
// Agente padrão
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
name: 'Ana — Atendente Virtual',
|
||||
description: 'Atendente geral para suporte, financeiro, dúvidas e agendamentos',
|
||||
model: 'gemini-2.0-flash',
|
||||
provider: 'gemini',
|
||||
temperature: 0.7,
|
||||
context_window: 10,
|
||||
active: true,
|
||||
}).returning('id')
|
||||
|
||||
const agentId = typeof agent === 'object' ? agent.id : agent
|
||||
|
||||
// Nós do cérebro
|
||||
await db('sec_brain_nodes').insert([
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'persona',
|
||||
title: 'Identidade — Ana',
|
||||
content: `Você é Ana, atendente da empresa. Seu jeito de ser: calorosa, paciente e direta — nunca robótica.
|
||||
|
||||
Adapte seu tom ao do cliente: se ele for informal e descontraído, seja assim também; se for mais sério e objetivo, acompanhe. Leia o ritmo dele.
|
||||
|
||||
Ao longo da conversa, use o nome do cliente de forma natural — não em toda mensagem, só quando fizer sentido, como uma pessoa real faria.
|
||||
|
||||
Quando o cliente estiver frustrado ou irritado, reconheça o sentimento antes de tentar resolver. Dizer "Entendo, isso é realmente chato" antes de dar a solução faz toda a diferença.
|
||||
|
||||
Varie suas respostas. Nunca use a mesma frase de confirmação duas vezes seguidas. Nunca use linguagem corporativa: esqueça "atenciosamente", "prezado cliente", "informamos que" e afins.
|
||||
|
||||
Quando não souber algo, seja honesta: "Deixa eu verificar isso com mais calma pra te dar uma resposta certa."`,
|
||||
active: true,
|
||||
sort_order: 0,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Regras de Comportamento',
|
||||
content: `1. Cumprimente o cliente pelo nome quando já souber — mas de forma natural, não mecânica.
|
||||
2. Nunca revele que é uma IA a menos que perguntado diretamente. Se perguntado, responda com honestidade e leveza.
|
||||
3. Para questões financeiras que você não consegue resolver, ofereça encaminhar para um especialista.
|
||||
4. Respostas curtas e diretas em geral. Só detalhe mais quando o assunto exigir — não encha de texto desnecessário.
|
||||
5. Antes de confirmar um agendamento, valide: data, horário e nome completo. Faça isso de forma conversacional, não como checklist.
|
||||
6. Nunca repita a mesma frase de confirmação em sequência. Varie: "Faz sentido!", "Entendi sim.", "Tudo certo.", "Combinado.", etc.
|
||||
7. Se o cliente mandar uma mensagem muito longa ou confusa, foque no ponto principal e pergunte apenas o que for essencial.`,
|
||||
active: true,
|
||||
sort_order: 1,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'rules',
|
||||
title: 'Inteligência Emocional',
|
||||
content: `Quando o cliente demonstrar frustração ou raiva:
|
||||
- Primeiro reconheça: "Entendo, isso é realmente frustrante." ou "Faz todo sentido ficar chateado com isso."
|
||||
- Só depois vá para a solução. Nunca pule direto para a resposta técnica quando o cliente está emotivo.
|
||||
|
||||
Quando o cliente estiver com urgência:
|
||||
- Responda de forma objetiva e sem enrolação. Priorize resolver.
|
||||
|
||||
Quando o cliente agradecer ou elogiar:
|
||||
- Responda de forma genuína e breve. Evite "Disponha! Qualquer coisa é só chamar." — prefira algo como "Fico feliz que resolveu!" ou "Que bom, até mais!"
|
||||
|
||||
Quando o cliente disser que vai cancelar ou está insatisfeito:
|
||||
- Não entre em modo de venda forçada. Ouça o motivo, reconheça, e só então — se fizer sentido — apresente alternativas.`,
|
||||
active: true,
|
||||
sort_order: 2,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'knowledge',
|
||||
title: 'Base de Conhecimento',
|
||||
content: `Horário de atendimento: Segunda a Sexta, 08h às 18h. Suporte emergencial 24h.
|
||||
Contatos: WhatsApp (11) 99999-9999 | Email: suporte@empresa.com
|
||||
|
||||
⚠️ Atenção: substitua estas informações pelas reais da sua empresa antes de usar em produção.
|
||||
|
||||
Planos disponíveis:
|
||||
• Básico: R$ 99/mês — até 2 usuários, 1 instância WhatsApp
|
||||
• Pro: R$ 199/mês — até 10 usuários, 5 instâncias
|
||||
• Enterprise: sob consulta com a equipe comercial
|
||||
|
||||
SLA de suporte: Crítico 2h | Alta 8h | Normal 24h
|
||||
Política de cancelamento: aviso prévio de 30 dias por email.`,
|
||||
active: true,
|
||||
sort_order: 3,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'calendar',
|
||||
title: 'Acesso à Agenda',
|
||||
content: `Você tem acesso à agenda da empresa e pode consultar horários disponíveis.
|
||||
Quando o cliente mencionar agendamento, consulte os horários e ofereça opções concretas — não peça que ele escolha sem saber o que está disponível.
|
||||
Ao confirmar um agendamento, repita o resumo de forma natural: "Então ficou marcado para [data] às [hora], certo? Vou registrar aqui."`,
|
||||
active: true,
|
||||
sort_order: 4,
|
||||
},
|
||||
{
|
||||
agent_id: agentId,
|
||||
type: 'escalation',
|
||||
title: 'Quando Escalar para Humano',
|
||||
content: `Transfira o atendimento para um humano quando:
|
||||
- O cliente pedir explicitamente falar com uma pessoa
|
||||
- O problema envolver contestação de cobrança, estorno ou situação financeira sensível
|
||||
- O cliente demonstrar raiva intensa por mais de 2 mensagens seguidas, sem que você consiga ajudar
|
||||
- Você não souber responder após 2 tentativas honestas
|
||||
|
||||
Como escalar de forma natural:
|
||||
Não diga "vou transferir você". Prefira: "Vou chamar a [nome/equipe] que consegue resolver isso melhor pra você. Um momento?" — e aguarde confirmação antes de encerrar.
|
||||
|
||||
Ao escalar, registre brevemente o contexto para quem vai assumir: o nome do cliente, o problema e o que já foi tentado.`,
|
||||
active: true,
|
||||
sort_order: 5,
|
||||
},
|
||||
])
|
||||
|
||||
// Calendário — próximos 7 dias com slots de teste
|
||||
const slots: any[] = []
|
||||
const types = ['Consulta Técnica', 'Reunião de Onboarding', 'Demonstração do Produto', 'Suporte Premium']
|
||||
const times = [
|
||||
{ s: '09:00', e: '10:00' },
|
||||
{ s: '10:00', e: '11:00' },
|
||||
{ s: '11:00', e: '12:00' },
|
||||
{ s: '14:00', e: '15:00' },
|
||||
{ s: '15:00', e: '16:00' },
|
||||
{ s: '16:00', e: '17:00' },
|
||||
]
|
||||
|
||||
for (let d = 1; d <= 7; d++) {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + d)
|
||||
// Skip weekends
|
||||
if (date.getDay() === 0 || date.getDay() === 6) continue
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
|
||||
times.forEach((t, idx) => {
|
||||
const isBooked = (d === 1 && idx === 2) || (d === 2 && idx === 4) || (d === 4 && idx === 1)
|
||||
slots.push({
|
||||
title: types[idx % types.length],
|
||||
date: dateStr,
|
||||
time_start: t.s,
|
||||
time_end: t.e,
|
||||
status: isBooked ? 'booked' : 'available',
|
||||
attendee_name: isBooked ? ['João Silva', 'Maria Santos', 'Carlos Lima'][d % 3] : null,
|
||||
attendee_phone: isBooked ? `119${String(d * 1111 + idx * 100).padStart(8, '0')}` : null,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (slots.length > 0) {
|
||||
await db('sec_calendar').insert(slots)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createSecretariaRoutes = createSecretariaRoutes;
|
||||
const express_1 = require("express");
|
||||
const brain_1 = require("./brain");
|
||||
function uuid() {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
catch {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
}
|
||||
function createSecretariaRoutes(db, config) {
|
||||
const router = (0, express_1.Router)();
|
||||
const brain = new brain_1.ProtocolEngine(db, config);
|
||||
// ── Agents ───────────────────────────────────────────────────────────────
|
||||
router.get('/agents', async (_req, res) => {
|
||||
try {
|
||||
const agents = await db('sec_agents').orderBy('created_at');
|
||||
res.json(agents);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/agents', async (req, res) => {
|
||||
try {
|
||||
const { name, description, model, provider, temperature, context_window } = req.body;
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
id: uuid(), name, description, model: model ?? 'gpt-4o-mini',
|
||||
provider: provider ?? 'openai', temperature: temperature ?? 0.7,
|
||||
context_window: context_window ?? 8, active: true,
|
||||
}).returning('*');
|
||||
res.status(201).json(agent);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/agents/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, description, model, provider, temperature, context_window, active } = req.body;
|
||||
const [agent] = await db('sec_agents').where({ id })
|
||||
.update({ name, description, model, provider, temperature, context_window, active, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(agent);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/agents/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_agents').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
router.get('/agents/:agentId/nodes', async (req, res) => {
|
||||
try {
|
||||
const nodes = await db('sec_brain_nodes')
|
||||
.where({ agent_id: req.params.agentId })
|
||||
.orderBy('sort_order');
|
||||
res.json(nodes);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/agents/:agentId/nodes', async (req, res) => {
|
||||
try {
|
||||
const { type, title, content, sort_order } = req.body;
|
||||
const [node] = await db('sec_brain_nodes').insert({
|
||||
id: uuid(), agent_id: req.params.agentId,
|
||||
type, title, content, active: true,
|
||||
sort_order: sort_order ?? 99,
|
||||
}).returning('*');
|
||||
res.status(201).json(node);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/nodes/:id', async (req, res) => {
|
||||
try {
|
||||
const { title, content, active, sort_order } = req.body;
|
||||
const [node] = await db('sec_brain_nodes').where({ id: req.params.id })
|
||||
.update({ title, content, active, sort_order, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(node);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/nodes/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_brain_nodes').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Conversations ────────────────────────────────────────────────────────
|
||||
router.get('/conversations', async (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.query;
|
||||
let q = db('sec_conversations').orderBy('updated_at', 'desc');
|
||||
if (agent_id)
|
||||
q = q.where({ agent_id: agent_id });
|
||||
const convs = await q;
|
||||
res.json(convs);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/conversations', async (req, res) => {
|
||||
try {
|
||||
const { agent_id, contact_name } = req.body;
|
||||
const [conv] = await db('sec_conversations').insert({
|
||||
id: uuid(), agent_id,
|
||||
contact_name: contact_name ?? 'Usuário Teste',
|
||||
protocol_number: brain_1.ProtocolEngine.generateProtocolNumber(),
|
||||
status: 'active',
|
||||
}).returning('*');
|
||||
res.status(201).json(conv);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.patch('/conversations/:id', async (req, res) => {
|
||||
try {
|
||||
const { status, contact_name } = req.body;
|
||||
const [conv] = await db('sec_conversations').where({ id: req.params.id })
|
||||
.update({ status, contact_name, updated_at: new Date() }).returning('*');
|
||||
res.json(conv);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/conversations/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_conversations').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
router.get('/conversations/:id/messages', async (req, res) => {
|
||||
try {
|
||||
const messages = await db('sec_messages')
|
||||
.where({ conversation_id: req.params.id })
|
||||
.orderBy('created_at');
|
||||
res.json(messages);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// Enviar mensagem → aciona o cérebro → retorna resposta da IA
|
||||
router.post('/conversations/:id/chat', async (req, res) => {
|
||||
try {
|
||||
const { message } = req.body;
|
||||
if (!message?.trim())
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
const reply = await brain.chat(String(req.params.id), message.trim());
|
||||
res.json({ reply });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// Finalizar protocolo → gera resumo completo → apaga mensagens → fecha conversa
|
||||
router.post('/conversations/:id/finalize', async (req, res) => {
|
||||
try {
|
||||
const result = await brain.finalizeProtocol(String(req.params.id));
|
||||
res.json(result);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
router.get('/calendar', async (req, res) => {
|
||||
try {
|
||||
const qs = (k) => { const v = req.query[k]; return v ? String(v) : undefined; };
|
||||
const from = qs('from'), to = qs('to'), status = qs('status');
|
||||
let q = db('sec_calendar').orderBy('date').orderBy('time_start');
|
||||
if (from)
|
||||
q = q.where('date', '>=', from);
|
||||
if (to)
|
||||
q = q.where('date', '<=', to);
|
||||
if (status)
|
||||
q = q.where({ status });
|
||||
res.json(await q);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/calendar', async (req, res) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes } = req.body;
|
||||
const [slot] = await db('sec_calendar').insert({
|
||||
id: uuid(), title, date, time_start, time_end,
|
||||
attendee_name, attendee_phone, notes, status: 'available',
|
||||
}).returning('*');
|
||||
res.status(201).json(slot);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/calendar/:id', async (req, res) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes } = req.body;
|
||||
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(slot);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/calendar/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_calendar').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
router.get('/numbers', async (_req, res) => {
|
||||
try {
|
||||
const numbers = await db('sec_numbers').orderBy('priority').orderBy('label');
|
||||
res.json(numbers);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.post('/numbers', async (req, res) => {
|
||||
try {
|
||||
const { instance_id, label, role, area, priority, notes } = req.body;
|
||||
if (!instance_id)
|
||||
return res.status(400).json({ error: 'instance_id é obrigatório' });
|
||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||
const existing = await db('sec_numbers').where({ instance_id }).first();
|
||||
if (existing) {
|
||||
const [num] = await db('sec_numbers').where({ instance_id })
|
||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
||||
.returning('*');
|
||||
return res.json(num);
|
||||
}
|
||||
const [num] = await db('sec_numbers').insert({
|
||||
id: uuid(), instance_id, label, role: role ?? 'clinic',
|
||||
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
||||
}).returning('*');
|
||||
res.status(201).json(num);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.put('/numbers/:id', async (req, res) => {
|
||||
try {
|
||||
const { label, role, area, instance_id, priority, active, notes } = req.body;
|
||||
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
||||
.returning('*');
|
||||
res.json(num);
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
router.delete('/numbers/:id', async (req, res) => {
|
||||
try {
|
||||
await db('sec_numbers').where({ id: req.params.id }).delete();
|
||||
res.json({ ok: true });
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,301 @@
|
||||
import { Router, Request, Response } from 'express'
|
||||
import type { Knex } from 'knex'
|
||||
import { ProtocolEngine } from './brain'
|
||||
import type { PluginConfigStore } from '../../backend/src/core/plugin-config'
|
||||
|
||||
|
||||
|
||||
function uuid(): string {
|
||||
try { return (crypto as any).randomUUID() } catch { return `${Date.now()}-${Math.random().toString(36).slice(2)}` }
|
||||
}
|
||||
|
||||
export function createSecretariaRoutes(db: Knex, config: PluginConfigStore): Router {
|
||||
const router = Router()
|
||||
const brain = new ProtocolEngine(db, config)
|
||||
|
||||
// ── Agents ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/agents', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const agents = await db('sec_agents').orderBy('created_at')
|
||||
res.json(agents)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/agents', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, description, model, provider, temperature, context_window } = req.body
|
||||
const [agent] = await db('sec_agents').insert({
|
||||
id: uuid(), name, description, model: model ?? 'gpt-4o-mini',
|
||||
provider: provider ?? 'openai', temperature: temperature ?? 0.7,
|
||||
context_window: context_window ?? 8, active: true,
|
||||
}).returning('*')
|
||||
res.status(201).json(agent)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/agents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params
|
||||
const { name, description, model, provider, temperature, context_window, active } = req.body
|
||||
const [agent] = await db('sec_agents').where({ id })
|
||||
.update({ name, description, model, provider, temperature, context_window, active, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(agent)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/agents/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_agents').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Brain Nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/agents/:agentId/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const nodes = await db('sec_brain_nodes')
|
||||
.where({ agent_id: req.params.agentId })
|
||||
.orderBy('sort_order')
|
||||
res.json(nodes)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/agents/:agentId/nodes', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { type, title, content, sort_order } = req.body
|
||||
const [node] = await db('sec_brain_nodes').insert({
|
||||
id: uuid(), agent_id: req.params.agentId,
|
||||
type, title, content, active: true,
|
||||
sort_order: sort_order ?? 99,
|
||||
}).returning('*')
|
||||
res.status(201).json(node)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/nodes/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, content, active, sort_order } = req.body
|
||||
const [node] = await db('sec_brain_nodes').where({ id: req.params.id })
|
||||
.update({ title, content, active, sort_order, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(node)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/nodes/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_brain_nodes').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Conversations ────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/conversations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { agent_id } = req.query
|
||||
let q = db('sec_conversations').orderBy('updated_at', 'desc')
|
||||
if (agent_id) q = q.where({ agent_id: agent_id as string })
|
||||
const convs = await q
|
||||
res.json(convs)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/conversations', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { agent_id, contact_name } = req.body
|
||||
const [conv] = await db('sec_conversations').insert({
|
||||
id: uuid(), agent_id,
|
||||
contact_name: contact_name ?? 'Usuário Teste',
|
||||
protocol_number: ProtocolEngine.generateProtocolNumber(),
|
||||
status: 'active',
|
||||
}).returning('*')
|
||||
res.status(201).json(conv)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.patch('/conversations/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { status, contact_name } = req.body
|
||||
const [conv] = await db('sec_conversations').where({ id: req.params.id })
|
||||
.update({ status, contact_name, updated_at: new Date() }).returning('*')
|
||||
res.json(conv)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/conversations/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_conversations').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/conversations/:id/messages', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const messages = await db('sec_messages')
|
||||
.where({ conversation_id: req.params.id })
|
||||
.orderBy('created_at')
|
||||
res.json(messages)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Enviar mensagem → aciona o cérebro → retorna resposta da IA
|
||||
router.post('/conversations/:id/chat', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { message } = req.body
|
||||
if (!message?.trim()) return res.status(400).json({ error: 'message is required' })
|
||||
|
||||
const reply = await brain.chat(String(req.params.id), message.trim())
|
||||
res.json({ reply })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Finalizar protocolo → gera resumo completo → apaga mensagens → fecha conversa
|
||||
router.post('/conversations/:id/finalize', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const result = await brain.finalizeProtocol(String(req.params.id))
|
||||
res.json(result)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Calendar ──────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/calendar', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const qs = (k: string) => { const v = req.query[k]; return v ? String(v) : undefined }
|
||||
const from = qs('from'), to = qs('to'), status = qs('status')
|
||||
let q = db('sec_calendar').orderBy('date').orderBy('time_start')
|
||||
if (from) q = q.where('date', '>=', from)
|
||||
if (to) q = q.where('date', '<=', to)
|
||||
if (status) q = q.where({ status })
|
||||
res.json(await q)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/calendar', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, notes } = req.body
|
||||
const [slot] = await db('sec_calendar').insert({
|
||||
id: uuid(), title, date, time_start, time_end,
|
||||
attendee_name, attendee_phone, notes, status: 'available',
|
||||
}).returning('*')
|
||||
res.status(201).json(slot)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/calendar/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { title, date, time_start, time_end, attendee_name, attendee_phone, status, notes } = req.body
|
||||
const [slot] = await db('sec_calendar').where({ id: req.params.id })
|
||||
.update({ title, date, time_start, time_end, attendee_name, attendee_phone, status, notes, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(slot)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/calendar/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_calendar').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Numbers ───────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/numbers', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const numbers = await db('sec_numbers').orderBy('priority').orderBy('label')
|
||||
res.json(numbers)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/numbers', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { instance_id, label, role, area, priority, notes } = req.body
|
||||
if (!instance_id) return res.status(400).json({ error: 'instance_id é obrigatório' })
|
||||
// Upsert: se já existe um registro para esse instance_id, atualiza o role
|
||||
const existing = await db('sec_numbers').where({ instance_id }).first()
|
||||
if (existing) {
|
||||
const [num] = await db('sec_numbers').where({ instance_id })
|
||||
.update({ label, role: role ?? 'clinic', area: area ?? null, priority: priority ?? 10, notes: notes ?? null, updated_at: new Date() })
|
||||
.returning('*')
|
||||
return res.json(num)
|
||||
}
|
||||
const [num] = await db('sec_numbers').insert({
|
||||
id: uuid(), instance_id, label, role: role ?? 'clinic',
|
||||
area: area ?? null, priority: priority ?? 10, active: true, notes: notes ?? null,
|
||||
}).returning('*')
|
||||
res.status(201).json(num)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.put('/numbers/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { label, role, area, instance_id, priority, active, notes } = req.body
|
||||
const [num] = await db('sec_numbers').where({ id: req.params.id })
|
||||
.update({ label, role, area, instance_id, priority, active, notes, updated_at: new Date() })
|
||||
.returning('*')
|
||||
res.json(num)
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
router.delete('/numbers/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
await db('sec_numbers').where({ id: req.params.id }).delete()
|
||||
res.json({ ok: true })
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS = void 0;
|
||||
exports.resolveTools = resolveTools;
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
function fmtDate(d) {
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
// ── Tools ──────────────────────────────────────────────────────────────────────
|
||||
exports.BUILTIN_TOOLS = [
|
||||
// ── listar_horarios ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'listar_horarios',
|
||||
description: 'Lista os horários disponíveis na agenda para agendamento. ' +
|
||||
'Use antes de propor datas ao cliente. ' +
|
||||
'Se não informar a data, retorna os próximos 7 dias.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'string',
|
||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const from = args['data'] ?? fmtDate(new Date());
|
||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86400000));
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.whereBetween('date', [from, to])
|
||||
.orderBy('date').orderBy('time_start')
|
||||
.limit(20);
|
||||
if (!slots.length) {
|
||||
return { disponivel: false, mensagem: 'Nenhum horário disponível no período informado.' };
|
||||
}
|
||||
return {
|
||||
disponivel: true,
|
||||
horarios: slots.map(s => ({
|
||||
id: s.id,
|
||||
data: s.date,
|
||||
inicio: String(s.time_start).slice(0, 5),
|
||||
fim: String(s.time_end).slice(0, 5),
|
||||
titulo: s.title,
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── agendar_horario ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'agendar_horario',
|
||||
description: 'Reserva um horário disponível na agenda para o cliente. ' +
|
||||
'Use listar_horarios primeiro para obter o slot_id correto.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slot_id: { type: 'string', description: 'ID do horário retornado por listar_horarios.' },
|
||||
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['slot_id', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const slot = await ctx.db('sec_calendar')
|
||||
.where({ id: args['slot_id'], status: 'available' })
|
||||
.first();
|
||||
if (!slot) {
|
||||
return { ok: false, erro: 'Horário não encontrado ou já reservado. Chame listar_horarios novamente.' };
|
||||
}
|
||||
await ctx.db('sec_calendar').where({ id: args['slot_id'] }).update({
|
||||
status: 'booked',
|
||||
attendee_name: args['nome_cliente'],
|
||||
attendee_phone: args['telefone_cliente'] ?? null,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
confirmacao: {
|
||||
data: slot.date,
|
||||
inicio: String(slot.time_start).slice(0, 5),
|
||||
fim: String(slot.time_end).slice(0, 5),
|
||||
titulo: slot.title,
|
||||
nome: args['nome_cliente'],
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
description: 'Transfere o atendimento para um atendente humano quando a situação exige. ' +
|
||||
'Use quando: cliente pede explicitamente, problema financeiro sensível, raiva intensa, ' +
|
||||
'ou quando você não consegue resolver após tentativas honestas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
motivo: {
|
||||
type: 'string',
|
||||
description: 'Motivo da transferência (ex: "cliente insatisfeito com cobrança").',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
handoff_mode: 'humano',
|
||||
handoff_human_at: new Date(),
|
||||
status: 'escalated',
|
||||
});
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId;
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
chatId,
|
||||
protocolNumber: conv?.protocol_number ?? '',
|
||||
motivo: args['motivo'] ?? '',
|
||||
};
|
||||
ctx.hooks.emit('ext:handoff', { ...payload, mode: 'humano', reason: 'escalation' }).catch(() => { });
|
||||
ctx.hooks.emit('ext:escalated', payload).catch(() => { });
|
||||
}
|
||||
return { ok: true, escalado: true, motivo: args['motivo'] ?? 'Solicitado pelo sistema' };
|
||||
},
|
||||
},
|
||||
// ── encerrar_protocolo ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'encerrar_protocolo',
|
||||
description: 'Encerra o protocolo de atendimento após resolver o problema do cliente. ' +
|
||||
'Use apenas quando tiver certeza de que tudo foi resolvido.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resumo: {
|
||||
type: 'string',
|
||||
description: 'Breve resumo do que foi tratado e resolvido neste atendimento.',
|
||||
},
|
||||
},
|
||||
required: ['resumo'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const summary = args['resumo'] ?? 'Atendimento encerrado.';
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first();
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
status: 'closed',
|
||||
summary,
|
||||
updated_at: new Date(),
|
||||
});
|
||||
await ctx.db('sec_messages').where({ conversation_id: ctx.conversationId }).delete();
|
||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary };
|
||||
},
|
||||
},
|
||||
];
|
||||
function resolveTools(names) {
|
||||
return names
|
||||
.map(n => exports.BUILTIN_TOOLS.find(t => t.name === n))
|
||||
.filter((t) => t !== undefined);
|
||||
}
|
||||
exports.ALL_TOOL_NAMES = exports.BUILTIN_TOOLS.map(t => t.name);
|
||||
//# sourceMappingURL=tools.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* Secretaria — Tool Definitions
|
||||
*
|
||||
* Cada ToolDef é uma função que a IA pode chamar durante o atendimento.
|
||||
* O motor executa a função, injeta o resultado e chama a IA novamente.
|
||||
*
|
||||
* Tools disponíveis:
|
||||
* listar_horarios — agenda: horários livres
|
||||
* agendar_horario — agenda: reserva um slot
|
||||
* escalar_humano — handoff: transfere para atendente
|
||||
* encerrar_protocolo — fecha o protocolo com resumo
|
||||
*/
|
||||
import { Knex } from 'knex'
|
||||
import type { HookBus } from '../../backend/src/core/hook-bus'
|
||||
|
||||
// ── Tipos públicos ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ToolParam {
|
||||
type: string
|
||||
description: string
|
||||
enum?: string[]
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
name: string
|
||||
description: string
|
||||
parameters: {
|
||||
type: 'object'
|
||||
properties: Record<string, ToolParam>
|
||||
required?: string[]
|
||||
}
|
||||
execute: (args: Record<string, any>, ctx: ToolContext) => Promise<any>
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
db: Knex
|
||||
conversationId: string
|
||||
extChatId?: string
|
||||
tenantId?: string
|
||||
hooks?: HookBus
|
||||
/** Telemetria cumulativa preenchida pelos tool loops (M1.4). */
|
||||
_telemetry?: {
|
||||
usage: { input: number; output: number; total: number; cache_read?: number; cached?: number }
|
||||
provider: string
|
||||
model: string
|
||||
iterations: number
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function fmtDate(d: Date): string {
|
||||
return d.toISOString().split('T')[0]!
|
||||
}
|
||||
|
||||
// ── Tools ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BUILTIN_TOOLS: ToolDef[] = [
|
||||
|
||||
// ── listar_horarios ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'listar_horarios',
|
||||
description:
|
||||
'Lista os horários disponíveis na agenda para agendamento. ' +
|
||||
'Use antes de propor datas ao cliente. ' +
|
||||
'Se não informar a data, retorna os próximos 7 dias.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
data: {
|
||||
type: 'string',
|
||||
description: 'Data no formato YYYY-MM-DD. Opcional — padrão: próximos 7 dias.',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const from = args['data'] ?? fmtDate(new Date())
|
||||
const to = args['data'] ?? fmtDate(new Date(Date.now() + 7 * 86_400_000))
|
||||
const slots = await ctx.db('sec_calendar')
|
||||
.where('status', 'available')
|
||||
.whereBetween('date', [from, to])
|
||||
.orderBy('date').orderBy('time_start')
|
||||
.limit(20)
|
||||
if (!slots.length) {
|
||||
return { disponivel: false, mensagem: 'Nenhum horário disponível no período informado.' }
|
||||
}
|
||||
return {
|
||||
disponivel: true,
|
||||
horarios: (slots as any[]).map(s => ({
|
||||
id: s.id,
|
||||
data: s.date,
|
||||
inicio: String(s.time_start).slice(0, 5),
|
||||
fim: String(s.time_end).slice(0, 5),
|
||||
titulo: s.title,
|
||||
})),
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── agendar_horario ──────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'agendar_horario',
|
||||
description:
|
||||
'Reserva um horário disponível na agenda para o cliente. ' +
|
||||
'Use listar_horarios primeiro para obter o slot_id correto.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
slot_id: { type: 'string', description: 'ID do horário retornado por listar_horarios.' },
|
||||
nome_cliente: { type: 'string', description: 'Nome completo do cliente.' },
|
||||
telefone_cliente: { type: 'string', description: 'Telefone do cliente (opcional).' },
|
||||
},
|
||||
required: ['slot_id', 'nome_cliente'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const slot = await ctx.db('sec_calendar')
|
||||
.where({ id: args['slot_id'], status: 'available' })
|
||||
.first()
|
||||
if (!slot) {
|
||||
return { ok: false, erro: 'Horário não encontrado ou já reservado. Chame listar_horarios novamente.' }
|
||||
}
|
||||
await ctx.db('sec_calendar').where({ id: args['slot_id'] }).update({
|
||||
status: 'booked',
|
||||
attendee_name: args['nome_cliente'],
|
||||
attendee_phone: args['telefone_cliente'] ?? null,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
confirmacao: {
|
||||
data: slot.date,
|
||||
inicio: String(slot.time_start).slice(0, 5),
|
||||
fim: String(slot.time_end).slice(0, 5),
|
||||
titulo: slot.title,
|
||||
nome: args['nome_cliente'],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
// ── escalar_humano ───────────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'escalar_humano',
|
||||
description:
|
||||
'Transfere o atendimento para um atendente humano quando a situação exige. ' +
|
||||
'Use quando: cliente pede explicitamente, problema financeiro sensível, raiva intensa, ' +
|
||||
'ou quando você não consegue resolver após tentativas honestas.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
motivo: {
|
||||
type: 'string',
|
||||
description: 'Motivo da transferência (ex: "cliente insatisfeito com cobrança").',
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
handoff_mode: 'humano',
|
||||
handoff_human_at: new Date(),
|
||||
status: 'escalated',
|
||||
})
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
||||
if (ctx.hooks && ctx.tenantId && ctx.extChatId) {
|
||||
const chatId = ctx.extChatId.includes(':')
|
||||
? ctx.extChatId.split(':').slice(1).join(':')
|
||||
: ctx.extChatId
|
||||
const payload = {
|
||||
tenantId: ctx.tenantId,
|
||||
conversationId: ctx.conversationId,
|
||||
chatId,
|
||||
protocolNumber: conv?.protocol_number ?? '',
|
||||
motivo: args['motivo'] ?? '',
|
||||
}
|
||||
ctx.hooks.emit('ext:handoff', { ...payload, mode: 'humano', reason: 'escalation' }).catch(() => {})
|
||||
ctx.hooks.emit('ext:escalated', payload).catch(() => {})
|
||||
}
|
||||
return { ok: true, escalado: true, motivo: args['motivo'] ?? 'Solicitado pelo sistema' }
|
||||
},
|
||||
},
|
||||
|
||||
// ── encerrar_protocolo ───────────────────────────────────────────────────────
|
||||
{
|
||||
name: 'encerrar_protocolo',
|
||||
description:
|
||||
'Encerra o protocolo de atendimento após resolver o problema do cliente. ' +
|
||||
'Use apenas quando tiver certeza de que tudo foi resolvido.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
resumo: {
|
||||
type: 'string',
|
||||
description: 'Breve resumo do que foi tratado e resolvido neste atendimento.',
|
||||
},
|
||||
},
|
||||
required: ['resumo'],
|
||||
},
|
||||
async execute(args, ctx) {
|
||||
const summary = args['resumo'] ?? 'Atendimento encerrado.'
|
||||
const conv = await ctx.db('sec_conversations').where({ id: ctx.conversationId }).first()
|
||||
await ctx.db('sec_conversations').where({ id: ctx.conversationId }).update({
|
||||
status: 'closed',
|
||||
summary,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
await ctx.db('sec_messages').where({ conversation_id: ctx.conversationId }).delete()
|
||||
return { ok: true, protocolo: conv?.protocol_number ?? '', resumo: summary }
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export function resolveTools(names: string[]): ToolDef[] {
|
||||
return names
|
||||
.map(n => BUILTIN_TOOLS.find(t => t.name === n))
|
||||
.filter((t): t is ToolDef => t !== undefined)
|
||||
}
|
||||
|
||||
export const ALL_TOOL_NAMES = BUILTIN_TOOLS.map(t => t.name)
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"strict": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"moduleResolution": "node",
|
||||
"noEmitOnError": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"*": ["*", "../backend/node_modules/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"./**/*.ts",
|
||||
"../backend/src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"**/migrations/**"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.createUploadRoutes = createUploadRoutes;
|
||||
const express_1 = require("express");
|
||||
const multer_1 = __importDefault(require("multer"));
|
||||
const path_1 = __importDefault(require("path"));
|
||||
const fs_1 = __importDefault(require("fs"));
|
||||
function createUploadRoutes(ctx) {
|
||||
const router = (0, express_1.Router)();
|
||||
const uploadDir = process.env.UPLOAD_DIR || path_1.default.join(process.cwd(), 'uploads');
|
||||
if (!fs_1.default.existsSync(uploadDir))
|
||||
fs_1.default.mkdirSync(uploadDir, { recursive: true });
|
||||
const storage = multer_1.default.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
|
||||
});
|
||||
const upload = (0, multer_1.default)({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
// POST / — Upload file
|
||||
router.post('/', upload.single('file'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file)
|
||||
return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||
const destination = req.query.destination;
|
||||
const category = req.query.category || 'media';
|
||||
let fileBuffer = fs_1.default.readFileSync(req.file.path);
|
||||
let mimetype = req.file.mimetype;
|
||||
// Emit transformation hook
|
||||
const transformResults = await ctx.hooks.emit('upload:transform', {
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimetype,
|
||||
category: category,
|
||||
originalname: req.file.originalname,
|
||||
partnerId: req.body.partnerId
|
||||
});
|
||||
// If a plugin transformed the file, use the last result
|
||||
if (transformResults && transformResults.length > 0) {
|
||||
const lastResult = transformResults[transformResults.length - 1];
|
||||
if (lastResult && lastResult.buffer) {
|
||||
fileBuffer = lastResult.buffer;
|
||||
mimetype = lastResult.mimetype || mimetype;
|
||||
}
|
||||
}
|
||||
if (destination === 'cloud') {
|
||||
if (fs_1.default.existsSync(req.file.path))
|
||||
fs_1.default.unlinkSync(req.file.path);
|
||||
// Use a more robust import for the singleton (conditional for production Docker)
|
||||
const storageCore = process.env.NODE_ENV === 'production'
|
||||
? require('../../../../dist/core/StorageProvider')
|
||||
: require('../../../../backend/src/core/StorageProvider');
|
||||
const provider = storageCore.storageProvider || (storageCore.default && storageCore.default.storageProvider);
|
||||
if (!provider) {
|
||||
ctx.logger.error('[Uploads] StorageProvider not found in core');
|
||||
return res.status(500).json({ error: 'Storage core unavailable' });
|
||||
}
|
||||
const cloudResult = await provider.uploadFile({
|
||||
category,
|
||||
partnerId: req.body.partnerId,
|
||||
benefitId: req.body.benefitId,
|
||||
postId: req.body.postId,
|
||||
whatsappId: req.body.whatsappId,
|
||||
file: {
|
||||
originalname: req.file.originalname,
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimetype
|
||||
}
|
||||
});
|
||||
return res.json({
|
||||
url: `https://${process.env.WASABI_BUCKET}.s3.wasabisys.com/${cloudResult.path}`,
|
||||
filename: req.file.filename,
|
||||
provider: 'wasabi'
|
||||
});
|
||||
}
|
||||
// Local storage fallback: save the potentially transformed buffer
|
||||
if (transformResults && transformResults.length > 0) {
|
||||
fs_1.default.writeFileSync(req.file.path, fileBuffer);
|
||||
}
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
await ctx.hooks.emit('upload:completed', { filename: req.file.filename, url });
|
||||
res.json({ url, filename: req.file.filename, size: fileBuffer.length });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Uploads] Error:', err);
|
||||
res.status(500).json({ error: 'Erro no upload' });
|
||||
}
|
||||
});
|
||||
// POST /logo — Upload partner logo
|
||||
router.post('/logo', upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file)
|
||||
return res.status(400).json({ error: 'Nenhum logo enviado' });
|
||||
let fileBuffer = fs_1.default.readFileSync(req.file.path);
|
||||
let mimetype = req.file.mimetype;
|
||||
// Emit transformation hook
|
||||
const transformResults = await ctx.hooks.emit('upload:transform', {
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimetype,
|
||||
category: 'partners',
|
||||
originalname: req.file.originalname
|
||||
});
|
||||
if (transformResults && transformResults.length > 0) {
|
||||
const lastResult = transformResults[transformResults.length - 1];
|
||||
if (lastResult && lastResult.buffer) {
|
||||
fileBuffer = lastResult.buffer;
|
||||
mimetype = lastResult.mimetype || mimetype;
|
||||
// Overwrite local file with optimized version
|
||||
fs_1.default.writeFileSync(req.file.path, fileBuffer);
|
||||
}
|
||||
}
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
res.json({ url, filename: req.file.filename });
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Uploads] Logo Error:', err);
|
||||
res.status(500).json({ error: 'Erro no upload do logo' });
|
||||
}
|
||||
});
|
||||
// DELETE /:filename — Delete file
|
||||
router.delete('/:filename', async (req, res) => {
|
||||
try {
|
||||
const filePath = path_1.default.join(uploadDir, req.params.filename);
|
||||
if (fs_1.default.existsSync(filePath)) {
|
||||
fs_1.default.unlinkSync(filePath);
|
||||
await ctx.hooks.emit('upload:deleted', { filename: req.params.filename });
|
||||
res.json({ message: 'Arquivo removido' });
|
||||
}
|
||||
else {
|
||||
res.status(404).json({ error: 'Arquivo não encontrado' });
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
res.status(500).json({ error: 'Erro ao remover' });
|
||||
}
|
||||
});
|
||||
// GET /:filename — Legacy access with Washabi Fallback 🛡️
|
||||
router.get('/:filename', async (req, res) => {
|
||||
const { filename } = req.params;
|
||||
const localPath = path_1.default.join(uploadDir, filename);
|
||||
// 🛡️ If exists locally, serve normally
|
||||
if (fs_1.default.existsSync(localPath)) {
|
||||
return res.sendFile(localPath);
|
||||
}
|
||||
// 🚀 If NOT local, redirect or proxy to UnifiedStorageProvider
|
||||
// We know most legacy uploads go to the 'media' category in the proxy
|
||||
const storageProxyUrl = `/api/storage/view/media/${filename}`;
|
||||
ctx.logger.info(`[Uploads:Fallback] Redirecting legacy file ${filename} -> ${storageProxyUrl}`);
|
||||
res.redirect(storageProxyUrl);
|
||||
});
|
||||
return router;
|
||||
}
|
||||
//# sourceMappingURL=routes.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,161 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { PluginContext } from '../../../backend/src/core/types';
|
||||
|
||||
export function createUploadRoutes(ctx: PluginContext): Router {
|
||||
const router = Router();
|
||||
const uploadDir = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads');
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||||
filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
|
||||
});
|
||||
const upload = multer({ storage, limits: { fileSize: 5 * 1024 * 1024 } });
|
||||
|
||||
// POST / — Upload file
|
||||
router.post('/', upload.single('file'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Nenhum arquivo enviado' });
|
||||
|
||||
const destination = req.query.destination as string;
|
||||
const category = req.query.category as any || 'media';
|
||||
|
||||
let fileBuffer = fs.readFileSync(req.file.path);
|
||||
let mimetype = req.file.mimetype;
|
||||
|
||||
// Emit transformation hook
|
||||
const transformResults = await ctx.hooks.emit('upload:transform', {
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimetype,
|
||||
category: category,
|
||||
originalname: req.file.originalname,
|
||||
partnerId: req.body.partnerId
|
||||
});
|
||||
|
||||
// If a plugin transformed the file, use the last result
|
||||
if (transformResults && transformResults.length > 0) {
|
||||
const lastResult = transformResults[transformResults.length - 1];
|
||||
if (lastResult && lastResult.buffer) {
|
||||
fileBuffer = lastResult.buffer;
|
||||
mimetype = lastResult.mimetype || mimetype;
|
||||
}
|
||||
}
|
||||
|
||||
if (destination === 'cloud') {
|
||||
if (fs.existsSync(req.file.path)) fs.unlinkSync(req.file.path);
|
||||
|
||||
// Use a more robust import for the singleton (conditional for production Docker)
|
||||
const storageCore = process.env.NODE_ENV === 'production'
|
||||
? require('../../../../dist/core/StorageProvider')
|
||||
: require('../../../../backend/src/core/StorageProvider');
|
||||
const provider = storageCore.storageProvider || (storageCore.default && storageCore.default.storageProvider);
|
||||
|
||||
if (!provider) {
|
||||
ctx.logger.error('[Uploads] StorageProvider not found in core');
|
||||
return res.status(500).json({ error: 'Storage core unavailable' });
|
||||
}
|
||||
|
||||
const cloudResult = await provider.uploadFile({
|
||||
category,
|
||||
partnerId: req.body.partnerId,
|
||||
benefitId: req.body.benefitId,
|
||||
postId: req.body.postId,
|
||||
whatsappId: req.body.whatsappId,
|
||||
file: {
|
||||
originalname: req.file.originalname,
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimetype
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({
|
||||
url: `https://${process.env.WASABI_BUCKET}.s3.wasabisys.com/${cloudResult.path}`,
|
||||
filename: req.file.filename,
|
||||
provider: 'wasabi'
|
||||
});
|
||||
}
|
||||
|
||||
// Local storage fallback: save the potentially transformed buffer
|
||||
if (transformResults && transformResults.length > 0) {
|
||||
fs.writeFileSync(req.file.path, fileBuffer);
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
await ctx.hooks.emit('upload:completed', { filename: req.file.filename, url });
|
||||
res.json({ url, filename: req.file.filename, size: fileBuffer.length });
|
||||
} catch (err: any) {
|
||||
console.error('[Uploads] Error:', err);
|
||||
res.status(500).json({ error: 'Erro no upload' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /logo — Upload partner logo
|
||||
router.post('/logo', upload.single('logo'), async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!req.file) return res.status(400).json({ error: 'Nenhum logo enviado' });
|
||||
|
||||
let fileBuffer = fs.readFileSync(req.file.path);
|
||||
let mimetype = req.file.mimetype;
|
||||
|
||||
// Emit transformation hook
|
||||
const transformResults = await ctx.hooks.emit('upload:transform', {
|
||||
buffer: fileBuffer,
|
||||
mimetype: mimetype,
|
||||
category: 'partners',
|
||||
originalname: req.file.originalname
|
||||
});
|
||||
|
||||
if (transformResults && transformResults.length > 0) {
|
||||
const lastResult = transformResults[transformResults.length - 1];
|
||||
if (lastResult && lastResult.buffer) {
|
||||
fileBuffer = lastResult.buffer;
|
||||
mimetype = lastResult.mimetype || mimetype;
|
||||
// Overwrite local file with optimized version
|
||||
fs.writeFileSync(req.file.path, fileBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/uploads/${req.file.filename}`;
|
||||
res.json({ url, filename: req.file.filename });
|
||||
} catch (err: any) {
|
||||
console.error('[Uploads] Logo Error:', err);
|
||||
res.status(500).json({ error: 'Erro no upload do logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /:filename — Delete file
|
||||
router.delete('/:filename', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const filePath = path.join(uploadDir, req.params.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
await ctx.hooks.emit('upload:deleted', { filename: req.params.filename });
|
||||
res.json({ message: 'Arquivo removido' });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Arquivo não encontrado' });
|
||||
}
|
||||
} catch (err: any) { res.status(500).json({ error: 'Erro ao remover' }); }
|
||||
});
|
||||
|
||||
// GET /:filename — Legacy access with Washabi Fallback 🛡️
|
||||
router.get('/:filename', async (req: Request, res: Response) => {
|
||||
const { filename } = req.params;
|
||||
const localPath = path.join(uploadDir, filename);
|
||||
|
||||
// 🛡️ If exists locally, serve normally
|
||||
if (fs.existsSync(localPath)) {
|
||||
return res.sendFile(localPath);
|
||||
}
|
||||
|
||||
// 🚀 If NOT local, redirect or proxy to UnifiedStorageProvider
|
||||
// We know most legacy uploads go to the 'media' category in the proxy
|
||||
const storageProxyUrl = `/api/storage/view/media/${filename}`;
|
||||
ctx.logger.info(`[Uploads:Fallback] Redirecting legacy file ${filename} -> ${storageProxyUrl}`);
|
||||
res.redirect(storageProxyUrl);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
const routes_1 = require("./backend/routes");
|
||||
const manifest_json_1 = __importDefault(require("./manifest.json"));
|
||||
const plugin = {
|
||||
manifest: manifest_json_1.default,
|
||||
async activate(ctx) {
|
||||
ctx.app.use('/api/uploads', (0, routes_1.createUploadRoutes)(ctx));
|
||||
ctx.logger.info('Upload routes registered');
|
||||
},
|
||||
async deactivate(ctx) { ctx.logger.info('Uploads deactivated'); },
|
||||
};
|
||||
exports.default = plugin;
|
||||
//# sourceMappingURL=index.js.map
|
||||
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;AACA,6CAAsD;AACtD,oEAAuC;AAEvC,MAAM,MAAM,GAAmB;IAC3B,QAAQ,EAAE,uBAAe;IACzB,KAAK,CAAC,QAAQ,CAAC,GAAkB;QAC7B,GAAG,CAAC,GAAG,CAAC,GAAG,CAAC,cAAc,EAAE,IAAA,2BAAkB,EAAC,GAAG,CAAC,CAAC,CAAC;QACrD,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,0BAA0B,CAAC,CAAC;IAChD,CAAC;IACD,KAAK,CAAC,UAAU,CAAC,GAAkB,IAAmB,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC;CAClG,CAAC;AACF,kBAAe,MAAM,CAAC"}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { PluginInstance, PluginContext } from '../../backend/src/core/types';
|
||||
import { createUploadRoutes } from './backend/routes';
|
||||
import manifest from './manifest.json';
|
||||
|
||||
const plugin: PluginInstance = {
|
||||
manifest: manifest as any,
|
||||
async activate(ctx: PluginContext): Promise<void> {
|
||||
ctx.app.use('/api/uploads', createUploadRoutes(ctx));
|
||||
ctx.logger.info('Upload routes registered');
|
||||
},
|
||||
async deactivate(ctx: PluginContext): Promise<void> { ctx.logger.info('Uploads deactivated'); },
|
||||
};
|
||||
export default plugin;
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "uploads",
|
||||
"displayName": "Uploads & Mídia",
|
||||
"version": "1.0.0",
|
||||
"description": "Upload de logos, imagens e documentos com suporte a S3.",
|
||||
"author": "Clube67",
|
||||
"category": "core",
|
||||
"enabled": true,
|
||||
"canDisable": false,
|
||||
"dependencies": [
|
||||
"core-auth"
|
||||
],
|
||||
"backend": {
|
||||
"routePrefix": "/api/uploads",
|
||||
"hasMigrations": false
|
||||
},
|
||||
"frontend": {
|
||||
"menuItems": []
|
||||
},
|
||||
"hooks": {
|
||||
"subscribes": [],
|
||||
"emits": [
|
||||
"upload:completed",
|
||||
"upload:deleted"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user