chore(ops): restore all source 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:28:29 +02:00
parent 52f73753e7
commit 2f8c04a0a7
755 changed files with 466910 additions and 0 deletions
@@ -0,0 +1,9 @@
node_modules
dist
.git
*.log
.env
sessions
media
storage
uploads
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
PORT=8008
NODE_ENV=development
JWT_SECRET=troque_por_secret_forte_aqui
# ─────────────────────────────────────────────
# PostgreSQL (Prisma)
# ─────────────────────────────────────────────
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/newwhats?schema=public"
# ─────────────────────────────────────────────
# DragonflyDB (compatível com Redis)
# ─────────────────────────────────────────────
DRAGONFLY_URL=redis://localhost:6379
DRAGONFLY_TTL_SECONDS=86400
# ─────────────────────────────────────────────
# NATS JetStream
# ─────────────────────────────────────────────
NATS_URL=nats://localhost:4222
# ─────────────────────────────────────────────
# Temporal.io
# ─────────────────────────────────────────────
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=newwhats-queue
# ─────────────────────────────────────────────
# IA (OpenAI + Google Gemini)
# ─────────────────────────────────────────────
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=AIza...
# ─────────────────────────────────────────────
# WhatsApp / Baileys
# ─────────────────────────────────────────────
# Pasta raiz onde as sessões multi-device são armazenadas (por instância)
BAILEYS_SESSIONS_PATH=./sessions
# ─────────────────────────────────────────────
# Frontend (CORS)
# ─────────────────────────────────────────────
FRONTEND_URL=http://localhost:3000
@@ -0,0 +1,24 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:20-slim AS runner
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
RUN ln -s /app/plugins /plugins && \
ln -s /app /app/backend && \
ln -s /app/dist /app/src
EXPOSE 8008
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
{
"name": "newwhats-backend",
"version": "1.0.0",
"description": "SaaS Omnichannel Backend — NewWhats",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json || true",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1029.0",
"@aws-sdk/lib-storage": "^3.1029.0",
"@google/generative-ai": "^0.24.1",
"@prisma/client": "^5.22.0",
"@temporalio/activity": "^1.15.0",
"@temporalio/client": "^1.15.0",
"@temporalio/worker": "^1.15.0",
"@temporalio/workflow": "^1.15.0",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@whiskeysockets/baileys": "^6.7.18",
"baileys": "file:./baileys-7.0.0-rc.9.tgz",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"croner": "^10.0.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^5.4.1",
"jimp": "^1.6.0",
"jsonwebtoken": "^9.0.3",
"knex": "^3.2.9",
"multer": "^2.1.1",
"nats": "^2.28.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.5",
"openai": "^6.34.0",
"pg": "^8.20.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"recharts": "^3.8.1",
"sharp": "^0.33.5",
"socket.io": "^4.8.1",
"systeminformation": "^5.31.5",
"tsx": "^4.19.2",
"uuid": "^11.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"prisma": "^5.22.0",
"qrcode-terminal": "^0.12.0",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
@@ -0,0 +1,560 @@
// ─────────────────────────────────────────────────────────────────────────────
// Prisma Schema — NewWhats SaaS Omnichannel
// ─────────────────────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── PLANOS ──────────────────────────────────────────────────────────────────
model Plan {
id String @id @default(uuid())
name String
maxInstances Int @default(1)
maxAgents Int @default(5)
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
// ─── USUÁRIOS (ADMIN + TENANTS) ───────────────────────────────────────────────
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
role UserRole @default(USER)
isActive Boolean @default(true)
planId String?
trialEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plan Plan? @relation(fields: [planId], references: [id])
instances Instance[]
sectors Sector[]
teams Team[]
apiKeys ApiKey[]
aiCredentials AICredential[]
aiBots AIBot[]
broadcasts Broadcast[]
pluginPairs PluginPair[]
extWebhooks ExtWebhook[]
messageTemplates MessageTemplate[]
scheduledMessages ScheduledMessage[]
@@map("users")
}
enum UserRole {
ADMIN
USER
}
// ─── API KEYS ─────────────────────────────────────────────────────────────────
model ApiKey {
id String @id @default(uuid())
tenantId String
key String @unique @default(uuid())
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
expiresAt DateTime?
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("api_keys")
}
// ─── INSTÂNCIAS WHATSAPP ──────────────────────────────────────────────────────
model Instance {
id String @id @default(uuid())
tenantId String
name String
phone String?
avatar String?
status InstanceStatus @default(DISCONNECTED)
engine String @default("infinite")
sessionPath String?
webhookUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
contacts Contact[]
chats Chat[]
messages Message[]
broadcasts Broadcast[]
@@unique([tenantId, name])
@@map("instances")
}
enum InstanceStatus {
DISCONNECTED
CONNECTING
QR_PENDING
CONNECTED
BANNED
}
// ─── SETORES ─────────────────────────────────────────────────────────────────
model Sector {
id String @id @default(uuid())
tenantId String
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
teams Team[]
protocols Protocol[]
@@unique([tenantId, name])
@@map("sectors")
}
// ─── EQUIPES ─────────────────────────────────────────────────────────────────
model Team {
id String @id @default(uuid())
tenantId String
sectorId String
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
sector Sector @relation(fields: [sectorId], references: [id], onDelete: Cascade)
protocols Protocol[]
@@unique([tenantId, sectorId, name])
@@map("teams")
}
// ─── CONTATOS ─────────────────────────────────────────────────────────────────
model Contact {
id String @id @default(uuid())
tenantId String
instanceId String
jid String
lidJid String?
// Prioridade: name (agenda) > verifiedName > notify (pushname)
name String?
verifiedName String?
notify String?
phone String?
avatarUrl String?
scoreReputacao Int @default(100)
flagRestricao Boolean @default(false)
isBlocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
chats Chat[]
protocols Protocol[]
@@unique([tenantId, instanceId, jid])
@@index([tenantId, instanceId, lidJid])
@@map("contacts")
}
// ─── CHATS ────────────────────────────────────────────────────────────────────
model Chat {
id String @id @default(uuid())
tenantId String
instanceId String
contactId String?
jid String
name String? // Subject para grupos (@g.us); null para individuais
unreadCount Int @default(0)
lastMessageAt DateTime?
isPinned Boolean @default(false)
isArchived Boolean @default(false)
// IA Chatbot
botPaused Boolean @default(false) // human takeover — pausa o bot neste chat
botSummary String? // Cérebro: resumo de 2 frases da conversa
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id])
messages Message[]
protocols Protocol[]
@@unique([tenantId, instanceId, jid])
// Índice composto para a query principal da inbox:
// WHERE tenantId + instanceId + isArchived (filtro)
// ORDER BY isPinned DESC, lastMessageAt DESC (ordenação)
@@index([tenantId, instanceId, isArchived, isPinned(sort: Desc), lastMessageAt(sort: Desc)])
@@map("chats")
}
// ─── MENSAGENS ────────────────────────────────────────────────────────────────
model Message {
id String @id @default(uuid())
tenantId String
instanceId String
chatId String
remoteJid String
messageId String
fromMe Boolean @default(false)
type MessageType @default(TEXT)
body String?
caption String?
mediaUrl String?
mediaPath String?
mimeType String?
fileName String?
duration Int?
pushName String? // Nome do remetente no momento do envio (grupos: quem enviou)
senderJid String? // JID do remetente real (grupos: key.participant)
replyToId String? // ID da mensagem citada (reply)
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
status MessageStatus @default(PENDING)
timestamp DateTime
createdAt DateTime @default(now())
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id])
replies Message[] @relation("MessageReply")
@@unique([instanceId, messageId])
// Índice para "última mensagem por chat" — usado no include da inbox
// ORDER BY timestamp DESC LIMIT 1 (por chatId)
@@index([chatId, timestamp(sort: Desc)])
// Índice para reconcile-names: DISTINCT ON remoteJid ORDER BY timestamp DESC
// WHERE instanceId = ? AND fromMe = false AND pushName IS NOT NULL
@@index([instanceId, fromMe, remoteJid, timestamp(sort: Desc)])
// Índice para obter a última mensagem da instância — usado na verificação de gap de sincronização
@@index([instanceId, timestamp(sort: Desc)])
@@map("messages")
}
enum MessageType {
TEXT
IMAGE
VIDEO
AUDIO
DOCUMENT
STICKER
LOCATION
CONTACT
REACTION
POLL
BUTTONS
LIST
CAROUSEL
INTERACTIVE
UNSUPPORTED
}
enum MessageStatus {
PENDING
SENT
DELIVERED
READ
FAILED
}
// ─── PROTOCOLOS DE ATENDIMENTO ────────────────────────────────────────────────
model Protocol {
id String @id @default(uuid())
tenantId String
number String @unique // Número sequencial rastreável: #0001-2024
chatId String
contactId String
sectorId String?
teamId String?
agentId String?
status ProtocolStatus @default(OPEN)
summary String? // Resumo de 2 frases gerado pelo Temporal
deadline DateTime?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chat Chat @relation(fields: [chatId], references: [id])
contact Contact @relation(fields: [contactId], references: [id])
sector Sector? @relation(fields: [sectorId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
// Índice para filtro de protocolos abertos por chat (usado no include da inbox)
// WHERE chatId = ? AND status IN ('OPEN', 'IN_PROGRESS', ...) ORDER BY createdAt DESC
@@index([chatId, status, createdAt(sort: Desc)])
@@map("protocols")
}
enum ProtocolStatus {
OPEN
IN_PROGRESS
WAITING_CLIENT
WAITING_AGENT
RESOLVED
CANCELLED
}
// ─── IA MULTI-AGENTE ──────────────────────────────────────────────────────────
model AICredential {
id String @id @default(uuid())
tenantId String
instanceId String
name String
provider LLMProvider @default(GEMINI)
apiKey String
createdAt DateTime @default(now())
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
bots AIBot[]
@@unique([tenantId, instanceId, name])
@@map("ai_credentials")
}
model AIBot {
id String @id @default(uuid())
tenantId String
instanceId String
credentialId String
name String
systemPrompt String @db.Text
model String @default("gemini-1.5-flash")
enabled Boolean @default(false)
triggerMode TriggerMode @default(ALL)
keywords String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
credential AICredential @relation(fields: [credentialId], references: [id])
@@unique([tenantId, instanceId])
@@map("ai_bots")
}
enum LLMProvider {
GEMINI
OPENAI
}
enum TriggerMode {
ALL
KEYWORD
}
// ─── BROADCAST (DISPARO EM MASSA) ─────────────────────────────────────────────
model Broadcast {
id String @id @default(uuid())
tenantId String
instanceId String
message String @db.Text
totalCount Int @default(0)
sentCount Int @default(0)
failedCount Int @default(0)
status String @default("RUNNING") // RUNNING | DONE | FAILED
createdAt DateTime @default(now())
finishedAt DateTime?
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
recipients BroadcastRecipient[]
@@map("broadcasts")
}
// ─── PLUGIN PAIRS ─────────────────────────────────────────────────────────────
// Registra sistemas externos pareados com uma conta NewWhats.
// Cada par tem uma integration_key exclusiva que autentica chamadas /nw/*.
model PluginPair {
id String @id @default(uuid())
userId String
clientSystem String // identificador técnico ex: "alemao-conveniencias"
clientName String // nome amigável ex: "Alemão Conveniências"
clientUrl String? // URL base do sistema cliente
integrationKey String @unique
lastSeenAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("plugin_pairs")
}
// ─── EXT WEBHOOKS ─────────────────────────────────────────────────────────────
// Endpoints externos registrados por tenant para receber push de eventos.
model ExtWebhook {
id String @id @default(uuid())
tenantId String
url String
events String[]
secret String
active Boolean @default(true)
createdAt DateTime @default(now())
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("ext_webhooks")
}
model BroadcastRecipient {
id String @id @default(uuid())
broadcastId String
jid String
status String @default("PENDING") // PENDING | SENT | FAILED
createdAt DateTime @default(now())
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
@@map("broadcast_recipients")
}
// ─── TEMPLATES DE MENSAGEM ────────────────────────────────────────────────────
model MessageTemplate {
id String @id @default(uuid())
tenantId String
name String
description String?
type TemplateType @default(TEXT)
payload Json
tags String[]
usageCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
schedules ScheduledMessage[]
@@map("message_templates")
}
enum TemplateType {
TEXT
BUTTONS
INTERACTIVE
LIST
POLL
CAROUSEL
}
// ─── AGENDAMENTOS DE MENSAGEM ─────────────────────────────────────────────────
model ScheduledMessage {
id String @id @default(uuid())
tenantId String
instanceId String
name String
templateId String?
payload Json
recipientMode RecipientMode @default(MANUAL)
recipients Json
scheduleType ScheduleType @default(ONCE)
cronExpr String?
scheduledAt DateTime?
eventType EventType?
timezone String @default("America/Sao_Paulo")
status ScheduleStatus @default(ACTIVE)
lastRunAt DateTime?
nextRunAt DateTime?
runCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
template MessageTemplate? @relation(fields: [templateId], references: [id])
logs ScheduledMessageLog[]
@@map("scheduled_messages")
}
model ScheduledMessageLog {
id String @id @default(uuid())
scheduleId String
status String
sentCount Int @default(0)
failedCount Int @default(0)
error String?
runAt DateTime @default(now())
schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@map("scheduled_message_logs")
}
enum RecipientMode {
MANUAL
ALL_CONTACTS
TAG_FILTER
}
enum ScheduleType {
ONCE
RECURRING
EVENT
}
enum EventType {
BIRTHDAY
ANNIVERSARY
SIGNUP_DAYS
}
enum ScheduleStatus {
ACTIVE
PAUSED
DONE
CANCELLED
}
// ─── FIGURINHAS (STICKERS) ────────────────────────────────────────────────────
// Armazenamento persistente de stickers usando fileSha256 do protocolo Baileys
// como chave de conteúdo. Garante deduplicação mesmo após reinstalação do WA.
model Sticker {
id String @id @default(uuid())
tenantId String
instanceId String
sha256 String // fileSha256 hex — identidade permanente do conteúdo
wasabiPath String // stickers/{tenantId}/{sha256}.webp
isAnimated Boolean @default(false)
isFavorite Boolean @default(false)
useCount Int @default(1)
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([tenantId, sha256])
@@index([tenantId, instanceId, isFavorite, lastSeenAt(sort: Desc)])
@@map("stickers")
}
// Webhook allowed host fix
// Webhook allowed host fix
// Webhook query string fix
@@ -0,0 +1,45 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const ROUNDS = 10
const users = [
{
name: 'Usuário Padrão',
email: 'user@user.com',
password: '12345678',
role: 'USER' as const,
},
{
name: 'Admin',
email: 'ruibto@gmail.com',
password: 'Rc362514',
role: 'ADMIN' as const,
},
]
for (const u of users) {
const passwordHash = await bcrypt.hash(u.password, ROUNDS)
const existing = await prisma.user.findUnique({ where: { email: u.email } })
if (existing) {
await prisma.user.update({
where: { email: u.email },
data: { passwordHash, name: u.name, role: u.role },
})
console.log(`✅ Atualizado: ${u.email}`)
} else {
await prisma.user.create({
data: { name: u.name, email: u.email, passwordHash, role: u.role },
})
console.log(`✅ Criado: ${u.email} (${u.role})`)
}
}
}
main()
.catch((e) => { console.error(e); process.exit(1) })
.finally(() => prisma.$disconnect())
@@ -0,0 +1,81 @@
/**
* Script standalone para testar a conexão Baileys e exibir o QR Code no terminal.
* Não requer banco de dados nem DragonflyDB.
* Uso: node qr-test.mjs
*/
import {
makeWASocket,
useMultiFileAuthState,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
} from '@whiskeysockets/baileys'
import { Boom } from '@hapi/boom'
import qrcode from 'qrcode-terminal'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SESSION_DIR = path.join(__dirname, 'sessions', 'qr-test')
async function start() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR)
const { version, isLatest } = await fetchLatestBaileysVersion()
console.log(`\n📱 Baileys v${version} (latest: ${isLatest})`)
console.log('🔄 Iniciando conexão...\n')
const sock = makeWASocket({
version,
auth: state,
printQRInTerminal: false, // controlamos manualmente
qrTimeout: 60_000,
browser: ['NewWhats', 'Chrome', '124.0.0'],
syncFullHistory: false,
})
sock.ev.on('creds.update', saveCreds)
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
if (qr) {
console.clear()
console.log('📲 Escaneie o QR Code abaixo com o WhatsApp:\n')
qrcode.generate(qr, { small: true })
console.log('\n⏳ Aguardando escaneamento...')
}
if (connection === 'open') {
const me = sock.user
console.log('\n✅ Conectado com sucesso!')
console.log(` Número: ${me?.id}`)
console.log(` Nome: ${me?.name}`)
console.log('\n🎉 Sessão salva em:', SESSION_DIR)
console.log(' Agora rode o servidor completo com: npm run dev\n')
}
if (connection === 'close') {
const code = (lastDisconnect?.error)?.output?.statusCode
const reason = DisconnectReason
console.log(`\n❌ Conexão encerrada (código: ${code})`)
const shouldReconnect =
code !== reason.loggedOut &&
code !== reason.forbidden
if (shouldReconnect) {
console.log('🔄 Reconectando...\n')
setTimeout(start, 3000)
} else {
console.log('🚫 Sessão encerrada definitivamente.')
process.exit(0)
}
}
})
}
start().catch((err) => {
console.error('Erro fatal:', err)
process.exit(1)
})
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = exports.config = void 0;
// Re-export unificado das configs para compatibilidade com plugins
var env_1 = require("./config/env");
Object.defineProperty(exports, "config", { enumerable: true, get: function () { return env_1.env; } });
var env_2 = require("./config/env");
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_2.env; } });
//# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":";;;AAAA,mEAAmE;AACnE,oCAA4C;AAAnC,6FAAA,GAAG,OAAU;AACtB,oCAAkC;AAAzB,0FAAA,GAAG,OAAA"}
@@ -0,0 +1,3 @@
// Re-export unificado das configs para compatibilidade com plugins
export { env as config } from './config/env'
export { env } from './config/env'
@@ -0,0 +1,27 @@
"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;
//# sourceMappingURL=env.js.map
@@ -0,0 +1 @@
{"version":3,"file":"env.js","sourceRoot":"","sources":["env.ts"],"names":[],"mappings":";;;AAAA,6BAAuB;AACvB,yBAAsB;AAEtB,MAAM,SAAS,GAAG,OAAC,CAAC,MAAM,CAAC;IACzB,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;IAChC,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC;IAC9E,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAC9B,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IAC9B,aAAa,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,wBAAwB,CAAC;IAC3D,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;IAClD,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACrD,gBAAgB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACtD,kBAAkB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC;IACjD,mBAAmB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACzD,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IACvD,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;CAC1D,CAAC,CAAA;AAEF,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;AAE/C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAA;IACnD,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAEY,QAAA,GAAG,GAAG,MAAM,CAAC,IAAI,CAAA"}
@@ -0,0 +1,27 @@
import { z } from 'zod'
import 'dotenv/config'
const envSchema = z.object({
PORT: z.string().default('8008'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: z.string().min(16),
DATABASE_URL: z.string().url(),
DRAGONFLY_URL: z.string().default('redis://localhost:6379'),
DRAGONFLY_TTL_SECONDS: z.string().default('86400'),
NATS_URL: z.string().default('nats://localhost:4222'),
TEMPORAL_ADDRESS: z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: z.string().default('default'),
TEMPORAL_TASK_QUEUE: z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: z.string().default('./sessions'),
FRONTEND_URL: 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)
}
export const env = parsed.data
@@ -0,0 +1,15 @@
"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,
});
//# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAuB;AACvB,+BAA2B;AAEd,QAAA,MAAM,GAAG,IAAA,cAAI,EAAC;IACzB,KAAK,EAAE,SAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;IACvD,SAAS,EACP,SAAG,CAAC,QAAQ,KAAK,YAAY;QAC3B,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,cAAc,EAAE,EAAE;QACvF,CAAC,CAAC,SAAS;CAChB,CAAC,CAAA"}
@@ -0,0 +1,10 @@
import pino from 'pino'
import { env } from './env'
export const logger = pino({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
: undefined,
})
@@ -0,0 +1,91 @@
"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;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
};
}
register(provider) {
this.provider = provider;
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
logger_1.logger.info('[StorageProvider] Provider registrado');
}
isRegistered() {
return this.provider !== null;
}
isHealthy() {
return this.healthy;
}
getLastHealthStatus() {
return this.lastHealthStatus;
}
setHealthStatus(status) {
this.lastHealthStatus = status;
this.healthy = status.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;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
};
return;
}
try {
const status = await this.provider.checkDetailedHealth();
this.healthy = status.healthy;
this.lastHealthStatus = status;
}
catch {
this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
};
}
}
async getBuffer(path) {
if (!this.provider)
return null;
return this.provider.getBuffer(path);
}
async deletePrefix(prefix) {
if (!this.provider) {
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
return { deleted: 0 };
}
return this.provider.deletePrefix(prefix);
}
unregister() {
this.provider = null;
this.healthy = false;
logger_1.logger.info('[StorageProvider] Provider removido');
}
}
exports.storageProvider = new StorageProviderRegistry();
//# sourceMappingURL=StorageProvider.js.map
@@ -0,0 +1 @@
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAoBvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;QACf,qBAAgB,GAAwB;YAC9C,OAAO,EAAE,KAAK;YACd,UAAU,EAAE,KAAK;YACjB,MAAM,EAAE,gBAAgB;YACxB,OAAO,EAAE,2BAA2B;SACrC,CAAA;IA6EH,CAAC;IA3EC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,gBAAgB,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAA;QAC1G,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,mBAAmB;QACjB,OAAO,IAAI,CAAC,gBAAgB,CAAA;IAC9B,CAAC;IAED,eAAe,CAAC,MAA2B;QACzC,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAC9B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IAC/B,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,KAAK;gBACjB,MAAM,EAAE,gBAAgB;gBACxB,OAAO,EAAE,2EAA2E;aACrF,CAAA;YACD,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAA;YACxD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;YAC7B,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,IAAI;gBAChB,MAAM,EAAE,SAAS;gBACjB,OAAO,EAAE,2CAA2C;aACrD,CAAA;QACH,CAAC;IACH,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,IAAY;QAC1B,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAA;QAC/B,OAAO,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
@@ -0,0 +1,114 @@
import { logger as rootLogger } from '../config/logger'
import type { StorageProviderInterface, StorageUploadPayload } from './types'
export type StorageHealthReason =
| 'ok'
| 'not_configured'
| 'needs_restart'
| 'auth_error'
| 'billing_error'
| 'bucket_not_found'
| 'network_error'
| 'unknown'
export interface StorageHealthStatus {
healthy: boolean
configured: boolean
reason: StorageHealthReason
message: string
}
/**
* 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 {
private provider: StorageProviderInterface | null = null
private healthy = false
private lastHealthStatus: StorageHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
}
register(provider: StorageProviderInterface): void {
this.provider = provider
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' }
rootLogger.info('[StorageProvider] Provider registrado')
}
isRegistered(): boolean {
return this.provider !== null
}
isHealthy(): boolean {
return this.healthy
}
getLastHealthStatus(): StorageHealthStatus {
return this.lastHealthStatus
}
setHealthStatus(status: StorageHealthStatus): void {
this.lastHealthStatus = status
this.healthy = status.healthy
}
async uploadFile(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
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(): Promise<void> {
if (!this.provider) {
this.healthy = false
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
}
return
}
try {
const status = await this.provider.checkDetailedHealth()
this.healthy = status.healthy
this.lastHealthStatus = status
} catch {
this.healthy = false
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
}
}
}
async getBuffer(path: string): Promise<Buffer | null> {
if (!this.provider) return null
return this.provider.getBuffer(path)
}
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
if (!this.provider) {
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
return { deleted: 0 }
}
return this.provider.deletePrefix(prefix)
}
unregister(): void {
this.provider = null
this.healthy = false
rootLogger.info('[StorageProvider] Provider removido')
}
}
export const storageProvider = new StorageProviderRegistry()
export type { StorageUploadPayload, StorageProviderInterface }
@@ -0,0 +1,55 @@
import knex, { Knex } from 'knex'
import { env } from '../config/env'
import { logger as rootLogger } from '../config/logger'
/**
* Instância Knex compartilhada entre todos os plugins.
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
*/
function createKnex(): Knex {
// DATABASE_URL formato: postgresql://user:pass@host:port/db
const connection = env.DATABASE_URL
const instance = knex({
client: 'pg',
connection,
pool: { min: 1, max: 5 },
acquireConnectionTimeout: 10000,
})
instance.on('query-error', (err: Error, query: { sql: string }) => {
rootLogger.error({ err, sql: query.sql }, '[Knex] Query error')
})
return instance
}
export const db = createKnex()
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
export async function ensurePluginTables(knexInstance: Knex): Promise<void> {
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs')
if (!hasJanitorLogs) {
await knexInstance.schema.createTable('janitor_logs', (t) => {
t.increments('id').primary()
t.string('filename').notNullable()
t.string('category').notNullable()
t.string('status').notNullable()
t.text('message').nullable()
t.timestamps(true, true)
})
rootLogger.info('[Knex] Tabela janitor_logs criada')
}
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage')
if (!hasNanobanaUsage) {
await knexInstance.schema.createTable('nanobana_usage', (t) => {
t.increments('id').primary()
t.string('partner_id').notNullable()
t.string('type').notNullable()
t.timestamps(true, true)
})
rootLogger.info('[Knex] Tabela nanobana_usage criada')
}
}
@@ -0,0 +1,50 @@
"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();
//# sourceMappingURL=hook-bus.js.map
@@ -0,0 +1 @@
{"version":3,"file":"hook-bus.js","sourceRoot":"","sources":["hook-bus.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAKvD;;;;GAIG;AACH,MAAa,OAAO;IAApB;QACU,aAAQ,GAAG,IAAI,GAAG,EAAyB,CAAA;IAuCrD,CAAC;IArCC,QAAQ,CAAC,KAAa,EAAE,OAAoB;QAC1C,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;QAC9B,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACvC,eAAU,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,8BAA8B,CAAC,CAAA;IAC7D,CAAC;IAED,8DAA8D;IAC9D,KAAK,CAAC,IAAI,CAAC,KAAa,EAAE,IAAS;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QACzC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAA;QAEjD,MAAM,OAAO,GAAc,EAAE,CAAA;QAE7B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;gBAClC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;YACtB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,2BAA2B,CAAC,CAAA;gBAC7D,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACpB,CAAC;QACH,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;IAED,6EAA6E;IAC7E,SAAS,CAAC,KAAa;QACrB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IAC7B,CAAC;IAED,kDAAkD;IAClD,UAAU;QACR,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IACzC,CAAC;CACF;AAxCD,0BAwCC;AAEY,QAAA,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA"}
@@ -0,0 +1,53 @@
import { logger as rootLogger } from '../config/logger'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HookHandler = (data: any) => Promise<any>
/**
* 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.
*/
export class HookBus {
private handlers = new Map<string, HookHandler[]>()
register(event: string, handler: HookHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, [])
}
this.handlers.get(event)!.push(handler)
rootLogger.debug({ event }, '[HookBus] Handler registrado')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async emit(event: string, data: any): Promise<any[]> {
const handlers = this.handlers.get(event)
if (!handlers || handlers.length === 0) return []
const results: unknown[] = []
for (const handler of handlers) {
try {
const result = await handler(data)
results.push(result)
} catch (err) {
rootLogger.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: string): void {
this.handlers.delete(event)
}
/** Lista eventos registrados — útil para debug */
listEvents(): string[] {
return Array.from(this.handlers.keys())
}
}
export const hookBus = new HookBus()
@@ -0,0 +1,52 @@
"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();
//# sourceMappingURL=plugin-config.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin-config.js","sourceRoot":"","sources":["plugin-config.ts"],"names":[],"mappings":";;;AAAA,wDAAoD;AACpD,6CAAuD;AAEvD,MAAM,UAAU,GAAG,gBAAgB,CAAA;AACnC,mDAAmD;AACnD,MAAM,WAAW,GAAG,GAAG,GAAG,EAAE,GAAG,IAAI,CAAA;AAEnC;;;;GAIG;AACH,MAAa,iBAAiB;IAA9B;QACU,UAAK,GAAG,IAAI,GAAG,EAAmC,CAAA;IAsC5D,CAAC;IApCC,8DAA8D;IAC9D,qEAAqE;IACrE,GAAG,CAAC,UAAkB;QACpB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAA;IACzC,CAAC;IAED,2DAA2D;IAC3D,8DAA8D;IAC9D,KAAK,CAAC,GAAG,CAAC,UAAkB,EAAE,MAA2B;QACvD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;QAClC,MAAM,qBAAS,CAAC,OAAO,CAAC,GAAG,UAAU,GAAG,UAAU,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,CAAA;QAC1E,eAAU,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,EAAE,6BAA6B,CAAC,CAAA;IACjE,CAAC;IAED,wEAAwE;IACxE,KAAK,CAAC,OAAO,CAAC,WAAqB;QACjC,MAAM,OAAO,CAAC,GAAG,CACf,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YAC7B,MAAM,MAAM,GAAG,MAAM,qBAAS,CAAC,OAAO,CAA0B,GAAG,UAAU,GAAG,IAAI,EAAE,CAAC,CAAA;YACvF,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;gBAC5B,eAAU,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,EAAE,8CAA8C,CAAC,CAAA;YAC5E,CAAC;QACH,CAAC,CAAC,CACH,CAAA;IACH,CAAC;IAED,6DAA6D;IAC7D,8DAA8D;IAC9D,YAAY;QACV,MAAM,MAAM,GAAwC,EAAE,CAAA;QACtD,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAA;QACvB,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;CACF;AAvCD,8CAuCC;AAEY,QAAA,YAAY,GAAG,IAAI,iBAAiB,EAAE,CAAA"}
@@ -0,0 +1,54 @@
import { dragonfly } from '../infra/cache/dragonfly'
import { logger as rootLogger } from '../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.
*/
export class PluginConfigStore {
private cache = new Map<string, Record<string, unknown>>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** Lê config de um plugin. Retorna {} se não houver config salva. */
get(pluginName: string): Record<string, any> {
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: string, config: Record<string, any>): Promise<void> {
this.cache.set(pluginName, config)
await dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS)
rootLogger.debug({ pluginName }, '[PluginConfig] Config salva')
}
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
async loadAll(pluginNames: string[]): Promise<void> {
await Promise.all(
pluginNames.map(async (name) => {
const stored = await dragonfly.getJson<Record<string, unknown>>(`${KEY_PREFIX}${name}`)
if (stored) {
this.cache.set(name, stored)
rootLogger.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(): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {}
for (const [name, config] of this.cache.entries()) {
result[name] = config
}
return result
}
}
export const pluginConfig = new PluginConfigStore()
@@ -0,0 +1,119 @@
import path from 'path'
import fs from 'fs'
import { logger as rootLogger } from '../config/logger'
import type { PluginInstance, PluginManifest } from './types'
export interface LoadedPlugin {
instance: PluginInstance
manifest: PluginManifest
dir: string
}
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
? '/app/plugins'
: path.resolve(__dirname, '../../../plugins')
/**
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
*/
function topoSort(plugins: LoadedPlugin[]): LoadedPlugin[] {
const byName = new Map(plugins.map((p) => [p.manifest.name, p]))
const inDegree = new Map<string, number>()
const dependents = new Map<string, string[]>()
for (const p of plugins) {
inDegree.set(p.manifest.name, 0)
dependents.set(p.manifest.name, [])
}
for (const p of plugins) {
for (const dep of p.manifest.dependencies) {
if (!byName.has(dep)) {
rootLogger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada')
continue
}
dependents.get(dep)!.push(p.manifest.name)
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1)
}
}
const queue = plugins
.filter((p) => inDegree.get(p.manifest.name) === 0)
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1))
const sorted: LoadedPlugin[] = []
while (queue.length > 0) {
const current = queue.shift()!
sorted.push(current)
for (const depName of dependents.get(current.manifest.name) ?? []) {
const newDegree = (inDegree.get(depName) ?? 1) - 1
inDegree.set(depName, newDegree)
if (newDegree === 0) {
queue.push(byName.get(depName)!)
}
}
}
if (sorted.length !== plugins.length) {
rootLogger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original')
return plugins
}
return sorted
}
/**
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
* Retorna plugins ordenados por dependência.
*/
export async function loadPlugins(): Promise<LoadedPlugin[]> {
if (!fs.existsSync(PLUGINS_DIR)) {
rootLogger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado')
return []
}
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name)
const loaded: LoadedPlugin[] = []
for (const dirName of pluginDirs) {
const pluginDir = path.join(PLUGINS_DIR, dirName)
const manifestPath = path.join(pluginDir, 'manifest.json')
const indexTs = path.join(pluginDir, 'index.ts')
const indexJs = path.join(pluginDir, 'index.js')
if (!fs.existsSync(manifestPath)) {
rootLogger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando')
continue
}
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
const entryPoint = fs.existsSync(indexJs) ? indexJs : fs.existsSync(indexTs) ? indexTs : null
if (!entryPoint) {
rootLogger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando')
continue
}
try {
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const mod = await import(entryPoint)
const instance: PluginInstance = mod.default ?? mod
if (typeof instance.activate !== 'function') {
rootLogger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando')
continue
}
loaded.push({ instance, manifest, dir: pluginDir })
rootLogger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado')
} catch (err) {
rootLogger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin')
}
}
return topoSort(loaded)
}
@@ -0,0 +1,122 @@
import { logger as rootLogger } from '../config/logger'
import type { PluginContext, PluginInstance, PluginManifest } from './types'
import type { LoadedPlugin } from './plugin-loader'
export interface RegistryEntry {
instance: PluginInstance
manifest: PluginManifest
active: boolean
dir: string
}
/**
* Registry central dos plugins.
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
*/
class PluginRegistry {
private entries = new Map<string, RegistryEntry>()
private ctx!: PluginContext
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
setContext(ctx: PluginContext): void {
this.ctx = ctx
}
/** Registra plugins carregados (sem ativar ainda) */
register(plugins: LoadedPlugin[]): void {
for (const { instance, manifest, dir } of plugins) {
this.entries.set(manifest.name, { instance, manifest, active: false, dir })
}
}
/** Ativa todos os plugins marcados como enabled no manifest.
* Plugins com dependências faltando são pulados com warning (não crasham o servidor). */
async activateAll(): Promise<void> {
for (const [name, entry] of this.entries) {
if (!entry.manifest.enabled) continue
try {
await this.activate(name)
} catch (err: any) {
rootLogger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização')
}
}
}
async activate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
if (entry.active) return
// Verifica dependências
for (const dep of entry.manifest.dependencies) {
const depEntry = this.entries.get(dep)
if (!depEntry?.active) {
throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`)
}
}
try {
await entry.instance.activate(this.ctx)
entry.active = true
entry.manifest.enabled = true
rootLogger.info({ name }, '[PluginRegistry] Plugin ativado')
} catch (err) {
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin')
throw err
}
}
async deactivate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
if (!entry.active) return
if (!entry.manifest.canDisable) throw new Error(`Plugin "${name}" não pode ser desativado`)
// Verifica se algum plugin ativo depende deste
for (const [depName, depEntry] of this.entries) {
if (depEntry.active && depEntry.manifest.dependencies.includes(name)) {
throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`)
}
}
try {
await entry.instance.deactivate(this.ctx)
entry.active = false
entry.manifest.enabled = false
rootLogger.info({ name }, '[PluginRegistry] Plugin desativado')
} catch (err) {
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin')
throw err
}
}
/** Retorna lista de todos os plugins com status para a API admin */
list(): Array<RegistryEntry & { name: string }> {
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }))
}
getEntry(name: string): RegistryEntry | undefined {
return this.entries.get(name)
}
/**
* Força reativação de um plugin sem passar pelo deactivate.
* Útil quando canDisable=false mas a config foi atualizada e precisa ser relida.
*/
async reactivate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
entry.active = false
await this.activate(name)
rootLogger.info({ name }, '[PluginRegistry] Plugin reativado com nova config')
}
/** Retorna menu items de todos os plugins ativos */
getActiveMenuItems(): Array<{ pluginName: string; items: PluginManifest['frontend'] }> {
return Array.from(this.entries.values())
.filter((e) => e.active && e.manifest.frontend?.menuItems?.length)
.map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend }))
}
}
export const pluginRegistry = new PluginRegistry()
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,138 @@
import type { Express } from 'express'
import type { Server as HttpServer } from 'http'
import type { Server as SocketServer } from 'socket.io'
import type { Knex } from 'knex'
import type { PrismaClient } from '@prisma/client'
import type { Logger } from 'pino'
import type { HookBus } from './hook-bus'
import type { PluginConfigStore } from './plugin-config'
// ── Manifest ─────────────────────────────────────────────────────────────────
export interface MenuItem {
id: string
label: string
icon: string
href: string
roles: string[]
order: number
}
export interface Widget {
id: string
component: string
span?: number
roles: string[]
order: number
}
export interface PluginManifest {
name: string
displayName: string
version: string
description: string
author: string
category: 'core' | 'business' | 'integration' | 'utility'
enabled: boolean
canDisable: boolean
dependencies: string[]
backend?: {
routePrefix: string
hasMigrations: boolean
globalMiddleware?: string[]
}
frontend?: {
menuItems: MenuItem[]
widgets?: Widget[]
pages?: Array<{ path: string; component: string; roles: string[] }>
}
hooks?: {
subscribes: string[]
emits: string[]
}
/** Schema de configuração para gerar o formulário no admin */
configSchema?: PluginConfigField[]
}
export interface PluginConfigField {
key: string
label: string
type: 'text' | 'password' | 'select' | 'toggle'
placeholder?: string
description?: string
tooltip?: string
options?: Array<{ label: string; value: string }>
required?: boolean
group?: string
default?: string
}
// ── Context ───────────────────────────────────────────────────────────────────
export interface PluginContext {
/** Express app — para registrar rotas */
app: Express
/** Knex — acesso às tabelas dinâmicas dos plugins */
db: Knex
/** Prisma — acesso ao core do sistema */
prisma: PrismaClient
/** Logger (pino) */
logger: Logger
/** Event bus entre plugins */
hooks: HookBus
/** Config store backed por DragonflyDB */
config: PluginConfigStore
/** Socket.IO — para eventos em tempo real */
io: SocketServer
/** HTTP server nativo — para plugins que precisam interceptar upgrade (WS raw) */
httpServer: HttpServer
}
// ── Instance ──────────────────────────────────────────────────────────────────
export interface PluginInstance {
manifest: PluginManifest
activate(ctx: PluginContext): Promise<void>
deactivate(ctx: PluginContext): Promise<void>
}
// ── Storage ───────────────────────────────────────────────────────────────────
export type StorageCategory =
| 'cdi'
| 'xrays'
| 'documents'
| 'exports'
| 'posts'
| 'cards'
| 'partners'
| 'whatsapp'
| 'media'
export interface StorageUploadPayload {
category: StorageCategory
partnerId?: string
benefitId?: string
postId?: string
whatsappId?: string
/** Identificador do usuário/tenant no sistema — usado para compor o path de mídia WhatsApp */
usuarioSis?: string
/** JID da sessão WhatsApp — usado para compor o path de mídia WhatsApp */
sessionJID?: string
/** Path completo no bucket — quando fornecido, substitui o path gerado automaticamente */
customPath?: string
file: {
originalname: string
buffer: Buffer
mimetype: string
}
}
export interface StorageProviderInterface {
checkHealth(): Promise<boolean>
checkDetailedHealth(): Promise<import('./StorageProvider').StorageHealthStatus>
upload(payload: StorageUploadPayload): Promise<{ provider: string; path: string }>
deletePrefix(prefix: string): Promise<{ deleted: number }>
/** Recupera os bytes de um arquivo pelo seu path relativo no bucket */
getBuffer(path: string): Promise<Buffer | null>
}
@@ -0,0 +1,116 @@
"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, {
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
// Falha imediata quando desconectado — evita fila infinita em memória
enableOfflineQueue: false,
// Reconexão exponencial: 200ms → 400ms → ... → 30s
retryStrategy(times) {
const delay = Math.min(200 * 2 ** times, 30000);
return delay;
},
});
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
this.client.on('ready', () => logger_1.logger.info('DragonflyDB pronto'));
this.client.on('error', (err) => logger_1.logger.error({ msg: 'DragonflyDB erro', code: err.code }));
this.client.on('close', () => logger_1.logger.warn('DragonflyDB conexão fechada — reconectando...'));
}
/** true se o cliente está conectado e pronto */
isAlive() {
return this.client.status === 'ready';
}
async connect() {
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
// retryStrategy cuida da reconexão automática
this.client.connect().catch(() => { });
}
async get(key) {
try {
return await this.client.get(key);
}
catch {
return null;
}
}
async getJson(key) {
try {
const raw = await this.client.get(key);
if (!raw)
return null;
return JSON.parse(raw);
}
catch {
return null;
}
}
async set(key, value, ttlSeconds) {
try {
const ttl = ttlSeconds ?? this.ttl;
if (ttl > 0) {
await this.client.set(key, value, 'EX', ttl);
}
else {
await this.client.set(key, value);
}
}
catch { /* Redis indisponível — ignora silenciosamente */ }
}
async setJson(key, value, ttlSeconds) {
try {
const ttl = ttlSeconds ?? this.ttl;
if (ttl > 0) {
await this.client.set(key, JSON.stringify(value), 'EX', ttl);
}
else {
await this.client.set(key, JSON.stringify(value));
}
}
catch { /* Redis indisponível — ignora silenciosamente */ }
}
async psetex(key, ttlMs, value) {
try {
await this.client.psetex(key, ttlMs, value);
}
catch { }
}
async del(key) {
try {
await this.client.del(key);
}
catch { }
}
async exists(key) {
try {
const count = await this.client.exists(key);
return count > 0;
}
catch {
return false;
}
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel, message) {
try {
await this.client.publish(channel, message);
}
catch { }
}
raw() {
return this.client;
}
}
exports.dragonfly = new DragonflyClient();
//# sourceMappingURL=dragonfly.js.map
@@ -0,0 +1 @@
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,wFAAwF;YACxF,oBAAoB,EAAE,IAAI;YAC1B,gBAAgB,EAAE,KAAK;YACvB,WAAW,EAAE,IAAI;YACjB,sEAAsE;YACtE,kBAAkB,EAAE,KAAK;YACzB,mDAAmD;YACnD,aAAa,CAAC,KAAa;gBACzB,MAAM,KAAK,GAAG,IAAI,CAAC,GAAG,CAAC,GAAG,GAAG,CAAC,IAAI,KAAK,EAAE,KAAM,CAAC,CAAA;gBAChD,OAAO,KAAK,CAAA;YACd,CAAC;SACF,CAAC,CAAA;QAEF,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,uBAAuB,CAAC,CAAC,CAAA;QACrE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,oBAAoB,CAAC,CAAC,CAAA;QAClE,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,kBAAkB,EAAE,IAAI,EAAG,GAAW,CAAC,IAAI,EAAE,CAAC,CAAC,CAAA;QACtG,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAI,GAAG,EAAE,CAAC,eAAM,CAAC,IAAI,CAAC,+CAA+C,CAAC,CAAC,CAAA;IAC/F,CAAC;IAED,gDAAgD;IAChD,OAAO;QACL,OAAO,IAAI,CAAC,MAAM,CAAC,MAAM,KAAK,OAAO,CAAA;IACvC,CAAC;IAED,KAAK,CAAC,OAAO;QACX,8EAA8E;QAC9E,8CAA8C;QAC9C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,GAAmD,CAAC,CAAC,CAAA;IACxF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,OAAO,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;YACtC,IAAI,CAAC,GAAG;gBAAE,OAAO,IAAI,CAAA;YACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;QAC7B,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,IAAI,CAAA;QAAC,CAAC;IACzB,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9C,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,CAAC,CAAA;YACnC,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,IAAI,CAAC;YACH,MAAM,GAAG,GAAG,UAAU,IAAI,IAAI,CAAC,GAAG,CAAA;YAClC,IAAI,GAAG,GAAG,CAAC,EAAE,CAAC;gBACZ,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,GAAG,CAAC,CAAA;YAC9D,CAAC;iBAAM,CAAC;gBACN,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAA;YACnD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC,CAAC,iDAAiD,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW,EAAE,KAAa,EAAE,KAAa;QACpD,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,EAAE,KAAK,EAAE,KAAK,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC9C,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,IAAI,CAAC;YACH,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;YAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;QAClB,CAAC;QAAC,MAAM,CAAC;YAAC,OAAO,KAAK,CAAA;QAAC,CAAC;IAC1B,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,IAAI,CAAC;YAAC,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,CAAC;IAC/D,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
@@ -0,0 +1,102 @@
import Redis from 'ioredis'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
class DragonflyClient {
private client: Redis
private readonly ttl: number
constructor() {
this.ttl = parseInt(env.DRAGONFLY_TTL_SECONDS, 10)
this.client = new Redis(env.DRAGONFLY_URL, {
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
// Falha imediata quando desconectado — evita fila infinita em memória
enableOfflineQueue: false,
// Reconexão exponencial: 200ms → 400ms → ... → 30s
retryStrategy(times: number) {
const delay = Math.min(200 * 2 ** times, 30_000)
return delay
},
})
this.client.on('connect', () => logger.info('DragonflyDB conectado'))
this.client.on('ready', () => logger.info('DragonflyDB pronto'))
this.client.on('error', (err) => logger.error({ msg: 'DragonflyDB erro', code: (err as any).code }))
this.client.on('close', () => logger.warn('DragonflyDB conexão fechada — reconectando...'))
}
/** true se o cliente está conectado e pronto */
isAlive(): boolean {
return this.client.status === 'ready'
}
async connect(): Promise<void> {
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
// retryStrategy cuida da reconexão automática
this.client.connect().catch(() => { /* reconexão em background via retryStrategy */ })
}
async get(key: string): Promise<string | null> {
try { return await this.client.get(key) } catch { return null }
}
async getJson<T>(key: string): Promise<T | null> {
try {
const raw = await this.client.get(key)
if (!raw) return null
return JSON.parse(raw) as T
} catch { return null }
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
try {
const ttl = ttlSeconds ?? this.ttl
if (ttl > 0) {
await this.client.set(key, value, 'EX', ttl)
} else {
await this.client.set(key, value)
}
} catch { /* Redis indisponível — ignora silenciosamente */ }
}
async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
try {
const ttl = ttlSeconds ?? this.ttl
if (ttl > 0) {
await this.client.set(key, JSON.stringify(value), 'EX', ttl)
} else {
await this.client.set(key, JSON.stringify(value))
}
} catch { /* Redis indisponível — ignora silenciosamente */ }
}
async psetex(key: string, ttlMs: number, value: string): Promise<void> {
try { await this.client.psetex(key, ttlMs, value) } catch { }
}
async del(key: string): Promise<void> {
try { await this.client.del(key) } catch { }
}
async exists(key: string): Promise<boolean> {
try {
const count = await this.client.exists(key)
return count > 0
} catch { return false }
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel: string, message: string): Promise<void> {
try { await this.client.publish(channel, message) } catch { }
}
raw(): Redis {
return this.client
}
}
export const dragonfly = new DragonflyClient()
@@ -0,0 +1,14 @@
"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;
}
//# sourceMappingURL=prisma.js.map
@@ -0,0 +1 @@
{"version":3,"file":"prisma.js","sourceRoot":"","sources":["prisma.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAQhC,QAAA,MAAM,GACjB,MAAM,CAAC,QAAQ;IACf,IAAI,qBAAY,CAAC;QACf,GAAG,EACD,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;YACnC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YACzE,CAAC,CAAC,EAAE;KACT,CAAC,CAAA;AAEJ,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,MAAM,CAAC,QAAQ,GAAG,cAAM,CAAA;AAC1B,CAAC"}
@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client'
import { logger } from '../../config/logger'
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined
}
export const prisma =
global.__prisma ??
new PrismaClient({
log:
process.env.NODE_ENV !== 'production'
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
: [],
})
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma
}
@@ -0,0 +1,78 @@
import { Server as SocketIOServer } from 'socket.io'
import type { Server as HttpServer } from 'http'
import jwt from 'jsonwebtoken'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
import { dragonfly } from '../cache/dragonfly'
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
const PRESENCE_TTL = 70
interface SocketUser {
sub: string // userId / tenantId (matches JWT payload)
role: string
}
export function buildSocketServer(httpServer: HttpServer): SocketIOServer {
const io = new SocketIOServer(httpServer, {
cors: {
origin: env.FRONTEND_URL,
credentials: true,
},
transports: ['websocket', 'polling'],
})
// Autenticação por token JWT no handshake
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
if (!token) {
next(new Error('Token não fornecido'))
return
}
try {
const payload = jwt.verify(token, env.JWT_SECRET) as SocketUser
socket.data.userId = payload.sub
socket.data.role = payload.role
next()
} catch {
next(new Error('Token inválido'))
}
})
io.on('connection', (socket) => {
const userId: string = socket.data.userId
logger.debug({ userId, socketId: socket.id }, 'Socket conectado')
// Cliente entra na sala do seu tenant automaticamente
socket.join(`tenant:${userId}`)
// Registra presença do usuário no Dragonfly com TTL
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
// Cliente pode entrar em salas específicas de instância ou chat
socket.on('join:instance', (instanceId: string) => {
socket.join(`instance:${instanceId}`)
})
socket.on('join:chat', (chatId: string) => {
socket.join(`chat:${chatId}`)
})
socket.on('leave:chat', (chatId: string) => {
socket.leave(`chat:${chatId}`)
})
// Heartbeat do frontend a cada 30s — renova TTL de presença
socket.on('user:heartbeat', () => {
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
})
socket.on('disconnect', (reason) => {
logger.debug({ userId, reason }, 'Socket desconectado')
// Remove presença imediatamente ao desconectar
dragonfly.del(`presence:user:${userId}`).catch(() => {})
})
})
return io
}
@@ -0,0 +1,87 @@
/**
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
*/
import { prisma } from '../database/prisma'
import { logger } from '../../config/logger'
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
export async function liberarSlotAgenda(input: {
solicitanteId: string
tituloSlot: string
chatIdSolicitante: string
}): Promise<void> {
logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante')
// Aqui você envia mensagem via WhatsApp avisando o solicitante
// Implementação real acoplada ao WhatsAppConnectionManager via NATS
// Por ora, marca no banco como disponível
await prisma.protocol.updateMany({
where: {
contactId: input.solicitanteId,
status: 'WAITING_CLIENT',
},
data: { status: 'OPEN' },
})
}
export async function notificarConflito(input: {
chatIdB: string
solicitanteNome: string
tituloSlot: string
}): Promise<void> {
logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot')
// Dispara mensagem via NATS → WhatsApp handler
}
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
export async function decrementarScore(input: {
contactId: string
motivo: string
pontos: number
}): Promise<{ novoScore: number; restrito: boolean }> {
const contact = await prisma.contact.findUnique({
where: { id: input.contactId },
})
if (!contact) throw new Error(`Contato ${input.contactId} não encontrado`)
const novoScore = Math.max(0, contact.scoreReputacao - input.pontos)
const restrito = novoScore <= 20
await prisma.contact.update({
where: { id: input.contactId },
data: {
scoreReputacao: novoScore,
flagRestricao: restrito,
},
})
logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo },
'[Temporal] Score de reputação atualizado')
return { novoScore, restrito }
}
export async function bloquearAgendamentosAutomaticos(input: {
contactId: string
}): Promise<void> {
await prisma.contact.update({
where: { id: input.contactId },
data: { flagRestricao: true },
})
logger.warn({ contactId: input.contactId },
'[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados')
}
export async function buscarScoreContato(contactId: string): Promise<{
score: number
restrito: boolean
}> {
const c = await prisma.contact.findUnique({
where: { id: contactId },
select: { scoreReputacao: true, flagRestricao: true },
})
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false }
}
@@ -0,0 +1,19 @@
import { Client, Connection } from '@temporalio/client'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
let _client: Client | null = null
export async function getTemporalClient(): Promise<Client> {
if (_client) return _client
const connection = await Connection.connect({ address: env.TEMPORAL_ADDRESS })
_client = new Client({
connection,
namespace: env.TEMPORAL_NAMESPACE,
})
logger.info({ address: env.TEMPORAL_ADDRESS }, 'Temporal Client conectado')
return _client
}
@@ -0,0 +1,25 @@
/**
* Worker do Temporal — roda como processo separado.
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
*/
import { Worker } from '@temporalio/worker'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
import * as activities from './activities'
async function runWorker() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: env.TEMPORAL_TASK_QUEUE,
namespace: env.TEMPORAL_NAMESPACE,
})
logger.info({ taskQueue: env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado')
await worker.run()
}
runWorker().catch((err) => {
logger.error(err, 'Falha fatal no Temporal Worker')
process.exit(1)
})
@@ -0,0 +1,76 @@
/**
* Workflow: Conflito de Agenda (1 Hora)
*
* Regra de negócio:
* - Pessoa A solicita o slot que Pessoa B já tem PENDENTE.
* - Sistema marca o conflito como WAITING_B e aguarda 1 hora.
* - Se B confirmar dentro de 1h → slot permanece com B, A é notificada.
* - Se B não responder em 1h → slot é liberado e transferido para A.
*/
import {
proxyActivities,
sleep,
defineSignal,
setHandler,
condition,
log,
} from '@temporalio/workflow'
import type * as acts from '../activities'
const {
liberarSlotAgenda,
notificarConflito,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
})
// Signal enviado quando B confirma o slot
export const confirmarSlotSignal = defineSignal<[{ confirmado: boolean }]>('confirmarSlot')
export interface ConflitoAgendaInput {
solicitanteId: string // Pessoa A
detentorId: string // Pessoa B
chatIdSolicitante: string
chatIdDetentor: string
tituloSlot: string // ex: "Consulta 14h do dia 10/04"
}
export async function conflitoAgendaWorkflow(input: ConflitoAgendaInput): Promise<{
resultado: 'MANTIDO_B' | 'LIBERADO_A'
}> {
let bConfirmou = false
// Registra o handler do signal de confirmação de B
setHandler(confirmarSlotSignal, ({ confirmado }) => {
bConfirmou = confirmado
log.info('Signal recebido de B', { confirmado })
})
// Notifica B sobre a disputa
await notificarConflito({
chatIdB: input.chatIdDetentor,
solicitanteNome: input.solicitanteId,
tituloSlot: input.tituloSlot,
})
log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot })
// Aguarda signal de B OU timeout de 1 hora
const bRespondeu = await condition(() => bConfirmou !== false, '1 hour')
if (bRespondeu && bConfirmou) {
log.info('B confirmou o slot — slot mantido com B')
return { resultado: 'MANTIDO_B' }
}
// B não respondeu ou recusou → libera para A
log.info('B não confirmou em 1 hora — liberando slot para A')
await liberarSlotAgenda({
solicitanteId: input.solicitanteId,
tituloSlot: input.tituloSlot,
chatIdSolicitante: input.chatIdSolicitante,
})
return { resultado: 'LIBERADO_A' }
}
@@ -0,0 +1,2 @@
export { conflitoAgendaWorkflow, confirmarSlotSignal } from './conflitoAgendaWorkflow'
export { reputacaoWorkflow, registrarInfracaoSignal, encerrarMonitoramentoSignal } from './reputacaoWorkflow'
@@ -0,0 +1,83 @@
/**
* Workflow: Gestão de Reputação de Contato
*
* Regra de negócio:
* - Cada falta/infração reduz o score_reputacao do contato.
* - Score ≤ 20 → contato entra em flag_restricao = true.
* - Agendamentos automáticos são bloqueados para contatos RESTRITOS.
* - Score é recuperável manualmente pelo agente (fora deste workflow).
*/
import {
proxyActivities,
defineSignal,
setHandler,
condition,
log,
} from '@temporalio/workflow'
import type * as acts from '../activities'
const {
decrementarScore,
bloquearAgendamentosAutomaticos,
buscarScoreContato,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
})
// Signal para registrar uma nova infração
export const registrarInfracaoSignal = defineSignal<[{
motivo: string
pontos: number
}]>('registrarInfracao')
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
export const encerrarMonitoramentoSignal = defineSignal('encerrarMonitoramento')
export interface ReputacaoInput {
contactId: string
tenantId: string
}
export async function reputacaoWorkflow(input: ReputacaoInput): Promise<void> {
let encerrado = false
const infracoesPendentes: Array<{ motivo: string; pontos: number }> = []
setHandler(registrarInfracaoSignal, (payload) => {
log.info('Infração registrada via signal', payload)
infracoesPendentes.push(payload)
})
setHandler(encerrarMonitoramentoSignal, () => {
encerrado = true
})
// Loop durável — o Temporal mantém o estado mesmo após reinícios
while (!encerrado) {
// Aguarda próxima infração ou encerramento
await condition(() => infracoesPendentes.length > 0 || encerrado)
if (encerrado) break
const infracao = infracoesPendentes.shift()!
const { novoScore, restrito } = await decrementarScore({
contactId: input.contactId,
motivo: infracao.motivo,
pontos: infracao.pontos,
})
log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId })
if (restrito) {
await bloquearAgendamentosAutomaticos({ contactId: input.contactId })
log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
contactId: input.contactId,
scoreAtual: novoScore,
})
// Continua monitorando (restrição é revertida manualmente pelo agente)
}
}
log.info('Monitoramento de reputação encerrado', { contactId: input.contactId })
}
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.authMiddleware = exports.authenticate = void 0;
// Re-export do middleware de autenticação para compatibilidade com plugins
var auth_middleware_1 = require("../shared/middlewares/auth.middleware");
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_middleware_1.authMiddleware; } });
var auth_middleware_2 = require("../shared/middlewares/auth.middleware");
Object.defineProperty(exports, "authMiddleware", { enumerable: true, get: function () { return auth_middleware_2.authMiddleware; } });
//# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":";;;AAAA,2EAA2E;AAC3E,yEAAsF;AAA7E,+GAAA,cAAc,OAAgB;AACvC,yEAAsE;AAA7D,iHAAA,cAAc,OAAA"}
@@ -0,0 +1,3 @@
// Re-export do middleware de autenticação para compatibilidade com plugins
export { authMiddleware as authenticate } from '../shared/middlewares/auth.middleware'
export { authMiddleware } from '../shared/middlewares/auth.middleware'
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.requireRole = requireRole;
// Stub de RBAC — aceita qualquer role válida
function requireRole(..._roles) {
return (_req, _res, next) => next();
}
//# sourceMappingURL=rbac.js.map
@@ -0,0 +1 @@
{"version":3,"file":"rbac.js","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":";;AAGA,kCAEC;AAHD,6CAA6C;AAC7C,SAAgB,WAAW,CAAC,GAAG,MAAgB;IAC7C,OAAO,CAAC,IAAa,EAAE,IAAc,EAAE,IAAkB,EAAE,EAAE,CAAC,IAAI,EAAE,CAAA;AACtE,CAAC"}
@@ -0,0 +1,6 @@
import type { Request, Response, NextFunction } from 'express'
// Stub de RBAC — aceita qualquer role válida
export function requireRole(..._roles: string[]) {
return (_req: Request, _res: Response, next: NextFunction) => next()
}
@@ -0,0 +1,93 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import crypto from 'crypto'
export function buildApiKeyRoutes(): Router {
const router = Router()
// GET /api/api-keys
router.get('/', async (req: Request, res: Response) => {
const keys = await prisma.apiKey.findMany({
where: { tenantId: req.tenantId! },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
key: true,
isActive: true,
createdAt: true,
expiresAt: true,
},
})
// Mask key: show only last 8 chars
const masked = keys.map((k) => ({
...k,
keyPreview: `••••••••${k.key.slice(-8)}`,
key: undefined,
}))
res.json(masked)
})
// POST /api/api-keys — cria nova chave
router.post('/', async (req: Request, res: Response) => {
const schema = z.object({
name: z.string().min(1).max(80),
expiresAt: z.string().datetime().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const key = `nw_${crypto.randomBytes(24).toString('hex')}`
const created = await prisma.apiKey.create({
data: {
tenantId: req.tenantId!,
name: body.name,
key,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
},
})
// Retorna a chave completa apenas uma vez
res.status(201).json({
id: created.id,
name: created.name,
key: created.key, // plaintext — única vez
isActive: created.isActive,
createdAt: created.createdAt,
expiresAt: created.expiresAt,
})
})
// PATCH /api/api-keys/:id — ativar/desativar
router.patch('/:id', async (req: Request, res: Response) => {
const id = req.params['id'] as string
const schema = z.object({ isActive: z.boolean() })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
const updated = await prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } })
res.json({ id: updated.id, name: updated.name, isActive: updated.isActive })
})
// DELETE /api/api-keys/:id
router.delete('/:id', async (req: Request, res: Response) => {
const id = req.params['id'] as string
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
await prisma.apiKey.delete({ where: { id } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,392 @@
/**
* Rotas exclusivas do ADMIN (dono do SaaS).
* Prefixo: /api/admin/
*/
import path from 'path'
import fs from 'fs/promises'
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import bcrypt from 'bcryptjs'
import multer from 'multer'
import si from 'systeminformation'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { pluginsRouter } from '../plugins/plugins.routes'
const SETTINGS_KEY = 'system:settings'
const DEFAULT_SETTINGS = {
systemName: 'NewWhats',
timezone: 'America/Sao_Paulo',
logoUrl: null as string | null,
faviconUrl: null as string | null,
accentColor: '#3b82f6',
sounds: { newMessage: true, notification: true, connectionStatus: true },
smtp: { host: '', port: 587, user: '', password: '', from: '', secure: false },
emailTemplates: { welcome: '', passwordReset: '', trialExpiring: '' },
registration: { mode: 'open' as 'open' | 'closed' | 'invite', defaultPlanId: null as string | null },
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
webhook: { url: '', secret: '' },
}
async function getSettings() {
const stored = await dragonfly.getJson<typeof DEFAULT_SETTINGS>(SETTINGS_KEY)
return { ...DEFAULT_SETTINGS, ...stored }
}
const uploadAsset = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 2 * 1024 * 1024 }, // 2 MB
fileFilter: (_req, file, cb) => {
if (file.mimetype.startsWith('image/')) cb(null, true)
else cb(new Error('Apenas imagens são permitidas'))
},
})
export const adminRouter = Router()
// ── Plugins ───────────────────────────────────────────────────────────────────
adminRouter.use('/plugins', pluginsRouter)
// ── Plans ─────────────────────────────────────────────────────────────────────
adminRouter.get('/plans', async (_req: Request, res: Response) => {
try {
const plans = await prisma.plan.findMany({
select: { id: true, name: true }
})
res.json(plans)
} catch {
res.status(500).json({ error: 'Erro ao listar planos' })
}
})
// ── Settings ──────────────────────────────────────────────────────────────────
adminRouter.get('/settings', async (_req: Request, res: Response) => {
try {
const settings = await getSettings()
// Nunca expõe a senha SMTP ao frontend
const { smtp, ...rest } = settings
res.json({ ...rest, smtp: { ...smtp, password: smtp.password ? '••••••••' : '' } })
} catch {
res.status(500).json({ error: 'Erro ao buscar configurações' })
}
})
adminRouter.patch('/settings', async (req: Request, res: Response) => {
try {
const current = await getSettings()
const body = req.body as Partial<typeof DEFAULT_SETTINGS>
// Se o frontend devolver o placeholder de senha, mantém a senha atual
if (body.smtp?.password === '••••••••') {
body.smtp = { ...body.smtp, password: current.smtp.password }
}
const updated = { ...current, ...body }
await dragonfly.setJson(SETTINGS_KEY, updated, 0) // TTL 0 = sem expiração
res.json({ ok: true })
} catch {
res.status(500).json({ error: 'Erro ao salvar configurações' })
}
})
// Upload de logo
adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req: Request, res: Response) => {
try {
if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return }
const ext = req.file.originalname.split('.').pop() ?? 'png'
const dest = path.resolve('./media/system/logo.' + ext)
await fs.writeFile(dest, req.file.buffer)
const url = `/media/system/logo.${ext}`
const settings = await getSettings()
await dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0)
res.json({ url })
} catch {
res.status(500).json({ error: 'Erro ao salvar logo' })
}
})
// Upload de favicon
adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req: Request, res: Response) => {
try {
if (!req.file) { res.status(400).json({ error: 'Arquivo não enviado' }); return }
const ext = req.file.originalname.split('.').pop() ?? 'ico'
const dest = path.resolve('./media/system/favicon.' + ext)
await fs.writeFile(dest, req.file.buffer)
const url = `/media/system/favicon.${ext}`
const settings = await getSettings()
await dragonfly.setJson(SETTINGS_KEY, { ...settings, faviconUrl: url }, 0)
res.json({ url })
} catch {
res.status(500).json({ error: 'Erro ao salvar favicon' })
}
})
// Endpoint público para o frontend ler as settings de branding (sem autenticação)
// Usado pelo _app.tsx para aplicar nome/logo/favicon sem precisar do token
adminRouter.get('/settings/public', async (_req: Request, res: Response) => {
try {
const { systemName, logoUrl, faviconUrl, accentColor, maintenance } = await getSettings()
res.json({ systemName, logoUrl, faviconUrl, accentColor, maintenance })
} catch {
res.status(500).json({ error: 'Erro' })
}
})
// ── Métricas do sistema ───────────────────────────────────────────────────────
adminRouter.get('/metrics', async (_req: Request, res: Response) => {
try {
const [cpuLoad, mem, disk] = await Promise.all([
si.currentLoad(),
si.mem(),
si.fsSize(),
])
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0]
const [connectedInstances, totalUsers] = await Promise.all([
prisma.instance.count({ where: { status: 'CONNECTED' } }),
prisma.user.count(),
])
res.json({
cpu: { percent: Math.round(cpuLoad.currentLoad) },
ram: {
total: mem.total,
used: mem.used,
percent: Math.round((mem.used / mem.total) * 100),
},
disk: {
total: rootDisk?.size ?? 0,
used: rootDisk?.used ?? 0,
percent: Math.round(rootDisk?.use ?? 0),
},
uptime: Math.floor(process.uptime()),
connectedInstances,
totalUsers,
})
} catch {
res.status(500).json({ error: 'Erro ao coletar métricas' })
}
})
// Listar todos os tenants com presença online
adminRouter.get('/users', async (_req: Request, res: Response) => {
try {
const [users, presenceKeys] = await Promise.all([
prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
_count: { select: { instances: true } },
},
orderBy: { createdAt: 'desc' },
}),
dragonfly.raw().keys('presence:user:*'),
])
const onlineIds = new Set(presenceKeys.map((k) => k.replace('presence:user:', '')))
res.json(users.map((u) => ({ ...u, online: onlineIds.has(u.id) })))
} catch (err) {
res.status(500).json({ error: 'Erro ao listar usuários' })
}
})
// Detalhes de um tenant
adminRouter.get('/users/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const user = await prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
instances: { select: { id: true, name: true, status: true } },
apiKeys: { select: { id: true, name: true, isActive: true, createdAt: true } },
},
})
if (!user) {
res.status(404).json({ error: 'Usuário não encontrado' })
return
}
res.json(user)
} catch (err) {
res.status(500).json({ error: 'Erro ao buscar usuário' })
}
})
// Ativar / desativar tenant
adminRouter.patch('/users/:id/status', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { isActive } = z.object({ isActive: z.boolean() }).parse(req.body)
const user = await prisma.user.update({
where: { id },
data: { isActive },
select: { id: true, name: true, email: true, isActive: true },
})
res.json(user)
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'isActive deve ser boolean' })
return
}
res.status(500).json({ error: 'Erro ao atualizar status' })
}
})
// Alterar plano do tenant
adminRouter.patch('/users/:id/plan', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { planId } = z.object({ planId: z.string().uuid() }).parse(req.body)
const user = await prisma.user.update({
where: { id },
data: { planId },
select: { id: true, name: true, planId: true },
})
res.json(user)
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'planId inválido' })
return
}
res.status(500).json({ error: 'Erro ao atualizar plano' })
}
})
// Estender trial de um tenant
adminRouter.patch('/users/:id/trial', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { days } = z.object({ days: z.number().int().min(1).max(365) }).parse(req.body)
const user = await prisma.user.update({
where: { id },
data: {
trialEndsAt: new Date(Date.now() + days * 24 * 60 * 60 * 1000),
},
select: { id: true, name: true, trialEndsAt: true },
})
res.json(user)
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'days inválido' })
return
}
res.status(500).json({ error: 'Erro ao estender trial' })
}
})
// Resetar senha de um tenant (admin)
adminRouter.patch('/users/:id/reset-password', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { newPassword } = z
.object({ newPassword: z.string().min(8).max(128) })
.parse(req.body)
const passwordHash = await bcrypt.hash(newPassword, 12)
await prisma.user.update({ where: { id }, data: { passwordHash } })
res.json({ message: 'Senha redefinida com sucesso' })
} catch (err) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'newPassword inválido' })
return
}
res.status(500).json({ error: 'Erro ao redefinir senha' })
}
})
// Obter engine ativa
adminRouter.get('/engine', async (_req: Request, res: Response) => {
try {
const settings = await getSettings()
const storedEngine = (settings as any).baileysEngine ?? process.env.BAILEYS_ENGINE ?? 'infinite'
res.json({ engine: storedEngine })
} catch (err) {
console.error('[Admin] Erro ao obter engine:', err)
res.status(500).json({ error: 'Erro ao obter a engine ativa' })
}
})
// Altera a engine e reinicia o backend (via PM2 ou Docker)
adminRouter.post('/engine', async (req: Request, res: Response) => {
try {
const { engine } = z.object({ engine: z.enum(['infinite', 'official']) }).parse(req.body)
// 1. Persiste nas configurações globais
const settings = await getSettings()
await dragonfly.setJson(SETTINGS_KEY, { ...settings, baileysEngine: engine }, 0)
// 2. Escreve a alteração no arquivo .env para que persista no próximo boot
const envPath = path.resolve('./.env')
try {
let content = await fs.readFile(envPath, 'utf-8')
if (content.includes('BAILEYS_ENGINE=')) {
content = content.replace(/BAILEYS_ENGINE=\w+/g, `BAILEYS_ENGINE=${engine}`)
} else {
content += `\nBAILEYS_ENGINE=${engine}\n`
}
await fs.writeFile(envPath, content, 'utf-8')
} catch (envErr) {
console.error('[Admin] Erro ao gravar BAILEYS_ENGINE no .env:', envErr)
}
// 3. Responde ao frontend antes de desligar/reiniciar
res.json({ ok: true, engine, message: 'Engine configurada. Reiniciando o servidor...' })
// 4. Executa o restart após uma breve janela para dar tempo do client receber a resposta HTTP 200 OK
setTimeout(() => {
console.info(`[Admin] Alternando engine para "${engine}". Reiniciando processo...`)
const { exec } = require('child_process')
exec('pm2 restart newwhats-backend', (err: any) => {
if (err) {
console.warn('[Admin] PM2 não disponível ou falhou ao reiniciar. Saindo com process.exit(0) para restart automático via Docker...')
process.exit(0)
}
})
}, 1500)
} catch (err) {
console.error('[Admin] Erro ao alternar engine:', err)
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' })
return
}
res.status(500).json({ error: 'Erro ao salvar a engine' })
}
})
// ── Rota para Disparo Manual do Deploy ──
adminRouter.post('/deploys/trigger', async (_req: Request, res: Response) => {
try {
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trigger: 'admin_panel' })
})
if (!response.ok) throw new Error(`Status ${response.status}`)
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' })
} catch (err: any) {
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message)
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' })
}
})
@@ -0,0 +1,60 @@
import { prisma } from '../../infra/database/prisma'
import type { UserRole } from '@prisma/client'
export class AuthRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({ where: { email } })
}
async findById(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
},
})
}
async create(data: {
name: string
email: string
passwordHash: string
role?: UserRole
planId?: string
}) {
return prisma.user.create({
data: {
name: data.name,
email: data.email,
passwordHash: data.passwordHash,
role: data.role ?? 'USER',
planId: data.planId,
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial
},
select: {
id: true,
name: true,
email: true,
role: true,
planId: true,
trialEndsAt: true,
createdAt: true,
},
})
}
async updatePassword(id: string, passwordHash: string) {
return prisma.user.update({ where: { id }, data: { passwordHash } })
}
async setActive(id: string, isActive: boolean) {
return prisma.user.update({ where: { id }, data: { isActive } })
}
}
@@ -0,0 +1,127 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { AuthService } from './auth.service'
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
const service = new AuthService()
// ─── Schemas de validação ─────────────────────────────────────────────────────
const registerSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
planId: z.string().uuid().optional(),
})
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
const refreshSchema = z.object({
refreshToken: z.string().min(1),
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
})
// ─── Helper para erros de validação / serviço ─────────────────────────────────
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
const e = err as Error & { statusCode?: number }
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const authRouter = Router()
/**
* POST /api/auth/register
* Cria novo tenant (usuário com role USER).
* Retorna access + refresh token e dados do usuário.
*/
authRouter.post('/register', async (req: Request, res: Response) => {
try {
const body = registerSchema.parse(req.body) as any
const result = await service.register(body)
res.status(201).json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/login
* Autentica usuário. Retorna access + refresh token.
*/
authRouter.post('/login', async (req: Request, res: Response) => {
try {
const body = loginSchema.parse(req.body) as any
const result = await service.login(body)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/refresh
* Renova o access token usando o refresh token.
*/
authRouter.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = refreshSchema.parse(req.body)
const result = await service.refresh(refreshToken)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/logout
* Revoga o refresh token do usuário autenticado.
*/
authRouter.post('/logout', authMiddleware, async (req: Request, res: Response) => {
try {
await service.logout(req.tenantId!)
res.json({ message: 'Sessão encerrada' })
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/auth/me
* Retorna dados do usuário autenticado + dias restantes do trial.
*/
authRouter.get('/me', authMiddleware, async (req: Request, res: Response) => {
try {
const user = await service.me(req.tenantId!)
res.json(user)
} catch (err) {
handleError(res, err)
}
})
/**
* PATCH /api/auth/password
* Altera a senha do usuário autenticado.
*/
authRouter.patch('/password', authMiddleware, async (req: Request, res: Response) => {
try {
const body = changePasswordSchema.parse(req.body) as any
await service.changePassword(req.tenantId!, body)
res.json({ message: 'Senha alterada com sucesso' })
} catch (err) {
handleError(res, err)
}
})
@@ -0,0 +1,176 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { env } from '../../config/env'
import { AuthRepository } from './auth.repository'
import { dragonfly } from '../../infra/cache/dragonfly'
const BCRYPT_ROUNDS = 12
const ACCESS_EXPIRES = '8h'
const REFRESH_EXPIRES = '30d'
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60
interface TokenPair {
accessToken: string
refreshToken: string
}
interface JwtPayload {
sub: string
role: string
type: 'access' | 'refresh'
}
export class AuthService {
private repo = new AuthRepository()
// ─── Register ─────────────────────────────────────────────────────────────
async register(input: {
name: string
email: string
password: string
planId?: string
}) {
const existing = await this.repo.findByEmail(input.email)
if (existing) {
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 })
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS)
const user = await this.repo.create({
name: input.name,
email: input.email,
passwordHash,
planId: input.planId,
})
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
return { user, ...tokens }
}
// ─── Login ────────────────────────────────────────────────────────────────
async login(input: { email: string; password: string }) {
const user = await this.repo.findByEmail(input.email)
if (!user) {
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
}
if (!user.isActive) {
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
}
const valid = await bcrypt.compare(input.password, user.passwordHash)
if (!valid) {
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
}
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
const { passwordHash: _, ...safeUser } = user
return { user: safeUser, ...tokens }
}
// ─── Refresh ──────────────────────────────────────────────────────────────
async refresh(refreshToken: string) {
let payload: JwtPayload
try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as JwtPayload
} catch {
throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 })
}
if (payload.type !== 'refresh') {
throw Object.assign(new Error('Token incorreto'), { statusCode: 401 })
}
// Valida que o token ainda está no cache (não foi revogado)
const stored = await dragonfly.get(this.refreshKey(payload.sub))
if (stored !== refreshToken) {
throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 })
}
const user = await this.repo.findById(payload.sub)
if (!user || !user.isActive) {
throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 })
}
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
return { user, ...tokens }
}
// ─── Logout ───────────────────────────────────────────────────────────────
async logout(userId: string) {
await dragonfly.del(this.refreshKey(userId))
}
// ─── Me ───────────────────────────────────────────────────────────────────
async me(userId: string) {
const user = await this.repo.findById(userId)
if (!user) {
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
}
const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null
const daysRemaining = trialEndsAt
? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86_400_000))
: null
return { ...user, daysRemaining }
}
// ─── Change Password ──────────────────────────────────────────────────────
async changePassword(userId: string, input: { currentPassword: string; newPassword: string }) {
const user = await this.repo.findByEmail(
(await this.repo.findById(userId))?.email ?? ''
)
if (!user) throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
const valid = await bcrypt.compare(input.currentPassword, user.passwordHash)
if (!valid) {
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 })
}
const newHash = await bcrypt.hash(input.newPassword, BCRYPT_ROUNDS)
await this.repo.updatePassword(userId, newHash)
await this.logout(userId) // invalida todas as sessões
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private issueTokens(userId: string, role: string): TokenPair {
const base = { sub: userId, role }
const accessToken = jwt.sign(
{ ...base, type: 'access' },
env.JWT_SECRET,
{ expiresIn: ACCESS_EXPIRES }
)
const refreshToken = jwt.sign(
{ ...base, type: 'refresh' },
env.JWT_SECRET,
{ expiresIn: REFRESH_EXPIRES }
)
return { accessToken, refreshToken }
}
private async saveRefreshToken(userId: string, token: string) {
await dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS)
}
private refreshKey(userId: string): string {
return `auth:refresh:${userId}`
}
}
@@ -0,0 +1,68 @@
"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 },
}),
};
//# sourceMappingURL=chatbot.repository.js.map
@@ -0,0 +1 @@
{"version":3,"file":"chatbot.repository.js","sourceRoot":"","sources":["chatbot.repository.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACH,wDAAoD;AAEpD,gFAAgF;AAEnE,QAAA,cAAc,GAAG;IAC5B,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,YAAY,CAAC,QAAQ,CAAC;QAC3B,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;QACjE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAMR,EAAE,EAAE,CACH,eAAM,CAAC,YAAY,CAAC,MAAM,CAAC;QACzB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,QAAQ;YACnC,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB;QACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;KAClE,CAAC;IAEJ,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,YAAY,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CAC9D,CAAA;AAED,iFAAiF;AAEpE,QAAA,OAAO,GAAG;IACrB,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QACpB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE;QAC7E,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,WAAW,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE;QAC9C,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,QAAQ,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACzC,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;QACvB,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAUR,EAAE,EAAE,CACH,eAAM,CAAC,KAAK,CAAC,MAAM,CAAC;QAClB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,kBAAkB;YACvC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,KAAK;YAC9B,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,KAAK;YACtC,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;SAC9B;KACF,CAAC;IAEJ,MAAM,EAAE,CACN,EAAU,EACV,QAAgB,EAChB,IAQE,EACF,EAAE,CACF,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAE5D,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CACvD,CAAA;AAED,iFAAiF;AAEpE,QAAA,YAAY,GAAG;IAC1B,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;IAE1E,SAAS,EAAE,CAAC,MAAc,EAAE,EAAE,CAC5B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC;IAE3E,aAAa,EAAE,CAAC,MAAc,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,EAAE,CAAC;IAErE,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,UAAU,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;QACrB,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9C,CAAC;CACL,CAAA"}
@@ -0,0 +1,121 @@
/**
* chatbot.repository.ts — CRUD para AICredential e AIBot.
*/
import { prisma } from '../../infra/database/prisma'
// ─── Credentials ─────────────────────────────────────────────────────────────
export const credentialRepo = {
findAll: (tenantId: string, instanceId: string) =>
prisma.aICredential.findMany({
where: { tenantId, instanceId },
select: { id: true, name: true, provider: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
create: (data: {
tenantId: string
instanceId: string
name: string
provider?: 'GEMINI' | 'OPENAI'
apiKey: string
}) =>
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: string, tenantId: string) =>
prisma.aICredential.deleteMany({ where: { id, tenantId } }),
}
// ─── Bots ─────────────────────────────────────────────────────────────────────
export const botRepo = {
findAll: (tenantId: string, instanceId: string) =>
prisma.aIBot.findMany({
where: { tenantId, instanceId },
include: { credential: { select: { id: true, name: true, provider: true } } },
orderBy: { createdAt: 'desc' },
}),
findEnabled: (tenantId: string, instanceId: string) =>
prisma.aIBot.findFirst({
where: { tenantId, instanceId, enabled: true },
include: { credential: true },
}),
findById: (id: string, tenantId: string) =>
prisma.aIBot.findFirst({
where: { id, tenantId },
include: { credential: true },
}),
create: (data: {
tenantId: string
instanceId: string
credentialId: string
name: string
systemPrompt: string
model?: string
enabled?: boolean
triggerMode?: 'ALL' | 'KEYWORD'
keywords?: string[]
}) =>
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: string,
tenantId: string,
data: Partial<{
name: string
systemPrompt: string
model: string
enabled: boolean
triggerMode: 'ALL' | 'KEYWORD'
keywords: string[]
credentialId: string
}>
) =>
prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
delete: (id: string, tenantId: string) =>
prisma.aIBot.deleteMany({ where: { id, tenantId } }),
}
// ─── Chat bot state ───────────────────────────────────────────────────────────
export const chatBotState = {
pauseBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false, botSummary: null } }),
updateSummary: (chatId: string, botSummary: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
getState: (chatId: string) =>
prisma.chat.findUnique({
where: { id: chatId },
select: { botPaused: true, botSummary: true },
}),
}
@@ -0,0 +1,199 @@
/**
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
*
* Montado em /api/chatbot (ver server.ts)
* Todos os endpoints requerem authMiddleware.
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { credentialRepo, botRepo, chatBotState } from './chatbot.repository'
import { logger } from '../../config/logger'
export function buildChatbotRoutes(): Router {
const router = Router()
// ── Helpers ──────────────────────────────────────────────────────────────
const validateInstance = async (instanceId: string, tenantId: string) => {
return prisma.instance.findFirst({ where: { id: instanceId, tenantId } })
}
// ─────────────────────────────────────────────────────────────────────────
// CREDENTIALS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/credentials/:instanceId
router.get('/credentials/:instanceId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const creds = await credentialRepo.findAll(tenantId, instanceId)
res.json(creds)
} catch (err: any) {
logger.error({ err }, 'Erro ao listar credenciais')
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/credentials/:instanceId
router.post('/credentials/:instanceId', async (req: Request, res: Response) => {
const schema = z.object({
name: z.string().min(1).max(80),
apiKey: z.string().min(10),
provider: z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
})
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const body = schema.parse(req.body)
const cred = await credentialRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(cred)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar credencial')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/credentials/:instanceId/:credId
router.delete('/credentials/:instanceId/:credId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const credId = req.params['credId'] as string
await credentialRepo.delete(credId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar credencial')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOTS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/bots/:instanceId
router.get('/bots/:instanceId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const bots = await botRepo.findAll(tenantId, instanceId)
res.json(bots)
} catch (err: any) {
logger.error({ err }, 'Erro ao listar bots')
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/bots/:instanceId
router.post('/bots/:instanceId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid(),
name: z.string().min(1).max(80),
systemPrompt: z.string().min(10),
model: z.string().default('gemini-1.5-flash'),
enabled: z.boolean().default(false),
triggerMode: z.enum(['ALL', 'KEYWORD']).default('ALL'),
keywords: z.array(z.string()).default([]),
})
try {
const tenantId = req.tenantId!
const instanceId = req.params['instanceId'] as string
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' }); return
}
const body = schema.parse(req.body)
const existing = await botRepo.findAll(tenantId, instanceId)
if (existing.length > 0) {
res.status(409).json({ error: 'Já existe um bot nesta instância. Use PUT para atualizar.' })
return
}
// SEC-10: garante que a credencial pertence a este tenant
const cred = await prisma.aICredential.findFirst({ where: { id: body.credentialId, tenantId } })
if (!cred) {
res.status(403).json({ error: 'Credencial não encontrada ou não pertence a este tenant.' })
return
}
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(bot)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar bot')
res.status(500).json({ error: err.message })
}
})
// PUT /api/chatbot/bots/:instanceId/:botId
router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid().optional(),
name: z.string().min(1).max(80).optional(),
systemPrompt: z.string().min(10).optional(),
model: z.string().optional(),
enabled: z.boolean().optional(),
triggerMode: z.enum(['ALL', 'KEYWORD']).optional(),
keywords: z.array(z.string()).optional(),
})
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
const body = schema.parse(req.body)
await botRepo.update(botId, tenantId, body as any)
const updated = await botRepo.findById(botId, tenantId)
res.json(updated)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao atualizar bot')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/bots/:instanceId/:botId
router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
await botRepo.delete(botId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar bot')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOT STATE — Human Takeover por chat
// ─────────────────────────────────────────────────────────────────────────
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
router.post('/chats/:chatId/pause', async (req: Request, res: Response) => {
try {
await chatBotState.pauseBot(req.params['chatId'] as string)
res.json({ paused: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
router.post('/chats/:chatId/resume', async (req: Request, res: Response) => {
try {
await chatBotState.resumeBot(req.params['chatId'] as string)
res.json({ paused: false })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
return router
}
@@ -0,0 +1,165 @@
"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;
//# sourceMappingURL=chatbot.service.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,263 @@
/**
* chatbot.service.ts — Motor de IA Multi-Agente (Chatbot Rápido).
*
* 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
*
* Providers suportados (SEC-06/SEC-07):
* - GEMINI → Google Generative AI (via SDK)
* - OPENAI → OpenAI Chat Completions (via fetch)
* O Prisma enum LLMProvider { GEMINI, OPENAI } mapeia diretamente a estes dois.
* A Secretária IA (ProtocolEngine) suporta adicionalmente anthropic e ollama
* via configuração de plugin — divergência intencional, pois os dois sistemas
* têm arquiteturas independentes.
*/
import { GoogleGenerativeAI } from '@google/generative-ai'
import type { WASocket } from '../whatsapp/engine'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import { botRepo, chatBotState } from './chatbot.repository'
import type { Server as SocketIOServer } from 'socket.io'
// ─── Cache de clientes Gemini ─────────────────────────────────────────────────
const geminiClients = new Map<string, GoogleGenerativeAI>()
function getGeminiClient(apiKey: string): GoogleGenerativeAI {
if (!geminiClients.has(apiKey)) {
geminiClients.set(apiKey, new GoogleGenerativeAI(apiKey))
}
return geminiClients.get(apiKey)!
}
// ─── Abstração de provider (SEC-06) ──────────────────────────────────────────
type Provider = 'GEMINI' | 'OPENAI'
interface AiResult {
text: string
inputTokens: number
outputTokens: number
}
async function aiCall(opts: {
provider: Provider
apiKey: string
model: string
systemPrompt: string
userPrompt: string
maxTokens?: number
temperature?: number
}): Promise<AiResult> {
const { provider, apiKey, model, systemPrompt, userPrompt, maxTokens = 300, temperature = 0.7 } = opts
if (provider === 'OPENAI') {
const res = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(25_000),
body: JSON.stringify({
model: model || 'gpt-4o-mini',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
max_tokens: maxTokens,
temperature,
}),
})
const data = (await res.json()) as any
if (!res.ok) throw new Error(data.error?.message ?? `OpenAI ${res.status}`)
return {
text: (data.choices[0].message.content as string).trim(),
inputTokens: data.usage?.prompt_tokens ?? 0,
outputTokens: data.usage?.completion_tokens ?? 0,
}
}
// Default: GEMINI
const geminiModel = getGeminiClient(apiKey).getGenerativeModel({ model: model || 'gemini-1.5-flash' })
const result = await geminiModel.generateContent({
contents: [{ role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}` }] }],
generationConfig: { maxOutputTokens: maxTokens, temperature },
})
const usage = result.response.usageMetadata
return {
text: result.response.text().trim(),
inputTokens: usage?.promptTokenCount ?? 0,
outputTokens: usage?.candidatesTokenCount ?? 0,
}
}
// ─── Classificação de intenção ────────────────────────────────────────────────
async function classifyIntent(opts: {
provider: Provider; apiKey: string; model: string
userMsg: string; summary: string | null; systemPrompt: string
}): Promise<'resolve' | 'escalate'> {
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
const userPrompt = [
`Você é um classificador de intenção para um chatbot de atendimento.`,
`Prompt do atendente: ${systemPrompt}`,
summary ? `Contexto da conversa: ${summary}` : '',
`Nova mensagem do cliente: "${userMsg}"`,
`Responda APENAS com o número: 1 = resolver, 2 = escalar humano`,
].filter(Boolean).join('\n')
try {
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 2, temperature: 0 })
return text.startsWith('2') ? 'escalate' : 'resolve'
} catch (err) {
logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
return 'resolve'
}
}
// ─── Geração de resposta ──────────────────────────────────────────────────────
async function generateResponse(opts: {
provider: Provider; apiKey: string; model: string
userMsg: string; summary: string | null; systemPrompt: string
}): Promise<AiResult> {
const { provider, apiKey, model, userMsg, summary, systemPrompt } = opts
const userPrompt = [
summary ? `Contexto da conversa até agora:\n${summary}` : '',
`Mensagem do cliente: "${userMsg}"`,
`Responda de forma natural, breve e direta. Não use markdown.`,
].filter(Boolean).join('\n')
return aiCall({ provider, apiKey, model, systemPrompt, userPrompt, maxTokens: 300, temperature: 0.7 })
}
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
async function updateSummary(opts: {
provider: Provider; apiKey: string; model: string
currentSummary: string | null; userMsg: string; botMsg: string
}): Promise<string> {
const { provider, apiKey, model, currentSummary, userMsg, botMsg } = opts
const userPrompt = [
currentSummary ? `Resumo atual: ${currentSummary}` : 'Primeira troca da conversa.',
`Nova troca — Cliente: "${userMsg}" / Assistente: "${botMsg}"`,
`Atualize o resumo em MÁXIMO 2 frases. Responda só com o resumo, sem introdução.`,
].join('\n')
try {
const { text } = await aiCall({ provider, apiKey, model, systemPrompt: '', userPrompt, maxTokens: 80, temperature: 0.3 })
return text
} catch {
return currentSummary ?? ''
}
}
// ─── Interface pública ────────────────────────────────────────────────────────
export class ChatbotService {
constructor(private io: SocketIOServer) {}
/**
* Ponto de entrada para cada mensagem recebida.
* Chamado pelo MessageHandler após persistir a mensagem.
*/
async handleIncoming(opts: {
tenantId: string
instanceId: string
chatId: string
jid: string
text: string
sock: WASocket
}): Promise<void> {
const { tenantId, instanceId, chatId, jid, text, sock } = opts
// 1. Verifica se há bot ativo para esta instância
const bot = await botRepo.findEnabled(tenantId, instanceId)
if (!bot) return
// 2. Verifica o estado do chat (human takeover)
const chatState = await 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
}
const provider = (bot.credential.provider ?? 'GEMINI') as Provider
const { apiKey } = bot.credential
const { model } = bot
const summary = chatState.botSummary ?? null
try {
// 4. Micro-prompt: intenção
const intent = await classifyIntent({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
if (intent === 'escalate') {
await chatBotState.pauseBot(chatId)
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
chatId,
message: 'Bot pausado — atendente humano necessário',
})
logger.info({ chatId, jid }, '[Chatbot] Escalação para humano')
return
}
// 5. Indicador de digitação (SEC-14)
await sock.sendPresenceUpdate('composing', jid).catch(() => {})
// 6. Gera resposta (com fallback para outro provider se falhar)
let result: AiResult
try {
result = await generateResponse({ provider, apiKey, model, userMsg: text, summary, systemPrompt: bot.systemPrompt })
} catch (primaryErr) {
// Fallback: tenta outra credencial da mesma instância com provider diferente
const fallbackCred = await prisma.aICredential.findFirst({
where: { tenantId, instanceId, NOT: { id: bot.credentialId } },
})
if (!fallbackCred) throw primaryErr
logger.warn({ chatId, primaryErr }, '[Chatbot] Provider primário falhou — tentando fallback')
const fbProvider = (fallbackCred.provider ?? 'GEMINI') as Provider
result = await generateResponse({
provider: fbProvider, apiKey: fallbackCred.apiKey, model,
userMsg: text, summary, systemPrompt: bot.systemPrompt,
})
}
// 7. Para indicador de digitação + envia
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
const sent = await sock.sendMessage(jid, { text: result.text })
// 8. Persiste mensagem do bot no banco
const chat = await prisma.chat.findUnique({ where: { id: chatId } })
if (chat) {
const botMsg = await prisma.message.create({
data: {
tenantId,
instanceId,
chatId,
remoteJid: jid,
messageId: sent?.key.id ?? `bot-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: result.text,
status: 'SENT',
timestamp: new Date(),
},
})
this.io.to(`chat:${chatId}`).emit('message:new', botMsg)
}
// 9. Atualiza Cérebro + telemetria (SEC-13)
const newSummary = await updateSummary({ provider, apiKey, model, currentSummary: summary, userMsg: text, botMsg: result.text })
await chatBotState.updateSummary(chatId, newSummary)
logger.info({ chatId, jid, intent, provider, inputTokens: result.inputTokens, outputTokens: result.outputTokens }, '[Chatbot] Resposta enviada')
} catch (err) {
await sock.sendPresenceUpdate('paused', jid).catch(() => {})
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
}
}
}
@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.invalidateChatListCache = invalidateChatListCache;
/**
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
*
* O cache é populado em GET /api/chats com TTL de 30s.
* Esta função invalida todas as variações (archived, limit) para um par
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
*
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
*/
const dragonfly_1 = require("../../infra/cache/dragonfly");
const logger_1 = require("../../config/logger");
/**
* Remove todas as chaves de cache da listagem de chats para um instance.
* Pattern: chats:list:{tenantId}:{instanceId}:*
* Operação fire-and-forget: erros são logados mas não propagados.
*/
async function invalidateChatListCache(tenantId, instanceId) {
if (!dragonfly_1.dragonfly.isAlive())
return;
try {
const client = dragonfly_1.dragonfly.raw();
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
const stream = client.scanStream({ match: pattern, count: 100 });
const keys = [];
await new Promise((resolve, reject) => {
stream.on('data', (batch) => {
for (const k of batch)
keys.push(k);
});
stream.on('end', () => resolve());
stream.on('error', (err) => reject(err));
});
if (keys.length > 0) {
await client.del(...keys);
}
}
catch (err) {
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
}
}
//# sourceMappingURL=chat.cache.js.map
@@ -0,0 +1 @@
{"version":3,"file":"chat.cache.js","sourceRoot":"","sources":["chat.cache.ts"],"names":[],"mappings":";;AAiBA,0DAsBC;AAvCD;;;;;;;;GAQG;AACH,2DAAuD;AACvD,gDAA4C;AAE5C;;;;GAIG;AACI,KAAK,UAAU,uBAAuB,CAAC,QAAgB,EAAE,UAAkB;IAChF,IAAI,CAAC,qBAAS,CAAC,OAAO,EAAE;QAAE,OAAM;IAChC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,qBAAS,CAAC,GAAG,EAAE,CAAA;QAC9B,MAAM,OAAO,GAAG,cAAc,QAAQ,IAAI,UAAU,IAAI,CAAA;QACxD,MAAM,MAAM,GAAG,MAAM,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,CAAC,CAAA;QAChE,MAAM,IAAI,GAAa,EAAE,CAAA;QAEzB,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE;YAC1C,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,KAAe,EAAE,EAAE;gBACpC,KAAK,MAAM,CAAC,IAAI,KAAK;oBAAE,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;YACrC,CAAC,CAAC,CAAA;YACF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,GAAG,EAAE,CAAC,OAAO,EAAE,CAAC,CAAA;YACjC,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAA;QAC1C,CAAC,CAAC,CAAA;QAEF,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YACpB,MAAM,MAAM,CAAC,GAAG,CAAC,GAAG,IAAI,CAAC,CAAA;QAC3B,CAAC;IACH,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,eAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,QAAQ,EAAE,UAAU,EAAE,EAAE,kDAAkD,CAAC,CAAA;IAChG,CAAC;AACH,CAAC"}
@@ -0,0 +1,40 @@
/**
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
*
* O cache é populado em GET /api/chats com TTL de 30s.
* Esta função invalida todas as variações (archived, limit) para um par
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
*
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
*/
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
/**
* Remove todas as chaves de cache da listagem de chats para um instance.
* Pattern: chats:list:{tenantId}:{instanceId}:*
* Operação fire-and-forget: erros são logados mas não propagados.
*/
export async function invalidateChatListCache(tenantId: string, instanceId: string): Promise<void> {
if (!dragonfly.isAlive()) return
try {
const client = dragonfly.raw()
const pattern = `chats:list:${tenantId}:${instanceId}:*`
const stream = client.scanStream({ match: pattern, count: 100 })
const keys: string[] = []
await new Promise<void>((resolve, reject) => {
stream.on('data', (batch: string[]) => {
for (const k of batch) keys.push(k)
})
stream.on('end', () => resolve())
stream.on('error', (err) => reject(err))
})
if (keys.length > 0) {
await client.del(...keys)
}
} catch (err) {
logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)')
}
}
@@ -0,0 +1,213 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../../infra/database/prisma'
interface ContactSlim {
id: string
name: string | null
notify: string | null
verifiedName: string | null
phone: string | null
avatarUrl: string | null
scoreReputacao: number
flagRestricao: boolean
}
interface ProtocolSlim {
chatId: string
number: string
status: string
}
interface LastMsgSlim {
chatId: string
body: string | null
fromMe: boolean
status: string
timestamp: Date
type: string
pushName: string | null
}
export class ChatRepository {
async findAllByInstance(tenantId: string, instanceId: string, opts: {
limit?: number
search?: string
archived?: boolean
} = {}) {
const { limit = 200, search, archived = false } = opts
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
const chats = await prisma.chat.findMany({
where: {
tenantId,
instanceId,
isArchived: archived,
NOT: { OR: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
]},
...(search
? {
OR: [
{ jid: { contains: search, mode: 'insensitive' as const } },
{ contact: { name: { contains: search, mode: 'insensitive' as const } } },
{ contact: { notify: { contains: search, mode: 'insensitive' as const } } },
{ contact: { phone: { contains: search, mode: 'insensitive' as const } } },
],
}
: {}),
},
orderBy: [
{ isPinned: 'desc' },
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
{ createdAt: 'desc' },
],
take: limit,
})
if (chats.length === 0) {
return [] as Array<typeof chats[number] & {
contact: ContactSlim | null
protocols: Array<{ number: string; status: string }>
messages: Array<Omit<LastMsgSlim, 'chatId'>>
}>
}
const chatIds = chats.map((c) => c.id)
const contactIds = Array.from(new Set(chats.map((c) => c.contactId).filter((x): x is string => !!x)))
// ── Q2/Q3/Q4 em paralelo: contatos, protocols (1 por chat), last msg (1 por chat) ──
const [contacts, protocols, lastMsgs] = await Promise.all([
contactIds.length > 0
? prisma.contact.findMany({
where: { id: { in: contactIds } },
select: {
id: true,
name: true,
notify: true,
verifiedName: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
})
: Promise.resolve([] as ContactSlim[]),
// DISTINCT ON: 1 query batch — pega o protocolo aberto mais recente por chat
prisma.$queryRaw<ProtocolSlim[]>`
SELECT DISTINCT ON ("chatId") "chatId", number, status::text AS status
FROM protocols
WHERE "chatId" = ANY(${chatIds}::text[])
AND status IN ('OPEN','IN_PROGRESS','WAITING_CLIENT','WAITING_AGENT')
ORDER BY "chatId", "createdAt" DESC
`,
// DISTINCT ON: 1 query batch — pega a última msg por chat
prisma.$queryRaw<LastMsgSlim[]>`
SELECT DISTINCT ON ("chatId") "chatId", body, "fromMe", status::text AS status, timestamp, type::text AS type, "pushName"
FROM messages
WHERE "chatId" = ANY(${chatIds}::text[])
ORDER BY "chatId", timestamp DESC
`,
])
// ── Indexa para lookup O(1) ──────────────────────────────────────────────
const contactById = new Map<string, ContactSlim>(contacts.map((c) => [c.id, c]))
const protocolByChat = new Map<string, ProtocolSlim>(protocols.map((p) => [p.chatId, p]))
const lastMsgByChat = new Map<string, LastMsgSlim>(lastMsgs.map((m) => [m.chatId, m]))
// ── Re-monta a estrutura no formato esperado pelo chat.routes.ts ─────────
return chats.map((c) => {
const contact = c.contactId ? contactById.get(c.contactId) ?? null : null
const protocol = protocolByChat.get(c.id)
const lastMsg = lastMsgByChat.get(c.id)
return {
...c,
contact: contact
? {
name: contact.name,
notify: contact.notify,
verifiedName: contact.verifiedName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: null,
protocols: protocol
? [{ number: protocol.number, status: protocol.status }]
: [],
messages: lastMsg
? [{
body: lastMsg.body,
fromMe: lastMsg.fromMe,
status: lastMsg.status,
timestamp: lastMsg.timestamp,
type: lastMsg.type,
pushName: lastMsg.pushName,
}]
: [],
}
})
}
async findById(id: string, tenantId: string) {
return prisma.chat.findFirst({
where: { id, tenantId },
include: {
contact: true,
protocols: {
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
take: 1,
orderBy: { createdAt: 'desc' },
},
},
})
}
async findByJid(tenantId: string, instanceId: string, jid: string) {
return prisma.chat.findUnique({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
include: { contact: true },
})
}
async clearUnread(id: string, tenantId: string) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { unreadCount: 0 },
})
}
async setArchived(id: string, tenantId: string, archived: boolean) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { isArchived: archived },
})
}
async setPinned(id: string, tenantId: string, pinned: boolean) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { isPinned: pinned },
})
}
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
const result = await prisma.chat.aggregate({
where: {
tenantId,
instanceId,
isArchived: false,
NOT: { OR: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
]}
},
_sum: { unreadCount: true },
})
return result._sum.unreadCount ?? 0
}
}
@@ -0,0 +1,408 @@
/**
* Rotas de Chats — API REST para a inbox do frontend.
*
* Endpoints:
* GET /api/chats — Lista chats (com filtros e último msg)
* GET /api/chats/search/messages — Busca full-text em mensagens
* GET /api/chats/stats/overview — Contagens para o dashboard
* GET /api/chats/:id — Detalhe de um chat
* POST /api/chats/:id/read — Zera unread_count
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
* PATCH /api/chats/:id/pin — Fixa/desfixa
* DELETE /api/chats/:id — Remove chat e mensagens
* GET /api/chats/:id/messages — Histórico paginado por cursor
* GET /api/chats/:id/protocol — Protocolos do chat
* POST /api/chats/:id/protocol — Abre novo protocolo
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
*
* @see ChatRepository — queries otimizadas com batch DISTINCT ON
* @see MessageRepository — busca e paginação de mensagens
*/
import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { z } from 'zod'
import { ChatRepository } from './chat.repository'
import { MessageRepository } from './message.repository'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { invalidateChatListCache } from './chat.cache'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
const chatRepo = new ChatRepository()
const msgRepo = new MessageRepository()
/** TTL do cache de listagem de chats — longo para estabilidade, invalidado por eventos e mutações */
const CHAT_LIST_CACHE_TTL_S = 86400
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
res.status(500).json({ error: (err as Error).message ?? 'Erro interno' })
}
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
function chatListCacheKey(tenantId: string, instanceId: string, archived: boolean, limit: number): string {
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`
}
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
const router = Router()
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats — Lista chats com snapshot da última mensagem
// ═══════════════════════════════════════════════════════════════════════════
router.get('/', async (req: Request, res: Response) => {
try {
const { instanceId, search, archived, limit } = z.object({
instanceId: z.string().uuid(),
search: z.string().optional(),
archived: z.coerce.boolean().optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}).parse(req.query)
const archivedFlag = archived ?? false
const limitVal = limit ?? 200
const tenantId = req.tenantId!
// ── Cache hit: só quando não houver search (busca tem termos únicos) ──
const useCache = !search
let cacheKey: string | null = null
if (useCache) {
cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal)
const cached = await dragonfly.getJson<unknown[]>(cacheKey)
if (cached) {
res.setHeader('X-Cache', 'HIT')
res.json(cached)
return
}
}
const chats = await chatRepo.findAllByInstance(tenantId, instanceId, {
search,
archived,
limit,
})
const result = chats.map((c) => {
const contact = c.contact
const isGroup = c.jid.endsWith('@g.us')
const lastMsg = c.messages[0]
const activeProtocol = c.protocols[0] ?? null
const resolvedName = isGroup
? (c.name ?? null)
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
?? null)
return {
id: c.id,
instanceId: c.instanceId,
jid: c.jid,
name: c.name ?? null,
isPinned: c.isPinned,
isArchived: c.isArchived,
unreadCount: c.unreadCount,
lastMessageAt: c.lastMessageAt,
createdAt: c.createdAt,
contact: contact
? {
name: resolvedName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
avatarVersion: contact.avatarUrl
? createHash('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
: null,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: isGroup
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage: lastMsg
? {
body: lastMsg.body,
fromMe: lastMsg.fromMe,
status: lastMsg.status,
type: lastMsg.type,
timestamp: lastMsg.timestamp,
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
}
: null,
protocol: activeProtocol
? { number: activeProtocol.number, status: activeProtocol.status }
: null,
}
})
if (useCache && cacheKey) {
// fire-and-forget — não bloqueia a resposta
dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => {})
}
res.setHeader('X-Cache', 'MISS')
res.json(result)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/search/messages
// ═══════════════════════════════════════════════════════════════════════════
router.get('/search/messages', async (req: Request, res: Response) => {
try {
const { instanceId, q, limit } = z.object({
instanceId: z.string().uuid(),
q: z.string().min(2),
limit: z.coerce.number().int().min(1).max(50).optional(),
}).parse(req.query)
const results = await msgRepo.search(req.tenantId!, instanceId, q, limit)
res.json(results)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/stats/overview
// ═══════════════════════════════════════════════════════════════════════════
router.get('/stats/overview', async (req: Request, res: Response) => {
try {
const { instanceId } = z.object({ instanceId: z.string().uuid() }).parse(req.query)
const cacheKey = `stats:${req.tenantId}:${instanceId}`
const cached = await dragonfly.getJson(cacheKey)
if (cached) { res.json(cached); return }
const [totalUnread, openProtocols, totalChats] = await Promise.all([
chatRepo.countUnreadByInstance(req.tenantId!, instanceId),
prisma.protocol.count({
where: {
tenantId: req.tenantId!,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
prisma.chat.count({
where: {
tenantId: req.tenantId!,
instanceId,
isArchived: false,
NOT: { jid: { endsWith: '@lid' } }
},
}),
])
const stats = { totalUnread, openProtocols, totalChats }
await dragonfly.setJson(cacheKey, stats, 30)
res.json(stats)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
res.json(chat)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/chats/:id/read
// ═══════════════════════════════════════════════════════════════════════════
router.post('/:id/read', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const tenantId = req.tenantId!
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
await chatRepo.clearUnread(id, tenantId)
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/archive
// ═══════════════════════════════════════════════════════════════════════════
router.patch('/:id/archive', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { archived } = z.object({ archived: z.boolean() }).parse(req.body)
const tenantId = req.tenantId!
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
await chatRepo.setArchived(id, tenantId, archived)
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/pin
// ═══════════════════════════════════════════════════════════════════════════
router.patch('/:id/pin', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { pinned } = z.object({ pinned: z.boolean() }).parse(req.body)
const tenantId = req.tenantId!
const chat = await prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } })
await chatRepo.setPinned(id, tenantId, pinned)
if (chat) invalidateChatListCache(tenantId, chat.instanceId).catch(() => {})
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// DELETE /api/chats/:id
// ═══════════════════════════════════════════════════════════════════════════
router.delete('/:id', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
await prisma.message.deleteMany({ where: { chatId: id } })
await prisma.chat.delete({ where: { id } })
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
res.status(204).send()
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id/messages
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id/messages', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { before, after, limit } = z.object({
before: z.string().datetime().optional(),
after: z.string().datetime().optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
}).parse(req.query)
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
before,
after,
limit,
})
res.json({ messages, hasMore })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PROTOCOLOS
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id/protocol', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
const protocols = await prisma.protocol.findMany({
where: { chatId: id },
orderBy: { createdAt: 'desc' },
take: 10,
include: {
sector: { select: { name: true } },
team: { select: { name: true } },
},
})
res.json(protocols)
} catch (err) {
handleError(res, err)
}
})
router.post('/:id/protocol', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
const { sectorId, teamId } = z.object({
sectorId: z.string().uuid().optional(),
teamId: z.string().uuid().optional(),
}).parse(req.body)
const chat = await chatRepo.findById(id, req.tenantId!)
if (!chat) { res.status(404).json({ error: 'Chat não encontrado' }); return }
if (!chat.contactId) { res.status(400).json({ error: 'Chat sem contato associado' }); return }
const count = await prisma.protocol.count({ where: { chatId: id } })
const year = new Date().getFullYear()
const number = `#${String(count + 1).padStart(4, '0')}-${year}`
const protocol = await prisma.protocol.create({
data: {
tenantId: req.tenantId!,
number,
chatId: id,
contactId: chat.contactId,
sectorId,
teamId,
status: 'OPEN',
},
})
invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
res.status(201).json(protocol)
} catch (err) {
handleError(res, err)
}
})
router.patch('/:chatId/protocol/:protocolId', async (req: Request, res: Response) => {
try {
const chatId = req.params['chatId'] as string
const protocolId = req.params['protocolId'] as string
const { status, summary } = z.object({
status: z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
summary: z.string().max(500).optional(),
}).parse(req.body)
const protocol = await prisma.protocol.updateMany({
where: { id: protocolId, chatId, tenantId: req.tenantId! },
data: {
...(status && { status }),
...(summary && { summary }),
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
},
})
const chat = await prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId! }, select: { instanceId: true } })
if (chat) invalidateChatListCache(req.tenantId!, chat.instanceId).catch(() => {})
res.json({ updated: protocol.count })
} catch (err) {
handleError(res, err)
}
})
return router
}
@@ -0,0 +1,86 @@
import { prisma } from '../../infra/database/prisma'
export class MessageRepository {
/**
* Histórico paginado por cursor (timestamp).
* Retorna `limit` mensagens anteriores ao cursor dado.
* Se cursor não for fornecido, retorna as mais recentes.
*/
async findByChatPaginated(chatId: string, opts: {
limit?: number
before?: string // timestamp ISO — cursor de paginação
after?: string // timestamp ISO — buscar mensagens mais novas
} = {}) {
const { limit = 40, before, after } = opts
const where: Record<string, unknown> = { chatId }
if (before) where['timestamp'] = { lt: new Date(before) }
if (after) where['timestamp'] = { gt: new Date(after) }
// Initial load (sem cursor) e scroll-up (before): busca DESC e inverte
// para retornar as mensagens mais recentes em ordem cronológica.
// Scroll-down (after): busca ASC naturalmente.
const needsReverse = !after
const messages = await prisma.message.findMany({
where,
orderBy: { timestamp: needsReverse ? 'desc' : 'asc' },
take: limit,
include: {
replyTo: {
select: {
id: true,
messageId: true,
body: true,
fromMe: true,
type: true,
mediaUrl: true,
},
},
},
})
// Inverte para ordem cronológica (mais antiga → mais recente)
if (needsReverse) messages.reverse()
const hasMore = messages.length === limit
return { messages, hasMore }
}
async findById(id: string) {
return prisma.message.findUnique({ where: { id } })
}
async search(tenantId: string, instanceId: string, query: string, limit = 20) {
return prisma.message.findMany({
where: {
tenantId,
instanceId,
body: { contains: query, mode: 'insensitive' },
},
include: {
chat: {
select: {
jid: true,
contact: { select: { name: true, notify: true, avatarUrl: true } },
},
},
},
orderBy: { timestamp: 'desc' },
take: limit,
})
}
async updateStatus(messageId: string, instanceId: string, status: string) {
return prisma.message.updateMany({
where: { messageId, instanceId },
data: { status: status as any },
})
}
async countByChat(chatId: string): Promise<number> {
return prisma.message.count({ where: { chatId } })
}
}
@@ -0,0 +1,171 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { normalizeJid } from '../../shared/utils/whatsapp'
import { invalidateChatListCache } from '../chats/chat.cache'
export function buildContactRoutes(): Router {
const router = Router()
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
router.get('/stats/overview', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.query['instanceId'] as string | undefined
const where: Record<string, unknown> = { tenantId }
if (instanceId) where['instanceId'] = instanceId
const [total, blocked, flagged] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.count({ where: { ...where, isBlocked: true } }),
prisma.contact.count({ where: { ...where, flagRestricao: true } }),
])
res.json({ total, blocked, flagged, active: total - blocked })
})
// ── GET /api/contacts — lista paginada ────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const instanceId = req.query['instanceId'] as string | undefined
const search = req.query['search'] as string | undefined
const blocked = req.query['blocked'] as string | undefined
const limit = Math.min(Number(req.query['limit'] ?? 50), 200)
const offset = Number(req.query['offset'] ?? 0)
const where: Record<string, unknown> = { tenantId }
if (instanceId) where['instanceId'] = instanceId
if (blocked === 'true') where['isBlocked'] = true
if (blocked === 'false') where['isBlocked'] = false
if (search) {
where['OR'] = [
{ name: { contains: search, mode: 'insensitive' } },
{ verifiedName: { contains: search, mode: 'insensitive' } },
{ notify: { contains: search, mode: 'insensitive' } },
{ phone: { contains: search } },
{ jid: { contains: search } },
]
}
const [total, contacts] = await Promise.all([
prisma.contact.count({ where }),
prisma.contact.findMany({
where,
// Tiebreaker por id é obrigatório: muitos contatos compartilham o mesmo
// updatedAt (bulk sync), e sem segunda coluna a paginação por OFFSET
// devolve linhas sobrepostas entre páginas — quebra "carregar mais".
orderBy: [{ updatedAt: 'desc' }, { id: 'asc' }],
take: limit,
skip: offset,
select: {
id: true,
instanceId: true,
jid: true,
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
isBlocked: true,
createdAt: true,
updatedAt: true,
},
}),
])
res.json({
total,
contacts: contacts.map(c => ({
...c,
displayName: c.name || c.verifiedName || c.notify || (c.phone ? `+${c.phone}` : normalizeJid(c.jid).split('@')[0])
}))
})
})
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const contact = await prisma.contact.findFirst({
where: { id, tenantId },
include: {
chats: {
orderBy: { lastMessageAt: 'desc' },
take: 1,
select: { id: true, unreadCount: true, lastMessageAt: true },
},
},
})
if (!contact) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
res.json(contact)
})
// ── PATCH /api/contacts/:id — editar nome / bloquear ─────────────────────
router.patch('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const schema = z.object({
name: z.string().min(1).max(255).optional(),
isBlocked: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try {
body = schema.parse(req.body)
} catch (err: any) {
res.status(400).json({ error: err.errors ?? err.message })
return
}
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
const updated = await prisma.contact.update({ where: { id }, data: body })
if (body.name && existing.instanceId) {
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
}
res.json(updated)
})
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const existing = await prisma.contact.findFirst({ where: { id, tenantId } })
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' })
return
}
// Desvincular chats antes de deletar o contato
await prisma.chat.updateMany({
where: { contactId: id },
data: { contactId: null },
})
await prisma.contact.delete({ where: { id } })
if (existing.instanceId) {
invalidateChatListCache(tenantId, existing.instanceId).catch(() => {})
}
res.status(204).send()
})
return router
}
@@ -0,0 +1,120 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { PairService } from './pair.service'
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
const service = new PairService()
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
const e = err as Error & { statusCode?: number }
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
}
// ─── Schemas ──────────────────────────────────────────────────────────────────
const requestSchema = z.object({
email: z.string().email(),
clientSystem: z.string().min(1).max(100),
clientName: z.string().min(1).max(200),
clientUrl: z.string().url().nullable().optional(),
})
const confirmSchema = z.object({
email: z.string().email(),
code: z.string().length(6),
clientSystem: z.string().min(1).max(100),
clientName: z.string().min(1).max(200),
clientUrl: z.string().url().nullable().optional(),
})
// ─── Router ───────────────────────────────────────────────────────────────────
export const pairRouter = Router()
/**
* POST /api/pair/request
* Inicia pareamento: gera OTP e envia por e-mail.
* Público (chamado pelo sistema cliente).
*/
pairRouter.post('/request', async (req: Request, res: Response) => {
try {
const body = requestSchema.parse(req.body) as any
const result = await service.request({
...body,
clientUrl: body.clientUrl ?? null,
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/pair/confirm
* Confirma o OTP e retorna a integration_key.
* Público (chamado pelo sistema cliente com o código que o usuário digitou).
*/
pairRouter.post('/confirm', async (req: Request, res: Response) => {
try {
const body = confirmSchema.parse(req.body) as any
const result = await service.confirm({
...body,
clientUrl: body.clientUrl ?? null,
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/pair/verify
* Verifica se uma integration_key é válida.
* Público — autenticado pelo header x-nw-key.
*/
pairRouter.get('/verify', async (req: Request, res: Response) => {
try {
const raw = req.headers['x-nw-key']
const key = Array.isArray(raw) ? raw[0] : raw
if (!key) {
res.status(401).json({ error: 'Header x-nw-key ausente' })
return
}
const result = await service.verify(key)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/pair/connections
* Lista pares ativos do usuário autenticado.
* Protegido — requer JWT via authMiddleware.
*/
pairRouter.get('/connections', authMiddleware, async (req: Request, res: Response) => {
try {
const pairs = await service.list(req.tenantId!)
res.json(pairs)
} catch (err) {
handleError(res, err)
}
})
/**
* DELETE /api/pair/connections/:id
* Revoga um par do usuário autenticado.
* Protegido — requer JWT via authMiddleware.
*/
pairRouter.delete('/connections/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const result = await service.revoke(req.tenantId!, String(req.params.id))
res.json(result)
} catch (err) {
handleError(res, err)
}
})
@@ -0,0 +1,265 @@
import crypto from 'crypto'
import nodemailer from 'nodemailer'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
const OTP_TTL = 10 * 60 // 10 minutos
const KEY_TTL = 90 * 24 * 3600 // 90 dias
interface OtpPayload {
code: string
clientSystem: string
clientName: string
clientUrl: string | null
}
interface KeyPayload {
userId: string
email: string
clientSystem: string
clientName: string
}
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
const otpKey = (email: string) => `pair:otp:${email}`
const intKey = (integrationKey: string) => `pair:key:${integrationKey}`
// ─── Email sender ─────────────────────────────────────────────────────────────
async function sendOtpEmail(to: string, code: string, clientName: string): Promise<boolean> {
// Lê config SMTP do admin settings
const raw = await dragonfly.getJson<any>('system:settings')
const smtp = raw?.smtp ?? {}
if (!smtp.host || !smtp.user) {
logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.')
logger.info(`[pair] OTP para ${to}: ${code}`) // fallback: loga localmente
return false
}
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port ?? 587,
secure: smtp.secure ?? false,
auth: { user: smtp.user, pass: smtp.password },
})
await transporter.sendMail({
from: smtp.from ?? smtp.user,
to,
subject: `[NewWhats] Código de pareamento — ${clientName}`,
text: [
`Olá!`,
``,
`O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`,
``,
`Seu código de confirmação: ${code}`,
``,
`Válido por 10 minutos.`,
``,
`Se você não solicitou isso, ignore este email.`,
].join('\n'),
html: `
<p>Olá!</p>
<p>O sistema <strong>${clientName}</strong> solicitou conexão com sua conta NewWhats.</p>
<h2 style="letter-spacing:8px;font-size:32px">${code}</h2>
<p style="color:#888">Válido por 10 minutos. Se não foi você, ignore este email.</p>
`,
})
logger.info(`[pair] OTP enviado para ${to}`)
return true
}
// ─── PairService ──────────────────────────────────────────────────────────────
export class PairService {
// Etapa 1 — solicitar pareamento
async request(input: {
email: string
clientSystem: string
clientName: string
clientUrl: string | null
}) {
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user) {
// Resposta genérica — não revela se o email existe ou não
return { ok: true, message: 'Se o email existir, um código será enviado.' }
}
if (!user.isActive) {
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
}
// Gera OTP de 6 dígitos
const code = Math.floor(100_000 + Math.random() * 900_000).toString()
const payload: OtpPayload = {
code,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
}
await dragonfly.setJson(otpKey(input.email), payload, OTP_TTL)
const emailSent = await sendOtpEmail(input.email, code, input.clientName)
// Quando SMTP não está configurado, devolve o código na resposta
// para que o admin possa completar o pareamento manualmente.
if (!emailSent) {
return { ok: true, message: 'SMTP não configurado.', dev_code: code }
}
return { ok: true, message: 'Se o email existir, um código será enviado.' }
}
// Etapa 2 — confirmar com o código recebido
async confirm(input: {
email: string
code: string
clientSystem: string
clientName: string
clientUrl: string | null
}) {
const stored = await dragonfly.getJson<OtpPayload>(otpKey(input.email))
if (!stored) {
throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 })
}
if (stored.code !== input.code) {
throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 })
}
if (stored.clientSystem !== input.clientSystem) {
throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 })
}
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user || !user.isActive) {
throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 })
}
// Revoga par anterior do mesmo sistema (evita duplicatas)
await prisma.pluginPair.updateMany({
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
data: { revokedAt: new Date() },
})
// Gera integration_key exclusiva
const integrationKey = `nwk_${crypto.randomUUID().replace(/-/g, '')}`
const pair = await prisma.pluginPair.create({
data: {
userId: user.id,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
integrationKey,
},
})
// Cache rápido para validação sem bater no banco
const keyPayload: KeyPayload = {
userId: user.id,
email: user.email,
clientSystem: input.clientSystem,
clientName: input.clientName,
}
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
// Limpa OTP usado
await dragonfly.del(otpKey(input.email))
logger.info(`[pair] ${user.email}${input.clientName} pareados (${pair.id})`)
return {
integration_key: integrationKey,
account: { name: user.name, email: user.email },
paired_at: pair.createdAt,
}
}
// Verifica se uma integration_key é válida (chamada pelo sistema cliente)
async verify(integrationKey: string) {
// Fast path — Dragonfly
const cached = await dragonfly.getJson<KeyPayload>(intKey(integrationKey))
if (cached) {
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
prisma.pluginPair.updateMany({
where: { integrationKey, revokedAt: null },
data: { lastSeenAt: new Date() },
}).catch(() => {})
return { valid: true, account: { email: cached.email }, client: cached.clientSystem }
}
// Slow path — banco
const pair = await prisma.pluginPair.findUnique({
where: { integrationKey },
include: { user: { select: { name: true, email: true, isActive: true } } },
})
if (!pair || pair.revokedAt || !pair.user.isActive) {
return { valid: false }
}
// Restaura cache
const keyPayload: KeyPayload = {
userId: pair.userId,
email: pair.user.email,
clientSystem: pair.clientSystem,
clientName: pair.clientName,
}
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
await prisma.pluginPair.update({
where: { integrationKey },
data: { lastSeenAt: new Date() },
})
return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem }
}
// Lista pares ativos do usuário autenticado
async list(userId: string) {
const pairs = await prisma.pluginPair.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: 'desc' },
select: {
id: true,
clientSystem: true,
clientName: true,
clientUrl: true,
lastSeenAt: true,
createdAt: true,
},
})
return pairs
}
// Revoga um par (usuário autenticado só pode revogar os seus)
async revoke(userId: string, pairId: string) {
const pair = await prisma.pluginPair.findFirst({
where: { id: pairId, userId, revokedAt: null },
})
if (!pair) {
throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 })
}
await prisma.pluginPair.update({
where: { id: pairId },
data: { revokedAt: new Date() },
})
// Remove do cache imediatamente
await dragonfly.del(intKey(pair.integrationKey))
logger.info(`[pair] Par ${pairId} revogado por ${userId}`)
return { ok: true }
}
}
@@ -0,0 +1,105 @@
import { Router } from 'express'
import { pluginRegistry } from '../../core/plugin-registry'
import { pluginConfig } from '../../core/plugin-config'
import { storageProvider } from '../../core/StorageProvider'
/**
* Rotas de gerenciamento de plugins — exclusivo admin.
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
*/
export const pluginsRouter = Router()
// GET /api/admin/plugins — lista todos os plugins com status e config atual
pluginsRouter.get('/', (_req, res) => {
const plugins = pluginRegistry.list().map((entry) => ({
name: entry.name,
displayName: entry.manifest.displayName,
version: entry.manifest.version,
description: entry.manifest.description,
author: entry.manifest.author,
category: entry.manifest.category,
active: entry.active,
canDisable: entry.manifest.canDisable,
dependencies: entry.manifest.dependencies,
configSchema: entry.manifest.configSchema ?? [],
config: pluginConfig.get(entry.name),
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
}))
res.json(plugins)
})
// POST /api/admin/plugins/:name/enable — ativa plugin
pluginsRouter.post('/:name/enable', async (req, res) => {
const { name } = req.params
try {
await pluginRegistry.activate(name)
res.json({ ok: true, message: `Plugin "${name}" ativado` })
} catch (err: any) {
res.status(400).json({ error: err.message })
}
})
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
pluginsRouter.post('/:name/disable', async (req, res) => {
const { name } = req.params
try {
await pluginRegistry.deactivate(name)
res.json({ ok: true, message: `Plugin "${name}" desativado` })
} catch (err: any) {
res.status(400).json({ error: err.message })
}
})
// GET /api/admin/plugins/:name/config — retorna config atual
pluginsRouter.get('/:name/config', (req, res) => {
const { name } = req.params
const entry = pluginRegistry.getEntry(name)
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
res.json({
name,
config: pluginConfig.get(name),
schema: entry.manifest.configSchema ?? [],
})
})
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
pluginsRouter.get('/storage/health', async (_req, res) => {
if (!storageProvider.isRegistered()) {
const cfg = pluginConfig.get('UnifiedStorageProvider')
const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket)
return res.json({
healthy: false,
configured,
reason: configured ? 'needs_restart' : 'not_configured',
message: configured
? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.'
: 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.',
})
}
await storageProvider.updateHealth()
return res.json(storageProvider.getLastHealthStatus())
})
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
pluginsRouter.put('/:name/config', async (req, res) => {
const { name } = req.params
const entry = pluginRegistry.getEntry(name)
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
const newConfig = req.body as Record<string, unknown>
await pluginConfig.set(name, newConfig)
// Reativa o plugin para aplicar a nova config imediatamente
if (entry.active) {
try {
await pluginRegistry.reactivate(name)
} catch (err: any) {
return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` })
}
}
res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig })
})
@@ -0,0 +1,232 @@
/**
* Rotas de Agendamentos de Mensagem
*
* GET /api/scheduled — Lista agendamentos do tenant
* POST /api/scheduled — Cria agendamento
* GET /api/scheduled/:id — Detalhe
* PUT /api/scheduled/:id — Atualiza
* DELETE /api/scheduled/:id — Remove
* POST /api/scheduled/:id/pause — Pausa
* POST /api/scheduled/:id/resume — Retoma
* POST /api/scheduled/:id/run-now — Executa imediatamente
* GET /api/scheduled/:id/logs — Histórico de execuções
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import type { SchedulerService } from './scheduler.service'
const recipientSchema = z.object({
jid: z.string(),
name: z.string().optional(),
birthday: z.string().optional(),
anniversary: z.string().optional(),
signupDate: z.string().optional(),
})
const scheduleSchema = z.object({
instanceId: z.string(),
name: z.string().min(1).max(100),
templateId: z.string().optional(),
payload: z.record(z.unknown()),
recipientMode: z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
recipients: z.array(recipientSchema).default([]),
scheduleType: z.enum(['ONCE', 'RECURRING', 'EVENT']),
cronExpr: z.string().optional(),
scheduledAt: z.string().datetime().optional(),
eventType: z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
timezone: z.string().default('America/Sao_Paulo'),
})
function getId(req: Request): string {
const id = req.params['id']
return Array.isArray(id) ? id[0] : id
}
function calcNextRun(data: Partial<z.infer<typeof scheduleSchema>>): Date | null {
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
return new Date(data.scheduledAt)
}
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
try {
const job = new Cron(data.cronExpr, { paused: true })
return job.nextRun() ?? null
} catch { return null }
}
return null
}
export function buildScheduledRoutes(_scheduler: SchedulerService): Router {
const router = Router()
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'] as string | undefined
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'] as string | undefined
const schedules = await prisma.scheduledMessage.findMany({
where: {
tenantId,
...(status ? { status: status as any } : {}),
...(type ? { scheduleType: type as any } : {}),
},
include: {
template: { select: { id: true, name: true, type: true } },
_count: { select: { logs: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json(schedules)
})
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const parse = scheduleSchema.safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const data = parse.data
const nextRunAt = calcNextRun(data)
const schedule = await prisma.scheduledMessage.create({
data: {
tenantId,
instanceId: data.instanceId,
name: data.name,
templateId: data.templateId,
payload: data.payload as any,
recipientMode: data.recipientMode,
recipients: data.recipients as any,
scheduleType: data.scheduleType,
cronExpr: data.cronExpr,
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
eventType: data.eventType ?? null,
timezone: data.timezone,
nextRunAt,
},
})
res.status(201).json(schedule)
})
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sched = await prisma.scheduledMessage.findFirst({
where: { id: getId(req), tenantId },
include: {
template: true,
logs: { orderBy: { runAt: 'desc' }, take: 5 },
},
})
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
res.json(sched)
})
// ── UPDATE ────────────────────────────────────────────────────────────────
router.put('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
const parse = scheduleSchema.partial().safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const data = parse.data
const nextRunAt = calcNextRun({ ...exists, ...data } as any)
const { payload, recipients, scheduledAt, eventType, ...rest } = data
const updated = await prisma.scheduledMessage.update({
where: { id },
data: {
...rest,
...(payload !== undefined ? { payload: payload as any } : {}),
...(recipients !== undefined ? { recipients: recipients as any } : {}),
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt as string) } : {}),
...(eventType !== undefined ? { eventType } : {}),
status: 'ACTIVE',
nextRunAt,
},
})
res.json(updated)
})
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.delete({ where: { id } })
res.status(204).send()
})
// ── PAUSE ─────────────────────────────────────────────────────────────────
router.post('/:id/pause', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } })
res.json({ ok: true })
})
// ── RESUME ────────────────────────────────────────────────────────────────
router.post('/:id/resume', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } })
res.json({ ok: true })
})
// ── RUN NOW ───────────────────────────────────────────────────────────────
router.post('/:id/run-now', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const sched = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
// Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto
await prisma.scheduledMessage.update({
where: { id },
data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' },
})
res.json({ ok: true, message: 'Execução agendada para o próximo tick' })
})
// ── LOGS ──────────────────────────────────────────────────────────────────
router.get('/:id/logs', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
const logs = await prisma.scheduledMessageLog.findMany({
where: { scheduleId: id },
orderBy: { runAt: 'desc' },
take: 50,
})
res.json(logs)
})
return router
}
@@ -0,0 +1,227 @@
/**
* SchedulerService — executa agendamentos de mensagens ricos.
*
* Tipos suportados:
* ONCE — executa uma vez em scheduledAt
* RECURRING — cron expression (cronExpr)
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
* verificado diariamente às 08:00 contra dados dos contatos
*
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
* Eventos são checados uma vez por dia via job separado.
*
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
* (sem passar pelo HTTP — evita latência e auth overhead).
*/
import { schedule as cronSchedule } from 'node-cron'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export class SchedulerService {
private manager: WhatsAppConnectionManager
private minuteJob: ReturnType<typeof cronSchedule> | null = null
private eventJob: ReturnType<typeof cronSchedule> | null = null
constructor(manager: WhatsAppConnectionManager) {
this.manager = manager
}
start() {
// Tick a cada minuto — processa ONCE e RECURRING vencidos
this.minuteJob = cronSchedule('* * * * *', () => {
this.processTimeBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de minuto')
)
})
// Tick diário às 08:00 — processa agendamentos por EVENTO
this.eventJob = cronSchedule('0 8 * * *', () => {
this.processEventBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de evento')
)
})
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
}
stop() {
this.minuteJob?.stop()
this.eventJob?.stop()
}
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
private async processTimeBased() {
const now = new Date()
const schedules = await prisma.scheduledMessage.findMany({
where: {
status: 'ACTIVE',
scheduleType: { in: ['ONCE', 'RECURRING'] },
OR: [
{ nextRunAt: { lte: now } },
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
],
},
})
for (const sched of schedules) {
await this.executeSchedule(sched)
}
}
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
private async processEventBased() {
const today = new Date()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const todayMmDd = `${mm}-${dd}`
const schedules = await prisma.scheduledMessage.findMany({
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
})
for (const sched of schedules) {
const recipients = sched.recipients as Recipient[]
const matched: Recipient[] = []
for (const r of recipients) {
if (!r.jid) continue
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
// birthday format: "YYYY-MM-DD" or "MM-DD"
const parts = r.birthday.split('-')
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday
if (bMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
const parts = r.anniversary.split('-')
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary
if (aMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
// Dispara N dias após o signup — N fica em cronExpr como "30"
const daysAfter = parseInt(sched.cronExpr ?? '0', 10)
const signup = new Date(r.signupDate)
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000)
if (diffDays === daysAfter) matched.push(r)
}
}
if (matched.length > 0) {
await this.executeSchedule(sched, matched)
}
}
}
// ── Executa um agendamento ─────────────────────────────────────────────────
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
const sock = this.manager.getSocket(sched.instanceId)
if (!sock) {
logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando')
return
}
let sentCount = 0
let failedCount = 0
const errors: string[] = []
for (const recipient of recipients) {
if (!recipient.jid) continue
try {
const payload = this.buildPayload(sched.payload as any, recipient)
await sock.sendMessage(recipient.jid, payload)
sentCount++
// Pequeno delay anti-ban
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
} catch (err: any) {
failedCount++
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
}
}
// Persiste log
await prisma.scheduledMessageLog.create({
data: {
scheduleId: sched.id,
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
sentCount,
failedCount,
error: errors.length ? errors.join('\n') : null,
},
})
// Calcula próximo nextRunAt ou marca como DONE
if (sched.scheduleType === 'ONCE') {
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
status: 'DONE',
lastRunAt: new Date(),
runCount: { increment: 1 },
nextRunAt: null,
},
})
} else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
const next = this.nextCronDate(sched.cronExpr)
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
lastRunAt: new Date(),
nextRunAt: next,
runCount: { increment: 1 },
},
})
} else {
// EVENT — apenas registra execução
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
})
}
logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado')
}
// ── Monta payload Baileys com substituição de variáveis ────────────────────
// Variáveis: {{name}}, {{birthday}}, etc.
private buildPayload(raw: any, recipient: Recipient): any {
const replace = (str: string) =>
str
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '')
const recurse = (node: any): any => {
if (typeof node === 'string') return replace(node)
if (Array.isArray(node)) return node.map(recurse)
if (node && typeof node === 'object') {
const out: any = {}
for (const k of Object.keys(node)) out[k] = recurse(node[k])
return out
}
return node
}
return recurse(raw)
}
// ── Calcula próxima data de um cron ───────────────────────────────────────
private nextCronDate(expr: string): Date | null {
try {
const job = new Cron(expr, { paused: true })
return job.nextRun() ?? null
} catch {
return null
}
}
}
@@ -0,0 +1,136 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
export function buildSectorRoutes(): Router {
const router = Router()
// GET /api/sectors
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectors = await prisma.sector.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { teams: true, protocols: true } } },
})
res.json(sectors)
})
// POST /api/sectors
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const schema = z.object({ name: z.string().min(1).max(100) })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
try {
const sector = await prisma.sector.create({ data: { tenantId, name: body.name } })
res.status(201).json(sector)
} catch {
res.status(409).json({ error: 'Setor com este nome já existe.' })
}
})
// PATCH /api/sectors/:id
router.patch('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const schema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
const updated = await prisma.sector.update({ where: { id }, data: body })
res.json(updated)
})
// DELETE /api/sectors/:id
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
await prisma.team.deleteMany({ where: { sectorId: id } })
await prisma.sector.delete({ where: { id } })
res.status(204).send()
})
// GET /api/sectors/:id/teams
router.get('/:id/teams', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectorId = req.params['id'] as string
const teams = await prisma.team.findMany({
where: { sectorId, tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { protocols: true } } },
})
res.json(teams)
})
// POST /api/sectors/:id/teams
router.post('/:id/teams', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectorId = req.params['id'] as string
const schema = z.object({ name: z.string().min(1).max(100) })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const sector = await prisma.sector.findFirst({ where: { id: sectorId, tenantId } })
if (!sector) { res.status(404).json({ error: 'Setor não encontrado' }); return }
try {
const team = await prisma.team.create({ data: { tenantId, sectorId, name: body.name } })
res.status(201).json(team)
} catch {
res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' })
}
})
// PATCH /api/sectors/:sectorId/teams/:teamId
router.patch('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const teamId = req.params['teamId'] as string
const schema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
const updated = await prisma.team.update({ where: { id: teamId }, data: body })
res.json(updated)
})
// DELETE /api/sectors/:sectorId/teams/:teamId
router.delete('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const teamId = req.params['teamId'] as string
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
await prisma.team.delete({ where: { id: teamId } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,99 @@
/**
* Rotas de Figurinhas (Stickers)
*
* GET /api/stickers — Lista stickers do tenant (com listVersion)
* GET /api/stickers/recent — Últimos 30 stickers usados
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
*/
import { Router, type Request, type Response } from 'express'
import { createHash } from 'crypto'
import { prisma } from '../../infra/database/prisma'
export function buildStickerRoutes(): Router {
const router = Router()
// ── LIST (com listVersion para cache invalidation) ────────────────────────
router.get('/', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const stickers = await prisma.sticker.findMany({
where: { tenantId },
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
select: {
id: true,
sha256: true,
wasabiPath: true,
isAnimated: true,
isFavorite: true,
useCount: true,
lastSeenAt: true,
},
})
const versionSource = stickers.length === 0
? 'empty'
: `${stickers.length}:${stickers[0]!.sha256}:${stickers[0]!.lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`
const listVersion = createHash('md5').update(versionSource).digest('hex').slice(0, 8)
res.json({ listVersion, stickers })
} catch (err) {
res.status(500).json({ error: 'Erro ao listar stickers' })
}
})
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
router.get('/recent', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const stickers = await prisma.sticker.findMany({
where: { tenantId },
orderBy: { lastSeenAt: 'desc' },
take: 30,
select: {
id: true,
sha256: true,
wasabiPath: true,
isAnimated: true,
isFavorite: true,
},
})
res.json({ stickers })
} catch (err) {
res.status(500).json({ error: 'Erro ao listar stickers recentes' })
}
})
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
router.patch('/:id/favorite', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const idParam = req.params['id']
const id = Array.isArray(idParam) ? idParam[0]! : idParam
const sticker = await prisma.sticker.findFirst({
where: { id, tenantId },
select: { id: true, isFavorite: true },
})
if (!sticker) {
res.status(404).json({ error: 'Sticker não encontrado' })
return
}
const updated = await prisma.sticker.update({
where: { id: sticker.id },
data: { isFavorite: !sticker.isFavorite },
select: { id: true, isFavorite: true },
})
res.json(updated)
} catch (err) {
res.status(500).json({ error: 'Erro ao atualizar favorito' })
}
})
return router
}
@@ -0,0 +1,134 @@
/**
* Rotas de Templates de Mensagem
*
* GET /api/templates — Lista templates do tenant
* POST /api/templates — Cria template
* GET /api/templates/:id — Detalhe do template
* PUT /api/templates/:id — Atualiza template
* DELETE /api/templates/:id — Remove template
* POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar)
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'] as const
const templateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
type: z.enum(templateTypeValues),
payload: z.record(z.unknown()),
tags: z.array(z.string()).default([]),
})
function getId(req: Request): string {
const id = req.params['id']
return Array.isArray(id) ? id[0] : id
}
export function buildTemplateRoutes(): Router {
const router = Router()
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type']
const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search']
const templates = await prisma.messageTemplate.findMany({
where: {
tenantId,
...(type ? { type: type as any } : {}),
...(search ? { name: { contains: search as string, mode: 'insensitive' } } : {}),
},
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
})
res.json(templates)
})
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const parse = templateSchema.safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const { name, description, type, payload, tags } = parse.data
const template = await prisma.messageTemplate.create({
data: { tenantId, name, description, type, payload: payload as any, tags },
})
res.status(201).json(template)
})
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const template = await prisma.messageTemplate.findFirst({
where: { id: getId(req), tenantId },
})
if (!template) { res.status(404).json({ error: 'Template não encontrado' }); return }
res.json(template)
})
// ── UPDATE ────────────────────────────────────────────────────────────────
router.put('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
const parse = templateSchema.partial().safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const { payload, ...rest } = parse.data
const updated = await prisma.messageTemplate.update({
where: { id },
data: { ...rest, ...(payload !== undefined ? { payload: payload as any } : {}) },
})
res.json(updated)
})
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.delete({ where: { id } })
res.status(204).send()
})
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
router.post('/:id/use', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.update({
where: { id },
data: { usageCount: { increment: 1 } },
})
res.json({ ok: true })
})
return router
}
@@ -0,0 +1,80 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
exports.getEngine = getEngine;
const official = __importStar(require("./official"));
const infinite = __importStar(require("./infinite"));
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
function resolveEngine(type) {
return type === 'infinite' ? infinite : official;
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
function getEngine(engineType) {
const e = resolveEngine(engineType ?? defaultEngineType);
return {
makeWASocket: e.makeWASocket,
useMultiFileAuthState: e.useMultiFileAuthState,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
downloadMediaMessage: e.downloadMediaMessage,
getContentType: e.getContentType,
generateMessageIDV2: e.generateMessageIDV2,
generateWAMessageFromContent: e.generateWAMessageFromContent,
isJidGroup: e.isJidGroup,
isJidStatusBroadcast: e.isJidStatusBroadcast,
};
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType);
exports.makeWASocket = selectedEngine.makeWASocket;
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
exports.DisconnectReason = selectedEngine.DisconnectReason;
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
exports.getContentType = selectedEngine.getContentType;
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
exports.isJidGroup = selectedEngine.isJidGroup;
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
var official_1 = require("./official");
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAeA,8BAyBC;AAxCD,qDAAsC;AACtC,qDAAsC;AAEtC,0EAA0E;AAC1E,MAAM,iBAAiB,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,IAAI,UAAU,CAAA;AAElE,SAAS,aAAa,CAAC,IAAY;IACjC,OAAO,IAAI,KAAK,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAA;AAClD,CAAC;AAED;;;;GAIG;AACH,SAAgB,SAAS,CAAC,UAA0B;IAYlD,MAAM,CAAC,GAAG,aAAa,CAAC,UAAU,IAAI,iBAAiB,CAAC,CAAA;IACxD,OAAO;QACL,YAAY,EAAgB,CAAC,CAAC,YAAmB;QACjD,qBAAqB,EAAO,CAAC,CAAC,qBAA4B;QAC1D,yBAAyB,EAAG,CAAC,CAAC,yBAAgC;QAC9D,2BAA2B,EAAE,CAAC,CAAC,2BAAkC;QACjE,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;QACzD,cAAc,EAAc,CAAC,CAAC,cAAqB;QACnD,mBAAmB,EAAS,CAAC,CAAC,mBAA0B;QACxD,4BAA4B,EAAE,CAAC,CAAC,4BAAmC;QACnE,UAAU,EAAkB,CAAC,CAAC,UAAiB;QAC/C,oBAAoB,EAAQ,CAAC,CAAC,oBAA2B;KAC1D,CAAA;AACH,CAAC;AAED,sFAAsF;AACtF,MAAM,cAAc,GAAG,aAAa,CAAC,iBAAiB,CAAC,CAAA;AAE1C,QAAA,YAAY,GAAG,cAAc,CAAC,YAAmB,CAAA;AACjD,QAAA,qBAAqB,GAAG,cAAc,CAAC,qBAA4B,CAAA;AACnE,QAAA,gBAAgB,GAAG,cAAc,CAAC,gBAAuB,CAAA;AACzD,QAAA,yBAAyB,GAAG,cAAc,CAAC,yBAAgC,CAAA;AAC3E,QAAA,2BAA2B,GAAG,cAAc,CAAC,2BAAkC,CAAA;AAC/E,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AACjE,QAAA,cAAc,GAAG,cAAc,CAAC,cAAqB,CAAA;AACrD,QAAA,mBAAmB,GAAG,cAAc,CAAC,mBAA0B,CAAA;AAC/D,QAAA,4BAA4B,GAAG,cAAc,CAAC,4BAAmC,CAAA;AACjF,QAAA,UAAU,GAAG,cAAc,CAAC,UAAiB,CAAA;AAC7C,QAAA,oBAAoB,GAAG,cAAc,CAAC,oBAA2B,CAAA;AAC9E,uCAAkC;AAAzB,iGAAA,KAAK,OAAA"}
@@ -0,0 +1,60 @@
import * as official from './official'
import * as infinite from './infinite'
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite'
function resolveEngine(type: string) {
return type === 'infinite' ? infinite : official
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
export function getEngine(engineType?: string | null): {
makeWASocket: any
useMultiFileAuthState: any
fetchLatestBaileysVersion: any
makeCacheableSignalKeyStore: any
downloadMediaMessage: any
getContentType: any
generateMessageIDV2: any
generateWAMessageFromContent: any
isJidGroup: any
isJidStatusBroadcast: any
} {
const e = resolveEngine(engineType ?? defaultEngineType)
return {
makeWASocket: e.makeWASocket as any,
useMultiFileAuthState: e.useMultiFileAuthState as any,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion as any,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore as any,
downloadMediaMessage: e.downloadMediaMessage as any,
getContentType: e.getContentType as any,
generateMessageIDV2: e.generateMessageIDV2 as any,
generateWAMessageFromContent: e.generateWAMessageFromContent as any,
isJidGroup: e.isJidGroup as any,
isJidStatusBroadcast: e.isJidStatusBroadcast as any,
}
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType)
export const makeWASocket = selectedEngine.makeWASocket as any
export const useMultiFileAuthState = selectedEngine.useMultiFileAuthState as any
export const DisconnectReason = selectedEngine.DisconnectReason as any
export const fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion as any
export const makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore as any
export const downloadMediaMessage = selectedEngine.downloadMediaMessage as any
export const getContentType = selectedEngine.getContentType as any
export const generateMessageIDV2 = selectedEngine.generateMessageIDV2 as any
export const generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent as any
export const isJidGroup = selectedEngine.isJidGroup as any
export const isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast as any
export { proto } from './official'
// Exportação estática de tipos para garantir segurança em tempo de compilação
export type { WASocket, ConnectionState, WAMessage, Contact, BinaryNode } from './official'
@@ -0,0 +1,18 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("baileys"), exports);
//# sourceMappingURL=infinite.js.map
@@ -0,0 +1 @@
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
@@ -0,0 +1 @@
export * from 'baileys'
@@ -0,0 +1,18 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("@whiskeysockets/baileys"), exports);
//# sourceMappingURL=official.js.map
@@ -0,0 +1 @@
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
@@ -0,0 +1 @@
export * from '@whiskeysockets/baileys'
@@ -0,0 +1,505 @@
"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 o JID real do telefone:
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const pnJid = c.jid
?? c.phoneNumber;
const contactJid = isLid && pnJid ? pnJid : c.id;
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
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 } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
});
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma_1.prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
contactId: null,
},
data: { contactId: contact.id },
});
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid');
if (!resolvedIsLid) {
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: contactJid,
avatarUrl: imgUrl,
});
}
else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(contactJid);
}
}
}
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;
//# sourceMappingURL=ContactHandler.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,559 @@
/**
* ContactHandler Gerencia contatos e avatares do WhatsApp.
*
* Responsabilidades:
* 1. Upsert de contatos em massa (evento contacts.upsert do Baileys)
* 2. Update parcial de contatos (evento contacts.update)
* 3. Sincronização de avatares com rate-limit (fila com batch + delay)
* 4. Reconciliação de nomes via pushName das mensagens (pós history sync)
* 5. Mapeamento LID phone JID (captura do campo `lid` nos contatos)
* 6. Sincronização do avatar da própria instância
*
* CONTEXTO LID:
* O Baileys pode enviar contatos com id no formato @lid (Linked ID).
* Quando o contato inclui o campo `jid` (history sync), usamos o JID real
* e armazenamos o LID em contacts.lidJid para resolução futura.
* @see MessageHandler.resolveLidToPhoneJid()
*
* REGRA DE PRIORIDADE DE NOMES:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
* Essa hierarquia é respeitada tanto no upsert quanto na resolução pelo frontend.
*
* @see WhatsAppConnectionManager registra os event listeners
* @see MessageHandler consome os contatos para vincular a mensagens
*/
import type { Contact, WASocket } from '../engine'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import type { Server as SocketIOServer } from 'socket.io'
// ─── 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
export class ContactHandler {
/** Fila de JIDs aguardando busca de avatar */
private avatarQueue: string[] = []
/** Flag de mutex simples — evita processamento concorrente da fila */
private processingAvatars = false
constructor(
/** UUID da instância WhatsApp */
private instanceId: string,
/** UUID do tenant (isolamento multi-tenant) */
private tenantId: string,
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
private sock?: WASocket,
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
private io?: SocketIOServer
) {}
// ═══════════════════════════════════════════════════════════════════════════
// 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.
*/
private resolveName(c: Partial<Contact>): string | undefined {
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 campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts: Contact[]): Promise<void> {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar: string[] = []
/** 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 as any).imgUrl as string | null | undefined
// 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 as any).lid as string | undefined
try {
// Para contatos @lid que possuem o JID real do telefone:
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const pnJid = (c as any).jid as string | undefined
?? (c as any).phoneNumber as string | undefined
const contactJid = isLid && pnJid ? pnJid : c.id
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined)
const contact = await 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 } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
})
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
contactId: null,
},
data: { contactId: contact.id },
})
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid')
if (!resolvedIsLid) {
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: contactJid,
avatarUrl: imgUrl,
})
} else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(contactJid)
}
}
} catch (err) {
logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato')
}
}
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 atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts: Partial<Contact>[]): Promise<void> {
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.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.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: string): Promise<string | null> {
if (!this.sock?.user?.id) return null
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image')
if (url) {
await prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } })
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 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(): Promise<void> {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await 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.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome')
return
}
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.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.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
})
updated++
}
}
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.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(): Promise<void> {
if (!this.sock) return
try {
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.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído')
} catch (err) {
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(): Promise<void> {
try {
const missing = await 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.info({ instanceId: this.instanceId, count: jids.length },
'[ContactHandler] Sync pós-conexão: buscando avatares ausentes')
this.enqueueAvatars(jids)
} catch (err) {
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 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(): Promise<void> {
try {
const contacts = await 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.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.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.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 está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids: string[]): void {
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
*/
private async processAvatarQueue(): Promise<void> {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0) return
this.processingAvatars = true
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.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<void>((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE))
}
}
this.processingAvatars = false
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído')
}
}
@@ -0,0 +1,921 @@
"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 engine_1 = require("../engine");
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");
const chat_cache_1 = require("../../chats/chat.cache");
/** 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)
]);
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg) {
let cur = msg;
let depth = 0;
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo');
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k));
if (!wrapperKey)
return cur;
const inner = cur[wrapperKey]?.message;
if (!inner || typeof inner !== 'object')
return cur;
cur = inner;
depth++;
}
return cur;
}
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;
// Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
const mensagensRecentes = [];
const mensagensAntigas = [];
// Separa as mensagens por idade
for (const msg of messages) {
const ts = Number(msg.messageTimestamp ?? 0);
if (ts >= doisDiasAtrasEmSegundos) {
mensagensRecentes.push(msg);
}
else {
mensagensAntigas.push(msg);
}
}
// 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
if (mensagensRecentes.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensRecentes.length
}, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
await this.processHistoryBatch(mensagensRecentes);
}
// 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
if (mensagensAntigas.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensAntigas.length
}, '[History] Agendando histórico antigo para sincronização gradual em background...');
this.processOlderHistoryGradually(mensagensAntigas);
}
}
/**
* Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
*/
processOlderHistoryGradually(messages) {
const tamanhoDoLote = 100;
const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
let offset = 0;
const processarProximoLote = async () => {
// Se processou tudo, encerra o ciclo
if (offset >= messages.length) {
logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
return;
}
// Corta o próximo pedaço do histórico
const lote = messages.slice(offset, offset + tamanhoDoLote);
offset += tamanhoDoLote;
try {
await this.processHistoryBatch(lote);
}
catch (err) {
logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
}
// Agenda o próximo lote de forma não bloqueante
setTimeout(processarProximoLote, intervaloMs).unref();
};
// Inicia o sincronismo em background após 5 segundos, garantindo que
// a conexão inicial e o frontend já estejam 100% carregados e estáveis
setTimeout(processarProximoLote, 5000).unref();
}
/**
* Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
*/
async processHistoryBatch(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).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) {
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
const remoteJidAlt = msg.key.remoteJidAlt;
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
}
else {
// 2. senderPn / participantPn
const senderPn = msg.key.senderPn || msg.key.participantPn;
if (senderPn) {
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
if (!jidRaw.endsWith('@s.whatsapp.net'))
continue;
}
else {
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
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 — descarta (será capturada via phoneNumberShare futuro)
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, messageTimestamp } = msg;
let message = msg.message;
if (!message || !key.id)
return [];
message = unwrapInnerMessage(message);
const contentType = (0, engine_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] Lote de ${totalSaved} 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, messageTimestamp, pushName } = msg;
let message = msg.message;
if (!message || !key.remoteJid)
return;
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
message = unwrapInnerMessage(message);
// 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, engine_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)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message };
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, 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 LIDphone 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,
});
// Invalida cache da listagem — força refetch na próxima request da inbox
(0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
}
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, engine_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}`;
let mediaPath = filePath;
let uploadedToRemote = false;
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;
if (result.provider !== 'local_fallback') {
mediaPath = null;
uploadedToRemote = true;
}
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,
mediaUrl,
// Preserva o nome original do documento para exibição no frontend
...(contentType === 'documentMessage' && originalFileName
? { fileName: originalFileName }
: {}),
},
});
if (uploadedToRemote) {
promises_1.default.unlink(filePath).catch((err) => {
logger_1.logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto');
});
}
// Notifica frontend que a mídia está pronta para exibição
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
messageId: savedMsgId,
mediaUrl,
});
// Persiste sticker na biblioteca (deduplicado por sha256)
if (contentType === 'stickerMessage') {
this.persistSticker(msg, mediaUrl).catch(() => { });
}
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
}
catch (err) {
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
}
}
async persistSticker(msg, wasabiPath) {
const stickerMsg = msg.message?.stickerMessage;
if (!stickerMsg)
return;
const sha256Bytes = stickerMsg.fileSha256;
if (!sha256Bytes || sha256Bytes.length === 0)
return;
const sha256 = Buffer.from(sha256Bytes).toString('hex');
const isAnimated = stickerMsg.isAnimated ?? false;
await prisma_1.prisma.sticker.upsert({
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
sha256,
wasabiPath,
isAnimated,
useCount: 1,
lastSeenAt: new Date(),
},
update: {
useCount: { increment: 1 },
lastSeenAt: new Date(),
instanceId: this.instanceId,
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// 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;
//# sourceMappingURL=MessageHandler.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,888 @@
/**
* 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
*/
import path from 'path'
import fs from 'fs/promises'
import {
type WASocket,
type WAMessage,
downloadMediaMessage,
getContentType,
} from '../engine'
import { MessageType } from '@prisma/client'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import { normalizeJid } from '../../../shared/utils/whatsapp'
import { storageProvider } from '../../../core/StorageProvider'
import type { Server as SocketIOServer } from 'socket.io'
import type { ChatbotService } from '../../../modules/chatbot/chatbot.service'
import { hookBus } from '../../../core/hook-bus'
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path.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)
])
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED — a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg: any): any {
let cur = msg
let depth = 0
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo')
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k))
if (!wrapperKey) return cur
const inner = cur[wrapperKey]?.message
if (!inner || typeof inner !== 'object') return cur
cur = inner
depth++
}
return cur
}
export class MessageHandler {
/**
* 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).
*/
private peerRecipientHints = new Map<string, string>()
constructor(
/** Socket Baileys ativo — usado para download de mídia e reupload */
private sock: WASocket,
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
private instanceId: string,
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
private tenantId: string,
/** Socket.IO server — emite eventos em tempo real para o frontend */
private io: SocketIOServer,
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
private chatbotService?: ChatbotService
) {}
/**
* 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: string, peerRecipientPn: string): void {
this.peerRecipientHints.set(msgId, peerRecipientPn)
setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).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 }: { messages: WAMessage[]; type: string }): Promise<void> {
if (type === 'notify') {
for (const msg of messages) {
try {
await this.processMessage(msg)
} catch (err) {
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: WAMessage[]): Promise<void> {
if (messages.length === 0) return
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
const ownJid = normalizeJid(this.sock.user?.id ?? '')
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
const byJid = new Map<string, WAMessage[]>()
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 = 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).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) {
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
const remoteJidAlt = (msg.key as any).remoteJidAlt as string | undefined
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = normalizeJid(remoteJidAlt)
} else {
// 2. senderPn / participantPn
const senderPn = (msg.key as any).senderPn || (msg.key as any).participantPn
if (senderPn) {
jidRaw = normalizeJid(senderPn)
if (!jidRaw.endsWith('@s.whatsapp.net')) continue
} else {
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
const contact = await 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 — descarta (será capturada via phoneNumberShare futuro)
continue
}
}
}
}
const jid = 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.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.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, messageTimestamp } = msg
let message = msg.message
if (!message || !key.id) return []
message = unwrapInnerMessage(message)
const contentType = getContentType(message as any)
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 === 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' as const, // históricas são consideradas já lidas
timestamp: ts,
}]
})
if (rows.length > 0) {
const { count } = await prisma.message.createMany({ data: rows, skipDuplicates: true })
totalSaved += count
}
} catch (err) {
logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat')
}
}
if (totalSaved > 0) {
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)
*/
private async processMessage(msg: WAMessage): Promise<void> {
const { key, messageTimestamp, pushName } = msg
let message = msg.message
if (!message || !key.remoteJid) return
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
message = unwrapInnerMessage(message)
// 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: string | null = null
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key as any)
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.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe },
'[MessageHandler] @lid sem resolução de telefone — descartando')
return
}
originalLidJid = key.remoteJid
// Reescreve o remoteJid para que TODO o processamento abaixo use o JID real.
// Isso garante que Contact, Chat e Message sejam criados com @s.whatsapp.net.
;(key as any).remoteJid = phoneJid
}
const contentType = getContentType(message as any)
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 === MessageType.UNSUPPORTED) return
const fromMe = key.fromMe ?? false
const remoteJidRaw = key.remoteJid
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = normalizeJid(remoteJidRaw)
const messageId = key.id!
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const ownJid = 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 as any).participant ?? null
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null
const contact = await 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.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 as number) * 1000),
},
update: {
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
lastMessageAt: new Date((messageTimestamp as number) * 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: string | null = 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: string | null = null
if (replyToWaId) {
const replyMsg = await 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.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 as number) * 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.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
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)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message }
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, 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.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
*/
private async resolveLidToPhoneJid(
lidJid: string,
key: { id?: string | null; fromMe?: boolean | null; senderPn?: string; participantPn?: string }
): Promise<string | null> {
// 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 = 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 = normalizeJid(hint)
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid
}
}
}
// 3. Busca no DB por mapeamento LID salvo anteriormente
const contact = await 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.
*/
private async emitChatUpsert(
chatId: string,
lastMessage: { body: string | null; fromMe: boolean; status: string; type: string; timestamp: Date }
): Promise<void> {
try {
const chat = await 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.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}
*/
private async downloadAndPersistMedia(
msg: WAMessage,
savedMsgId: string,
chatId: string,
contentType: string
): Promise<void> {
try {
const buffer = await downloadMediaMessage(
msg as any,
'buffer',
{},
{ logger: logger as any, reuploadRequest: this.sock.updateMediaMessage as any }
)
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: string
if (contentType === 'documentMessage' && originalFileName) {
ext = path.extname(originalFileName).replace('.', '') || 'bin'
} else {
ext = this.extensionForType(contentType)
}
const fileName = `${savedMsgId}.${ext}`
const filePath = path.join(MEDIA_DIR, this.instanceId, fileName)
await fs.mkdir(path.dirname(filePath), { recursive: true })
await fs.writeFile(filePath, buffer as Buffer)
// Tenta upload para Wasabi; se falhar usa path local como fallback
let mediaUrl = `/media/${this.instanceId}/${fileName}`
let mediaPath: string | null = filePath
let uploadedToRemote = false
if (storageProvider.isRegistered()) {
try {
const result = await storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: this.tenantId,
sessionJID: this.instanceId,
file: {
originalname: originalFileName ?? fileName,
buffer: buffer as Buffer,
mimetype: originalMimetype,
},
})
mediaUrl = result.path
if (result.provider !== 'local_fallback') {
mediaPath = null
uploadedToRemote = true
}
logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage')
} catch (uploadErr) {
logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local')
}
}
await prisma.message.update({
where: { id: savedMsgId },
data: {
mediaPath,
mediaUrl,
// Preserva o nome original do documento para exibição no frontend
...(contentType === 'documentMessage' && originalFileName
? { fileName: originalFileName }
: {}),
},
})
if (uploadedToRemote) {
fs.unlink(filePath).catch((err) => {
logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto')
})
}
// Notifica frontend que a mídia está pronta para exibição
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
messageId: savedMsgId,
mediaUrl,
})
logger.debug({ savedMsgId, filePath }, 'Mídia salva')
} catch (err) {
logger.error({ err, savedMsgId }, 'Falha ao baixar mídia')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════
/** Verifica se o contentType é de mídia (precisa de download) */
private isMediaMessage(contentType: string): boolean {
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.
*/
private mapContentType(contentType: string): MessageType {
const map: Record<string, MessageType> = {
conversation: MessageType.TEXT,
extendedTextMessage: MessageType.TEXT,
imageMessage: MessageType.IMAGE,
videoMessage: MessageType.VIDEO,
audioMessage: MessageType.AUDIO,
documentMessage: MessageType.DOCUMENT,
stickerMessage: MessageType.STICKER,
locationMessage: MessageType.LOCATION,
contactMessage: MessageType.CONTACT,
reactionMessage: MessageType.REACTION,
pollCreationMessage: MessageType.POLL,
pollCreationMessageV2: MessageType.POLL, // poll multi-select grupos
pollCreationMessageV3: MessageType.POLL, // poll single-select
// Resposta ao clique de botão / seleção de item de lista
interactiveResponseMessage: MessageType.INTERACTIVE,
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
interactiveMessage: MessageType.BUTTONS,
}
return map[contentType] ?? MessageType.UNSUPPORTED
}
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
private extensionForType(contentType: string): string {
const map: Record<string, string> = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
}
return map[contentType] ?? 'bin'
}
/** Retorna o mimetype adequado para o tipo de mídia */
private mimetypeForType(contentType: string): string {
const map: Record<string, string> = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
}
return map[contentType] ?? 'application/octet-stream'
}
}
@@ -0,0 +1,29 @@
import { prisma } from '../../../infra/database/prisma'
import type { InstanceStatus } from '@prisma/client'
export class InstanceRepository {
async findAllByTenant(tenantId: string) {
return prisma.instance.findMany({
where: { tenantId },
orderBy: { createdAt: 'asc' },
})
}
async findById(id: string) {
return prisma.instance.findUnique({ where: { id } })
}
async create(tenantId: string, name: string) {
return prisma.instance.create({
data: { tenantId, name },
})
}
async updateStatus(id: string, status: InstanceStatus) {
return prisma.instance.update({ where: { id }, data: { status } })
}
async delete(id: string) {
return prisma.instance.delete({ where: { id } })
}
}

Some files were not shown because too many files have changed in this diff Show More