chore(ops): cleanly purge old clube67 folder references
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
continuous-integration/webhook Falha no deploy de clube67_newwhats.local (VPS 4)
This commit is contained in:
@@ -1,9 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.log
|
||||
.env
|
||||
sessions
|
||||
media
|
||||
storage
|
||||
uploads
|
||||
@@ -1,46 +0,0 @@
|
||||
# ─────────────────────────────────────────────
|
||||
# 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
|
||||
@@ -1,24 +0,0 @@
|
||||
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"]
|
||||
Binary file not shown.
-9610
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -1,560 +0,0 @@
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 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
|
||||
@@ -1,45 +0,0 @@
|
||||
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())
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
@@ -1,9 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Re-export unificado das configs para compatibilidade com plugins
|
||||
export { env as config } from './config/env'
|
||||
export { env } from './config/env'
|
||||
@@ -1,27 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,27 +0,0 @@
|
||||
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
|
||||
@@ -1,15 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,10 +0,0 @@
|
||||
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,
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,114 +0,0 @@
|
||||
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 }
|
||||
@@ -1,55 +0,0 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,53 +0,0 @@
|
||||
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()
|
||||
@@ -1,52 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,54 +0,0 @@
|
||||
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()
|
||||
@@ -1,119 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
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()
|
||||
@@ -1,3 +0,0 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
//# sourceMappingURL=types.js.map
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
|
||||
@@ -1,138 +0,0 @@
|
||||
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>
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,102 +0,0 @@
|
||||
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()
|
||||
@@ -1,14 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* 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 }
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* 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)
|
||||
})
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 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' }
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { conflitoAgendaWorkflow, confirmarSlotSignal } from './conflitoAgendaWorkflow'
|
||||
export { reputacaoWorkflow, registrarInfracaoSignal, encerrarMonitoramentoSignal } from './reputacaoWorkflow'
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,3 +0,0 @@
|
||||
// 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'
|
||||
@@ -1,8 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,6 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
/**
|
||||
* 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' })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -1,176 +0,0 @@
|
||||
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}`
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,121 +0,0 @@
|
||||
/**
|
||||
* 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 },
|
||||
}),
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
"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
@@ -1,263 +0,0 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,40 +0,0 @@
|
||||
/**
|
||||
* 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)')
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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 } })
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -1,265 +0,0 @@
|
||||
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 }
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
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 })
|
||||
})
|
||||
@@ -1,232 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
-1127
File diff suppressed because it is too large
Load Diff
-1
File diff suppressed because one or more lines are too long
-1307
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"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"}
|
||||
@@ -1,60 +0,0 @@
|
||||
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'
|
||||
@@ -1,18 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"infinite.js","sourceRoot":"","sources":["infinite.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0CAAuB"}
|
||||
@@ -1 +0,0 @@
|
||||
export * from 'baileys'
|
||||
@@ -1,18 +0,0 @@
|
||||
"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
|
||||
@@ -1 +0,0 @@
|
||||
{"version":3,"file":"official.js","sourceRoot":"","sources":["official.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,0DAAuC"}
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@whiskeysockets/baileys'
|
||||
@@ -1,505 +0,0 @@
|
||||
"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
@@ -1,559 +0,0 @@
|
||||
/**
|
||||
* 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 há 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 só 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 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(): 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 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(): 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 já 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')
|
||||
}
|
||||
}
|
||||
@@ -1,921 +0,0 @@
|
||||
"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 LID→phone foi movida para processMessage
|
||||
* (após o upsert do Contact), para evitar o bug anterior onde o
|
||||
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
|
||||
*
|
||||
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
|
||||
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
|
||||
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
|
||||
*/
|
||||
async resolveLidToPhoneJid(lidJid, key) {
|
||||
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
|
||||
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
|
||||
// descreve o DONO da sessão, não o destinatário — inútil aqui.
|
||||
const senderPn = key.senderPn || key.participantPn;
|
||||
if (senderPn && !key.fromMe) {
|
||||
const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn);
|
||||
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
|
||||
return phoneJid;
|
||||
}
|
||||
}
|
||||
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
|
||||
// mensagens fromMe=true que chegam via sync de outro dispositivo.
|
||||
if (key.fromMe && key.id) {
|
||||
const hint = this.peerRecipientHints.get(key.id);
|
||||
this.peerRecipientHints.delete(key.id); // uso único
|
||||
if (hint) {
|
||||
const phoneJid = (0, whatsapp_1.normalizeJid)(hint);
|
||||
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
|
||||
return phoneJid;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. Busca no DB por mapeamento LID salvo anteriormente
|
||||
const contact = await prisma_1.prisma.contact.findFirst({
|
||||
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
|
||||
select: { jid: true },
|
||||
});
|
||||
if (contact?.jid)
|
||||
return contact.jid;
|
||||
return null;
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// EMISSÃO DE EVENTOS SOCKET.IO
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
/**
|
||||
* Emite evento `chat:upsert` para todos os clientes do tenant.
|
||||
*
|
||||
* Usado para atualizar a lista de chats no frontend em tempo real:
|
||||
* - Novos chats aparecem instantaneamente
|
||||
* - Chats existentes atualizam lastMessage, unreadCount, etc.
|
||||
* - Chats @lid são filtrados (não devem aparecer no frontend)
|
||||
*
|
||||
* O frontend usa este evento para reordenar a lista por lastMessageAt
|
||||
* e exibir o preview da última mensagem.
|
||||
*/
|
||||
async emitChatUpsert(chatId, lastMessage) {
|
||||
try {
|
||||
const chat = await prisma_1.prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
contact: {
|
||||
select: {
|
||||
name: true,
|
||||
verifiedName: true,
|
||||
notify: true,
|
||||
phone: true,
|
||||
avatarUrl: true,
|
||||
scoreReputacao: true,
|
||||
flagRestricao: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!chat)
|
||||
return;
|
||||
// Chats @lid são artefatos internos — nunca expor ao frontend
|
||||
if (chat.jid.endsWith('@lid'))
|
||||
return;
|
||||
const c = chat.contact;
|
||||
const isGroup = chat.jid.endsWith('@g.us');
|
||||
// Regra de resolução de nome:
|
||||
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
|
||||
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
|
||||
// NUNCA usar pushName do remetente como nome de grupo
|
||||
const displayName = isGroup
|
||||
? (chat.name ?? null)
|
||||
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
|
||||
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
|
||||
id: chat.id,
|
||||
tenantId: chat.tenantId,
|
||||
instanceId: chat.instanceId,
|
||||
jid: chat.jid,
|
||||
name: chat.name ?? null,
|
||||
unreadCount: chat.unreadCount,
|
||||
lastMessageAt: chat.lastMessageAt,
|
||||
isPinned: chat.isPinned,
|
||||
isArchived: chat.isArchived,
|
||||
botPaused: chat.botPaused,
|
||||
contact: c
|
||||
? {
|
||||
name: displayName,
|
||||
phone: c.phone,
|
||||
avatarUrl: c.avatarUrl,
|
||||
scoreReputacao: c.scoreReputacao,
|
||||
flagRestricao: c.flagRestricao,
|
||||
}
|
||||
: isGroup
|
||||
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
|
||||
: null,
|
||||
lastMessage,
|
||||
});
|
||||
// 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
File diff suppressed because it is too large
Load Diff
@@ -1,888 +0,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
|
||||
*/
|
||||
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'
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
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
Reference in New Issue
Block a user