chore(ops): restore missing root files in newwhats.clube67.com
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)

This commit is contained in:
VPS 4 Deploy Agent
2026-05-18 03:27:25 +02:00
parent 5ec6bd6354
commit 0dc5eefa06
406 changed files with 237013 additions and 0 deletions
@@ -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":"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"}
@@ -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 });
@@ -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;
}
@@ -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 },
}),
};
@@ -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;
@@ -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;
@@ -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":"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"}
@@ -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":"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"}
@@ -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": []
}
}