feat: sincronização de desenvolvimento local da VPS 4 para o Gitea

This commit is contained in:
VPS 4 Deploy Agent
2026-05-10 07:38:54 +02:00
parent 621fbfba64
commit 3396fa0598
402 changed files with 234697 additions and 0 deletions
+31
View File
@@ -3,3 +3,34 @@ gcal_key.json
ssh_alerts_config.json
*.log
.DS_Store
# Next.js & Node builds
**/node_modules/
**/.next/
**/out/
**/dist/
**/build/
.env*
!.env.example
# Traefik & SSL (Cuidado com segredos!)
**/acme.json
**/traefik_logs.txt
# Databases / Backups / Dumps
*.sql
*.tar.gz
*.zip
# Monitoramento e Dados de Estado (SOC)
**/prom-data/
**/wal/
**/chunks/
**/tombstones/
**/positions.yaml
# Sessões e Mídia locais
**/sessions/
**/media/
**/newwhats-sessions/
**/newwhats-media/
+3
View File
@@ -0,0 +1,3 @@
{
"docker.host": "unix:///run/user/1000/docker.sock"
}
+244
View File
@@ -0,0 +1,244 @@
# 📐 Arquitetura de Rede e Topologia de Servidores
Este documento descreve detalhadamente a arquitetura de servidores e rede privada projetada para garantir **alta performance, isolamento de riscos, segurança de dados e facilidade de escalonamento**.
---
## 🗺️ Visão Geral da Topologia (Rede VPN WireGuard)
Toda a comunicação crítica entre os servidores trafega por dentro de uma rede privada criptografada baseada no protocolo **WireGuard** (`10.99.0.0/24`). Os servidores possuem seus firewalls públicos trancados, permitindo apenas acessos necessários e isolando os dados vitais.
```mermaid
graph TD
subgraph "Rede Pública (Internet)"
Internet((Internet Pública))
Client([Notebook Rui])
end
subgraph "Rede Privada VPN (10.99.0.0/24)"
VPS1["🖥️ VPS 1 (Produção) <br> IP: 10.99.0.1 <br> (Traefik / Apps)"]
VPS3["🗄️ VPS 3 (Banco de Dados) <br> IP: 10.99.0.3 <br> (PostgreSQL Dedicado)"]
VPS4["🧪 VPS 4 (Desenvolvimento) <br> IP: 10.99.0.4 <br> (Staging / Builds)"]
VPS5["💬 VPS 5 (CRM WhatsApp) <br> IP: 10.99.0.5 <br> (Bots / APIs)"]
end
%% Conexões Públicas
Internet == HTTP/HTTPS (80/443) ==> VPS1
Client == Conexão VPN Criptografada ==> VPS1
%% Conexões Privadas VPN
VPS1 <== Canal Seguro VPN ==> VPS3
VPS1 <== Canal Seguro VPN ==> VPS4
VPS1 <== Canal Seguro VPN ==> VPS5
Client <== Acesso SSH Seguro ==> VPS1
Client <== Acesso SSH Seguro ==> VPS3
Client <== Acesso SSH Seguro ==> VPS4
Client <== Acesso SSH Seguro ==> VPS5
%% Conexões de Banco de Dados Internas
VPS1 -- PostgreSQL (Porta 5432) --> VPS3
VPS4 -- PostgreSQL (Porta 5432) --> VPS3
VPS5 -- PostgreSQL (Porta 5432) --> VPS3
style VPS3 fill:#1E293B,stroke:#3B82F6,stroke-width:3px,color:#fff
style VPS1 fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
style VPS4 fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
style VPS5 fill:#1E293B,stroke:#EC4899,stroke-width:2px,color:#fff
```
---
## 📋 Especificação Detalhada dos Servidores
### 1. 🗄️ VPS 3: Servidor de Banco de Dados Dedicado (Appliance)
* **Função Principal**: Hospedar exclusivamente o banco de dados **PostgreSQL Bare-Metal (Nativo)** e serviços fundamentais de persistência.
* **IP Privado VPN**: `10.99.0.3`
* **Tecnologias**: PostgreSQL (Instalação Nativa via Host), WireGuard, nftables. (O Docker é **estritamente omitido** para reduzir superfície de ataque e otimizar I/O).
* **Políticas do Firewall (nftables)**:
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Liberada **apenas** para o IP reservado do administrador via VPN (`10.99.0.10`).
* **Porta 5432 (PostgreSQL)**: **Bloqueada publicamente**. Liberada **apenas** para conexões originadas de IPs autorizados na VPN (`10.99.0.1` e `10.99.0.5`).
* **Vantagens de Segurança & Performance**:
* **Zero vazamento de dados**: O banco não pode ser acessado de fora da VPN sob hipótese alguma.
* **Conceito de Appliance**: Sem Docker daemon (sem socket de root adicional) ou compiladores, a superfície de invasão é reduzida ao menor nível matemático possível.
* **Performance Máxima (Tuning de Host)**: Como a máquina é nativa e dedicada, as configurações de cache e buffers de memória do PostgreSQL podem usufruir livremente do Page Cache nativo do Linux e Huge Pages do Kernel, garantindo o máximo rendimento físico do SSD e RAM.
---
### 🚀 2. VPS 1: Servidor de Sistemas em Produção
* **Função Principal**: Gateway de entrada do tráfego público e hospedagem das aplicações em produção.
* **IP Privado VPN**: `10.99.0.1` (Atua como Hub WireGuard)
* **Políticas do Firewall (UFW)**:
* **Portas 80 (HTTP) e 443 (HTTPS)**: **Abertas ao público** para permitir o acesso dos clientes aos sites e sistemas.
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Porta 3002 (Antigo Dev)**: **Bloqueada publicamente** no Docker (atrelada apenas ao IP privado `10.99.0.1` do container).
* **Vantagens de Segurança & Performance**:
* **Serviços sem Estado (Stateless)**: A VPS 1 apenas processa as requisições web e consome os dados da VPS 3. Caso a VPS 1 sofra um pico extremo de acessos, o banco de dados continua intacto e seguro.
* **Centralização com Traefik**: Proxy reverso centralizado gerenciando SSL e direcionamento interno seguro de containers.
---
### 🧪 3. VPS 4: Servidor de Desenvolvimento e Homologação (Staging)
* **Função Principal**: Testar novos recursos, hospedar containers de staging e realizar builds de desenvolvimento.
* **IP Privado VPN**: `10.99.0.4`
* **Políticas do Firewall (UFW)**:
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Portas de teste (ex: 3002, 8080)**: **Bloqueadas publicamente** ou atreladas apenas ao IP privado da VPN para acesso exclusivo dos desenvolvedores autorizados.
* **Vantagens de Segurança & Performance**:
* **Isolamento de Riscos**: Compilações de código (`npm run build`, `docker build`) e testes pesados de carga rodam aqui e consomem CPU sem comprometer os servidores de produção e banco de dados.
* **Ambiente Espelho**: Homologação idêntica à produção para validações precisas antes de aplicar modificações em produção.
---
### 💬 4. VPS 5: Servidor de Aplicação Proprietária (newwhats)
* **Função Principal**: Hospedar o backend e workers da plataforma proprietária **newwhats** (Backend Express com Socket.IO, Workers do Temporal e broker NATS).
* **IP Privado VPN**: `10.99.0.5`
* **Políticas do Firewall (UFW)**:
* **Portas de Integração Web**: Liberadas apenas para os Webhooks e requisições públicas de Socket.IO mapeadas via gateway.
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Vantagens de Segurança & Performance**:
* **Isolamento de Estado**: Executar o backend Node.js (Prisma/Knex), os workers de workflows do Temporal e o broker de mensagens NATS na VPS 5 impede que oscilações no tráfego de mensagens do WhatsApp ou alto uso de CPU nos workers interfiram com o banco de dados principal de produção (VPS 3).
* **Separação de CPU**: O processamento pesado das sessões de WhatsApp e emulação de instâncias roda na VPS 5, consumindo sua CPU e RAM de forma isolada do Gateway (VPS 1) e do Banco (VPS 3).
---
## 🔒 Diretrizes de Segurança Corporativa (Resumo)
1. **Acesso Administrativo**: Realizado **exclusivamente via chave SSH criptográfica**. Autenticação por senhas desativada em todas as máquinas.
2. **Login de Root Desativado**: Bloqueado login direto de root via SSH. Todos conectam como o usuário `deploy` e elevam privilégios de forma segura via `sudo` após a conexão.
3. **Ponto Único de Falha**: Se a VPS 1 (Hub WireGuard) cair, a comunicação VPN é temporariamente pausada. O console VNC da Contabo é mantido ativo como canal redundante de suporte imediato.
4. **Logs Automatizados**: Logins SSH bem-sucedidos ou suspeitos geram alertas em segundo plano com marcações de 30 minutos na agenda central do Google, permitindo controle visual de acessos em tempo real.
---
## 🛡️ Fase 2: Hardening Avançado e Arquitetura Zero-Trust (Roadmap)
Esta seção documenta as decisões estratégicas e o plano de ação de segurança de curto e médio prazo projetados para elevar a maturidade da infraestrutura de rede e servidores ao nível **Enterprise / DevSecOps Sênior**.
### 🗺️ Visão Geral do Fluxo de Segurança na Borda
```mermaid
graph TD
subgraph "Camadas de Defesa (Defense in Depth)"
Internet((Internet Pública)) --> PM[1. Proteção de Borda do Provedor]
PM --> NFT[2. nftables + CrowdSec <br> Bloqueio Dinâmico / Comportamental]
NFT --> TF[3. Traefik Reverse Proxy <br> SSL/TLS & Roteamento]
TF --> DK[4. Containers Docker <br> Rootless / Hardened]
end
```
---
### 1. 🌐 Desativação Proativa de IPv6 (Curto/Médio Prazo)
Para eliminar vetores de bypass acidentais de firewall e reduzir a complexidade da superfície de ataque, o suporte a IPv6 é **totalmente desativado** em todas as VPSs.
* **Ações de Configuração**:
Para desativar em tempo de execução e garantir persistência pós-reboot, o arquivo `/etc/sysctl.d/99-disable-ipv6.conf` deve ser criado com as seguintes diretivas:
```text
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
```
*Comando para aplicar imediatamente:* `sudo sysctl --system`
* **Gatilhos para Reativação do IPv6**:
O IPv6 só será reabilitado caso surja uma necessidade real de negócio, tais como:
* Requisitos de conformidade corporativa (*Enterprise Compliance*).
* Integração obrigatória com CDNs IPv6-only ou arquiteturas de borda distribuída (*Anycast*).
* Casos em que a reativação exigirá a implementação completa de **nftables dual-stack, filtros de Router Advertisement (RA), NDP e SLAAC seguros**.
---
### 2. 🐋 Isolamento de Redes Docker (Prevenção de Movimentação Lateral)
O uso da rede de ponte padrão (`bridge`) do Docker é estritamente proibido em produção. Os containers são segmentados em **redes de domínios funcionais isolados**, limitando severamente o raio de colisão (*blast radius*) caso um container de aplicação (especialmente bots de CRM/WhatsApp ou navegadores invisíveis) seja comprometido.
#### 🔀 Matriz de Conectividade de Dados (newwhats):
```mermaid
graph TD
subgraph "VPS 1: Gateway de Borda pública"
Traefik[Traefik Router]
end
subgraph "VPS 5: Camada de Aplicação (newwhats)"
Express[Backend Express & Socket.IO]
TempWorker[Temporal Worker]
NATS[NATS Broker :4222]
Express <--> NATS
TempWorker <--> Express
end
subgraph "Túnel VPN Criptografado (wg0)"
WG0((Canal Privado WireGuard))
end
subgraph "VPS 3: Appliance de Dados Bare-Metal"
Postgres[(PostgreSQL :5432)]
Dragonfly[(DragonflyDB :6379)]
Temporal[(Temporal Server :7233)]
end
%% Roteamento Web e Sockets
Traefik == HTTPS Proxy / Sockets ==> WG0 ==> Express
%% Conexões de Dados da Aplicação via VPN
Express ==> WG0 ==> Postgres
Express ==> WG0 ==> Dragonfly
TempWorker ==> WG0 ==> Temporal
Temporal -. Persistência Interna .-> Postgres
style Postgres fill:#1E293B,stroke:#3B82F6,stroke-width:2px,color:#fff
style Dragonfly fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
style Temporal fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
```
* **Diretrizes de Conectividade**:
* **Tráfego de Borda**: O Traefik na VPS 1 encaminha o tráfego HTTP/HTTPS e conexões persistentes do Socket.IO diretamente para a VPS 5 (`10.99.0.5`) pela interface de túnel criptografado, mantendo as portas da VPS 5 públicas totalmente fechadas.
* **Persistência de Negócio (Prisma + Knex)**: Ambas as ORMs/Query Builders centralizadas na VPS 5 apontam diretamente para o IP interno `10.99.0.3:5432` da VPS 3.
* **Cache e Estados Rápidos**: O DragonflyDB na VPS 3 atende instantaneamente às solicitações de sessões e QR codes da VPS 5 via `10.99.0.3:6379`, provendo tempos de resposta na faixa de microssegundos sem concorrência local de CPU.
* **Workflows Assíncronos**: O Temporal Server processa o agendamento de tarefas e status de reputação na VPS 3, enquanto o **Temporal Worker** na VPS 5 consome e executa as atividades pesadas locais da aplicação.
---
### 3. 🔑 Zero-Trust Interno no WireGuard (ACLs no Firewall)
A rede VPN WireGuard (`10.99.0.0/24`) deixa de ser tratada como uma zona de confiança irrestrita. Implementa-se uma política de privilégio mínimo na comunicação entre os servidores da VPN.
* **O Problema de Movimentação Lateral**:
Se a máquina de desenvolvimento/staging (VPS 4) for comprometida por um script malicioso, o atacante não pode ter permissão para escaneá-la ou conectar-se ao banco de dados de produção (VPS 3).
* **Regras de Isolamento Lateral (Exemplo no Firewall da VPS 3)**:
```text
# Bloquear por padrão toda conexão vinda da VPN exceto as estritamente autorizadas:
- Permitir VPS 1 (Produção) -> Porta 5432 (PostgreSQL) [ACEITAR]
- Permitir VPS 5 (CRM) -> Porta 5432 (PostgreSQL) [ACEITAR]
- Permitir VPS 4 (Staging) -> VPS 3 (Produção) [BLOQUEAR]
```
---
### 4. 🎛️ Migração de Borda: nftables + CrowdSec
Substitui-se a pilha clássica e reativa do `iptables` / `Fail2Ban` por uma arquitetura ativa, dinâmica e de alta performance na VPS pública 1.
* **Por que `nftables`?**
A sintaxe nativa do `nftables` permite a criação de conjuntos dinâmicos (*sets*) de IP de forma extremamente eficiente, processados diretamente no kernel com consumo mínimo de CPU durante ataques volumétricos.
* **Por que `CrowdSec`?**
Diferente do Fail2Ban (que analisa logs localmente e de forma reativa por expressões regulares), o CrowdSec utiliza detecção baseada em comportamentos locais de ataque e sincroniza uma base global de reputação de IPs (*inteligência coletiva*). Se um IP atacou outra infraestrutura na internet, ele é bloqueado na sua VPS antes mesmo de fazer a primeira requisição ao seu Traefik.
---
### 5. 💔 Mitigação do Ponto Único de Falha (SPOF) da VPS 1
Atualmente, a VPS 1 acumula as funções de **Gateway de Borda (Traefik)**, **Hub Central de VPN (WireGuard)** e **Hospedagem de Aplicações**.
* **Visão de Evolução Futura**:
Para eliminar o risco de interrupção total dos serviços em caso de queda da VPS 1, planeja-se a separação física de responsabilidades em médio prazo:
1. **VPS Edge Dedicated**: Uma máquina leve rodando exclusivamente o gateway de entrada (`Traefik`), `nftables` e o concentrador `WireGuard`.
2. **VPS App Dedicated**: Servidores internos que hospedam as aplicações (sem expor portas SSH ou HTTP à internet), consumindo tráfego encaminhado diretamente pelo Gateway da Edge.
---
### 6. 🔒 Hardening Avançado de Containers
Para garantir que a invasão de um container não resulte no comprometimento do sistema operacional host, aplicam-se as seguintes diretrizes de segurança no provisionamento de containers Docker:
* **No-New-Privileges**: Impede que processos dentro do container ganhem novos privilégios via binários `setuid` ou `setgid`.
* **Read-Only Root Filesystem**: Monta o sistema de arquivos do container como apenas leitura, forçando gravações temporárias apenas em volumes declarados ou `/tmp` na memória (tmpfs).
* **CAP Drop**: Remove capacidades de sistema desnecessárias ao container (ex: `NET_ADMIN`, `SYS_ADMIN`, `SYS_CHROOT`).
* **Rootless Containers**: Sempre que suportado pela aplicação, os containers devem rodar com IDs de usuário não privilegiados (non-root).
@@ -0,0 +1,319 @@
# Baileys Engine Swap — Plano de Implementação
**Data:** 2026-04-29
**Contexto:** Mensagens ricas (botões, lista, carrossel) não renderizam no WhatsApp Web com `@whiskeysockets/baileys` padrão. A solução é usar o fork InfiniteAPI que implementa esses tipos nativamente dentro do `sendMessage()`.
---
## 1. Problema Raiz
O `@whiskeysockets/baileys` v6.7.18 (oficial) **não suporta** os campos:
- `nativeButtons`
- `nativeList`
- `nativeCarousel`
O WhatsApp Web exige nós binários adicionais (`<biz><interactive>`, `<bot biz_bot="1"/>`) injetados no stanza via `relayMessage(additionalNodes)`. O `rich-message.ts` atual tenta replicar isso manualmente, mas **ainda falha no WhatsApp Web**.
**Erro observado:** "Não foi possível carregar a mensagem. Use seu celular para acessá-la."
### Por que o InfiniteAPI funciona
O fork `github:rsalcara/InfiniteAPI` (v7.0.0-rc.9, package name: `baileys`) implementa toda a lógica de `additionalNodes` internamente no `generateWAMessageContent()` / `relayMessage()`. O dev só chama `sock.sendMessage(jid, { nativeButtons: [...] })` e o fork faz o resto.
### Por que whatsmeow (Go) NÃO resolve
Pesquisa confirmada (abril 2026):
- `InteractiveMessage` + `NativeFlowMessage` → struct existe, botões **não aparecem** (issue #711, sem solução)
- `ListMessage`**não exibe** na conversa do destinatário (issue #669)
- `CarouselMessage`**não existe** (exclusivo da Cloud API)
- Migração seria ~23 meses de reescrita para o mesmo resultado quebrado
---
## 2. Solução Escolhida: Engine Swap com Fallback
Instalar **os dois** packages (nomes diferentes, convivem no mesmo `node_modules`) e criar uma camada de abstração `engine/` que permite trocar via variável de ambiente + restart automático.
**Downtime por troca:** ~510 segundos (restart + reconexão automática, sem novo QR — auth state no disco é compatível entre os dois, InfiniteAPI é fork direto).
---
## 3. Estrutura de Arquivos
```
backend/
src/
modules/whatsapp/
engine/
official.ts ← re-exporta @whiskeysockets/baileys
infinite.ts ← re-exporta baileys (InfiniteAPI)
index.ts ← exporta conforme process.env.BAILEYS_ENGINE
connection/
WhatsAppConnectionManager.ts ← muda import para '../engine'
handlers/
MessageHandler.ts ← muda import para '../../engine' (ou relativo)
ContactHandler.ts ← idem
rich-message.ts ← simplificado + fallback comentado
modules/chatbot/
chatbot.service.ts ← muda import
shared/utils/
whatsapp.ts ← muda import
```
---
## 4. Implementação Passo a Passo
### 4.1 Instalar os dois packages
```bash
cd /home/deploy/projetos/newwhats.local/backend
# Manter o oficial
# @whiskeysockets/baileys já está em package.json
# Adicionar InfiniteAPI com alias para não conflitar
npm install baileys@github:rsalcara/InfiniteAPI
```
Ambos convivem no `node_modules` porque têm nomes diferentes:
- `node_modules/@whiskeysockets/baileys/`
- `node_modules/baileys/`
### 4.2 Criar `engine/official.ts`
```typescript
// Re-exporta tudo do Baileys oficial
export * from '@whiskeysockets/baileys'
export { proto } from '@whiskeysockets/baileys'
```
### 4.3 Criar `engine/infinite.ts`
```typescript
// Re-exporta tudo do InfiniteAPI
export * from 'baileys'
export { proto } from 'baileys'
```
### 4.4 Criar `engine/index.ts`
```typescript
// Seleciona engine conforme BAILEYS_ENGINE (padrão: infinite)
// IMPORTANTE: process.env lido em tempo de inicialização do módulo.
// Para trocar a engine: alterar BAILEYS_ENGINE + reiniciar o processo.
const engine = process.env.BAILEYS_ENGINE ?? 'infinite'
if (engine === 'infinite') {
module.exports = require('./infinite')
} else {
module.exports = require('./official')
}
```
> **Nota TypeScript:** usar `require()` condicional ou um `export *` com re-export dinâmico.
> Alternativa mais simples: dois arquivos de entrada separados e o `tsconfig` aponta para um deles via path alias.
### 4.5 Trocar imports nos 6 arquivos
| Arquivo | Import atual | Novo import |
|---|---|---|
| `WhatsAppConnectionManager.ts` | `@whiskeysockets/baileys` | `../engine` |
| `handlers/MessageHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
| `handlers/ContactHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
| `rich-message.ts` | `@whiskeysockets/baileys` (3 imports) | `./engine` |
| `modules/chatbot/chatbot.service.ts` | `@whiskeysockets/baileys` | `../whatsapp/engine` |
| `shared/utils/whatsapp.ts` | (verificar) | path relativo para engine |
### 4.6 Simplificar `rich-message.ts` para InfiniteAPI
Com InfiniteAPI, o `sendRichMessage()` fica:
```typescript
export async function sendRichMessage(
sock: WASocket,
jid: string,
payload: RichPayload,
quoted?: { key: proto.IMessageKey; message: proto.IMessage },
): Promise<string> {
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload
if (poll) {
const sent = await sock.sendMessage(jid, {
poll: { name: poll.name, values: poll.values, selectableCount: poll.selectableCount ?? 1 },
}, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `poll-${Date.now()}`
}
if (nativeButtons) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeButtons, // InfiniteAPI trata tudo internamente
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `btn-${Date.now()}`
}
if (nativeList) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeList,
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `list-${Date.now()}`
}
if (nativeCarousel) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeCarousel,
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `carousel-${Date.now()}`
}
// Texto simples
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `text-${Date.now()}`
}
```
> **Fallback:** Manter o código atual do proto manual em `rich-message.official.ts` caso precise debugar com a engine oficial.
### 4.7 Adicionar BAILEYS_ENGINE ao .env
```bash
# /home/deploy/projetos/newwhats.local/backend/.env
BAILEYS_ENGINE=infinite # ou: official
```
### 4.8 Endpoint de toggle no painel admin
**Backend** — novo endpoint em `whatsapp.routes.ts` ou `admin.routes.ts`:
```typescript
// POST /api/admin/engine
// Body: { engine: 'infinite' | 'official' }
router.post('/engine', adminOnly, async (req, res) => {
const { engine } = req.body
if (!['infinite', 'official'].includes(engine)) return res.status(400).json({ error: 'invalid engine' })
// 1. Persiste no banco (tabela config ou settings)
await prisma.config.upsert({
where: { key: 'BAILEYS_ENGINE' },
update: { value: engine },
create: { key: 'BAILEYS_ENGINE', value: engine },
})
// 2. Reinicia o processo (pm2 ou systemctl)
// Usar exec() com o comando correto para o ambiente
exec('pm2 restart newwhats-backend', (err) => {
if (err) return res.status(500).json({ error: 'restart failed' })
res.json({ ok: true, engine })
})
})
// GET /api/admin/engine — retorna engine ativa
router.get('/engine', adminOnly, async (req, res) => {
const config = await prisma.config.findUnique({ where: { key: 'BAILEYS_ENGINE' } })
res.json({ engine: config?.value ?? process.env.BAILEYS_ENGINE ?? 'infinite' })
})
```
**Frontend** — componente simples na página de administração:
```tsx
// Toggle na página /admin/settings ou /admin/system
<div>
<span>Engine WhatsApp</span>
<select value={engine} onChange={handleSwitch}>
<option value="infinite">InfiniteAPI (botões/lista/carrossel)</option>
<option value="official">Baileys Oficial (estável, sem botões)</option>
</select>
<span className="text-yellow-500"> Troca reinicia o servidor (~10s)</span>
</div>
```
---
## 5. Tipos Suportados por Engine
| Tipo de Mensagem | Baileys Oficial | InfiniteAPI |
|---|---|---|
| Texto simples | ✅ | ✅ |
| Reply / Quote | ✅ | ✅ |
| Menções (@) | ✅ | ✅ |
| Mídia (foto/vídeo/doc/áudio) | ✅ | ✅ |
| Enquete (poll) | ✅ | ✅ |
| Botões (`nativeButtons`) — reply, url, copy, call | ❌ (quebrado no Web) | ✅ |
| Lista (`nativeList`) | ❌ (quebrado no Web) | ✅ |
| Carrossel (`nativeCarousel`) | ❌ (quebrado no Web) | ✅ |
| Botão com header imagem/vídeo | ❌ | ✅ (`headerImage`, `headerVideo`) |
### Tipos de botão suportados pelo InfiniteAPI
```typescript
type NativeButton =
| { type: 'reply'; id: string; text: string } // Quick reply
| { type: 'url'; text: string; url: string } // Abrir URL
| { type: 'copy'; text: string; copyText: string } // Copiar texto
| { type: 'call'; text: string; phoneNumber: string } // Ligar
```
---
## 6. Riscos e Mitigações
| Risco | Mitigação |
|---|---|
| InfiniteAPI é RC (não estável) | Engine swap — volta pro oficial em 10s |
| Repo `github:rsalcara/InfiniteAPI` pode sair do ar | Fazer fork próprio no GitHub interno |
| Breaking changes na API do fork | Wrapper `engine/` isola o impacto |
| Auth state incompatível após troca | Não é problema — InfiniteAPI usa o mesmo formato `useMultiFileAuthState` |
| TypeScript: tipos diferentes entre engines | Usar `as any` nos campos extras do InfiniteAPI |
---
## 7. Estimativa de Esforço
| Tarefa | Tempo |
|---|---|
| Instalar packages + criar `engine/` | 30 min |
| Trocar imports nos 6 arquivos | 30 min |
| Simplificar `rich-message.ts` | 30 min |
| Endpoint admin + frontend toggle | 2 horas |
| Teste real (celular + Web) | 1 hora |
| **Total** | **~45 horas** |
---
## 8. Arquivos-Chave para Referência
| Arquivo | Descrição |
|---|---|
| `backend/src/modules/whatsapp/rich-message.ts` | Lógica atual de envio rico (proto manual) |
| `backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts` | Core da conexão Baileys (1265 linhas) |
| `backend/src/modules/whatsapp/handlers/MessageHandler.ts` | Processa mensagens recebidas (865 linhas) |
| `backend/src/modules/whatsapp/whatsapp.send.routes.ts` | Rotas de envio (757 linhas) |
| `/home/deploy/projetos/whatsbotoes/baileys_interactive/` | Projeto de referência usando InfiniteAPI |
| `/home/deploy/projetos/whatsbotoes/tipos_de_disparo.md` | Documentação dos tipos de disparo |
| `node_modules/baileys/lib/Types/Message.d.ts` | Tipos do InfiniteAPI (após instalar) |
---
## 9. Comandos Úteis
```bash
# Compilar backend
cd /home/deploy/projetos/newwhats.local/backend
npx tsc --skipLibCheck
# Compilar só rich-message
npx tsc src/modules/whatsapp/rich-message.ts --outDir dist/modules/whatsapp --skipLibCheck --esModuleInterop --module commonjs --moduleResolution node --target es2020
# Reiniciar backend
pm2 restart newwhats-backend # ou o nome do processo
# Ver engine ativa
grep BAILEYS_ENGINE .env
```
+86
View File
@@ -0,0 +1,86 @@
# Continuar — NewWhats como Motor Central (SaaS multi-tenant)
## Instrução inicial para a próxima sessão
> Faça uma **análise profissional** da ideia descrita abaixo. Antes de escrever qualquer código:
> 1. Gere uma **lista de tarefas detalhada** (fases, dependências, critérios de aceite) e salve em `TAREFAS.md`.
> 2. Mantenha um **relatório vivo** de todas as implementações em `RELATORIO.md` (o que foi feito, decisões técnicas, pontos de atenção, próximos passos).
> 3. **Regra-ouro:** toda funcionalidade nova deve ser pensada e entregue **como plugin** (em `/plugins/<nome>/` com `manifest.json`, hooks, config_schema). Nada vai direto ao core se couber como plugin.
---
## Contexto do projeto
- **Projeto motor:** `/home/deploy/projetos/newwhats.local` — já possui Baileys (WhatsApp), inbox, sessões, storage (Wasabi), sistema de plugins estilo WordPress, e API de pareamento OTP (`/api/pair/*`) com `integration_key`.
- **Projetos satélites:** múltiplos sistemas (ex.: Alemão Conveniências em `/home/deploy/projetos/alemaoconveniencias.local`) que **não devem instalar Baileys**. Devem consumir o motor via API autenticada por `integration_key`.
- **Já implementado:** pair system (OTP por e-mail), plugin `newwhats` no Alemão com `x-nw-key` auth, modal de pareamento em 3 passos no dashboard.
- **Falta:** expor inbox, sessões e secretária virtual como serviço consumível pelos satélites.
## Ideia a analisar
Transformar o NewWhats em **motor SaaS multi-tenant**, onde cada projeto satélite é um cliente leve que consome:
- **Inbox** (chats, mensagens, envio, mídias)
- **Sessões** (status WA, QR, pareamento de números)
- **Secretária virtual** (IA com contexto — roda 100% no motor, satélite só consome output)
## Arquitetura-alvo
```
NewWhats (motor)
├─ Baileys + Inbox + Storage + Secretária (IA)
└─ API /api/ext/* (auth: x-nw-key)
▼ HTTPS + WebSocket
┌───────┴────────┬──────────┐
Alemão Projeto B Projeto C
(plugin (plugin (plugin
nw-client) nw-client) nw-client)
```
## Decisões propostas (validar antes de implementar)
1. **Plugin `nw-client` portável** em `/plugins-sdk/newwhats-client/` — reutilizável em todos os satélites, zero dependência de Baileys.
2. **API `/api/ext/*` nova** (além de `/api/pair/*` já existente), autenticada por `x-nw-key`:
- `GET /api/ext/sessions`
- `GET /api/ext/inbox?session=X`
- `GET /api/ext/inbox/:chatId/messages`
- `POST /api/ext/inbox/:chatId/send`
- `POST /api/ext/secretaria/ask`
- `WS /api/ext/stream` (eventos realtime — fallback polling 5s)
3. **Secretária virtual sempre no motor** — centraliza custo LLM e contexto.
4. **Isolamento multi-tenant**`integration_key` resolve para `user_id` + `allowed_sessions[]`. Satélite nunca vê dados de outro tenant.
5. **Distribuição do client plugin** — opções:
- (a) `npm publish @newwhats/client` (registry privado/público)
- (b) git submodule
- (c) **CLI `nw plugin install`** que baixa tarball assinado do motor ← recomendado
6. **Tudo como plugin:**
- Motor: `plugins/ext-api/` (expõe `/api/ext/*`), `plugins/secretaria-virtual/` (IA), `plugins/realtime-bridge/` (WS).
- Satélite: `plugins/newwhats-client/` (consumidor único, com UI de inbox embutida).
## Tradeoffs a documentar no relatório
- **WS vs polling** — WS é melhor para UX, mas adiciona complexidade de reconexão/auth. V1 polling 5s, V2 WS.
- **Proxy de mídias** — satélite baixa mídia via motor (`/api/ext/media/:id`) ou recebe URL assinada Wasabi? URL assinada é mais leve mas expõe storage.
- **Rate limit por integration_key** — necessário desde o v1 para proteger motor.
- **Versionamento da API** — `/api/ext/v1/*` desde o início.
## Pendências herdadas (do `project_alemao_plugin_session.md`)
- `aria-hidden` warning no pairModal (Bootstrap 5 — usar `inert` ou Bootstrap 5.3+).
- Inbox no Alemão via plugin — **esta é a primeira entrega desta nova fase**.
- SMTP não configurado no NewWhats — dev_code exposto na resposta até configurar.
## Entregáveis esperados da próxima sessão
1. `TAREFAS.md` — backlog fase-a-fase (motor → SDK → satélite → secretária).
2. `RELATORIO.md` — iniciado com análise profissional da ideia, riscos, e decisões arquiteturais.
3. Esqueleto dos plugins (manifests + hooks declarados, sem lógica ainda) para validar a forma antes de implementar.
4. Pergunta ao usuário: **(A)** contrato detalhado da API primeiro, ou **(B)** PoC end-to-end de `/api/ext/inbox` no Alemão para validar fluxo.
## Referências rápidas
- Sistema de plugins: `backend/src/core/` (types, hook-bus, plugin-loader, plugin-registry)
- Pair system: `backend/src/modules/pair/pair.routes.ts` + `pair.service.ts`
- Plugin consumidor existente: `/home/deploy/projetos/alemaoconveniencias.local/sistema-nuvem/plugins/newwhats/`
- Build backend: `npm run build` em `/home/deploy/projetos/newwhats.local/backend`
- Serviços: `newwhats-backend.service` (8008), `alemaoconveniencias.service` (4001)
+201
View File
@@ -0,0 +1,201 @@
# Correção Wasabi Storage — newwhats
Data: 2026-05-07
Investigado e corrigido por: Claude Code
---
## Contexto
O plugin `UnifiedStorageProvider` estava configurado para enviar mídias do WhatsApp ao Wasabi, porém os arquivos continuavam acumulando localmente em `backend/media`, chegando a **7.8 GB** de mídia que deveria estar na nuvem. A causa raiz foi uma combinação de 4 bugs no código e uma inconsistência de path descoberta na revisão final.
---
## Diagnóstico — 4 bugs + 1 inconsistência corrigidos
### Bug 1 — Arquivo local nunca era deletado após upload Wasabi (crítico)
**Arquivo:** `backend/src/modules/whatsapp/handlers/MessageHandler.ts`
O fluxo era:
1. Arquivo de mídia chegava → salvo em `backend/media/{instanceId}/{msgId}.ext`
2. StorageProvider registrado → upload para Wasabi ✅
3.**Arquivo local NUNCA era deletado após upload bem-sucedido**
Resultado: disco enchia indefinidamente mesmo com Wasabi funcionando.
**Correção:** Após upload bem-sucedido (`provider !== 'local_fallback'`), o arquivo local é removido com `fs.unlink()` assíncrono.
---
### Bug 2 — `mediaPath` apontava para arquivo local inexistente (crítico)
**Arquivo:** `backend/src/modules/whatsapp/handlers/MessageHandler.ts`
Mesmo após o upload para Wasabi, o campo `mediaPath` no banco continuava sendo salvo com o caminho local completo (`/home/deploy/.../media/...`), que após o Bug 1 corrigido deixa de existir no disco.
**Correção:** Quando `uploadedToRemote = true`, `mediaPath` é salvo como `null` no banco. O acesso à mídia passa a ser exclusivamente pelo `mediaUrl` que aponta para o Wasabi.
---
### Bug 3 — Rotas HTTP duplicadas ao reativar plugin via admin (médio)
**Arquivo:** `plugins/UnifiedStorageProvider/index.ts`
O `pluginRegistry.reactivate()` chama `activate()` novamente a cada salvamento de config. O `activate()` registrava as rotas Express (`/api/storage/view/*`, `/api/storage/wasabi/list-buckets`, etc.) a cada chamada, acumulando handlers duplicados em memória.
**Correção:** Adicionada flag `private routesRegistered = false`. As rotas Express são registradas apenas na primeira chamada de `activate()`. Reativações subsequentes reinicializam apenas o S3 client e o registro do StorageProvider.
---
### Bug 4 — Deleção de instância não limpava mídias no Wasabi (funcionalidade ausente)
**Arquivo:** `backend/src/modules/whatsapp/whatsapp.routes.ts`
Ao deletar uma instância pelo admin, o fluxo só desconectava o socket Baileys, removia os arquivos de sessão e apagava o registro do banco. As mídias no Wasabi (e no fallback local) ficavam permanentemente órfãs.
**Implementação:**
- Adicionado `deletePrefix(prefix: string): Promise<{ deleted: number }>` à interface `StorageProviderInterface` em `types.ts`
- Exposto `deletePrefix()` no `StorageProviderRegistry` em `StorageProvider.ts` (silencioso se provider não registrado)
- Implementado no plugin com `ListObjectsV2Command` paginado em batches de 1000 + `DeleteObjectsCommand`, limpando também o fallback local
- Chamado em background na rota `DELETE /:id` após `repo.delete(id)`, sem bloquear a resposta
---
### Inconsistência 5 — Path incorreto no deletePrefix (crítico)
**Arquivo:** `backend/src/modules/whatsapp/whatsapp.routes.ts`
O plugin grava arquivos no Wasabi com o path:
```
whatsapp/{tenantId}/{instanceId}/{fileName}
```
A implementação inicial do `deletePrefix` usava:
```
whatsapp/{instanceId}/ ← errado, nunca encontraria os arquivos
```
**Correção:** Prefix corrigido para `whatsapp/${tenantId}/${id}/`, que bate exatamente com a estrutura gravada pelo plugin.
---
## Arquivos modificados
| Arquivo | Mudança |
|---|---|
| `backend/src/core/types.ts` | Adicionado `deletePrefix()` à interface `StorageProviderInterface` |
| `backend/src/core/StorageProvider.ts` | Exposto `deletePrefix()` no registry com fallback silencioso |
| `backend/src/modules/whatsapp/handlers/MessageHandler.ts` | `fs.unlink()` após upload + `mediaPath = null` quando remoto |
| `backend/src/modules/whatsapp/whatsapp.routes.ts` | `deletePrefix("whatsapp/{tenantId}/{id}/")` ao deletar instância |
| `plugins/UnifiedStorageProvider/index.ts` | Flag `routesRegistered` + import `DeleteObjectsCommand` + implementação de `deletePrefix()` |
---
## Configuração atual (verificada)
```json
// DragonflyDB — plugin:config:UnifiedStorageProvider
{
"wasabiAccessKey": "OFLCYUUZRYJFKHLB81QC",
"wasabiSecretKey": "R4GI2s7W...",
"wasabiBucket": "whatsbasemidia",
"wasabiRegion": "us-east-1"
}
```
Endpoint padrão: `https://s3.wasabisys.com`
---
## Situação das mídias antigas (pós-investigação)
| Situação | Quantidade | Ação |
|---|---|---|
| Migradas para Wasabi pelo script | 231 arquivos | ✅ no Wasabi (path: `whatsapp/{instanceId}/`) |
| Deletadas do servidor após migração | 231 arquivos | ✅ removidas de `backend/media` |
| Banco atualizado (mediaUrl → Wasabi) | 231 registros | ✅ `mediaPath = null`, `mediaUrl = whatsapp/...` |
| Ainda locais em `backend/media` | ~1.430 registros | Serão refeitos ao reconectar sessões |
| Novas mídias (pós-correção) | todas | ✅ vão direto para Wasabi e local é deletado |
> ⚠️ Os 231 arquivos migrados pelo script têm path `whatsapp/{instanceId}/` (sem tenantId) pois foram lidos direto do disco. O plugin novo grava como `whatsapp/{tenantId}/{instanceId}/`. Isso não quebra o funcionamento mas o `deletePrefix` automático não os apagará quando a instância for deletada — precisarão ser removidos manualmente do bucket se necessário.
---
## Script de migração criado
**Arquivo:** `migrate-media-wasabi.mjs` (raiz do projeto)
Script Node.js criado para migrar os arquivos acumulados em `backend/media` para o Wasabi. Suporta `--dry-run` para simulação sem alterações.
**O que o script faz por arquivo:**
1.`messages` do banco onde `mediaUrl LIKE '/media/%'`
2. Verifica se o arquivo já existe no Wasabi (`HeadObjectCommand`) — pula upload se sim
3. Faz upload para `whatsapp/{instanceId}/{fileName}` no bucket `whatsbasemidia`
4. Atualiza o banco: `mediaUrl = 'whatsapp/...'`, `mediaPath = NULL`
5. Remove o arquivo local com `fs.unlinkSync()`
6. Ao final, remove diretórios vazios de `backend/media`
**Execução:**
```bash
# Simulação (sem alterar nada):
node migrate-media-wasabi.mjs --dry-run
# Migração real:
node migrate-media-wasabi.mjs
```
**Status da execução em 2026-05-07:**
- Migração iniciada e interrompida manualmente aos 231/1.661 arquivos
- Motivo da parada: decisão de deletar sessões manualmente e reconectar para que as mídias sejam baixadas novamente do WhatsApp já com o Wasabi funcionando
---
## Backup gerado antes das operações
**Arquivo:** `/home/deploy/projetos/newwhats_export/newwhats_completo.tar.gz`**7.7 GB**
**Dump do banco:** `/home/deploy/projetos/newwhats_export/newwhats_dump.sql`**18 MB**
O tar.gz contém o projeto completo (incluindo `backend/media` com todas as mídias locais no estado anterior à migração) e o dump PostgreSQL. Gerado em 2026-05-07 como snapshot de segurança antes de qualquer alteração.
---
## Fluxo completo após as correções
### Upload de nova mídia:
```
WhatsApp recebe mídia
→ salva buffer temporariamente em backend/media/{instanceId}/{msgId}.ext
→ storageProvider.uploadFile() → Wasabi: whatsapp/{tenantId}/{instanceId}/{fileName}
→ fs.unlink(localFile) ← arquivo local removido
→ banco: mediaPath = null, mediaUrl = whatsapp/{tenantId}/{instanceId}/{fileName}
→ frontend acessa via /api/storage/view/whatsapp/... (proxy → Wasabi)
```
### Se Wasabi falhar:
```
→ arquivo local mantido em backend/media
→ banco: mediaPath = /caminho/local, mediaUrl = /media/{instanceId}/{fileName}
→ SyncWorker tenta reenviar a cada 2 minutos
→ quando enviado: arquivo local removido automaticamente
```
### Ao deletar instância:
```
DELETE /api/whatsapp/{id}
→ manager.disconnect() → desconecta Baileys
→ repo.delete() → remove do banco (cascade: chats, mensagens, contatos)
→ responde 200 ← não bloqueia
→ [background] storageProvider.deletePrefix("whatsapp/{tenantId}/{id}/")
→ ListObjectsV2 paginado (batches de 1000)
→ DeleteObjects em cada batch
→ Remove fallback local se existir
→ Loga total removidos
---
## Status da Implementação Física em Produção (VPS 1) — 2026-05-07
Todas as correções descritas neste documento foram fisicamente implementadas, compiladas, testadas e implantadas com sucesso na VPS 1 de Produção.
### Ações Executadas:
1. **Ajuste de Interfaces e Código Core:** Modificados `types.ts`, `StorageProvider.ts`, `MessageHandler.ts`, e `whatsapp.routes.ts`.
2. **Implementação de Limpeza Automática:** `UnifiedStorageProvider/index.ts` agora conta com `deletePrefix` S3 e deleção local robusta.
3. **Correção de Arquitetura de Container (Crítica):** Foi detectado que nenhum plugin era carregado em produção por falta de mapeamento. Montamos o volume de plugins e criamos os links simbólicos necessários (`/plugins`, `/app/backend`, `/app/src`) no Dockerfile para que os imports relativos funcionem na perfeição.
4. **Re-deploy Completo:** O container `newwhats-backend` foi totalmente rebuildado e reiniciado com `docker compose up -d --build`.
**Status Final:****CORRIGIDO E IMPLEMENTADO EM PRODUÇÃO**
@@ -0,0 +1,240 @@
# Plugin Empresa — Arquitetura de Integração
## Visão Geral
O sistema NewWhats roda em servidor próprio e nunca acessa diretamente o banco da empresa cliente.
A comunicação acontece por dois plugins que se reconhecem via chave compartilhada:
```
[ Servidor NewWhats ] [ Servidor da Empresa ]
Plugin: secretaria ←→ Plugin: newwhats-client
- Secretária IA - CRUD nos bancos
- WhatsApp / Baileys - Agente de consulta
- Inbox / Sessions - Frontend embarcado
```
---
## Plugin da Empresa (`newwhats-client`)
### Responsabilidades
- Conecta ao banco de dados interno da empresa (leitura e escrita)
- Expõe endpoints controlados para o NewWhats consumir
- Recebe webhooks do NewWhats (eventos, notificações)
- Serve o frontend embarcado (inbox, sessions, secretária)
- Gerencia a chave de integração e autenticação cruzada
### Instalação
O plugin é distribuído como pacote independente.
A empresa instala no próprio servidor — Node.js, PHP, Python ou via Docker.
Após instalação, gera-se uma `integration_key` que é inserida nos dois lados.
```
1. Empresa instala o plugin
2. Plugin gera integration_key + registra URL do servidor NewWhats
3. Admin copia a key e cola nas configurações do NewWhats
4. Os dois sistemas se reconhecem e começam a conversar
```
---
## Comunicação entre os Plugins
### Direções
| Direção | Quando | Exemplo |
|---|---|---|
| NewWhats → Empresa (pull) | Secretária precisa de dados em tempo real | Consultar agenda do Dr. |
| Empresa → NewWhats (push) | Evento proativo no sistema da empresa | Dr. cancelou consulta, avisar paciente |
### Autenticação
- Chave JWT gerada no NewWhats, compartilhada com o plugin da empresa
- Rotacionável pelo admin a qualquer momento
- Cada requisição carrega o JWT no header `Authorization: Bearer <key>`
- Expiração configurável (padrão: 90 dias)
---
## Contrato de API (Endpoints Mínimos)
O plugin da empresa **obrigatoriamente** expõe estes endpoints para a secretária funcionar:
### Agenda
```
GET /nw/agenda?data=YYYY-MM-DD&profissional_id= → horários disponíveis
POST /nw/agenda/agendar → confirmar agendamento
PUT /nw/agenda/:id/cancelar → cancelar agendamento
GET /nw/agenda/:id → detalhes de um agendamento
```
### Profissionais / Responsáveis
```
GET /nw/profissionais → lista com nome, área, número WhatsApp
GET /nw/profissionais/:id → dados de um profissional
```
### Clientes / Pacientes / Contatos
```
GET /nw/cliente?telefone=5511999999999 → dados pelo número de WhatsApp
GET /nw/cliente/:id → dados por ID
POST /nw/cliente → cadastrar novo cliente
```
### Webhook
```
POST /nw/webhook/registrar → registrar URL de callback no NewWhats
```
### Endpoints opcionais (dependem do ramo)
```
GET /nw/produtos → catálogo
GET /nw/pedidos?cliente_id= → histórico de pedidos
GET /nw/financeiro?cliente_id= → situação financeira
GET /nw/historico?cliente_id= → histórico de atendimentos
```
---
## Agente de Consulta (dentro do plugin da empresa)
O plugin não expõe o banco diretamente — ele tem um **agente interno** que:
1. Recebe a query da secretária (ex: `agenda?data=2026-04-15&profissional=Dr. Carlos`)
2. Traduz para uma query SQL ou chamada interna do sistema da empresa
3. Filtra apenas os campos permitidos (sem dados sensíveis)
4. Devolve JSON padronizado
```
Secretária IA (NewWhats)
↓ GET /nw/agenda?data=15/04
Plugin da Empresa
↓ SELECT * FROM agenda WHERE data = '2026-04-15' AND ativo = true
Banco da Empresa
↓ rows
Plugin filtra e formata
↓ JSON limpo
Secretária injeta no contexto e responde o cliente
```
### Permissões por endpoint
Cada endpoint tem uma flag de permissão configurável pelo admin da empresa:
```
agenda.read = true ✓
agenda.write = true ✓
cliente.read = true ✓
cliente.write = false ✗ (somente leitura por enquanto)
financeiro.read = false ✗ (dado sensível, bloqueado)
```
---
## Frontend Embarcado
### O que é
As três telas principais do NewWhats servidas **dentro do sistema da empresa**,
sem precisar abrir outro sistema ou fazer login separado.
```
Sistema da Empresa (ERP / CRM)
└── Menu
├── [módulos da empresa]
└── NewWhats ← plugin embarcado
├── Inbox (conversas WhatsApp em tempo real)
├── Sessions (instâncias Baileys, status de conexão)
└── Secretária IA (configuração + chat de teste)
```
### Opções de implementação
**Opção 1 — iFrame com token SSO** (mais simples)
- Plugin gera um token de sessão temporário (15min, renovável)
- Abre `https://newwhats-server/embed?token=<sso_token>` em iFrame
- NewWhats valida o token e serve a interface sem tela de login
**Opção 2 — Micro-frontend (componentes standalone)** (mais integrado)
- Plugin distribui componentes React/Vue que consomem a API do NewWhats
- Renderizam dentro do layout da empresa sem iFrame
- Requerem que a empresa use React/Vue no frontend
**Recomendação:** começar com iFrame SSO — funciona em qualquer stack,
entrega valor rápido. Migrar para micro-frontend quando houver demanda.
### Autenticação cruzada (SSO)
```
1. Usuário logado no sistema da empresa clica em "NewWhats"
2. Sistema da empresa chama POST /nw/auth/sso no NewWhats com a integration_key
3. NewWhats retorna um token de sessão temporário
4. Plugin abre o iFrame com esse token
5. Usuário entra direto, sem segunda tela de login
```
---
## Números e Papéis
O plugin da empresa cadastra os números WhatsApp relevantes e seus papéis:
| Papel | Descrição | Exemplo |
|---|---|---|
| `secretary_virtual` | Número principal da secretária IA | Atende clientes |
| `clinic` | Número geral da empresa | Recebe notificações |
| `doctor` / `specialist` | Profissional responsável por área | Recebe dúvidas específicas |
| `manager` | Fallback universal | Recebe quando ninguém mais responde |
| `reserve` | Backup da secretária | Entra quando principal cai ou é banido |
| `human_secretary` | Secretária humana | Pode assumir conversa da IA |
O NewWhats consulta esses papéis via `GET /nw/profissionais` e usa para roteamento interno.
---
## Fluxo Completo — Exemplo Real
**Cenário:** Paciente pergunta se pode agendar com o Dr. Carlos na quinta-feira.
```
1. Paciente envia mensagem no WhatsApp
2. Baileys recebe → NewWhats aciona Secretária IA
3. Nó de intenção classifica: "agendamento"
4. Nó de calendário chama GET /nw/agenda?data=2026-04-16&profissional=Dr.Carlos
5. Plugin da empresa consulta banco → retorna horários livres
6. Secretária oferece opções ao paciente
7. Paciente confirma horário das 14h
8. Secretária chama POST /nw/agenda/agendar com os dados
9. Plugin grava no banco da empresa
10. Secretária confirma para o paciente via WhatsApp
11. Sistema da empresa exibe o agendamento normalmente no próprio sistema
```
**Tudo isso sem o paciente saber que são dois sistemas.**
---
## Roadmap de Implementação
### Fase 1 — Conexão básica
- [ ] Geração e troca de `integration_key` entre os dois plugins
- [ ] Endpoints mínimos no plugin da empresa (agenda, profissionais, cliente)
- [ ] Nó do tipo `api_query` no cérebro da secretária
- [ ] Teste end-to-end: secretária consulta agenda real
### Fase 2 — Agente de consulta
- [ ] Agente interno no plugin da empresa com mapeamento de permissões
- [ ] Push de eventos (webhook): cancelamentos, atualizações
- [ ] Roteamento por papel: secretária sabe para qual número perguntar
### Fase 3 — Frontend embarcado
- [ ] SSO por token temporário
- [ ] iFrame com Inbox + Sessions + Secretária IA
- [ ] Personalização básica (logo, cores da empresa)
### Fase 4 — Expansão
- [ ] SDK para empresas implementarem o plugin em qualquer stack
- [ ] Marketplace de conectores prontos (ex: conector para sistemas populares do setor)
- [ ] Painel de auditoria cruzada (o que a secretária consultou e quando)
Executable
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
# ─── dev.sh — Alterna entre modo dev e produção ───────────────────────────────
#
# ./dev.sh up → sobe em modo DEV (tsx watch + next dev, sem build)
# ./dev.sh down → derruba tudo
# ./dev.sh prod → sobe em modo PROD (imagens buildadas)
# ./dev.sh logs → segue logs de backend e frontend
#
# ─────────────────────────────────────────────────────────────────────────────
DOCKER="DOCKER_HOST=unix:///run/user/1000/docker.sock /home/deploy/bin/docker"
DC="$DOCKER compose"
DC_DEV="$DC -f docker-compose.yml -f docker-compose.dev.yml"
cd "$(dirname "$0")"
case "$1" in
up)
echo "▶ Subindo em modo DEV (tsx watch + next dev)..."
eval "$DC_DEV up -d newwhats-backend newwhats-frontend"
echo ""
echo " Backend : tsx watch — edite ./newwhats.local/backend/src/**/*.ts"
echo " Frontend: next dev — edite ./newwhats.local/frontend/**/*.tsx"
echo ""
echo " Logs: ./dev.sh logs"
;;
down)
echo "■ Derrubando containers..."
eval "$DC down"
;;
prod)
echo "▶ Subindo em modo PROD (imagens buildadas)..."
eval "$DC up -d"
;;
logs)
eval "$DC logs -f --tail=60 newwhats-backend newwhats-frontend"
;;
*)
echo "Uso: ./dev.sh [up|down|prod|logs]"
exit 1
;;
esac
+39
View File
@@ -0,0 +1,39 @@
# ─── Dev override — NÃO requer docker compose build ──────────────────────────
#
# Uso:
# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
#
# O que muda em relação ao docker-compose.yml de produção:
# - Backend: tsx watch (interpreta TypeScript direto — sem tsc)
# Volume monta ./newwhats.local/backend/src no container.
# Altere qualquer .ts → container reinicia automaticamente.
#
# - Frontend: next dev (HMR — sem next build)
# Volume monta ./newwhats.local/frontend no container.
# Altere qualquer componente → browser atualiza em <2s.
#
# Para voltar à produção:
# docker compose up -d (sem o -f docker-compose.dev.yml)
# ─────────────────────────────────────────────────────────────────────────────
services:
newwhats-backend:
command: ["node", "node_modules/.bin/tsx", "watch", "src/server.ts"]
environment:
- NODE_ENV=development
volumes:
- "/home/deploy/newwhats-sessions:/app/sessions"
- "./newwhats.local/plugins:/app/plugins"
- "./newwhats.local/backend/src:/app/src"
- "./newwhats.local/backend/prisma:/app/prisma"
newwhats-frontend:
command: ["node", "node_modules/.bin/next", "dev", "-p", "3003"]
environment:
- PORT=3003
- NODE_ENV=development
- NEXT_PUBLIC_API_URL=https://clube67.com/api
volumes:
- "./newwhats.local/frontend:/app"
- /app/node_modules
- /app/.next
+65
View File
@@ -0,0 +1,65 @@
services:
nginx:
image: nginx:alpine
restart: always
volumes:
- "./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro"
labels:
- "traefik.enable=true"
# Roteador de Produção (clube67.com)
- "traefik.http.routers.clube67-prod.rule=Host(`clube67.com`) || Host(`www.clube67.com`)"
- "traefik.http.routers.clube67-prod.entrypoints=websecure"
- "traefik.http.routers.clube67-prod.tls.certresolver=le"
- "traefik.http.services.clube67-prod.loadbalancer.server.port=80"
- "traefik.docker.network=web"
networks:
- soc
- web
depends_on:
- newwhats-backend
- newwhats-frontend
newwhats-backend:
build:
context: "./newwhats.local/backend"
dockerfile: Dockerfile
restart: always
env_file:
- "./newwhats.local/backend/.env"
environment:
- BAILEYS_ENGINE=infinite
- PORT=8008
- NODE_ENV=production
- DATABASE_URL=postgresql://newwhats:clube67_newwhats_pass_9903@10.99.0.3:5432/newwhats?schema=public
- DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379
- NATS_URL=nats://10.99.0.3:4222
- TEMPORAL_ADDRESS=10.99.0.3:7233
- BAILEYS_SESSIONS_PATH=/app/sessions
volumes:
- "/home/deploy/newwhats-sessions:/app/sessions"
- "/home/deploy/newwhats-media:/app/media"
- "./newwhats.local/plugins:/app/plugins"
networks:
- soc
- web
newwhats-frontend:
build:
context: "./newwhats.local/frontend"
dockerfile: Dockerfile
restart: always
env_file:
- "./newwhats.local/frontend/.env.production.local"
environment:
- PORT=3003
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=https://clube67.com/api
networks:
- soc
- web
networks:
soc:
external: true
web:
external: true
+5
View File
@@ -0,0 +1,5 @@
# Ignorar arquivos de configuração sensíveis locais se copiados acidentalmente
gcal_key.json
ssh_alerts_config.json
*.log
.DS_Store
@@ -0,0 +1,319 @@
# Baileys Engine Swap — Plano de Implementação
**Data:** 2026-04-29
**Contexto:** Mensagens ricas (botões, lista, carrossel) não renderizam no WhatsApp Web com `@whiskeysockets/baileys` padrão. A solução é usar o fork InfiniteAPI que implementa esses tipos nativamente dentro do `sendMessage()`.
---
## 1. Problema Raiz
O `@whiskeysockets/baileys` v6.7.18 (oficial) **não suporta** os campos:
- `nativeButtons`
- `nativeList`
- `nativeCarousel`
O WhatsApp Web exige nós binários adicionais (`<biz><interactive>`, `<bot biz_bot="1"/>`) injetados no stanza via `relayMessage(additionalNodes)`. O `rich-message.ts` atual tenta replicar isso manualmente, mas **ainda falha no WhatsApp Web**.
**Erro observado:** "Não foi possível carregar a mensagem. Use seu celular para acessá-la."
### Por que o InfiniteAPI funciona
O fork `github:rsalcara/InfiniteAPI` (v7.0.0-rc.9, package name: `baileys`) implementa toda a lógica de `additionalNodes` internamente no `generateWAMessageContent()` / `relayMessage()`. O dev só chama `sock.sendMessage(jid, { nativeButtons: [...] })` e o fork faz o resto.
### Por que whatsmeow (Go) NÃO resolve
Pesquisa confirmada (abril 2026):
- `InteractiveMessage` + `NativeFlowMessage` → struct existe, botões **não aparecem** (issue #711, sem solução)
- `ListMessage`**não exibe** na conversa do destinatário (issue #669)
- `CarouselMessage`**não existe** (exclusivo da Cloud API)
- Migração seria ~23 meses de reescrita para o mesmo resultado quebrado
---
## 2. Solução Escolhida: Engine Swap com Fallback
Instalar **os dois** packages (nomes diferentes, convivem no mesmo `node_modules`) e criar uma camada de abstração `engine/` que permite trocar via variável de ambiente + restart automático.
**Downtime por troca:** ~510 segundos (restart + reconexão automática, sem novo QR — auth state no disco é compatível entre os dois, InfiniteAPI é fork direto).
---
## 3. Estrutura de Arquivos
```
backend/
src/
modules/whatsapp/
engine/
official.ts ← re-exporta @whiskeysockets/baileys
infinite.ts ← re-exporta baileys (InfiniteAPI)
index.ts ← exporta conforme process.env.BAILEYS_ENGINE
connection/
WhatsAppConnectionManager.ts ← muda import para '../engine'
handlers/
MessageHandler.ts ← muda import para '../../engine' (ou relativo)
ContactHandler.ts ← idem
rich-message.ts ← simplificado + fallback comentado
modules/chatbot/
chatbot.service.ts ← muda import
shared/utils/
whatsapp.ts ← muda import
```
---
## 4. Implementação Passo a Passo
### 4.1 Instalar os dois packages
```bash
cd /home/deploy/projetos/newwhats.local/backend
# Manter o oficial
# @whiskeysockets/baileys já está em package.json
# Adicionar InfiniteAPI com alias para não conflitar
npm install baileys@github:rsalcara/InfiniteAPI
```
Ambos convivem no `node_modules` porque têm nomes diferentes:
- `node_modules/@whiskeysockets/baileys/`
- `node_modules/baileys/`
### 4.2 Criar `engine/official.ts`
```typescript
// Re-exporta tudo do Baileys oficial
export * from '@whiskeysockets/baileys'
export { proto } from '@whiskeysockets/baileys'
```
### 4.3 Criar `engine/infinite.ts`
```typescript
// Re-exporta tudo do InfiniteAPI
export * from 'baileys'
export { proto } from 'baileys'
```
### 4.4 Criar `engine/index.ts`
```typescript
// Seleciona engine conforme BAILEYS_ENGINE (padrão: infinite)
// IMPORTANTE: process.env lido em tempo de inicialização do módulo.
// Para trocar a engine: alterar BAILEYS_ENGINE + reiniciar o processo.
const engine = process.env.BAILEYS_ENGINE ?? 'infinite'
if (engine === 'infinite') {
module.exports = require('./infinite')
} else {
module.exports = require('./official')
}
```
> **Nota TypeScript:** usar `require()` condicional ou um `export *` com re-export dinâmico.
> Alternativa mais simples: dois arquivos de entrada separados e o `tsconfig` aponta para um deles via path alias.
### 4.5 Trocar imports nos 6 arquivos
| Arquivo | Import atual | Novo import |
|---|---|---|
| `WhatsAppConnectionManager.ts` | `@whiskeysockets/baileys` | `../engine` |
| `handlers/MessageHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
| `handlers/ContactHandler.ts` | `@whiskeysockets/baileys` | `../../engine` |
| `rich-message.ts` | `@whiskeysockets/baileys` (3 imports) | `./engine` |
| `modules/chatbot/chatbot.service.ts` | `@whiskeysockets/baileys` | `../whatsapp/engine` |
| `shared/utils/whatsapp.ts` | (verificar) | path relativo para engine |
### 4.6 Simplificar `rich-message.ts` para InfiniteAPI
Com InfiniteAPI, o `sendRichMessage()` fica:
```typescript
export async function sendRichMessage(
sock: WASocket,
jid: string,
payload: RichPayload,
quoted?: { key: proto.IMessageKey; message: proto.IMessage },
): Promise<string> {
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload
if (poll) {
const sent = await sock.sendMessage(jid, {
poll: { name: poll.name, values: poll.values, selectableCount: poll.selectableCount ?? 1 },
}, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `poll-${Date.now()}`
}
if (nativeButtons) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeButtons, // InfiniteAPI trata tudo internamente
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `btn-${Date.now()}`
}
if (nativeList) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeList,
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `list-${Date.now()}`
}
if (nativeCarousel) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeCarousel,
} as any, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `carousel-${Date.now()}`
}
// Texto simples
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined)
return sent?.key?.id ?? `text-${Date.now()}`
}
```
> **Fallback:** Manter o código atual do proto manual em `rich-message.official.ts` caso precise debugar com a engine oficial.
### 4.7 Adicionar BAILEYS_ENGINE ao .env
```bash
# /home/deploy/projetos/newwhats.local/backend/.env
BAILEYS_ENGINE=infinite # ou: official
```
### 4.8 Endpoint de toggle no painel admin
**Backend** — novo endpoint em `whatsapp.routes.ts` ou `admin.routes.ts`:
```typescript
// POST /api/admin/engine
// Body: { engine: 'infinite' | 'official' }
router.post('/engine', adminOnly, async (req, res) => {
const { engine } = req.body
if (!['infinite', 'official'].includes(engine)) return res.status(400).json({ error: 'invalid engine' })
// 1. Persiste no banco (tabela config ou settings)
await prisma.config.upsert({
where: { key: 'BAILEYS_ENGINE' },
update: { value: engine },
create: { key: 'BAILEYS_ENGINE', value: engine },
})
// 2. Reinicia o processo (pm2 ou systemctl)
// Usar exec() com o comando correto para o ambiente
exec('pm2 restart newwhats-backend', (err) => {
if (err) return res.status(500).json({ error: 'restart failed' })
res.json({ ok: true, engine })
})
})
// GET /api/admin/engine — retorna engine ativa
router.get('/engine', adminOnly, async (req, res) => {
const config = await prisma.config.findUnique({ where: { key: 'BAILEYS_ENGINE' } })
res.json({ engine: config?.value ?? process.env.BAILEYS_ENGINE ?? 'infinite' })
})
```
**Frontend** — componente simples na página de administração:
```tsx
// Toggle na página /admin/settings ou /admin/system
<div>
<span>Engine WhatsApp</span>
<select value={engine} onChange={handleSwitch}>
<option value="infinite">InfiniteAPI (botões/lista/carrossel)</option>
<option value="official">Baileys Oficial (estável, sem botões)</option>
</select>
<span className="text-yellow-500"> Troca reinicia o servidor (~10s)</span>
</div>
```
---
## 5. Tipos Suportados por Engine
| Tipo de Mensagem | Baileys Oficial | InfiniteAPI |
|---|---|---|
| Texto simples | ✅ | ✅ |
| Reply / Quote | ✅ | ✅ |
| Menções (@) | ✅ | ✅ |
| Mídia (foto/vídeo/doc/áudio) | ✅ | ✅ |
| Enquete (poll) | ✅ | ✅ |
| Botões (`nativeButtons`) — reply, url, copy, call | ❌ (quebrado no Web) | ✅ |
| Lista (`nativeList`) | ❌ (quebrado no Web) | ✅ |
| Carrossel (`nativeCarousel`) | ❌ (quebrado no Web) | ✅ |
| Botão com header imagem/vídeo | ❌ | ✅ (`headerImage`, `headerVideo`) |
### Tipos de botão suportados pelo InfiniteAPI
```typescript
type NativeButton =
| { type: 'reply'; id: string; text: string } // Quick reply
| { type: 'url'; text: string; url: string } // Abrir URL
| { type: 'copy'; text: string; copyText: string } // Copiar texto
| { type: 'call'; text: string; phoneNumber: string } // Ligar
```
---
## 6. Riscos e Mitigações
| Risco | Mitigação |
|---|---|
| InfiniteAPI é RC (não estável) | Engine swap — volta pro oficial em 10s |
| Repo `github:rsalcara/InfiniteAPI` pode sair do ar | Fazer fork próprio no GitHub interno |
| Breaking changes na API do fork | Wrapper `engine/` isola o impacto |
| Auth state incompatível após troca | Não é problema — InfiniteAPI usa o mesmo formato `useMultiFileAuthState` |
| TypeScript: tipos diferentes entre engines | Usar `as any` nos campos extras do InfiniteAPI |
---
## 7. Estimativa de Esforço
| Tarefa | Tempo |
|---|---|
| Instalar packages + criar `engine/` | 30 min |
| Trocar imports nos 6 arquivos | 30 min |
| Simplificar `rich-message.ts` | 30 min |
| Endpoint admin + frontend toggle | 2 horas |
| Teste real (celular + Web) | 1 hora |
| **Total** | **~45 horas** |
---
## 8. Arquivos-Chave para Referência
| Arquivo | Descrição |
|---|---|
| `backend/src/modules/whatsapp/rich-message.ts` | Lógica atual de envio rico (proto manual) |
| `backend/src/modules/whatsapp/connection/WhatsAppConnectionManager.ts` | Core da conexão Baileys (1265 linhas) |
| `backend/src/modules/whatsapp/handlers/MessageHandler.ts` | Processa mensagens recebidas (865 linhas) |
| `backend/src/modules/whatsapp/whatsapp.send.routes.ts` | Rotas de envio (757 linhas) |
| `/home/deploy/projetos/whatsbotoes/baileys_interactive/` | Projeto de referência usando InfiniteAPI |
| `/home/deploy/projetos/whatsbotoes/tipos_de_disparo.md` | Documentação dos tipos de disparo |
| `node_modules/baileys/lib/Types/Message.d.ts` | Tipos do InfiniteAPI (após instalar) |
---
## 9. Comandos Úteis
```bash
# Compilar backend
cd /home/deploy/projetos/newwhats.local/backend
npx tsc --skipLibCheck
# Compilar só rich-message
npx tsc src/modules/whatsapp/rich-message.ts --outDir dist/modules/whatsapp --skipLibCheck --esModuleInterop --module commonjs --moduleResolution node --target es2020
# Reiniciar backend
pm2 restart newwhats-backend # ou o nome do processo
# Ver engine ativa
grep BAILEYS_ENGINE .env
```
+211
View File
@@ -0,0 +1,211 @@
# 🚀 Guia de Desenvolvimento - NewWhats
Este documento descreve como configurar e rodar o ambiente de desenvolvimento com **hot-reload** para desenvolvimento rápido.
---
## 📋 Portas
| Serviço | Produção | Desenvolvimento |
|---------|----------|-----------------|
| Frontend (Next.js) | 3003 | 4004 |
| Backend (Express) | 8008 | 8009 |
---
## 🛠️ Setup Inicial
### 1. Requisitos
- Node.js (verifique com `node -v`)
- npm (verifique com `npm -v`)
- PostgreSQL rodando
- DragonflyDB (Redis-compatible) rodando
- NATS JetStream rodando
- Temporal.io rodando
### 2. Instalar Dependências
```bash
# Frontend
cd frontend
npm install
# Backend
cd ../backend
npm install
```
---
## 🏃 Executar em Desenvolvimento (Hot-Reload)
### Opção 1: Usar Serviços Systemd (Recomendado)
#### Iniciar Ambos os Serviços
```bash
# Iniciar dev services
sudo systemctl start newwhats-frontend-dev newwhats-backend-dev
# Verificar status
sudo systemctl status newwhats-frontend-dev newwhats-backend-dev
# Ver logs em tempo real
sudo journalctl -u newwhats-frontend-dev -f
sudo journalctl -u newwhats-backend-dev -f
```
#### Parar Serviços Dev
```bash
sudo systemctl stop newwhats-frontend-dev newwhats-backend-dev
```
#### Ativar Auto-start na Boot
```bash
sudo systemctl enable newwhats-frontend-dev newwhats-backend-dev
```
---
### Opção 2: Rodar Manualmente (Terminal)
#### Terminal 1 - Frontend (Hot-Reload)
```bash
cd /home/deploy/projetos/newwhats.local/frontend
npm run dev
# Output esperado:
# ▲ Next.js 14.2.35
# - Local: http://localhost:4004
# ✓ Ready in X.Xs
```
#### Terminal 2 - Backend (Hot-Reload)
```bash
cd /home/deploy/projetos/newwhats.local/backend
npm run dev
# Output esperado:
# Server running on port 8009
# Watching for file changes...
```
---
## 📝 Variáveis de Ambiente
### Frontend
- `NODE_ENV=development` → Ativa React DevTools e hot-reload
- Porta: `4004` (configurado em `package.json`)
### Backend
- `NODE_ENV=development` → Logs detalhados, sem compressão
- `PORT=8009` → Porta dev (não conflita com produção 8008)
- `.env` já está configurado com valores corretos
---
## 🔄 Hot-Reload em Ação
### Frontend
- Edite arquivos em `frontend/pages/`, `frontend/components/`, etc.
- Next.js recompila automaticamente e atualiza o navegador
- Para mudanças em `next.config.mjs`, reinicie o serviço
### Backend
- Edite arquivos em `backend/src/`
- `tsx watch` reinicia o servidor automaticamente
- Mudanças em `.env` requerem restart manual
---
## 🚨 Troubleshooting
### Porta já em uso
```bash
# Verificar qual processo usa a porta
lsof -i :4004 # Frontend
lsof -i :8009 # Backend
# Matar processo (último recurso)
sudo kill -9 <PID>
```
### Serviço não inicia
```bash
# Verificar logs detalhados
sudo systemctl status newwhats-frontend-dev
sudo journalctl -u newwhats-frontend-dev -n 50
# Recarregar systemd após editar .service
sudo systemctl daemon-reload
```
### Mudanças não aparecem
- **Frontend**: Limpar cache do navegador (Ctrl+Shift+Del)
- **Backend**: Aguarde a mensagem de restart no terminal
---
## ⚙️ Scripts Disponíveis
### Frontend
```bash
npm run dev # Desenvolvimento com hot-reload (porta 4004)
npm run build # Build para produção
npm run start # Rodar build (produção, porta 3003)
npm run lint # Linting
```
### Backend
```bash
npm run dev # Desenvolvimento com tsx watch (porta 8009)
npm run build # Compilar TypeScript
npm run start # Rodar compilado (produção, porta 8008)
npm run db:migrate # Migrar database
npm run db:push # Push schema para database
npm run db:studio # Prisma Studio
```
---
## 🔗 URLs de Desenvolvimento
- **Frontend**: http://localhost:4004
- **Backend API**: http://localhost:8009
- **Prisma Studio**: `npm run db:studio` (backend)
---
## 📌 Produção vs Desenvolvimento
| Aspecto | Produção | Desenvolvimento |
|---------|----------|-----------------|
| Hot-reload | ❌ | ✅ |
| Compilação | ✅ Build prévio | ❌ On-the-fly |
| Logs | Estruturados (JSON) | Pretty-printed |
| Otimização | ✅ | ❌ |
| Velocidade Dev | Lenta | ⚡ Rápida |
---
## 🤝 Boas Práticas
1. **Sempre use dev em desenvolvimento** — hot-reload economiza tempo
2. **Teste em produção antes de deploy** — verifique otimizações
3. **Mantenha `.env` sincronizado** — mudanças críticas requerem restart
4. **Limpe logs regularmente**`journalctl --vacuum=time=7d`
5. **Verifique portas antes de mudar** — evite conflitos
---
## 📞 Suporte
Para problemas ou dúvidas, verifique:
- Logs dos serviços: `journalctl -u newwhats-*-dev -f`
- Status das dependências: `systemctl status postgresql dragonfly nats`
- Conectividade: `curl http://localhost:8009/health`
---
**Última atualização**: 2026-04-10
**Ambiente**: Linux (Debian/Ubuntu)
+95
View File
@@ -0,0 +1,95 @@
# 📝 Relatório Técnico de Implementação — NewWhats SaaS
Este relatório descreve as ações executadas, decisões arquiteturais consolidadas, riscos mapeados e próximos passos recomendados para o ecossistema NewWhats.
---
## 🏗️ Decisões Arquiteturais e Infraestrutura
Para atender à premissa de manter o host limpo (sem dependências locais de Node/npm/PM2) e em conformidade com o design "V8 Bunker", a aplicação foi **100% containerizada**:
1. **Estrutura de Containerização (Multi-Stage) e Resolução do Erro do Prisma**:
* **newwhats-backend**: Inicialmente construído com `node:20-alpine`. Identificamos que a engine binária do Prisma requer `libssl.so.1.1` (Glibc/OpenSSL 1.1) que foi removido no Alpine moderno.
* **Solução Proativa**: Migramos a base do backend para `node:20-slim` (Debian-based) e instalamos nativamente as dependências `openssl` e `ca-certificates`.
* Adicionamos `"debian-openssl-3.0.x"` no bloco `binaryTargets` do `schema.prisma` para compilar o driver otimizado. O backend agora compila e inicializa instantaneamente em conformidade de segurança máxima.
2. **Reverse Proxy Unificado e Resiliente (Nginx + Traefik)**:
* Mantivemos o Traefik gerenciando os certificados SSL (`HTTPS`) de forma nativa e automática na borda (Portas `80/443`).
* **Correção no Nginx (`default.conf`)**: Corrigimos o typo da variável de cabeçalho (`$proxy_add_x_forwarded-for` para `$proxy_add_x_forwarded_for` com sublinha).
* **Configuração de Alta Resiliência**: Para evitar que o Nginx falhasse caso o backend estivesse em inicialização, adicionamos a dependência `depends_on` no Compose e configuramos o resolver DNS interno do Docker (`resolver 127.0.0.11 valid=30s`) com variáveis dinâmicas de proxy. O Nginx agora inicia de forma instantânea e tolerante a falhas.
3. **Persistência de Sessões**:
* O Baileys armazena os arquivos físicos de conexões das contas do WhatsApp. Criamos um volume compartilhado que mapeia o diretório interno do container para a pasta física do host `/home/deploy/newwhats-sessions`, garantindo que os usuários não precisem re-escanear o QR code quando os containers forem atualizados ou recriados.
---
## 📂 Arquivos Modificados e Criados
* 📝 [docker-compose.yml](file:///home/deploy/stack/clube67/docker-compose.yml): Registra os novos serviços, regras de dependências e volumes compartilhados.
* 📝 [default.conf](file:///home/deploy/stack/clube67/nginx/default.conf): Mapeamento de proxies Nginx resilientes e regras de conexões WebSockets.
* 📝 [Dockerfile (Backend)](file:///home/deploy/stack/clube67/newwhats.local/backend/Dockerfile): Definição de build modular baseada em Debian Slim com suporte total ao Prisma Engine.
* 📝 [schema.prisma](file:///home/deploy/stack/clube67/newwhats.local/backend/prisma/schema.prisma): Configuração de compilação multi-target para garantir compatibilidade OpenSSL 3.0.
* 📝 [TAREFAS.md](file:///home/deploy/stack/clube67/newwhats.local/TAREFAS.md): Planejamento detalhado por entregáveis.
---
## 🚨 Pontos de Atenção e Homologação da VPS 3
> [!WARNING]
> **Status das Dependências de Rede na VPS 3 (`10.99.0.3`)**:
> Ao inicializar o backend, verificamos nos logs do container que a conexão com o DragonflyDB (Redis local no compose) conectou perfeitamente (`{"msg":"DragonflyDB conectado"}`), mas o Prisma parou na barreira de inicialização:
>
> `Can't reach database server at 10.99.0.3:5432`
>
> * **Diagnóstico**: O IP da VPS 3 responde aos pings da VPN instantaneamente, mas a porta `5432` retorna `Connection Refused` do host de destino.
---
## 🛡️ Guia de Ativação do PostgreSQL na VPS 3
Para liberar o acesso do backend (VPS 1) ao PostgreSQL na VPS 3 de forma totalmente segura, siga os passos abaixo diretamente na VPS 3 (como usuário `deploy`):
### 1. Configurar escuta do PostgreSQL na interface VPN
Abra o arquivo de configuração principal (o caminho exato depende da versão instalada, ex: `14` ou `15`):
```bash
sudo nano /etc/postgresql/15/main/postgresql.conf
```
Procure pela linha `listen_addresses` e altere para:
```ini
listen_addresses = 'localhost,10.99.0.3'
```
*(Isso instrui o PostgreSQL a aceitar conexões locais e conexões vindas apenas da interface segura do WireGuard, mantendo a porta fechada para a internet pública).*
### 2. Autorizar o IP da VPS 1 no controle de acesso do PostgreSQL
Abra o arquivo de controle de conexões do cliente:
```bash
sudo nano /etc/postgresql/15/main/pg_hba.conf
```
Adicione a seguinte linha de liberação ao final do arquivo:
```text
# Liberar conexões seguras da VPS 1 (Produção) para o NewWhats
host newwhats newwhats 10.99.0.1/32 scram-sha-256
```
*(Substitua `scram-sha-256` por `md5` se o seu banco estiver configurado com criptografia legado).*
### 3. Reiniciar o serviço do PostgreSQL
Aplique as novas configurações recarregando o serviço:
```bash
sudo systemctl restart postgresql
```
### 4. Validar as regras de Firewall (nftables) na VPS 3
Se a VPS 3 usar `nftables` (como indicado no layout "V8 Bunker"), verifique se as regras atuais permitem o tráfego de entrada na porta `5432` vindo de `10.99.0.1`.
Para listar e validar as regras atuais na VPS 3:
```bash
sudo nft list ruleset
```
Certifique-se de que há uma regra de aceitação similar a esta na tabela de entrada da VPN:
```text
ip saddr 10.99.0.1 tcp dport 5432 accept
```
---
## ⏭️ Próximos Passos
1. **Liberar PostgreSQL na VPS 3**: Execute os passos acima na VPS 3 para abrir o canal seguro de conexão.
2. **Atualização Automática**: Assim que a porta `5432` estiver liberada, o container `clube67-newwhats-backend-1` (que está configurado para tentar se reconectar automaticamente) se conectará instantaneamente, executará as rotinas de inicialização e o painel em `clube67.com` estará 100% no ar!
+51
View File
@@ -0,0 +1,51 @@
# 📋 LISTA DE TAREFAS — DEPLOY NEWWHATS (FRENTE A: WASABI STORAGE)
Este documento registra o escopo de implementação física, build, deploy e validação das correções da **Frente A (Wasabi Storage)** realizadas no ambiente de produção da VPS 1.
---
## 🚀 FRENTE A: Correções Wasabi Storage
| ID | Tarefa | Arquivo(s) Afetado(s) | Status | Detalhes |
|---|---|---|---|---|
| **T1** | Evitar vazamento de mídia local | `MessageHandler.ts` | **CONCLUÍDO** | Adicionado `fs.unlink()` assíncrono para limpar arquivos locais após upload para Wasabi. |
| **T2** | Correção de `mediaPath` no DB | `MessageHandler.ts` | **CONCLUÍDO** | Salvamento de `mediaPath = null` para mídias salvas com sucesso em storage remoto, forçando o carregamento direto do remote link. |
| **T3** | Evitar duplicação de handlers Express | `UnifiedStorageProvider/index.ts` | **CONCLUÍDO** | Adicionada flag privada `routesRegistered` para evitar múltiplos registros de rotas HTTP no Express em reativações. |
| **T4** | Interface para deleção recursiva | `types.ts`, `StorageProvider.ts` | **CONCLUÍDO** | Adicionado o método `deletePrefix` no contrato `StorageProviderInterface` e wrapper no `StorageProviderRegistry`. |
| **T5** | Implementação de limpeza Wasabi + Fallback | `UnifiedStorageProvider/index.ts` | **CONCLUÍDO** | Implementado `deletePrefix` usando `ListObjectsV2Command` + `DeleteObjectsCommand` paginados e remoção recursiva local. |
| **T6** | Limpeza de mídias ao deletar instância | `whatsapp.routes.ts` | **CONCLUÍDO** | Integrada chamada assíncrona ao `storageProvider.deletePrefix` na rota `DELETE /:id` usando prefixo correto. |
| **T7** | Correção de carregamento de plugins (Container) | `Dockerfile`, `docker-compose.yml` | **CONCLUÍDO** | Corrigida a ausência da pasta de plugins no container montando-a como volume e criando symlinks para resolução de imports. |
---
## 📈 Resumo do Status
- **Fase de Planejamento:** 100% Concluído
- **Implementação do Código:** 100% Concluído
- **Compilação e Transpilação (Plugins):** 100% Concluído
- **Build da Imagem e Re-deploy (Docker):** 100% Concluído
- **Validação de Logs e Ativação de Plugins:** 100% Concluído (Todos os plugins ativos sem erros!)
---
## 🔒 Próximos Passos recomendados ao Usuário
1. **Reconexão de Instâncias:** Reconectar as instâncias no painel administrativo para reativar as conexões de mensagens e testar o envio de mídias direto ao Wasabi.
2. **Validação do Bucket:** Confirmar que os novos arquivos estão sendo gerados sob o prefixo correto `whatsapp/{tenantId}/{instanceId}/` dentro do bucket `whatsbasemidia`.
3. **Validação da Exclusão:** Excluir uma instância de teste e verificar a remoção automática das mídias correspondentes tanto localmente quanto no bucket Wasabi.
---
*Última atualização: 08 de Maio de 2026 às 02:00 (UTC).*
---
## 🚀 FRENTE B: Baileys Engine Swap (Mensagens Ricas no WA Web)
| ID | Tarefa | Arquivo(s) Afetado(s) | Status | Detalhes / Critérios de Aceite |
|---|---|---|---|---|
| **B1** | Instalar fork InfiniteAPI | `package.json` | **CONCLUÍDO** | Instalar `baileys@github:rsalcara/InfiniteAPI` sob alias/coexistência no backend. |
| **B2** | Camada de abstração `engine` | `engine/official.ts`, `engine/infinite.ts`, `engine/index.ts` | **CONCLUÍDO** | Criar arquivos de ponte de re-export dinâmico dependentes de `BAILEYS_ENGINE` (`infinite` ou `official`). |
| **B3** | Mapeamento de imports | `WhatsAppConnectionManager.ts`, `MessageHandler.ts`, `ContactHandler.ts`, `rich-message.ts`, etc. | **CONCLUÍDO** | Redirecionar todos os imports diretos de `@whiskeysockets/baileys` para a nova camada unificada `./engine`. |
| **B4** | Simplificação de envio de mídias ricas | `rich-message.ts` | **CONCLUÍDO** | Refatorar a lógica complexa de binary nodes para invocar os campos nativos do fork InfiniteAPI com fallback inteligente. |
| **B5** | Endpoints de Administração & Reinício | `admin.routes.ts`, `whatsapp.routes.ts` | **CONCLUÍDO** | Adicionar rotas `GET /engine` e `POST /engine` para persistir escolha no arquivo .env e auto-reiniciar o processo (PM2/Docker). |
| **B6** | Componente de Toggle no Painel Admin | Frontend (Páginas Admin) | **CONCLUÍDO** | Criar interface visual de seleção de engine com aviso de reinicialização (~10s) na aba Sistema de admin/settings.tsx. |
+247
View File
@@ -0,0 +1,247 @@
# 📐 Arquitetura de Rede e Topologia de Servidores
Este documento descreve detalhadamente a arquitetura de servidores e rede privada projetada para garantir **alta performance, isolamento de riscos, segurança de dados e facilidade de escalonamento**.
---
## 🗺️ Visão Geral da Topologia (Rede VPN WireGuard)
Toda a comunicação crítica entre os servidores trafega por dentro de uma rede privada criptografada baseada no protocolo **WireGuard** (`10.99.0.0/24`). Os servidores possuem seus firewalls públicos trancados, permitindo apenas acessos necessários e isolando os dados vitais.
```mermaid
graph TD
subgraph "Rede Pública (Internet)"
Internet((Internet Pública))
Client([Notebook Rui])
end
subgraph "Rede Privada VPN (10.99.0.0/24)"
VPS1["🖥️ VPS 1 (Produção) <br> IP: 10.99.0.1 <br> (Traefik / Apps)"]
VPS3["🗄️ VPS 3 (Banco de Dados) <br> IP: 10.99.0.3 <br> (PostgreSQL Dedicado)"]
VPS4["🧪 VPS 4 (Desenvolvimento) <br> IP: 10.99.0.4 <br> (Staging / Builds)"]
VPS5["💬 VPS 5 (CRM WhatsApp) <br> IP: 10.99.0.5 <br> (Bots / APIs)"]
end
%% Conexões Públicas
Internet == HTTP/HTTPS (80/443) ==> VPS1
Client == Conexão VPN Criptografada ==> VPS1
%% Conexões Privadas VPN
VPS1 <== Canal Seguro VPN ==> VPS3
VPS1 <== Canal Seguro VPN ==> VPS4
VPS1 <== Canal Seguro VPN ==> VPS5
Client <== Acesso SSH Seguro ==> VPS1
Client <== Acesso SSH Seguro ==> VPS3
Client <== Acesso SSH Seguro ==> VPS4
Client <== Acesso SSH Seguro ==> VPS5
%% Conexões de Banco de Dados Internas
VPS1 -- PostgreSQL (Porta 5432) --> VPS3
VPS4 -- PostgreSQL (Porta 5432) --> VPS3
VPS5 -- PostgreSQL (Porta 5432) --> VPS3
style VPS3 fill:#1E293B,stroke:#3B82F6,stroke-width:3px,color:#fff
style VPS1 fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
style VPS4 fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
style VPS5 fill:#1E293B,stroke:#EC4899,stroke-width:2px,color:#fff
```
---
## 📋 Especificação Detalhada dos Servidores
### 1. 🗄️ VPS 3: Servidor de Banco de Dados Dedicado (Appliance)
* **Função Principal**: Hospedar exclusivamente o banco de dados **PostgreSQL Bare-Metal (Nativo)** e serviços fundamentais de persistência.
* **IP Privado VPN**: `10.99.0.3`
* **Tecnologias**: PostgreSQL (Instalação Nativa via Host), WireGuard, nftables. (O Docker é **estritamente omitido** para reduzir superfície de ataque e otimizar I/O).
* **Políticas do Firewall (nftables)**:
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Liberada **apenas** para o IP reservado do administrador via VPN (`10.99.0.10`).
* **Porta 5432 (PostgreSQL)**: **Bloqueada publicamente**. Liberada **apenas** para conexões originadas de IPs autorizados na VPN (`10.99.0.1` e `10.99.0.5`).
* **Vantagens de Segurança & Performance**:
* **Zero vazamento de dados**: O banco não pode ser acessado de fora da VPN sob hipótese alguma.
* **Conceito de Appliance**: Sem Docker daemon (sem socket de root adicional) ou compiladores, a superfície de invasão é reduzida ao menor nível matemático possível.
* **Performance Máxima (Tuning de Host)**: Como a máquina é nativa e dedicada, as configurações de cache e buffers de memória do PostgreSQL podem usufruir livremente do Page Cache nativo do Linux e Huge Pages do Kernel, garantindo o máximo rendimento físico do SSD e RAM.
---
### 🚀 2. VPS 1: Servidor de Sistemas em Produção
* **Função Principal**: Gateway de entrada do tráfego público e hospedagem das aplicações em produção.
* **IP Privado VPN**: `10.99.0.1` (Atua como Hub WireGuard)
* **Políticas do Firewall (UFW)**:
* **Portas 80 (HTTP) e 443 (HTTPS)**: **Abertas ao público** para permitir o acesso dos clientes aos sites e sistemas.
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Porta 3002 (Antigo Dev)**: **Bloqueada publicamente** no Docker (atrelada apenas ao IP privado `10.99.0.1` do container).
* **Vantagens de Segurança & Performance**:
* **Serviços sem Estado (Stateless)**: A VPS 1 apenas processa as requisições web e consome os dados da VPS 3. Caso a VPS 1 sofra um pico extremo de acessos, o banco de dados continua intacto e seguro.
* **Centralização com Traefik**: Proxy reverso centralizado gerenciando SSL e direcionamento interno seguro de containers.
---
### 🧪 3. VPS 4: Servidor de Desenvolvimento e Homologação (Staging)
* **Função Principal**: Testar novos recursos, hospedar containers de staging e realizar builds de desenvolvimento.
* **IP Privado VPN**: `10.99.0.4`
* **Políticas do Firewall (UFW)**:
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Portas de teste (ex: 3002, 8080)**: **Bloqueadas publicamente** ou atreladas apenas ao IP privado da VPN para acesso exclusivo dos desenvolvedores autorizados.
* **Tecnologias**: Docker, Docker Compose, PostgreSQL 18 (Standby Replica em Container).
* **Vantagens de Segurança & Performance**:
* **Replicação Streaming Real-Time (Staging)**: Hospeda um container PostgreSQL 18 configurado como réplica de leitura (Standby) do banco de dados nativo de produção da VPS 3. Isso permite testar queries pesadas e validar migrações com dados reais em tempo de desenvolvimento de forma 100% segura e com zero impacto no banco principal.
* **Isolamento de Riscos**: Compilações de código (`npm run build`, `docker build`) e testes pesados de carga rodam aqui e consomem CPU sem comprometer os servidores de produção e banco de dados.
* **Ambiente Espelho**: Homologação idêntica à produção para validações precisas antes de aplicar modificações em produção.
---
### 💬 4. VPS 5: Servidor de Aplicação Proprietária (newwhats)
* **Função Principal**: Hospedar o backend e workers da plataforma proprietária **newwhats** (Backend Express com Socket.IO, Workers do Temporal e broker NATS).
* **IP Privado VPN**: `10.99.0.5`
* **Políticas do Firewall (UFW)**:
* **Portas de Integração Web**: Liberadas apenas para os Webhooks e requisições públicas de Socket.IO mapeadas via gateway.
* **Porta 22 (SSH)**: **Bloqueada publicamente**. Apenas acessível via VPN.
* **Vantagens de Segurança & Performance**:
* **Isolamento de Estado**: Executar o backend Node.js (Prisma/Knex), os workers de workflows do Temporal e o broker de mensagens NATS na VPS 5 impede que oscilações no tráfego de mensagens do WhatsApp ou alto uso de CPU nos workers interfiram com o banco de dados principal de produção (VPS 3).
* **Separação de CPU**: O processamento pesado das sessões de WhatsApp e emulação de instâncias roda na VPS 5, consumindo sua CPU e RAM de forma isolada do Gateway (VPS 1) e do Banco (VPS 3).
---
## 🔒 Diretrizes de Segurança Corporativa (Resumo)
1. **Acesso Administrativo**: Realizado **exclusivamente via chave SSH criptográfica**. Autenticação por senhas desativada em todas as máquinas.
2. **Login de Root Desativado**: Bloqueado login direto de root via SSH. Todos conectam como o usuário `deploy` e elevam privilégios de forma segura via `sudo` após a conexão.
3. **Ponto Único de Falha**: Se a VPS 1 (Hub WireGuard) cair, a comunicação VPN é temporariamente pausada. O console VNC da Contabo é mantido ativo como canal redundante de suporte imediato.
4. **Logs Automatizados**: Logins SSH bem-sucedidos ou suspeitos geram alertas em segundo plano com marcações de 30 minutos na agenda central do Google, permitindo controle visual de acessos em tempo real.
---
## 🛡️ Fase 2: Hardening Avançado e Arquitetura Zero-Trust (Roadmap)
Esta seção documenta as decisões estratégicas e o plano de ação de segurança de curto e médio prazo projetados para elevar a maturidade da infraestrutura de rede e servidores ao nível **Enterprise / DevSecOps Sênior**.
### 🗺️ Visão Geral do Fluxo de Segurança na Borda
```mermaid
graph TD
subgraph "Camadas de Defesa (Defense in Depth)"
Internet((Internet Pública)) --> PM[1. Proteção de Borda do Provedor]
PM --> NFT[2. nftables + CrowdSec <br> Bloqueio Dinâmico / Comportamental]
NFT --> TF[3. Traefik Reverse Proxy <br> SSL/TLS & Roteamento]
TF --> DK[4. Containers Docker <br> Rootless / Hardened]
end
```
---
### 1. 🌐 Desativação Proativa de IPv6 (Curto/Médio Prazo)
Para eliminar vetores de bypass acidentais de firewall e reduzir a complexidade da superfície de ataque, o suporte a IPv6 é **totalmente desativado** em todas as VPSs.
* **Ações de Configuração**:
Para desativar em tempo de execução e garantir persistência pós-reboot, o arquivo `/etc/sysctl.d/99-disable-ipv6.conf` deve ser criado com as seguintes diretivas:
```text
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
```
*Comando para aplicar imediatamente:* `sudo sysctl --system`
* **Gatilhos para Reativação do IPv6**:
O IPv6 só será reabilitado caso surja uma necessidade real de negócio, tais como:
* Requisitos de conformidade corporativa (*Enterprise Compliance*).
* Integração obrigatória com CDNs IPv6-only ou arquiteturas de borda distribuída (*Anycast*).
* Casos em que a reativação exigirá a implementação completa de **nftables dual-stack, filtros de Router Advertisement (RA), NDP e SLAAC seguros**.
---
### 2. 🐋 Isolamento de Redes Docker (Prevenção de Movimentação Lateral)
O uso da rede de ponte padrão (`bridge`) do Docker é estritamente proibido em produção. Os containers são segmentados em **redes de domínios funcionais isolados**, limitando severamente o raio de colisão (*blast radius*) caso um container de aplicação (especialmente bots de CRM/WhatsApp ou navegadores invisíveis) seja comprometido.
#### 🔀 Matriz de Conectividade de Dados (newwhats):
```mermaid
graph TD
subgraph "VPS 1: Gateway de Borda pública"
Traefik[Traefik Router]
end
subgraph "VPS 5: Camada de Aplicação (newwhats)"
Express[Backend Express & Socket.IO]
TempWorker[Temporal Worker]
NATS[NATS Broker :4222]
Express <--> NATS
TempWorker <--> Express
end
subgraph "Túnel VPN Criptografado (wg0)"
WG0((Canal Privado WireGuard))
end
subgraph "VPS 3: Appliance de Dados Bare-Metal"
Postgres[(PostgreSQL :5432)]
Dragonfly[(DragonflyDB :6379)]
Temporal[(Temporal Server :7233)]
end
%% Roteamento Web e Sockets
Traefik == HTTPS Proxy / Sockets ==> WG0 ==> Express
%% Conexões de Dados da Aplicação via VPN
Express ==> WG0 ==> Postgres
Express ==> WG0 ==> Dragonfly
TempWorker ==> WG0 ==> Temporal
Temporal -. Persistência Interna .-> Postgres
style Postgres fill:#1E293B,stroke:#3B82F6,stroke-width:2px,color:#fff
style Dragonfly fill:#1E293B,stroke:#10B981,stroke-width:2px,color:#fff
style Temporal fill:#1E293B,stroke:#F59E0B,stroke-width:2px,color:#fff
```
* **Diretrizes de Conectividade**:
* **Tráfego de Borda**: O Traefik na VPS 1 encaminha o tráfego HTTP/HTTPS e conexões persistentes do Socket.IO diretamente para a VPS 5 (`10.99.0.5`) pela interface de túnel criptografado, mantendo as portas da VPS 5 públicas totalmente fechadas.
* **Persistência de Negócio (Prisma + Knex)**: Ambas as ORMs/Query Builders centralizadas na VPS 5 apontam diretamente para o IP interno `10.99.0.3:5432` da VPS 3.
* **Cache e Estados Rápidos**: O DragonflyDB na VPS 3 atende instantaneamente às solicitações de sessões e QR codes da VPS 5 via `10.99.0.3:6379`, provendo tempos de resposta na faixa de microssegundos sem concorrência local de CPU.
* **Workflows Assíncronos**: O Temporal Server processa o agendamento de tarefas e status de reputação na VPS 3, enquanto o **Temporal Worker** na VPS 5 consome e executa as atividades pesadas locais da aplicação.
---
### 3. 🔑 Zero-Trust Interno no WireGuard (ACLs no Firewall)
A rede VPN WireGuard (`10.99.0.0/24`) deixa de ser tratada como uma zona de confiança irrestrita. Implementa-se uma política de privilégio mínimo na comunicação entre os servidores da VPN.
* **O Problema de Movimentação Lateral**:
Se a máquina de desenvolvimento/staging (VPS 4) for comprometida por um script malicioso, o atacante não pode ter permissão para escaneá-la ou conectar-se ao banco de dados de produção (VPS 3), exceto para o fluxo estritamente necessário de replicação de dados.
* **Regras de Isolamento Lateral (Exemplo no Firewall da VPS 3)**:
```text
# Bloquear por padrão toda conexão vinda da VPN exceto as estritamente autorizadas:
- Permitir VPS 1 (Produção) -> Porta 5432 (PostgreSQL) [ACEITAR]
- Permitir VPS 5 (CRM) -> Porta 5432 (PostgreSQL) [ACEITAR]
- Permitir VPS 4 (Staging) -> Porta 5432 (PostgreSQL - Apenas 'replicator') [ACEITAR]
- Permitir VPS 4 (Staging) -> VPS 3 (Produção - Outras portas / serviços) [BLOQUEAR]
```
---
### 4. 🎛️ Migração de Borda: nftables + CrowdSec
Substitui-se a pilha clássica e reativa do `iptables` / `Fail2Ban` por uma arquitetura ativa, dinâmica e de alta performance na VPS pública 1.
* **Por que `nftables`?**
A sintaxe nativa do `nftables` permite a criação de conjuntos dinâmicos (*sets*) de IP de forma extremamente eficiente, processados diretamente no kernel com consumo mínimo de CPU durante ataques volumétricos.
* **Por que `CrowdSec`?**
Diferente do Fail2Ban (que analisa logs localmente e de forma reativa por expressões regulares), o CrowdSec utiliza detecção baseada em comportamentos locais de ataque e sincroniza uma base global de reputação de IPs (*inteligência coletiva*). Se um IP atacou outra infraestrutura na internet, ele é bloqueado na sua VPS antes mesmo de fazer a primeira requisição ao seu Traefik.
---
### 5. 💔 Mitigação do Ponto Único de Falha (SPOF) da VPS 1
Atualmente, a VPS 1 acumula as funções de **Gateway de Borda (Traefik)**, **Hub Central de VPN (WireGuard)** e **Hospedagem de Aplicações**.
* **Visão de Evolução Futura**:
Para eliminar o risco de interrupção total dos serviços em caso de queda da VPS 1, planeja-se a separação física de responsabilidades em médio prazo:
1. **VPS Edge Dedicated**: Uma máquina leve rodando exclusivamente o gateway de entrada (`Traefik`), `nftables` e o concentrador `WireGuard`.
2. **VPS App Dedicated**: Servidores internos que hospedam as aplicações (sem expor portas SSH ou HTTP à internet), consumindo tráfego encaminhado diretamente pelo Gateway da Edge.
---
### 6. 🔒 Hardening Avançado de Containers
Para garantir que a invasão de um container não resulte no comprometimento do sistema operacional host, aplicam-se as seguintes diretrizes de segurança no provisionamento de containers Docker:
* **No-New-Privileges**: Impede que processos dentro do container ganhem novos privilégios via binários `setuid` ou `setgid`.
* **Read-Only Root Filesystem**: Monta o sistema de arquivos do container como apenas leitura, forçando gravações temporárias apenas em volumes declarados ou `/tmp` na memória (tmpfs).
* **CAP Drop**: Remove capacidades de sistema desnecessárias ao container (ex: `NET_ADMIN`, `SYS_ADMIN`, `SYS_CHROOT`).
* **Rootless Containers**: Sempre que suportado pela aplicação, os containers devem rodar com IDs de usuário não privilegiados (non-root).
@@ -0,0 +1,9 @@
node_modules
dist
.git
*.log
.env
sessions
media
storage
uploads
@@ -0,0 +1,46 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
PORT=8008
NODE_ENV=development
JWT_SECRET=troque_por_secret_forte_aqui
# ─────────────────────────────────────────────
# PostgreSQL (Prisma)
# ─────────────────────────────────────────────
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/newwhats?schema=public"
# ─────────────────────────────────────────────
# DragonflyDB (compatível com Redis)
# ─────────────────────────────────────────────
DRAGONFLY_URL=redis://localhost:6379
DRAGONFLY_TTL_SECONDS=86400
# ─────────────────────────────────────────────
# NATS JetStream
# ─────────────────────────────────────────────
NATS_URL=nats://localhost:4222
# ─────────────────────────────────────────────
# Temporal.io
# ─────────────────────────────────────────────
TEMPORAL_ADDRESS=localhost:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=newwhats-queue
# ─────────────────────────────────────────────
# IA (OpenAI + Google Gemini)
# ─────────────────────────────────────────────
OPENAI_API_KEY=sk-...
GEMINI_API_KEY=AIza...
# ─────────────────────────────────────────────
# WhatsApp / Baileys
# ─────────────────────────────────────────────
# Pasta raiz onde as sessões multi-device são armazenadas (por instância)
BAILEYS_SESSIONS_PATH=./sessions
# ─────────────────────────────────────────────
# Frontend (CORS)
# ─────────────────────────────────────────────
FRONTEND_URL=http://localhost:3000
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-slim AS builder
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci
COPY . .
RUN npx prisma generate
RUN npm run build
FROM node:20-slim AS runner
RUN apt-get update -y && apt-get install -y openssl ca-certificates && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY package*.json baileys-7.0.0-rc.9.tgz ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
RUN ln -s /app/plugins /plugins && \
ln -s /app /app/backend && \
ln -s /app/dist /app/src
EXPOSE 8008
ENV NODE_ENV=production
CMD ["node", "dist/server.js"]
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,68 @@
{
"name": "newwhats-backend",
"version": "1.0.0",
"description": "SaaS Omnichannel Backend — NewWhats",
"main": "dist/server.js",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json || true",
"start": "node dist/server.js",
"db:generate": "prisma generate",
"db:migrate": "prisma migrate dev",
"db:push": "prisma db push",
"db:studio": "prisma studio"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1029.0",
"@aws-sdk/lib-storage": "^3.1029.0",
"@google/generative-ai": "^0.24.1",
"@prisma/client": "^5.22.0",
"@temporalio/activity": "^1.15.0",
"@temporalio/client": "^1.15.0",
"@temporalio/worker": "^1.15.0",
"@temporalio/workflow": "^1.15.0",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.20.0",
"@whiskeysockets/baileys": "^6.7.18",
"baileys": "file:./baileys-7.0.0-rc.9.tgz",
"bcryptjs": "^3.0.3",
"cors": "^2.8.5",
"croner": "^10.0.1",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"helmet": "^8.0.0",
"ioredis": "^5.4.1",
"jimp": "^1.6.0",
"jsonwebtoken": "^9.0.3",
"knex": "^3.2.9",
"multer": "^2.1.1",
"nats": "^2.28.2",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.5",
"openai": "^6.34.0",
"pg": "^8.20.0",
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"qrcode": "^1.5.4",
"recharts": "^3.8.1",
"sharp": "^0.33.5",
"socket.io": "^4.8.1",
"systeminformation": "^5.31.5",
"tsx": "^4.19.2",
"uuid": "^11.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.9.0",
"@types/qrcode": "^1.5.5",
"@types/uuid": "^10.0.0",
"prisma": "^5.22.0",
"qrcode-terminal": "^0.12.0",
"typescript": "^5.7.2"
}
}
@@ -0,0 +1,533 @@
// ─────────────────────────────────────────────────────────────────────────────
// Prisma Schema — NewWhats SaaS Omnichannel
// ─────────────────────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.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)
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)])
@@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
}
@@ -0,0 +1,45 @@
import { PrismaClient } from '@prisma/client'
import bcrypt from 'bcryptjs'
const prisma = new PrismaClient()
async function main() {
const ROUNDS = 10
const users = [
{
name: 'Usuário Padrão',
email: 'user@user.com',
password: '12345678',
role: 'USER' as const,
},
{
name: 'Admin',
email: 'ruibto@gmail.com',
password: 'Rc362514',
role: 'ADMIN' as const,
},
]
for (const u of users) {
const passwordHash = await bcrypt.hash(u.password, ROUNDS)
const existing = await prisma.user.findUnique({ where: { email: u.email } })
if (existing) {
await prisma.user.update({
where: { email: u.email },
data: { passwordHash, name: u.name, role: u.role },
})
console.log(`✅ Atualizado: ${u.email}`)
} else {
await prisma.user.create({
data: { name: u.name, email: u.email, passwordHash, role: u.role },
})
console.log(`✅ Criado: ${u.email} (${u.role})`)
}
}
}
main()
.catch((e) => { console.error(e); process.exit(1) })
.finally(() => prisma.$disconnect())
@@ -0,0 +1,81 @@
/**
* Script standalone para testar a conexão Baileys e exibir o QR Code no terminal.
* Não requer banco de dados nem DragonflyDB.
* Uso: node qr-test.mjs
*/
import {
makeWASocket,
useMultiFileAuthState,
DisconnectReason,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
} from '@whiskeysockets/baileys'
import { Boom } from '@hapi/boom'
import qrcode from 'qrcode-terminal'
import path from 'path'
import { fileURLToPath } from 'url'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SESSION_DIR = path.join(__dirname, 'sessions', 'qr-test')
async function start() {
const { state, saveCreds } = await useMultiFileAuthState(SESSION_DIR)
const { version, isLatest } = await fetchLatestBaileysVersion()
console.log(`\n📱 Baileys v${version} (latest: ${isLatest})`)
console.log('🔄 Iniciando conexão...\n')
const sock = makeWASocket({
version,
auth: state,
printQRInTerminal: false, // controlamos manualmente
qrTimeout: 60_000,
browser: ['NewWhats', 'Chrome', '124.0.0'],
syncFullHistory: false,
})
sock.ev.on('creds.update', saveCreds)
sock.ev.on('connection.update', ({ connection, lastDisconnect, qr }) => {
if (qr) {
console.clear()
console.log('📲 Escaneie o QR Code abaixo com o WhatsApp:\n')
qrcode.generate(qr, { small: true })
console.log('\n⏳ Aguardando escaneamento...')
}
if (connection === 'open') {
const me = sock.user
console.log('\n✅ Conectado com sucesso!')
console.log(` Número: ${me?.id}`)
console.log(` Nome: ${me?.name}`)
console.log('\n🎉 Sessão salva em:', SESSION_DIR)
console.log(' Agora rode o servidor completo com: npm run dev\n')
}
if (connection === 'close') {
const code = (lastDisconnect?.error)?.output?.statusCode
const reason = DisconnectReason
console.log(`\n❌ Conexão encerrada (código: ${code})`)
const shouldReconnect =
code !== reason.loggedOut &&
code !== reason.forbidden
if (shouldReconnect) {
console.log('🔄 Reconectando...\n')
setTimeout(start, 3000)
} else {
console.log('🚫 Sessão encerrada definitivamente.')
process.exit(0)
}
}
})
}
start().catch((err) => {
console.error('Erro fatal:', err)
process.exit(1)
})
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = exports.config = void 0;
// Re-export unificado das configs para compatibilidade com plugins
var env_1 = require("./config/env");
Object.defineProperty(exports, "config", { enumerable: true, get: function () { return env_1.env; } });
var env_2 = require("./config/env");
Object.defineProperty(exports, "env", { enumerable: true, get: function () { return env_2.env; } });
//# sourceMappingURL=config.js.map
@@ -0,0 +1 @@
{"version":3,"file":"config.js","sourceRoot":"","sources":["config.ts"],"names":[],"mappings":";;;AAAA,mEAAmE;AACnE,oCAA4C;AAAnC,6FAAA,GAAG,OAAU;AACtB,oCAAkC;AAAzB,0FAAA,GAAG,OAAA"}
@@ -0,0 +1,3 @@
// Re-export unificado das configs para compatibilidade com plugins
export { env as config } from './config/env'
export { env } from './config/env'
@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = void 0;
const zod_1 = require("zod");
require("dotenv/config");
const envSchema = zod_1.z.object({
PORT: zod_1.z.string().default('8008'),
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: zod_1.z.string().min(16),
DATABASE_URL: zod_1.z.string().url(),
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Variáveis de ambiente inválidas:');
console.error(parsed.error.format());
process.exit(1);
}
exports.env = parsed.data;
//# sourceMappingURL=env.js.map
@@ -0,0 +1 @@
{"version":3,"file":"env.js","sourceRoot":"","sources":["env.ts"],"names":[],"mappings":";;;AAAA,6BAAuB;AACvB,yBAAsB;AAEtB,MAAM,SAAS,GAAG,OAAC,CAAC,MAAM,CAAC;IACzB,IAAI,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC;IAChC,QAAQ,EAAE,OAAC,CAAC,IAAI,CAAC,CAAC,aAAa,EAAE,YAAY,EAAE,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC;IAC9E,UAAU,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;IAC9B,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE;IAC9B,aAAa,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,wBAAwB,CAAC;IAC3D,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC;IAClD,QAAQ,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;IACrD,gBAAgB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACtD,kBAAkB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,SAAS,CAAC;IACjD,mBAAmB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,gBAAgB,CAAC;IACzD,qBAAqB,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,YAAY,CAAC;IACvD,YAAY,EAAE,OAAC,CAAC,MAAM,EAAE,CAAC,OAAO,CAAC,uBAAuB,CAAC;CAC1D,CAAC,CAAA;AAEF,MAAM,MAAM,GAAG,SAAS,CAAC,SAAS,CAAC,OAAO,CAAC,GAAG,CAAC,CAAA;AAE/C,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;IACpB,OAAO,CAAC,KAAK,CAAC,oCAAoC,CAAC,CAAA;IACnD,OAAO,CAAC,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,MAAM,EAAE,CAAC,CAAA;IACpC,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAA;AACjB,CAAC;AAEY,QAAA,GAAG,GAAG,MAAM,CAAC,IAAI,CAAA"}
@@ -0,0 +1,27 @@
import { z } from 'zod'
import 'dotenv/config'
const envSchema = z.object({
PORT: z.string().default('8008'),
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: z.string().min(16),
DATABASE_URL: z.string().url(),
DRAGONFLY_URL: z.string().default('redis://localhost:6379'),
DRAGONFLY_TTL_SECONDS: z.string().default('86400'),
NATS_URL: z.string().default('nats://localhost:4222'),
TEMPORAL_ADDRESS: z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: z.string().default('default'),
TEMPORAL_TASK_QUEUE: z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: z.string().default('./sessions'),
FRONTEND_URL: z.string().default('http://localhost:3000'),
})
const parsed = envSchema.safeParse(process.env)
if (!parsed.success) {
console.error('❌ Variáveis de ambiente inválidas:')
console.error(parsed.error.format())
process.exit(1)
}
export const env = parsed.data
@@ -0,0 +1,15 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.logger = void 0;
const pino_1 = __importDefault(require("pino"));
const env_1 = require("./env");
exports.logger = (0, pino_1.default)({
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: env_1.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
: undefined,
});
//# sourceMappingURL=logger.js.map
@@ -0,0 +1 @@
{"version":3,"file":"logger.js","sourceRoot":"","sources":["logger.ts"],"names":[],"mappings":";;;;;;AAAA,gDAAuB;AACvB,+BAA2B;AAEd,QAAA,MAAM,GAAG,IAAA,cAAI,EAAC;IACzB,KAAK,EAAE,SAAG,CAAC,QAAQ,KAAK,YAAY,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO;IACvD,SAAS,EACP,SAAG,CAAC,QAAQ,KAAK,YAAY;QAC3B,CAAC,CAAC,EAAE,MAAM,EAAE,aAAa,EAAE,OAAO,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,aAAa,EAAE,cAAc,EAAE,EAAE;QACvF,CAAC,CAAC,SAAS;CAChB,CAAC,CAAA"}
@@ -0,0 +1,10 @@
import pino from 'pino'
import { env } from './env'
export const logger = pino({
level: env.NODE_ENV === 'production' ? 'info' : 'debug',
transport:
env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
: undefined,
})
@@ -0,0 +1,58 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.storageProvider = void 0;
const logger_1 = require("../config/logger");
/**
* Singleton registry para o storage provider ativo.
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
*/
class StorageProviderRegistry {
constructor() {
this.provider = null;
this.healthy = false;
}
register(provider) {
this.provider = provider;
logger_1.logger.info('[StorageProvider] Provider registrado');
}
isRegistered() {
return this.provider !== null;
}
isHealthy() {
return this.healthy;
}
async uploadFile(payload) {
if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
}
return this.provider.upload(payload);
}
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
async updateHealth() {
if (!this.provider) {
this.healthy = false;
return;
}
try {
this.healthy = await this.provider.checkHealth();
}
catch {
this.healthy = false;
}
}
async deletePrefix(prefix) {
if (!this.provider) {
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
return { deleted: 0 };
}
return this.provider.deletePrefix(prefix);
}
unregister() {
this.provider = null;
this.healthy = false;
logger_1.logger.info('[StorageProvider] Provider removido');
}
}
exports.storageProvider = new StorageProviderRegistry();
//# sourceMappingURL=StorageProvider.js.map
@@ -0,0 +1 @@
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAGvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;IAgDzB,CAAC;IA9CC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,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,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,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,IAAI,CAAC,OAAO,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,WAAW,EAAE,CAAA;QAClD,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACtB,CAAC;IACH,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
@@ -0,0 +1,109 @@
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 deletePrefix(prefix: string): Promise<{ deleted: number }> {
if (!this.provider) {
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
return { deleted: 0 }
}
return this.provider.deletePrefix(prefix)
}
unregister(): void {
this.provider = null
this.healthy = false
rootLogger.info('[StorageProvider] Provider removido')
}
}
export const storageProvider = new StorageProviderRegistry()
export type { StorageUploadPayload, StorageProviderInterface }
@@ -0,0 +1,55 @@
import knex, { Knex } from 'knex'
import { env } from '../config/env'
import { logger as rootLogger } from '../config/logger'
/**
* Instância Knex compartilhada entre todos os plugins.
* Usa o mesmo DATABASE_URL do Prisma — mesmo banco, mesma conexão pool.
* Prisma cuida das tabelas core; Knex cuida das tabelas dinâmicas dos plugins.
*/
function createKnex(): Knex {
// DATABASE_URL formato: postgresql://user:pass@host:port/db
const connection = env.DATABASE_URL
const instance = knex({
client: 'pg',
connection,
pool: { min: 1, max: 5 },
acquireConnectionTimeout: 10000,
})
instance.on('query-error', (err: Error, query: { sql: string }) => {
rootLogger.error({ err, sql: query.sql }, '[Knex] Query error')
})
return instance
}
export const db = createKnex()
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
export async function ensurePluginTables(knexInstance: Knex): Promise<void> {
const hasJanitorLogs = await knexInstance.schema.hasTable('janitor_logs')
if (!hasJanitorLogs) {
await knexInstance.schema.createTable('janitor_logs', (t) => {
t.increments('id').primary()
t.string('filename').notNullable()
t.string('category').notNullable()
t.string('status').notNullable()
t.text('message').nullable()
t.timestamps(true, true)
})
rootLogger.info('[Knex] Tabela janitor_logs criada')
}
const hasNanobanaUsage = await knexInstance.schema.hasTable('nanobana_usage')
if (!hasNanobanaUsage) {
await knexInstance.schema.createTable('nanobana_usage', (t) => {
t.increments('id').primary()
t.string('partner_id').notNullable()
t.string('type').notNullable()
t.timestamps(true, true)
})
rootLogger.info('[Knex] Tabela nanobana_usage criada')
}
}
@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.hookBus = exports.HookBus = void 0;
const logger_1 = require("../config/logger");
/**
* Event bus entre plugins.
* emit() executa todos os handlers registrados e retorna array de resultados.
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
*/
class HookBus {
constructor() {
this.handlers = new Map();
}
register(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
}
this.handlers.get(event).push(handler);
logger_1.logger.debug({ event }, '[HookBus] Handler registrado');
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async emit(event, data) {
const handlers = this.handlers.get(event);
if (!handlers || handlers.length === 0)
return [];
const results = [];
for (const handler of handlers) {
try {
const result = await handler(data);
results.push(result);
}
catch (err) {
logger_1.logger.error({ err, event }, '[HookBus] Erro em handler');
results.push(null);
}
}
return results;
}
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
removeAll(event) {
this.handlers.delete(event);
}
/** Lista eventos registrados — útil para debug */
listEvents() {
return Array.from(this.handlers.keys());
}
}
exports.HookBus = HookBus;
exports.hookBus = new HookBus();
//# sourceMappingURL=hook-bus.js.map
@@ -0,0 +1 @@
{"version":3,"file":"hook-bus.js","sourceRoot":"","sources":["hook-bus.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAKvD;;;;GAIG;AACH,MAAa,OAAO;IAApB;QACU,aAAQ,GAAG,IAAI,GAAG,EAAyB,CAAA;IAuCrD,CAAC;IArCC,QAAQ,CAAC,KAAa,EAAE,OAAoB;QAC1C,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,EAAE,CAAC;YAC9B,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,EAAE,EAAE,CAAC,CAAA;QAC9B,CAAC;QACD,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAE,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QACvC,eAAU,CAAC,KAAK,CAAC,EAAE,KAAK,EAAE,EAAE,8BAA8B,CAAC,CAAA;IAC7D,CAAC;IAED,8DAA8D;IAC9D,KAAK,CAAC,IAAI,CAAC,KAAa,EAAE,IAAS;QACjC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,KAAK,CAAC,CAAA;QACzC,IAAI,CAAC,QAAQ,IAAI,QAAQ,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,EAAE,CAAA;QAEjD,MAAM,OAAO,GAAc,EAAE,CAAA;QAE7B,KAAK,MAAM,OAAO,IAAI,QAAQ,EAAE,CAAC;YAC/B,IAAI,CAAC;gBACH,MAAM,MAAM,GAAG,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;gBAClC,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;YACtB,CAAC;YAAC,OAAO,GAAG,EAAE,CAAC;gBACb,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE,2BAA2B,CAAC,CAAA;gBAC7D,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;YACpB,CAAC;QACH,CAAC;QAED,OAAO,OAAO,CAAA;IAChB,CAAC;IAED,6EAA6E;IAC7E,SAAS,CAAC,KAAa;QACrB,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;IAC7B,CAAC;IAED,kDAAkD;IAClD,UAAU;QACR,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IACzC,CAAC;CACF;AAxCD,0BAwCC;AAEY,QAAA,OAAO,GAAG,IAAI,OAAO,EAAE,CAAA"}
@@ -0,0 +1,53 @@
import { logger as rootLogger } from '../config/logger'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type HookHandler = (data: any) => Promise<any>
/**
* Event bus entre plugins.
* emit() executa todos os handlers registrados e retorna array de resultados.
* O plugin uploads usa o último resultado não-nulo retornado pelo SmartVision.
*/
export class HookBus {
private handlers = new Map<string, HookHandler[]>()
register(event: string, handler: HookHandler): void {
if (!this.handlers.has(event)) {
this.handlers.set(event, [])
}
this.handlers.get(event)!.push(handler)
rootLogger.debug({ event }, '[HookBus] Handler registrado')
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async emit(event: string, data: any): Promise<any[]> {
const handlers = this.handlers.get(event)
if (!handlers || handlers.length === 0) return []
const results: unknown[] = []
for (const handler of handlers) {
try {
const result = await handler(data)
results.push(result)
} catch (err) {
rootLogger.error({ err, event }, '[HookBus] Erro em handler')
results.push(null)
}
}
return results
}
/** Remove todos os handlers de um evento (usado no deactivate de plugins) */
removeAll(event: string): void {
this.handlers.delete(event)
}
/** Lista eventos registrados — útil para debug */
listEvents(): string[] {
return Array.from(this.handlers.keys())
}
}
export const hookBus = new HookBus()
@@ -0,0 +1,52 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.pluginConfig = exports.PluginConfigStore = void 0;
const dragonfly_1 = require("../infra/cache/dragonfly");
const logger_1 = require("../config/logger");
const KEY_PREFIX = 'plugin:config:';
// Config de plugins não expira — TTL longo (1 ano)
const TTL_SECONDS = 365 * 24 * 3600;
/**
* Config store para plugins, backed por DragonflyDB.
* get() usa cache in-memory para evitar round-trips no hot-path.
* set() persiste no Dragonfly e invalida o cache local.
*/
class PluginConfigStore {
constructor() {
this.cache = new Map();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** Lê config de um plugin. Retorna {} se não houver config salva. */
get(pluginName) {
return this.cache.get(pluginName) ?? {};
}
/** Persiste config no Dragonfly e atualiza cache local. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async set(pluginName, config) {
this.cache.set(pluginName, config);
await dragonfly_1.dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS);
logger_1.logger.debug({ pluginName }, '[PluginConfig] Config salva');
}
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
async loadAll(pluginNames) {
await Promise.all(pluginNames.map(async (name) => {
const stored = await dragonfly_1.dragonfly.getJson(`${KEY_PREFIX}${name}`);
if (stored) {
this.cache.set(name, stored);
logger_1.logger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly');
}
}));
}
/** Retorna config de todos os plugins (para a API admin). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllCached() {
const result = {};
for (const [name, config] of this.cache.entries()) {
result[name] = config;
}
return result;
}
}
exports.PluginConfigStore = PluginConfigStore;
exports.pluginConfig = new PluginConfigStore();
//# sourceMappingURL=plugin-config.js.map
@@ -0,0 +1 @@
{"version":3,"file":"plugin-config.js","sourceRoot":"","sources":["plugin-config.ts"],"names":[],"mappings":";;;AAAA,wDAAoD;AACpD,6CAAuD;AAEvD,MAAM,UAAU,GAAG,gBAAgB,CAAA;AACnC,mDAAmD;AACnD,MAAM,WAAW,GAAG,GAAG,GAAG,EAAE,GAAG,IAAI,CAAA;AAEnC;;;;GAIG;AACH,MAAa,iBAAiB;IAA9B;QACU,UAAK,GAAG,IAAI,GAAG,EAAmC,CAAA;IAsC5D,CAAC;IApCC,8DAA8D;IAC9D,qEAAqE;IACrE,GAAG,CAAC,UAAkB;QACpB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,EAAE,CAAA;IACzC,CAAC;IAED,2DAA2D;IAC3D,8DAA8D;IAC9D,KAAK,CAAC,GAAG,CAAC,UAAkB,EAAE,MAA2B;QACvD,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,CAAA;QAClC,MAAM,qBAAS,CAAC,OAAO,CAAC,GAAG,UAAU,GAAG,UAAU,EAAE,EAAE,MAAM,EAAE,WAAW,CAAC,CAAA;QAC1E,eAAU,CAAC,KAAK,CAAC,EAAE,UAAU,EAAE,EAAE,6BAA6B,CAAC,CAAA;IACjE,CAAC;IAED,wEAAwE;IACxE,KAAK,CAAC,OAAO,CAAC,WAAqB;QACjC,MAAM,OAAO,CAAC,GAAG,CACf,WAAW,CAAC,GAAG,CAAC,KAAK,EAAE,IAAI,EAAE,EAAE;YAC7B,MAAM,MAAM,GAAG,MAAM,qBAAS,CAAC,OAAO,CAA0B,GAAG,UAAU,GAAG,IAAI,EAAE,CAAC,CAAA;YACvF,IAAI,MAAM,EAAE,CAAC;gBACX,IAAI,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,EAAE,MAAM,CAAC,CAAA;gBAC5B,eAAU,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,EAAE,8CAA8C,CAAC,CAAA;YAC5E,CAAC;QACH,CAAC,CAAC,CACH,CAAA;IACH,CAAC;IAED,6DAA6D;IAC7D,8DAA8D;IAC9D,YAAY;QACV,MAAM,MAAM,GAAwC,EAAE,CAAA;QACtD,KAAK,MAAM,CAAC,IAAI,EAAE,MAAM,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,EAAE,EAAE,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,GAAG,MAAM,CAAA;QACvB,CAAC;QACD,OAAO,MAAM,CAAA;IACf,CAAC;CACF;AAvCD,8CAuCC;AAEY,QAAA,YAAY,GAAG,IAAI,iBAAiB,EAAE,CAAA"}
@@ -0,0 +1,54 @@
import { dragonfly } from '../infra/cache/dragonfly'
import { logger as rootLogger } from '../config/logger'
const KEY_PREFIX = 'plugin:config:'
// Config de plugins não expira — TTL longo (1 ano)
const TTL_SECONDS = 365 * 24 * 3600
/**
* Config store para plugins, backed por DragonflyDB.
* get() usa cache in-memory para evitar round-trips no hot-path.
* set() persiste no Dragonfly e invalida o cache local.
*/
export class PluginConfigStore {
private cache = new Map<string, Record<string, unknown>>()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** Lê config de um plugin. Retorna {} se não houver config salva. */
get(pluginName: string): Record<string, any> {
return this.cache.get(pluginName) ?? {}
}
/** Persiste config no Dragonfly e atualiza cache local. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async set(pluginName: string, config: Record<string, any>): Promise<void> {
this.cache.set(pluginName, config)
await dragonfly.setJson(`${KEY_PREFIX}${pluginName}`, config, TTL_SECONDS)
rootLogger.debug({ pluginName }, '[PluginConfig] Config salva')
}
/** Carrega todas as configs do Dragonfly para o cache local no boot. */
async loadAll(pluginNames: string[]): Promise<void> {
await Promise.all(
pluginNames.map(async (name) => {
const stored = await dragonfly.getJson<Record<string, unknown>>(`${KEY_PREFIX}${name}`)
if (stored) {
this.cache.set(name, stored)
rootLogger.debug({ name }, '[PluginConfig] Config carregada do Dragonfly')
}
}),
)
}
/** Retorna config de todos os plugins (para a API admin). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
getAllCached(): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {}
for (const [name, config] of this.cache.entries()) {
result[name] = config
}
return result
}
}
export const pluginConfig = new PluginConfigStore()
@@ -0,0 +1,119 @@
import path from 'path'
import fs from 'fs'
import { logger as rootLogger } from '../config/logger'
import type { PluginInstance, PluginManifest } from './types'
export interface LoadedPlugin {
instance: PluginInstance
manifest: PluginManifest
dir: string
}
// Em produção: __dirname = backend/dist/core → 3 níveis acima = raiz do projeto
// Em dev com tsx: __dirname = backend/src/core → 3 níveis acima = raiz do projeto
const PLUGINS_DIR = path.resolve(__dirname, '../../../plugins')
/**
* Ordena plugins por dependências usando topological sort (Kahn's algorithm).
* Plugins sem dependências vêm primeiro; core plugins antes dos business plugins.
*/
function topoSort(plugins: LoadedPlugin[]): LoadedPlugin[] {
const byName = new Map(plugins.map((p) => [p.manifest.name, p]))
const inDegree = new Map<string, number>()
const dependents = new Map<string, string[]>()
for (const p of plugins) {
inDegree.set(p.manifest.name, 0)
dependents.set(p.manifest.name, [])
}
for (const p of plugins) {
for (const dep of p.manifest.dependencies) {
if (!byName.has(dep)) {
rootLogger.warn({ plugin: p.manifest.name, dep }, '[PluginLoader] Dependência não encontrada')
continue
}
dependents.get(dep)!.push(p.manifest.name)
inDegree.set(p.manifest.name, (inDegree.get(p.manifest.name) ?? 0) + 1)
}
}
const queue = plugins
.filter((p) => inDegree.get(p.manifest.name) === 0)
.sort((a, b) => (a.manifest.category === 'core' ? -1 : 1) - (b.manifest.category === 'core' ? -1 : 1))
const sorted: LoadedPlugin[] = []
while (queue.length > 0) {
const current = queue.shift()!
sorted.push(current)
for (const depName of dependents.get(current.manifest.name) ?? []) {
const newDegree = (inDegree.get(depName) ?? 1) - 1
inDegree.set(depName, newDegree)
if (newDegree === 0) {
queue.push(byName.get(depName)!)
}
}
}
if (sorted.length !== plugins.length) {
rootLogger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original')
return plugins
}
return sorted
}
/**
* Escaneia o diretório /plugins, lê cada manifest.json e importa index.ts/js.
* Retorna plugins ordenados por dependência.
*/
export async function loadPlugins(): Promise<LoadedPlugin[]> {
if (!fs.existsSync(PLUGINS_DIR)) {
rootLogger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado')
return []
}
const entries = fs.readdirSync(PLUGINS_DIR, { withFileTypes: true })
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name)
const loaded: LoadedPlugin[] = []
for (const dirName of pluginDirs) {
const pluginDir = path.join(PLUGINS_DIR, dirName)
const manifestPath = path.join(pluginDir, 'manifest.json')
const indexTs = path.join(pluginDir, 'index.ts')
const indexJs = path.join(pluginDir, 'index.js')
if (!fs.existsSync(manifestPath)) {
rootLogger.warn({ dir: dirName }, '[PluginLoader] manifest.json não encontrado — pulando')
continue
}
// Prefere .js (produção compilada); fallback para .ts (tsx watch em dev)
const entryPoint = fs.existsSync(indexJs) ? indexJs : fs.existsSync(indexTs) ? indexTs : null
if (!entryPoint) {
rootLogger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando')
continue
}
try {
const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'))
const mod = await import(entryPoint)
const instance: PluginInstance = mod.default ?? mod
if (typeof instance.activate !== 'function') {
rootLogger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando')
continue
}
loaded.push({ instance, manifest, dir: pluginDir })
rootLogger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado')
} catch (err) {
rootLogger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin')
}
}
return topoSort(loaded)
}
@@ -0,0 +1,122 @@
import { logger as rootLogger } from '../config/logger'
import type { PluginContext, PluginInstance, PluginManifest } from './types'
import type { LoadedPlugin } from './plugin-loader'
export interface RegistryEntry {
instance: PluginInstance
manifest: PluginManifest
active: boolean
dir: string
}
/**
* Registry central dos plugins.
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
*/
class PluginRegistry {
private entries = new Map<string, RegistryEntry>()
private ctx!: PluginContext
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
setContext(ctx: PluginContext): void {
this.ctx = ctx
}
/** Registra plugins carregados (sem ativar ainda) */
register(plugins: LoadedPlugin[]): void {
for (const { instance, manifest, dir } of plugins) {
this.entries.set(manifest.name, { instance, manifest, active: false, dir })
}
}
/** Ativa todos os plugins marcados como enabled no manifest.
* Plugins com dependências faltando são pulados com warning (não crasham o servidor). */
async activateAll(): Promise<void> {
for (const [name, entry] of this.entries) {
if (!entry.manifest.enabled) continue
try {
await this.activate(name)
} catch (err: any) {
rootLogger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização')
}
}
}
async activate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
if (entry.active) return
// Verifica dependências
for (const dep of entry.manifest.dependencies) {
const depEntry = this.entries.get(dep)
if (!depEntry?.active) {
throw new Error(`Dependência "${dep}" do plugin "${name}" não está ativa`)
}
}
try {
await entry.instance.activate(this.ctx)
entry.active = true
entry.manifest.enabled = true
rootLogger.info({ name }, '[PluginRegistry] Plugin ativado')
} catch (err) {
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin')
throw err
}
}
async deactivate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
if (!entry.active) return
if (!entry.manifest.canDisable) throw new Error(`Plugin "${name}" não pode ser desativado`)
// Verifica se algum plugin ativo depende deste
for (const [depName, depEntry] of this.entries) {
if (depEntry.active && depEntry.manifest.dependencies.includes(name)) {
throw new Error(`Plugin "${depName}" depende de "${name}". Desative-o primeiro.`)
}
}
try {
await entry.instance.deactivate(this.ctx)
entry.active = false
entry.manifest.enabled = false
rootLogger.info({ name }, '[PluginRegistry] Plugin desativado')
} catch (err) {
rootLogger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin')
throw err
}
}
/** Retorna lista de todos os plugins com status para a API admin */
list(): Array<RegistryEntry & { name: string }> {
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }))
}
getEntry(name: string): RegistryEntry | undefined {
return this.entries.get(name)
}
/**
* Força reativação de um plugin sem passar pelo deactivate.
* Útil quando canDisable=false mas a config foi atualizada e precisa ser relida.
*/
async reactivate(name: string): Promise<void> {
const entry = this.entries.get(name)
if (!entry) throw new Error(`Plugin "${name}" não encontrado`)
entry.active = false
await this.activate(name)
rootLogger.info({ name }, '[PluginRegistry] Plugin reativado com nova config')
}
/** Retorna menu items de todos os plugins ativos */
getActiveMenuItems(): Array<{ pluginName: string; items: PluginManifest['frontend'] }> {
return Array.from(this.entries.values())
.filter((e) => e.active && e.manifest.frontend?.menuItems?.length)
.map((e) => ({ pluginName: e.manifest.name, items: e.manifest.frontend }))
}
}
export const pluginRegistry = new PluginRegistry()
@@ -0,0 +1,3 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
{"version":3,"file":"types.js","sourceRoot":"","sources":["types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,134 @@
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
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 }>
}
@@ -0,0 +1,55 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.dragonfly = void 0;
const ioredis_1 = __importDefault(require("ioredis"));
const env_1 = require("../../config/env");
const logger_1 = require("../../config/logger");
class DragonflyClient {
constructor() {
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
this.client.on('error', (err) => logger_1.logger.error(err, 'DragonflyDB erro'));
}
async connect() {
await this.client.connect();
}
async get(key) {
return this.client.get(key);
}
async getJson(key) {
const raw = await this.client.get(key);
if (!raw)
return null;
return JSON.parse(raw);
}
async set(key, value, ttlSeconds) {
await this.client.set(key, value, 'EX', ttlSeconds ?? this.ttl);
}
async setJson(key, value, ttlSeconds) {
await this.client.set(key, JSON.stringify(value), 'EX', ttlSeconds ?? this.ttl);
}
async del(key) {
await this.client.del(key);
}
async exists(key) {
const count = await this.client.exists(key);
return count > 0;
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel, message) {
await this.client.publish(channel, message);
}
raw() {
return this.client;
}
}
exports.dragonfly = new DragonflyClient();
//# sourceMappingURL=dragonfly.js.map
@@ -0,0 +1 @@
{"version":3,"file":"dragonfly.js","sourceRoot":"","sources":["dragonfly.ts"],"names":[],"mappings":";;;;;;AAAA,sDAA2B;AAC3B,0CAAsC;AACtC,gDAA4C;AAE5C,MAAM,eAAe;IAInB;QACE,IAAI,CAAC,GAAG,GAAG,QAAQ,CAAC,SAAG,CAAC,qBAAqB,EAAE,EAAE,CAAC,CAAA;QAElD,IAAI,CAAC,MAAM,GAAG,IAAI,iBAAK,CAAC,SAAG,CAAC,aAAa,EAAE;YACzC,oBAAoB,EAAE,CAAC;YACvB,gBAAgB,EAAE,IAAI;YACtB,WAAW,EAAE,IAAI;SAClB,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,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,eAAM,CAAC,KAAK,CAAC,GAAG,EAAE,kBAAkB,CAAC,CAAC,CAAA;IACzE,CAAC;IAED,KAAK,CAAC,OAAO;QACX,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,EAAE,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,OAAO,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,OAAO,CAAI,GAAW;QAC1B,MAAM,GAAG,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;QACtC,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAA;QACrB,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAM,CAAA;IAC7B,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW,EAAE,KAAa,EAAE,UAAmB;QACvD,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjE,CAAC;IAED,KAAK,CAAC,OAAO,CAAC,GAAW,EAAE,KAAc,EAAE,UAAmB;QAC5D,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,IAAI,EAAE,UAAU,IAAI,IAAI,CAAC,GAAG,CAAC,CAAA;IACjF,CAAC;IAED,KAAK,CAAC,GAAG,CAAC,GAAW;QACnB,MAAM,IAAI,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IAC5B,CAAC;IAED,KAAK,CAAC,MAAM,CAAC,GAAW;QACtB,MAAM,KAAK,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAA;QAC3C,OAAO,KAAK,GAAG,CAAC,CAAA;IAClB,CAAC;IAED,yEAAyE;IACzE,KAAK,CAAC,OAAO,CAAC,OAAe,EAAE,OAAe;QAC5C,MAAM,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;IAC7C,CAAC;IAED,GAAG;QACD,OAAO,IAAI,CAAC,MAAM,CAAA;IACpB,CAAC;CACF;AAEY,QAAA,SAAS,GAAG,IAAI,eAAe,EAAE,CAAA"}
@@ -0,0 +1,102 @@
import Redis from 'ioredis'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
class DragonflyClient {
private client: Redis
private readonly ttl: number
constructor() {
this.ttl = parseInt(env.DRAGONFLY_TTL_SECONDS, 10)
this.client = new Redis(env.DRAGONFLY_URL, {
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
// Falha imediata quando desconectado — evita fila infinita em memória
enableOfflineQueue: false,
// Reconexão exponencial: 200ms → 400ms → ... → 30s
retryStrategy(times: number) {
const delay = Math.min(200 * 2 ** times, 30_000)
return delay
},
})
this.client.on('connect', () => logger.info('DragonflyDB conectado'))
this.client.on('ready', () => logger.info('DragonflyDB pronto'))
this.client.on('error', (err) => logger.error({ msg: 'DragonflyDB erro', code: (err as any).code }))
this.client.on('close', () => logger.warn('DragonflyDB conexão fechada — reconectando...'))
}
/** true se o cliente está conectado e pronto */
isAlive(): boolean {
return this.client.status === 'ready'
}
async connect(): Promise<void> {
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
// retryStrategy cuida da reconexão automática
this.client.connect().catch(() => { /* reconexão em background via retryStrategy */ })
}
async get(key: string): Promise<string | null> {
try { return await this.client.get(key) } catch { return null }
}
async getJson<T>(key: string): Promise<T | null> {
try {
const raw = await this.client.get(key)
if (!raw) return null
return JSON.parse(raw) as T
} catch { return null }
}
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
try {
const ttl = ttlSeconds ?? this.ttl
if (ttl > 0) {
await this.client.set(key, value, 'EX', ttl)
} else {
await this.client.set(key, value)
}
} catch { /* Redis indisponível — ignora silenciosamente */ }
}
async setJson(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
try {
const ttl = ttlSeconds ?? this.ttl
if (ttl > 0) {
await this.client.set(key, JSON.stringify(value), 'EX', ttl)
} else {
await this.client.set(key, JSON.stringify(value))
}
} catch { /* Redis indisponível — ignora silenciosamente */ }
}
async psetex(key: string, ttlMs: number, value: string): Promise<void> {
try { await this.client.psetex(key, ttlMs, value) } catch { }
}
async del(key: string): Promise<void> {
try { await this.client.del(key) } catch { }
}
async exists(key: string): Promise<boolean> {
try {
const count = await this.client.exists(key)
return count > 0
} catch { return false }
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel: string, message: string): Promise<void> {
try { await this.client.publish(channel, message) } catch { }
}
raw(): Redis {
return this.client
}
}
export const dragonfly = new DragonflyClient()
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = void 0;
const client_1 = require("@prisma/client");
exports.prisma = global.__prisma ??
new client_1.PrismaClient({
log: process.env.NODE_ENV !== 'production'
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
: [],
});
if (process.env.NODE_ENV !== 'production') {
global.__prisma = exports.prisma;
}
//# sourceMappingURL=prisma.js.map
@@ -0,0 +1 @@
{"version":3,"file":"prisma.js","sourceRoot":"","sources":["prisma.ts"],"names":[],"mappings":";;;AAAA,2CAA6C;AAQhC,QAAA,MAAM,GACjB,MAAM,CAAC,QAAQ;IACf,IAAI,qBAAY,CAAC;QACf,GAAG,EACD,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY;YACnC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC;YACzE,CAAC,CAAC,EAAE;KACT,CAAC,CAAA;AAEJ,IAAI,OAAO,CAAC,GAAG,CAAC,QAAQ,KAAK,YAAY,EAAE,CAAC;IAC1C,MAAM,CAAC,QAAQ,GAAG,cAAM,CAAA;AAC1B,CAAC"}
@@ -0,0 +1,20 @@
import { PrismaClient } from '@prisma/client'
import { logger } from '../../config/logger'
declare global {
// eslint-disable-next-line no-var
var __prisma: PrismaClient | undefined
}
export const prisma =
global.__prisma ??
new PrismaClient({
log:
process.env.NODE_ENV !== 'production'
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
: [],
})
if (process.env.NODE_ENV !== 'production') {
global.__prisma = prisma
}
@@ -0,0 +1,78 @@
import { Server as SocketIOServer } from 'socket.io'
import type { Server as HttpServer } from 'http'
import jwt from 'jsonwebtoken'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
import { dragonfly } from '../cache/dragonfly'
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
const PRESENCE_TTL = 70
interface SocketUser {
sub: string // userId / tenantId (matches JWT payload)
role: string
}
export function buildSocketServer(httpServer: HttpServer): SocketIOServer {
const io = new SocketIOServer(httpServer, {
cors: {
origin: env.FRONTEND_URL,
credentials: true,
},
transports: ['websocket', 'polling'],
})
// Autenticação por token JWT no handshake
io.use((socket, next) => {
const token = socket.handshake.auth?.token as string | undefined
if (!token) {
next(new Error('Token não fornecido'))
return
}
try {
const payload = jwt.verify(token, env.JWT_SECRET) as SocketUser
socket.data.userId = payload.sub
socket.data.role = payload.role
next()
} catch {
next(new Error('Token inválido'))
}
})
io.on('connection', (socket) => {
const userId: string = socket.data.userId
logger.debug({ userId, socketId: socket.id }, 'Socket conectado')
// Cliente entra na sala do seu tenant automaticamente
socket.join(`tenant:${userId}`)
// Registra presença do usuário no Dragonfly com TTL
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
// Cliente pode entrar em salas específicas de instância ou chat
socket.on('join:instance', (instanceId: string) => {
socket.join(`instance:${instanceId}`)
})
socket.on('join:chat', (chatId: string) => {
socket.join(`chat:${chatId}`)
})
socket.on('leave:chat', (chatId: string) => {
socket.leave(`chat:${chatId}`)
})
// Heartbeat do frontend a cada 30s — renova TTL de presença
socket.on('user:heartbeat', () => {
dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => {})
})
socket.on('disconnect', (reason) => {
logger.debug({ userId, reason }, 'Socket desconectado')
// Remove presença imediatamente ao desconectar
dragonfly.del(`presence:user:${userId}`).catch(() => {})
})
})
return io
}
@@ -0,0 +1,87 @@
/**
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
*/
import { prisma } from '../database/prisma'
import { logger } from '../../config/logger'
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
export async function liberarSlotAgenda(input: {
solicitanteId: string
tituloSlot: string
chatIdSolicitante: string
}): Promise<void> {
logger.info(input, '[Temporal] Slot liberado por timeout — notificando solicitante')
// Aqui você envia mensagem via WhatsApp avisando o solicitante
// Implementação real acoplada ao WhatsAppConnectionManager via NATS
// Por ora, marca no banco como disponível
await prisma.protocol.updateMany({
where: {
contactId: input.solicitanteId,
status: 'WAITING_CLIENT',
},
data: { status: 'OPEN' },
})
}
export async function notificarConflito(input: {
chatIdB: string
solicitanteNome: string
tituloSlot: string
}): Promise<void> {
logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot')
// Dispara mensagem via NATS → WhatsApp handler
}
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
export async function decrementarScore(input: {
contactId: string
motivo: string
pontos: number
}): Promise<{ novoScore: number; restrito: boolean }> {
const contact = await prisma.contact.findUnique({
where: { id: input.contactId },
})
if (!contact) throw new Error(`Contato ${input.contactId} não encontrado`)
const novoScore = Math.max(0, contact.scoreReputacao - input.pontos)
const restrito = novoScore <= 20
await prisma.contact.update({
where: { id: input.contactId },
data: {
scoreReputacao: novoScore,
flagRestricao: restrito,
},
})
logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo },
'[Temporal] Score de reputação atualizado')
return { novoScore, restrito }
}
export async function bloquearAgendamentosAutomaticos(input: {
contactId: string
}): Promise<void> {
await prisma.contact.update({
where: { id: input.contactId },
data: { flagRestricao: true },
})
logger.warn({ contactId: input.contactId },
'[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados')
}
export async function buscarScoreContato(contactId: string): Promise<{
score: number
restrito: boolean
}> {
const c = await prisma.contact.findUnique({
where: { id: contactId },
select: { scoreReputacao: true, flagRestricao: true },
})
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false }
}
@@ -0,0 +1,19 @@
import { Client, Connection } from '@temporalio/client'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
let _client: Client | null = null
export async function getTemporalClient(): Promise<Client> {
if (_client) return _client
const connection = await Connection.connect({ address: env.TEMPORAL_ADDRESS })
_client = new Client({
connection,
namespace: env.TEMPORAL_NAMESPACE,
})
logger.info({ address: env.TEMPORAL_ADDRESS }, 'Temporal Client conectado')
return _client
}
@@ -0,0 +1,25 @@
/**
* Worker do Temporal — roda como processo separado.
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
*/
import { Worker } from '@temporalio/worker'
import { env } from '../../config/env'
import { logger } from '../../config/logger'
import * as activities from './activities'
async function runWorker() {
const worker = await Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: env.TEMPORAL_TASK_QUEUE,
namespace: env.TEMPORAL_NAMESPACE,
})
logger.info({ taskQueue: env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado')
await worker.run()
}
runWorker().catch((err) => {
logger.error(err, 'Falha fatal no Temporal Worker')
process.exit(1)
})
@@ -0,0 +1,76 @@
/**
* Workflow: Conflito de Agenda (1 Hora)
*
* Regra de negócio:
* - Pessoa A solicita o slot que Pessoa B já tem PENDENTE.
* - Sistema marca o conflito como WAITING_B e aguarda 1 hora.
* - Se B confirmar dentro de 1h → slot permanece com B, A é notificada.
* - Se B não responder em 1h → slot é liberado e transferido para A.
*/
import {
proxyActivities,
sleep,
defineSignal,
setHandler,
condition,
log,
} from '@temporalio/workflow'
import type * as acts from '../activities'
const {
liberarSlotAgenda,
notificarConflito,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
})
// Signal enviado quando B confirma o slot
export const confirmarSlotSignal = defineSignal<[{ confirmado: boolean }]>('confirmarSlot')
export interface ConflitoAgendaInput {
solicitanteId: string // Pessoa A
detentorId: string // Pessoa B
chatIdSolicitante: string
chatIdDetentor: string
tituloSlot: string // ex: "Consulta 14h do dia 10/04"
}
export async function conflitoAgendaWorkflow(input: ConflitoAgendaInput): Promise<{
resultado: 'MANTIDO_B' | 'LIBERADO_A'
}> {
let bConfirmou = false
// Registra o handler do signal de confirmação de B
setHandler(confirmarSlotSignal, ({ confirmado }) => {
bConfirmou = confirmado
log.info('Signal recebido de B', { confirmado })
})
// Notifica B sobre a disputa
await notificarConflito({
chatIdB: input.chatIdDetentor,
solicitanteNome: input.solicitanteId,
tituloSlot: input.tituloSlot,
})
log.info('Aguardando confirmação de B por 1 hora', { tituloSlot: input.tituloSlot })
// Aguarda signal de B OU timeout de 1 hora
const bRespondeu = await condition(() => bConfirmou !== false, '1 hour')
if (bRespondeu && bConfirmou) {
log.info('B confirmou o slot — slot mantido com B')
return { resultado: 'MANTIDO_B' }
}
// B não respondeu ou recusou → libera para A
log.info('B não confirmou em 1 hora — liberando slot para A')
await liberarSlotAgenda({
solicitanteId: input.solicitanteId,
tituloSlot: input.tituloSlot,
chatIdSolicitante: input.chatIdSolicitante,
})
return { resultado: 'LIBERADO_A' }
}
@@ -0,0 +1,2 @@
export { conflitoAgendaWorkflow, confirmarSlotSignal } from './conflitoAgendaWorkflow'
export { reputacaoWorkflow, registrarInfracaoSignal, encerrarMonitoramentoSignal } from './reputacaoWorkflow'
@@ -0,0 +1,83 @@
/**
* Workflow: Gestão de Reputação de Contato
*
* Regra de negócio:
* - Cada falta/infração reduz o score_reputacao do contato.
* - Score ≤ 20 → contato entra em flag_restricao = true.
* - Agendamentos automáticos são bloqueados para contatos RESTRITOS.
* - Score é recuperável manualmente pelo agente (fora deste workflow).
*/
import {
proxyActivities,
defineSignal,
setHandler,
condition,
log,
} from '@temporalio/workflow'
import type * as acts from '../activities'
const {
decrementarScore,
bloquearAgendamentosAutomaticos,
buscarScoreContato,
} = proxyActivities<typeof acts>({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
})
// Signal para registrar uma nova infração
export const registrarInfracaoSignal = defineSignal<[{
motivo: string
pontos: number
}]>('registrarInfracao')
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
export const encerrarMonitoramentoSignal = defineSignal('encerrarMonitoramento')
export interface ReputacaoInput {
contactId: string
tenantId: string
}
export async function reputacaoWorkflow(input: ReputacaoInput): Promise<void> {
let encerrado = false
const infracoesPendentes: Array<{ motivo: string; pontos: number }> = []
setHandler(registrarInfracaoSignal, (payload) => {
log.info('Infração registrada via signal', payload)
infracoesPendentes.push(payload)
})
setHandler(encerrarMonitoramentoSignal, () => {
encerrado = true
})
// Loop durável — o Temporal mantém o estado mesmo após reinícios
while (!encerrado) {
// Aguarda próxima infração ou encerramento
await condition(() => infracoesPendentes.length > 0 || encerrado)
if (encerrado) break
const infracao = infracoesPendentes.shift()!
const { novoScore, restrito } = await decrementarScore({
contactId: input.contactId,
motivo: infracao.motivo,
pontos: infracao.pontos,
})
log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId })
if (restrito) {
await bloquearAgendamentosAutomaticos({ contactId: input.contactId })
log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
contactId: input.contactId,
scoreAtual: novoScore,
})
// Continua monitorando (restrição é revertida manualmente pelo agente)
}
}
log.info('Monitoramento de reputação encerrado', { contactId: input.contactId })
}
@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.authMiddleware = exports.authenticate = void 0;
// Re-export do middleware de autenticação para compatibilidade com plugins
var auth_middleware_1 = require("../shared/middlewares/auth.middleware");
Object.defineProperty(exports, "authenticate", { enumerable: true, get: function () { return auth_middleware_1.authMiddleware; } });
var auth_middleware_2 = require("../shared/middlewares/auth.middleware");
Object.defineProperty(exports, "authMiddleware", { enumerable: true, get: function () { return auth_middleware_2.authMiddleware; } });
//# sourceMappingURL=auth.js.map
@@ -0,0 +1 @@
{"version":3,"file":"auth.js","sourceRoot":"","sources":["auth.ts"],"names":[],"mappings":";;;AAAA,2EAA2E;AAC3E,yEAAsF;AAA7E,+GAAA,cAAc,OAAgB;AACvC,yEAAsE;AAA7D,iHAAA,cAAc,OAAA"}
@@ -0,0 +1,3 @@
// Re-export do middleware de autenticação para compatibilidade com plugins
export { authMiddleware as authenticate } from '../shared/middlewares/auth.middleware'
export { authMiddleware } from '../shared/middlewares/auth.middleware'
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.requireRole = requireRole;
// Stub de RBAC — aceita qualquer role válida
function requireRole(..._roles) {
return (_req, _res, next) => next();
}
//# sourceMappingURL=rbac.js.map
@@ -0,0 +1 @@
{"version":3,"file":"rbac.js","sourceRoot":"","sources":["rbac.ts"],"names":[],"mappings":";;AAGA,kCAEC;AAHD,6CAA6C;AAC7C,SAAgB,WAAW,CAAC,GAAG,MAAgB;IAC7C,OAAO,CAAC,IAAa,EAAE,IAAc,EAAE,IAAkB,EAAE,EAAE,CAAC,IAAI,EAAE,CAAA;AACtE,CAAC"}
@@ -0,0 +1,6 @@
import type { Request, Response, NextFunction } from 'express'
// Stub de RBAC — aceita qualquer role válida
export function requireRole(..._roles: string[]) {
return (_req: Request, _res: Response, next: NextFunction) => next()
}
@@ -0,0 +1,93 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import crypto from 'crypto'
export function buildApiKeyRoutes(): Router {
const router = Router()
// GET /api/api-keys
router.get('/', async (req: Request, res: Response) => {
const keys = await prisma.apiKey.findMany({
where: { tenantId: req.tenantId! },
orderBy: { createdAt: 'desc' },
select: {
id: true,
name: true,
key: true,
isActive: true,
createdAt: true,
expiresAt: true,
},
})
// Mask key: show only last 8 chars
const masked = keys.map((k) => ({
...k,
keyPreview: `••••••••${k.key.slice(-8)}`,
key: undefined,
}))
res.json(masked)
})
// POST /api/api-keys — cria nova chave
router.post('/', async (req: Request, res: Response) => {
const schema = z.object({
name: z.string().min(1).max(80),
expiresAt: z.string().datetime().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const key = `nw_${crypto.randomBytes(24).toString('hex')}`
const created = await prisma.apiKey.create({
data: {
tenantId: req.tenantId!,
name: body.name,
key,
expiresAt: body.expiresAt ? new Date(body.expiresAt) : undefined,
},
})
// Retorna a chave completa apenas uma vez
res.status(201).json({
id: created.id,
name: created.name,
key: created.key, // plaintext — única vez
isActive: created.isActive,
createdAt: created.createdAt,
expiresAt: created.expiresAt,
})
})
// PATCH /api/api-keys/:id — ativar/desativar
router.patch('/:id', async (req: Request, res: Response) => {
const id = req.params['id'] as string
const schema = z.object({ isActive: z.boolean() })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
const updated = await prisma.apiKey.update({ where: { id }, data: { isActive: body.isActive } })
res.json({ id: updated.id, name: updated.name, isActive: updated.isActive })
})
// DELETE /api/api-keys/:id
router.delete('/:id', async (req: Request, res: Response) => {
const id = req.params['id'] as string
const existing = await prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId! } })
if (!existing) { res.status(404).json({ error: 'Chave não encontrada' }); return }
await prisma.apiKey.delete({ where: { id } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,375 @@
/**
* 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' })
}
})
@@ -0,0 +1,60 @@
import { prisma } from '../../infra/database/prisma'
import type { UserRole } from '@prisma/client'
export class AuthRepository {
async findByEmail(email: string) {
return prisma.user.findUnique({ where: { email } })
}
async findById(id: string) {
return prisma.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
isActive: true,
planId: true,
trialEndsAt: true,
createdAt: true,
},
})
}
async create(data: {
name: string
email: string
passwordHash: string
role?: UserRole
planId?: string
}) {
return prisma.user.create({
data: {
name: data.name,
email: data.email,
passwordHash: data.passwordHash,
role: data.role ?? 'USER',
planId: data.planId,
trialEndsAt: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000), // 14 dias de trial
},
select: {
id: true,
name: true,
email: true,
role: true,
planId: true,
trialEndsAt: true,
createdAt: true,
},
})
}
async updatePassword(id: string, passwordHash: string) {
return prisma.user.update({ where: { id }, data: { passwordHash } })
}
async setActive(id: string, isActive: boolean) {
return prisma.user.update({ where: { id }, data: { isActive } })
}
}
@@ -0,0 +1,127 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { AuthService } from './auth.service'
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
const service = new AuthService()
// ─── Schemas de validação ─────────────────────────────────────────────────────
const registerSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
password: z.string().min(8).max(128),
planId: z.string().uuid().optional(),
})
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
})
const refreshSchema = z.object({
refreshToken: z.string().min(1),
})
const changePasswordSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8).max(128),
})
// ─── Helper para erros de validação / serviço ─────────────────────────────────
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
const e = err as Error & { statusCode?: number }
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
}
// ─── Router ───────────────────────────────────────────────────────────────────
export const authRouter = Router()
/**
* POST /api/auth/register
* Cria novo tenant (usuário com role USER).
* Retorna access + refresh token e dados do usuário.
*/
authRouter.post('/register', async (req: Request, res: Response) => {
try {
const body = registerSchema.parse(req.body) as any
const result = await service.register(body)
res.status(201).json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/login
* Autentica usuário. Retorna access + refresh token.
*/
authRouter.post('/login', async (req: Request, res: Response) => {
try {
const body = loginSchema.parse(req.body) as any
const result = await service.login(body)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/refresh
* Renova o access token usando o refresh token.
*/
authRouter.post('/refresh', async (req: Request, res: Response) => {
try {
const { refreshToken } = refreshSchema.parse(req.body)
const result = await service.refresh(refreshToken)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/auth/logout
* Revoga o refresh token do usuário autenticado.
*/
authRouter.post('/logout', authMiddleware, async (req: Request, res: Response) => {
try {
await service.logout(req.tenantId!)
res.json({ message: 'Sessão encerrada' })
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/auth/me
* Retorna dados do usuário autenticado + dias restantes do trial.
*/
authRouter.get('/me', authMiddleware, async (req: Request, res: Response) => {
try {
const user = await service.me(req.tenantId!)
res.json(user)
} catch (err) {
handleError(res, err)
}
})
/**
* PATCH /api/auth/password
* Altera a senha do usuário autenticado.
*/
authRouter.patch('/password', authMiddleware, async (req: Request, res: Response) => {
try {
const body = changePasswordSchema.parse(req.body) as any
await service.changePassword(req.tenantId!, body)
res.json({ message: 'Senha alterada com sucesso' })
} catch (err) {
handleError(res, err)
}
})
@@ -0,0 +1,176 @@
import bcrypt from 'bcryptjs'
import jwt from 'jsonwebtoken'
import { env } from '../../config/env'
import { AuthRepository } from './auth.repository'
import { dragonfly } from '../../infra/cache/dragonfly'
const BCRYPT_ROUNDS = 12
const ACCESS_EXPIRES = '8h'
const REFRESH_EXPIRES = '30d'
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60
interface TokenPair {
accessToken: string
refreshToken: string
}
interface JwtPayload {
sub: string
role: string
type: 'access' | 'refresh'
}
export class AuthService {
private repo = new AuthRepository()
// ─── Register ─────────────────────────────────────────────────────────────
async register(input: {
name: string
email: string
password: string
planId?: string
}) {
const existing = await this.repo.findByEmail(input.email)
if (existing) {
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 })
}
const passwordHash = await bcrypt.hash(input.password, BCRYPT_ROUNDS)
const user = await this.repo.create({
name: input.name,
email: input.email,
passwordHash,
planId: input.planId,
})
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
return { user, ...tokens }
}
// ─── Login ────────────────────────────────────────────────────────────────
async login(input: { email: string; password: string }) {
const user = await this.repo.findByEmail(input.email)
if (!user) {
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
}
if (!user.isActive) {
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
}
const valid = await bcrypt.compare(input.password, user.passwordHash)
if (!valid) {
throw Object.assign(new Error('Credenciais inválidas'), { statusCode: 401 })
}
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
const { passwordHash: _, ...safeUser } = user
return { user: safeUser, ...tokens }
}
// ─── Refresh ──────────────────────────────────────────────────────────────
async refresh(refreshToken: string) {
let payload: JwtPayload
try {
payload = jwt.verify(refreshToken, env.JWT_SECRET) as JwtPayload
} catch {
throw Object.assign(new Error('Refresh token inválido'), { statusCode: 401 })
}
if (payload.type !== 'refresh') {
throw Object.assign(new Error('Token incorreto'), { statusCode: 401 })
}
// Valida que o token ainda está no cache (não foi revogado)
const stored = await dragonfly.get(this.refreshKey(payload.sub))
if (stored !== refreshToken) {
throw Object.assign(new Error('Refresh token expirado ou revogado'), { statusCode: 401 })
}
const user = await this.repo.findById(payload.sub)
if (!user || !user.isActive) {
throw Object.assign(new Error('Usuário inativo'), { statusCode: 403 })
}
const tokens = this.issueTokens(user.id, user.role)
await this.saveRefreshToken(user.id, tokens.refreshToken)
return { user, ...tokens }
}
// ─── Logout ───────────────────────────────────────────────────────────────
async logout(userId: string) {
await dragonfly.del(this.refreshKey(userId))
}
// ─── Me ───────────────────────────────────────────────────────────────────
async me(userId: string) {
const user = await this.repo.findById(userId)
if (!user) {
throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
}
const trialEndsAt = user.trialEndsAt ? new Date(user.trialEndsAt) : null
const daysRemaining = trialEndsAt
? Math.max(0, Math.ceil((trialEndsAt.getTime() - Date.now()) / 86_400_000))
: null
return { ...user, daysRemaining }
}
// ─── Change Password ──────────────────────────────────────────────────────
async changePassword(userId: string, input: { currentPassword: string; newPassword: string }) {
const user = await this.repo.findByEmail(
(await this.repo.findById(userId))?.email ?? ''
)
if (!user) throw Object.assign(new Error('Usuário não encontrado'), { statusCode: 404 })
const valid = await bcrypt.compare(input.currentPassword, user.passwordHash)
if (!valid) {
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 })
}
const newHash = await bcrypt.hash(input.newPassword, BCRYPT_ROUNDS)
await this.repo.updatePassword(userId, newHash)
await this.logout(userId) // invalida todas as sessões
}
// ─── Helpers ──────────────────────────────────────────────────────────────
private issueTokens(userId: string, role: string): TokenPair {
const base = { sub: userId, role }
const accessToken = jwt.sign(
{ ...base, type: 'access' },
env.JWT_SECRET,
{ expiresIn: ACCESS_EXPIRES }
)
const refreshToken = jwt.sign(
{ ...base, type: 'refresh' },
env.JWT_SECRET,
{ expiresIn: REFRESH_EXPIRES }
)
return { accessToken, refreshToken }
}
private async saveRefreshToken(userId: string, token: string) {
await dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS)
}
private refreshKey(userId: string): string {
return `auth:refresh:${userId}`
}
}
@@ -0,0 +1,68 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0;
/**
* chatbot.repository.ts — CRUD para AICredential e AIBot.
*/
const prisma_1 = require("../../infra/database/prisma");
// ─── Credentials ─────────────────────────────────────────────────────────────
exports.credentialRepo = {
findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({
where: { tenantId, instanceId },
select: { id: true, name: true, provider: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
create: (data) => prisma_1.prisma.aICredential.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
name: data.name,
provider: data.provider ?? 'GEMINI',
apiKey: data.apiKey,
},
select: { id: true, name: true, provider: true, createdAt: true },
}),
delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }),
};
// ─── Bots ─────────────────────────────────────────────────────────────────────
exports.botRepo = {
findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({
where: { tenantId, instanceId },
include: { credential: { select: { id: true, name: true, provider: true } } },
orderBy: { createdAt: 'desc' },
}),
findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({
where: { tenantId, instanceId, enabled: true },
include: { credential: true },
}),
findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({
where: { id, tenantId },
include: { credential: true },
}),
create: (data) => prisma_1.prisma.aIBot.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
credentialId: data.credentialId,
name: data.name,
systemPrompt: data.systemPrompt,
model: data.model ?? 'gemini-1.5-flash',
enabled: data.enabled ?? false,
triggerMode: data.triggerMode ?? 'ALL',
keywords: data.keywords ?? [],
},
}),
update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }),
};
// ─── Chat bot state ───────────────────────────────────────────────────────────
exports.chatBotState = {
pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
getState: (chatId) => prisma_1.prisma.chat.findUnique({
where: { id: chatId },
select: { botPaused: true, botSummary: true },
}),
};
//# sourceMappingURL=chatbot.repository.js.map
@@ -0,0 +1 @@
{"version":3,"file":"chatbot.repository.js","sourceRoot":"","sources":["chatbot.repository.ts"],"names":[],"mappings":";;;AAAA;;GAEG;AACH,wDAAoD;AAEpD,gFAAgF;AAEnE,QAAA,cAAc,GAAG;IAC5B,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,YAAY,CAAC,QAAQ,CAAC;QAC3B,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;QACjE,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAMR,EAAE,EAAE,CACH,eAAM,CAAC,YAAY,CAAC,MAAM,CAAC;QACzB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,QAAQ;YACnC,MAAM,EAAE,IAAI,CAAC,MAAM;SACpB;QACD,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE;KAClE,CAAC;IAEJ,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,YAAY,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CAC9D,CAAA;AAED,iFAAiF;AAEpE,QAAA,OAAO,GAAG;IACrB,OAAO,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CAChD,eAAM,CAAC,KAAK,CAAC,QAAQ,CAAC;QACpB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE;QAC/B,OAAO,EAAE,EAAE,UAAU,EAAE,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE;QAC7E,OAAO,EAAE,EAAE,SAAS,EAAE,MAAM,EAAE;KAC/B,CAAC;IAEJ,WAAW,EAAE,CAAC,QAAgB,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,QAAQ,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE;QAC9C,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,QAAQ,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACzC,eAAM,CAAC,KAAK,CAAC,SAAS,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE;QACvB,OAAO,EAAE,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9B,CAAC;IAEJ,MAAM,EAAE,CAAC,IAUR,EAAE,EAAE,CACH,eAAM,CAAC,KAAK,CAAC,MAAM,CAAC;QAClB,IAAI,EAAE;YACJ,QAAQ,EAAE,IAAI,CAAC,QAAQ;YACvB,UAAU,EAAE,IAAI,CAAC,UAAU;YAC3B,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,YAAY,EAAE,IAAI,CAAC,YAAY;YAC/B,KAAK,EAAE,IAAI,CAAC,KAAK,IAAI,kBAAkB;YACvC,OAAO,EAAE,IAAI,CAAC,OAAO,IAAI,KAAK;YAC9B,WAAW,EAAE,IAAI,CAAC,WAAW,IAAI,KAAK;YACtC,QAAQ,EAAE,IAAI,CAAC,QAAQ,IAAI,EAAE;SAC9B;KACF,CAAC;IAEJ,MAAM,EAAE,CACN,EAAU,EACV,QAAgB,EAChB,IAQE,EACF,EAAE,CACF,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,IAAI,EAAE,CAAC;IAE5D,MAAM,EAAE,CAAC,EAAU,EAAE,QAAgB,EAAE,EAAE,CACvC,eAAM,CAAC,KAAK,CAAC,UAAU,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,QAAQ,EAAE,EAAE,CAAC;CACvD,CAAA;AAED,iFAAiF;AAEpE,QAAA,YAAY,GAAG;IAC1B,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,EAAE,CAAC;IAE1E,SAAS,EAAE,CAAC,MAAc,EAAE,EAAE,CAC5B,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,EAAE,CAAC;IAE3E,aAAa,EAAE,CAAC,MAAc,EAAE,UAAkB,EAAE,EAAE,CACpD,eAAM,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE,EAAE,IAAI,EAAE,EAAE,UAAU,EAAE,EAAE,CAAC;IAErE,QAAQ,EAAE,CAAC,MAAc,EAAE,EAAE,CAC3B,eAAM,CAAC,IAAI,CAAC,UAAU,CAAC;QACrB,KAAK,EAAE,EAAE,EAAE,EAAE,MAAM,EAAE;QACrB,MAAM,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE;KAC9C,CAAC;CACL,CAAA"}
@@ -0,0 +1,121 @@
/**
* chatbot.repository.ts — CRUD para AICredential e AIBot.
*/
import { prisma } from '../../infra/database/prisma'
// ─── Credentials ─────────────────────────────────────────────────────────────
export const credentialRepo = {
findAll: (tenantId: string, instanceId: string) =>
prisma.aICredential.findMany({
where: { tenantId, instanceId },
select: { id: true, name: true, provider: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
create: (data: {
tenantId: string
instanceId: string
name: string
provider?: 'GEMINI' | 'OPENAI'
apiKey: string
}) =>
prisma.aICredential.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
name: data.name,
provider: data.provider ?? 'GEMINI',
apiKey: data.apiKey,
},
select: { id: true, name: true, provider: true, createdAt: true },
}),
delete: (id: string, tenantId: string) =>
prisma.aICredential.deleteMany({ where: { id, tenantId } }),
}
// ─── Bots ─────────────────────────────────────────────────────────────────────
export const botRepo = {
findAll: (tenantId: string, instanceId: string) =>
prisma.aIBot.findMany({
where: { tenantId, instanceId },
include: { credential: { select: { id: true, name: true, provider: true } } },
orderBy: { createdAt: 'desc' },
}),
findEnabled: (tenantId: string, instanceId: string) =>
prisma.aIBot.findFirst({
where: { tenantId, instanceId, enabled: true },
include: { credential: true },
}),
findById: (id: string, tenantId: string) =>
prisma.aIBot.findFirst({
where: { id, tenantId },
include: { credential: true },
}),
create: (data: {
tenantId: string
instanceId: string
credentialId: string
name: string
systemPrompt: string
model?: string
enabled?: boolean
triggerMode?: 'ALL' | 'KEYWORD'
keywords?: string[]
}) =>
prisma.aIBot.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
credentialId: data.credentialId,
name: data.name,
systemPrompt: data.systemPrompt,
model: data.model ?? 'gemini-1.5-flash',
enabled: data.enabled ?? false,
triggerMode: data.triggerMode ?? 'ALL',
keywords: data.keywords ?? [],
},
}),
update: (
id: string,
tenantId: string,
data: Partial<{
name: string
systemPrompt: string
model: string
enabled: boolean
triggerMode: 'ALL' | 'KEYWORD'
keywords: string[]
credentialId: string
}>
) =>
prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
delete: (id: string, tenantId: string) =>
prisma.aIBot.deleteMany({ where: { id, tenantId } }),
}
// ─── Chat bot state ───────────────────────────────────────────────────────────
export const chatBotState = {
pauseBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
updateSummary: (chatId: string, botSummary: string) =>
prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
getState: (chatId: string) =>
prisma.chat.findUnique({
where: { id: chatId },
select: { botPaused: true, botSummary: true },
}),
}
@@ -0,0 +1,193 @@
/**
* 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
}
const bot = await botRepo.create({ tenantId, instanceId, ...body } as any)
res.status(201).json(bot)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao criar bot')
res.status(500).json({ error: err.message })
}
})
// PUT /api/chatbot/bots/:instanceId/:botId
router.put('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
const schema = z.object({
credentialId: z.string().uuid().optional(),
name: z.string().min(1).max(80).optional(),
systemPrompt: z.string().min(10).optional(),
model: z.string().optional(),
enabled: z.boolean().optional(),
triggerMode: z.enum(['ALL', 'KEYWORD']).optional(),
keywords: z.array(z.string()).optional(),
})
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
const body = schema.parse(req.body)
await botRepo.update(botId, tenantId, body as any)
const updated = await botRepo.findById(botId, tenantId)
res.json(updated)
} catch (err: any) {
if (err instanceof z.ZodError) { res.status(400).json({ error: err.errors }); return }
logger.error({ err }, 'Erro ao atualizar bot')
res.status(500).json({ error: err.message })
}
})
// DELETE /api/chatbot/bots/:instanceId/:botId
router.delete('/bots/:instanceId/:botId', async (req: Request, res: Response) => {
try {
const tenantId = req.tenantId!
const botId = req.params['botId'] as string
await botRepo.delete(botId, tenantId)
res.status(204).send()
} catch (err: any) {
logger.error({ err }, 'Erro ao deletar bot')
res.status(500).json({ error: err.message })
}
})
// ─────────────────────────────────────────────────────────────────────────
// BOT STATE — Human Takeover por chat
// ─────────────────────────────────────────────────────────────────────────
// POST /api/chatbot/chats/:chatId/pause — pausa o bot (human takeover)
router.post('/chats/:chatId/pause', async (req: Request, res: Response) => {
try {
await chatBotState.pauseBot(req.params['chatId'] as string)
res.json({ paused: true })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
router.post('/chats/:chatId/resume', async (req: Request, res: Response) => {
try {
await chatBotState.resumeBot(req.params['chatId'] as string)
res.json({ paused: false })
} catch (err: any) {
res.status(500).json({ error: err.message })
}
})
return router
}
@@ -0,0 +1,165 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ChatbotService = void 0;
/**
* chatbot.service.ts — Motor de IA Multi-Agente com Gemini Flash.
*
* Padrão Cérebro (instrucoes.md §5):
* - Não envia histórico completo para economizar tokens
* - Mantém resumo de 2 frases por chat (botSummary no banco)
* - Micro-prompt de intenção retorna 1 token: "1" = resolver, "2" = escalar
*/
const generative_ai_1 = require("@google/generative-ai");
const prisma_1 = require("../../infra/database/prisma");
const logger_1 = require("../../config/logger");
const chatbot_repository_1 = require("./chatbot.repository");
// Cache de clientes Gemini por apiKey (evita recriar para cada mensagem)
const geminiClients = new Map();
function getGeminiClient(apiKey) {
if (!geminiClients.has(apiKey)) {
geminiClients.set(apiKey, new generative_ai_1.GoogleGenerativeAI(apiKey));
}
return geminiClients.get(apiKey);
}
function getModel(apiKey, modelName) {
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName });
}
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
async function classifyIntent(userMsg, summary, systemPrompt, model) {
const prompt = [
`Você é um classificador de intenção para um chatbot de atendimento.`,
`Prompt do atendente: ${systemPrompt}`,
summary ? `Contexto da conversa até agora: ${summary}` : '',
`Nova mensagem do cliente: "${userMsg}"`,
`Responda APENAS com o número:`,
`1 = Consigo responder essa pergunta dentro do meu papel`,
`2 = Precisa de um agente humano`,
].filter(Boolean).join('\n');
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 2, temperature: 0 },
});
const text = result.response.text().trim();
return text.startsWith('2') ? 'escalate' : 'resolve';
}
catch (err) {
logger_1.logger.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve');
return 'resolve';
}
}
// ─── Geração de resposta ──────────────────────────────────────────────────────
async function generateResponse(userMsg, summary, systemPrompt, model) {
const prompt = [
systemPrompt,
summary ? `\nContexto da conversa até agora:\n${summary}` : '',
`\nMensagem do cliente: "${userMsg}"`,
`\nResponda de forma natural, breve e direta. Não use markdown.`,
].filter(Boolean).join('\n');
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 300, temperature: 0.7 },
});
return result.response.text().trim();
}
// ─── Atualização do Cérebro (resumo de 2 frases) ─────────────────────────────
async function updateSummary(currentSummary, userMsg, botMsg, model) {
const prompt = [
currentSummary
? `Resumo atual da conversa: ${currentSummary}`
: 'Esta é a primeira troca da conversa.',
`Nova troca:`,
`Cliente: "${userMsg}"`,
`Assistente: "${botMsg}"`,
`Atualize o resumo em MÁXIMO 2 frases curtas, capturando o essencial da conversa inteira.`,
`Responda apenas com o resumo, sem introdução.`,
].join('\n');
try {
const result = await model.generateContent({
contents: [{ role: 'user', parts: [{ text: prompt }] }],
generationConfig: { maxOutputTokens: 80, temperature: 0.3 },
});
return result.response.text().trim();
}
catch {
// Em caso de erro, mantém o resumo anterior
return currentSummary ?? '';
}
}
// ─── Interface pública ────────────────────────────────────────────────────────
class ChatbotService {
constructor(io) {
this.io = io;
}
/**
* Ponto de entrada para cada mensagem recebida.
* Chamado pelo MessageHandler após persistir a mensagem.
*/
async handleIncoming(opts) {
const { tenantId, instanceId, chatId, jid, text, sock } = opts;
// 1. Verifica se há bot ativo para esta instância
const bot = await chatbot_repository_1.botRepo.findEnabled(tenantId, instanceId);
if (!bot)
return;
// 2. Verifica o estado do chat (human takeover)
const chatState = await chatbot_repository_1.chatBotState.getState(chatId);
if (!chatState || chatState.botPaused)
return;
// 3. Aplica modo de gatilho
if (bot.triggerMode === 'KEYWORD') {
const lowerText = text.toLowerCase();
const hasKeyword = bot.keywords.some((kw) => lowerText.includes(kw.toLowerCase()));
if (!hasKeyword)
return;
}
try {
const model = getModel(bot.credential.apiKey, bot.model);
const summary = chatState.botSummary ?? null;
// 4. Micro-prompt: intenção
const intent = await classifyIntent(text, summary, bot.systemPrompt, model);
if (intent === 'escalate') {
// Pausa o bot e notifica via Socket.IO
await chatbot_repository_1.chatBotState.pauseBot(chatId);
this.io.to(`chat:${chatId}`).emit('bot:escalated', {
chatId,
message: 'Bot pausado — atendente humano necessário',
});
logger_1.logger.info({ chatId, jid }, '[Chatbot] Escalação para humano');
return;
}
// 5. Gera resposta
const responseText = await generateResponse(text, summary, bot.systemPrompt, model);
// 6. Envia via Baileys
const sent = await sock.sendMessage(jid, { text: responseText });
// 7. Persiste mensagem do bot no banco
const chat = await prisma_1.prisma.chat.findUnique({ where: { id: chatId } });
if (chat) {
const botMsg = await prisma_1.prisma.message.create({
data: {
tenantId,
instanceId,
chatId,
remoteJid: jid,
messageId: sent?.key.id ?? `bot-${Date.now()}`,
fromMe: true,
type: 'TEXT',
body: responseText,
status: 'SENT',
timestamp: new Date(),
},
});
// Notifica frontend
this.io.to(`chat:${chatId}`).emit('message:new', botMsg);
}
// 8. Atualiza Cérebro
const newSummary = await updateSummary(summary, text, responseText, model);
await chatbot_repository_1.chatBotState.updateSummary(chatId, newSummary);
logger_1.logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada');
}
catch (err) {
logger_1.logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem');
}
}
}
exports.ChatbotService = ChatbotService;
//# sourceMappingURL=chatbot.service.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,203 @@
/**
* 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
*/
import { GoogleGenerativeAI, type GenerativeModel } 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 por apiKey (evita recriar para cada mensagem)
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)!
}
function getModel(apiKey: string, modelName: string): GenerativeModel {
return getGeminiClient(apiKey).getGenerativeModel({ model: modelName })
}
// ─── Micro-prompt: classificação de intenção (1 token) ───────────────────────
async function classifyIntent(
userMsg: string,
summary: string | null,
systemPrompt: string,
model: GenerativeModel
): Promise<'resolve' | 'escalate'> {
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.error({ err }, '[Chatbot] Erro na classificação de intenção — assumindo resolve')
return 'resolve'
}
}
// ─── Geração de resposta ──────────────────────────────────────────────────────
async function generateResponse(
userMsg: string,
summary: string | null,
systemPrompt: string,
model: GenerativeModel
): Promise<string> {
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: string | null,
userMsg: string,
botMsg: string,
model: GenerativeModel
): Promise<string> {
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 ────────────────────────────────────────────────────────
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
}
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 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. 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.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: 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 chatBotState.updateSummary(chatId, newSummary)
logger.info({ chatId, jid, intent }, '[Chatbot] Resposta enviada')
} catch (err) {
logger.error({ err, chatId }, '[Chatbot] Erro ao processar mensagem')
}
}
}
@@ -0,0 +1,133 @@
import { Prisma } from '@prisma/client'
import { prisma } from '../../infra/database/prisma'
export class ChatRepository {
async findAllByInstance(tenantId: string, instanceId: string, opts: {
limit?: number
search?: string
archived?: boolean
} = {}) {
const { limit = 200, search, archived = false } = opts
return 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 } } },
],
}
: {}),
},
include: {
contact: {
select: {
name: true,
notify: true,
verifiedName: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
protocols: {
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
select: { number: true, status: true },
take: 1,
orderBy: { createdAt: 'desc' },
},
messages: {
select: {
body: true,
fromMe: true,
status: true,
timestamp: true,
type: true,
pushName: true,
},
orderBy: { timestamp: 'desc' },
take: 1,
},
},
orderBy: [
{ isPinned: 'desc' },
// COALESCE: usa lastMessageAt se existir, senão createdAt (chats novos sem msg)
// Garante que chats recém-criados não caiam para o fundo da lista
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
{ createdAt: 'desc' },
],
take: limit,
})
}
async findById(id: string, tenantId: string) {
return prisma.chat.findFirst({
where: { id, tenantId },
include: {
contact: true,
protocols: {
where: { status: { in: ['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT'] } },
take: 1,
orderBy: { createdAt: 'desc' },
},
},
})
}
async findByJid(tenantId: string, instanceId: string, jid: string) {
return prisma.chat.findUnique({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
include: { contact: true },
})
}
async clearUnread(id: string, tenantId: string) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { unreadCount: 0 },
})
}
async setArchived(id: string, tenantId: string, archived: boolean) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { isArchived: archived },
})
}
async setPinned(id: string, tenantId: string, pinned: boolean) {
return prisma.chat.updateMany({
where: { id, tenantId },
data: { isPinned: pinned },
})
}
async countUnreadByInstance(tenantId: string, instanceId: string): Promise<number> {
const result = await prisma.chat.aggregate({
where: {
tenantId,
instanceId,
isArchived: false,
NOT: { OR: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
]}
},
_sum: { unreadCount: true },
})
return result._sum.unreadCount ?? 0
}
}
@@ -0,0 +1,480 @@
/**
* 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
*
* RESOLUÇÃO DE NOME (displayName):
* - Grupos: usa Chat.name (subject do grupo, sincronizado por groups.upsert)
* - Individuais: Contact.name > Contact.verifiedName > Contact.notify > lastMsg.pushName
* - Se nenhum nome disponível: retorna null → frontend formata o telefone
*
* @see ChatRepository — queries otimizadas com JOINs
* @see MessageRepository — busca e paginação de mensagens
*/
import { Router, type Request, type Response } from 'express'
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 type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
const chatRepo = new ChatRepository()
const msgRepo = new MessageRepository()
/** 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 as rotas de chat.
*
* Montadas em: /api/chats
* O WhatsAppConnectionManager é injetado mas não usado diretamente aqui
* (reservado para futuras features como typing indicator).
*/
export function buildChatRoutes(manager: WhatsAppConnectionManager): Router {
const router = Router()
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats — Lista chats com snapshot da última mensagem
// ═══════════════════════════════════════════════════════════════════════════
/**
* Lista chats do tenant para uma instância, com:
* - Último mensagem (preview para a lista de chats)
* - Protocolo ativo (se houver)
* - Dados do contato (nome resolvido, avatar, reputação)
*
* Query params:
* - instanceId: UUID (obrigatório)
* - search?: string (busca por nome/telefone)
* - archived?: boolean (filtrar por arquivados)
* - limit?: number (1-200)
*
* REGRA DE RESOLUÇÃO DE NOME (resolvedName):
* Grupos: Chat.name (subject do grupo)
* Individuais:
* 1. Contact.name (agenda do telefone — via contacts.upsert)
* 2. Contact.verifiedName (conta business verificada)
* 3. Contact.notify (pushname — via contacts.upsert ou mensagem)
* 4. lastMsg.pushName (fallback: pushName da última mensagem recebida)
* 5. null (frontend formata o telefone como "55 11 99999-9999")
*/
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 chats = await chatRepo.findAllByInstance(req.tenantId!, instanceId, {
search,
archived,
limit,
})
// Serializa cada chat com nome resolvido e dados formatados para o frontend
const result = chats.map((c) => {
const contact = c.contact
const isGroup = c.jid.endsWith('@g.us')
// Última mensagem (já vem do JOIN no repository, limitado a 1)
const lastMsg = c.messages[0]
// Protocolo ativo mais recente (OPEN ou IN_PROGRESS)
const activeProtocol = c.protocols[0] ?? null
// ── Resolução de nome ─────────────────────────────────────────────
// Regra: nunca retorna o JID bruto como nome — retorna null e deixa
// o frontend fazer o fallback (telefone formatado ou "Grupo").
//
// Para individuais: usa hierarchia Contact > lastMsg.pushName > null
// Para grupos: usa Chat.name (subject) ou null
//
// O fallback lastMsg.pushName cobre contatos que vieram do history
// sync sem nome (a maioria — só ~22 de ~3000 têm verifiedName).
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, // null quando não há nome real
phone: contact.phone,
avatarUrl: contact.avatarUrl,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: isGroup
? { name: resolvedName, phone: null, avatarUrl: 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,
}
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/search/messages — Busca full-text em mensagens
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca full-text no corpo das mensagens.
* MUST ser definido ANTES de /:id para evitar captura de "search" como param.
*
* Query params:
* - instanceId: UUID
* - q: string (mínimo 2 chars)
* - limit?: number (1-50)
*/
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 — Contagens para o dashboard
// ═══════════════════════════════════════════════════════════════════════════
/**
* Retorna métricas resumidas para o dashboard do frontend.
* MUST ser definido ANTES de /:id para evitar captura de "stats" como param.
*
* Response: { totalUnread, openProtocols, totalChats }
*
* Cache DragonflyDB de 30s para não sobrecarregar o banco em dashboards
* com refresh automático.
*/
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 }
// Executa as 3 queries em paralelo para minimizar latência
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,
// Exclui chats @lid (artefatos internos do WhatsApp)
NOT: { jid: { endsWith: '@lid' } }
},
}),
])
const stats = { totalUnread, openProtocols, totalChats }
await dragonfly.setJson(cacheKey, stats, 30) // cache 30s
res.json(stats)
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id — Detalhe de um chat
// ═══════════════════════════════════════════════════════════════════════════
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 — Zera contador de não-lidas
// ═══════════════════════════════════════════════════════════════════════════
/** Marca todas as mensagens do chat como lidas (zera unreadCount) */
router.post('/:id/read', async (req: Request, res: Response) => {
try {
const id = req.params['id'] as string
await chatRepo.clearUnread(id, req.tenantId!)
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/archive — Arquivar/desarquivar
// ═══════════════════════════════════════════════════════════════════════════
/** Body: { archived: boolean } */
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)
await chatRepo.setArchived(id, req.tenantId!, archived)
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/pin — Fixar/desfixar
// ═══════════════════════════════════════════════════════════════════════════
/** Body: { pinned: boolean } */
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)
await chatRepo.setPinned(id, req.tenantId!, pinned)
res.json({ ok: true })
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// DELETE /api/chats/:id — Remove chat e mensagens
// ═══════════════════════════════════════════════════════════════════════════
/**
* Remove um chat e TODAS as suas mensagens permanentemente.
* Operação irreversível — use com cuidado.
* Remove mensagens primeiro (FK constraint) e depois o chat.
*/
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 } })
res.status(204).send()
} catch (err) {
handleError(res, err)
}
})
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id/messages — Histórico paginado
// ═══════════════════════════════════════════════════════════════════════════
/**
* Retorna mensagens do chat com paginação por cursor (timestamp ISO).
*
* Query params:
* - before?: ISO datetime (mensagens anteriores a este timestamp)
* - after?: ISO datetime (mensagens posteriores a este timestamp)
* - limit?: number (1-100, default definido no repository)
*
* Response: { messages: Message[], hasMore: boolean }
*
* O frontend usa infinite scroll: carrega as mais recentes primeiro,
* depois pede mais antigas passando `before` do timestamp mais antigo.
*/
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)
// Valida que o chat pertence ao tenant (segurança multi-tenant)
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 DE ATENDIMENTO
// ═══════════════════════════════════════════════════════════════════════════
/**
* GET /api/chats/:id/protocol — Últimos 10 protocolos do chat.
* Inclui setor e equipe associados.
*/
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)
}
})
/**
* POST /api/chats/:id/protocol — Abre novo protocolo de atendimento.
*
* Body (opcional):
* - sectorId?: UUID — setor responsável
* - teamId?: UUID — equipe responsável
*
* O número do protocolo é gerado sequencialmente: #0001-2026, #0002-2026, etc.
* Exige que o chat tenha um contactId vinculado (não funciona para chats órfãos).
*/
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 }
// Gera número de protocolo sequencial com ano
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',
},
})
res.status(201).json(protocol)
} catch (err) {
handleError(res, err)
}
})
/**
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo.
*
* Body:
* - status?: enum — novo status do protocolo
* - summary?: string (max 500) — resumo do atendimento
*
* Se status='RESOLVED', preenche resolvedAt automaticamente.
*
* Status possíveis:
* OPEN → IN_PROGRESS → WAITING_CLIENT / WAITING_AGENT → RESOLVED / CANCELLED
*/
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() }),
},
})
res.json({ updated: protocol.count })
} catch (err) {
handleError(res, err)
}
})
return router
}
@@ -0,0 +1,86 @@
import { prisma } from '../../infra/database/prisma'
export class MessageRepository {
/**
* Histórico paginado por cursor (timestamp).
* Retorna `limit` mensagens anteriores ao cursor dado.
* Se cursor não for fornecido, retorna as mais recentes.
*/
async findByChatPaginated(chatId: string, opts: {
limit?: number
before?: string // timestamp ISO — cursor de paginação
after?: string // timestamp ISO — buscar mensagens mais novas
} = {}) {
const { limit = 40, before, after } = opts
const where: Record<string, unknown> = { chatId }
if (before) where['timestamp'] = { lt: new Date(before) }
if (after) where['timestamp'] = { gt: new Date(after) }
// Initial load (sem cursor) e scroll-up (before): busca DESC e inverte
// para retornar as mensagens mais recentes em ordem cronológica.
// Scroll-down (after): busca ASC naturalmente.
const needsReverse = !after
const messages = await prisma.message.findMany({
where,
orderBy: { timestamp: needsReverse ? 'desc' : 'asc' },
take: limit,
include: {
replyTo: {
select: {
id: true,
messageId: true,
body: true,
fromMe: true,
type: true,
mediaUrl: true,
},
},
},
})
// Inverte para ordem cronológica (mais antiga → mais recente)
if (needsReverse) messages.reverse()
const hasMore = messages.length === limit
return { messages, hasMore }
}
async findById(id: string) {
return prisma.message.findUnique({ where: { id } })
}
async search(tenantId: string, instanceId: string, query: string, limit = 20) {
return prisma.message.findMany({
where: {
tenantId,
instanceId,
body: { contains: query, mode: 'insensitive' },
},
include: {
chat: {
select: {
jid: true,
contact: { select: { name: true, notify: true, avatarUrl: true } },
},
},
},
orderBy: { timestamp: 'desc' },
take: limit,
})
}
async updateStatus(messageId: string, instanceId: string, status: string) {
return prisma.message.updateMany({
where: { messageId, instanceId },
data: { status: status as any },
})
}
async countByChat(chatId: string): Promise<number> {
return prisma.message.count({ where: { chatId } })
}
}
@@ -0,0 +1,160 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
import { normalizeJid } from '../../shared/utils/whatsapp'
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 })
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 } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,120 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { PairService } from './pair.service'
import { authMiddleware } from '../../shared/middlewares/auth.middleware'
const service = new PairService()
function handleError(res: Response, err: unknown) {
if (err instanceof z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors })
return
}
const e = err as Error & { statusCode?: number }
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' })
}
// ─── Schemas ──────────────────────────────────────────────────────────────────
const requestSchema = z.object({
email: z.string().email(),
clientSystem: z.string().min(1).max(100),
clientName: z.string().min(1).max(200),
clientUrl: z.string().url().nullable().optional(),
})
const confirmSchema = z.object({
email: z.string().email(),
code: z.string().length(6),
clientSystem: z.string().min(1).max(100),
clientName: z.string().min(1).max(200),
clientUrl: z.string().url().nullable().optional(),
})
// ─── Router ───────────────────────────────────────────────────────────────────
export const pairRouter = Router()
/**
* POST /api/pair/request
* Inicia pareamento: gera OTP e envia por e-mail.
* Público (chamado pelo sistema cliente).
*/
pairRouter.post('/request', async (req: Request, res: Response) => {
try {
const body = requestSchema.parse(req.body) as any
const result = await service.request({
...body,
clientUrl: body.clientUrl ?? null,
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* POST /api/pair/confirm
* Confirma o OTP e retorna a integration_key.
* Público (chamado pelo sistema cliente com o código que o usuário digitou).
*/
pairRouter.post('/confirm', async (req: Request, res: Response) => {
try {
const body = confirmSchema.parse(req.body) as any
const result = await service.confirm({
...body,
clientUrl: body.clientUrl ?? null,
})
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/pair/verify
* Verifica se uma integration_key é válida.
* Público — autenticado pelo header x-nw-key.
*/
pairRouter.get('/verify', async (req: Request, res: Response) => {
try {
const raw = req.headers['x-nw-key']
const key = Array.isArray(raw) ? raw[0] : raw
if (!key) {
res.status(401).json({ error: 'Header x-nw-key ausente' })
return
}
const result = await service.verify(key)
res.json(result)
} catch (err) {
handleError(res, err)
}
})
/**
* GET /api/pair/connections
* Lista pares ativos do usuário autenticado.
* Protegido — requer JWT via authMiddleware.
*/
pairRouter.get('/connections', authMiddleware, async (req: Request, res: Response) => {
try {
const pairs = await service.list(req.tenantId!)
res.json(pairs)
} catch (err) {
handleError(res, err)
}
})
/**
* DELETE /api/pair/connections/:id
* Revoga um par do usuário autenticado.
* Protegido — requer JWT via authMiddleware.
*/
pairRouter.delete('/connections/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const result = await service.revoke(req.tenantId!, String(req.params.id))
res.json(result)
} catch (err) {
handleError(res, err)
}
})
@@ -0,0 +1,265 @@
import crypto from 'crypto'
import nodemailer from 'nodemailer'
import { prisma } from '../../infra/database/prisma'
import { dragonfly } from '../../infra/cache/dragonfly'
import { logger } from '../../config/logger'
const OTP_TTL = 10 * 60 // 10 minutos
const KEY_TTL = 90 * 24 * 3600 // 90 dias
interface OtpPayload {
code: string
clientSystem: string
clientName: string
clientUrl: string | null
}
interface KeyPayload {
userId: string
email: string
clientSystem: string
clientName: string
}
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
const otpKey = (email: string) => `pair:otp:${email}`
const intKey = (integrationKey: string) => `pair:key:${integrationKey}`
// ─── Email sender ─────────────────────────────────────────────────────────────
async function sendOtpEmail(to: string, code: string, clientName: string): Promise<boolean> {
// Lê config SMTP do admin settings
const raw = await dragonfly.getJson<any>('system:settings')
const smtp = raw?.smtp ?? {}
if (!smtp.host || !smtp.user) {
logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.')
logger.info(`[pair] OTP para ${to}: ${code}`) // fallback: loga localmente
return false
}
const transporter = nodemailer.createTransport({
host: smtp.host,
port: smtp.port ?? 587,
secure: smtp.secure ?? false,
auth: { user: smtp.user, pass: smtp.password },
})
await transporter.sendMail({
from: smtp.from ?? smtp.user,
to,
subject: `[NewWhats] Código de pareamento — ${clientName}`,
text: [
`Olá!`,
``,
`O sistema "${clientName}" solicitou conexão com sua conta NewWhats.`,
``,
`Seu código de confirmação: ${code}`,
``,
`Válido por 10 minutos.`,
``,
`Se você não solicitou isso, ignore este email.`,
].join('\n'),
html: `
<p>Olá!</p>
<p>O sistema <strong>${clientName}</strong> solicitou conexão com sua conta NewWhats.</p>
<h2 style="letter-spacing:8px;font-size:32px">${code}</h2>
<p style="color:#888">Válido por 10 minutos. Se não foi você, ignore este email.</p>
`,
})
logger.info(`[pair] OTP enviado para ${to}`)
return true
}
// ─── PairService ──────────────────────────────────────────────────────────────
export class PairService {
// Etapa 1 — solicitar pareamento
async request(input: {
email: string
clientSystem: string
clientName: string
clientUrl: string | null
}) {
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user) {
// Resposta genérica — não revela se o email existe ou não
return { ok: true, message: 'Se o email existir, um código será enviado.' }
}
if (!user.isActive) {
throw Object.assign(new Error('Conta desativada'), { statusCode: 403 })
}
// Gera OTP de 6 dígitos
const code = Math.floor(100_000 + Math.random() * 900_000).toString()
const payload: OtpPayload = {
code,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
}
await dragonfly.setJson(otpKey(input.email), payload, OTP_TTL)
const emailSent = await sendOtpEmail(input.email, code, input.clientName)
// Quando SMTP não está configurado, devolve o código na resposta
// para que o admin possa completar o pareamento manualmente.
if (!emailSent) {
return { ok: true, message: 'SMTP não configurado.', dev_code: code }
}
return { ok: true, message: 'Se o email existir, um código será enviado.' }
}
// Etapa 2 — confirmar com o código recebido
async confirm(input: {
email: string
code: string
clientSystem: string
clientName: string
clientUrl: string | null
}) {
const stored = await dragonfly.getJson<OtpPayload>(otpKey(input.email))
if (!stored) {
throw Object.assign(new Error('Código expirado ou inexistente. Solicite um novo.'), { statusCode: 400 })
}
if (stored.code !== input.code) {
throw Object.assign(new Error('Código incorreto.'), { statusCode: 400 })
}
if (stored.clientSystem !== input.clientSystem) {
throw Object.assign(new Error('Sistema não corresponde ao da solicitação.'), { statusCode: 400 })
}
const user = await prisma.user.findUnique({ where: { email: input.email } })
if (!user || !user.isActive) {
throw Object.assign(new Error('Conta não encontrada ou inativa.'), { statusCode: 404 })
}
// Revoga par anterior do mesmo sistema (evita duplicatas)
await prisma.pluginPair.updateMany({
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
data: { revokedAt: new Date() },
})
// Gera integration_key exclusiva
const integrationKey = `nwk_${crypto.randomUUID().replace(/-/g, '')}`
const pair = await prisma.pluginPair.create({
data: {
userId: user.id,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
integrationKey,
},
})
// Cache rápido para validação sem bater no banco
const keyPayload: KeyPayload = {
userId: user.id,
email: user.email,
clientSystem: input.clientSystem,
clientName: input.clientName,
}
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
// Limpa OTP usado
await dragonfly.del(otpKey(input.email))
logger.info(`[pair] ${user.email}${input.clientName} pareados (${pair.id})`)
return {
integration_key: integrationKey,
account: { name: user.name, email: user.email },
paired_at: pair.createdAt,
}
}
// Verifica se uma integration_key é válida (chamada pelo sistema cliente)
async verify(integrationKey: string) {
// Fast path — Dragonfly
const cached = await dragonfly.getJson<KeyPayload>(intKey(integrationKey))
if (cached) {
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
prisma.pluginPair.updateMany({
where: { integrationKey, revokedAt: null },
data: { lastSeenAt: new Date() },
}).catch(() => {})
return { valid: true, account: { email: cached.email }, client: cached.clientSystem }
}
// Slow path — banco
const pair = await prisma.pluginPair.findUnique({
where: { integrationKey },
include: { user: { select: { name: true, email: true, isActive: true } } },
})
if (!pair || pair.revokedAt || !pair.user.isActive) {
return { valid: false }
}
// Restaura cache
const keyPayload: KeyPayload = {
userId: pair.userId,
email: pair.user.email,
clientSystem: pair.clientSystem,
clientName: pair.clientName,
}
await dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL)
await prisma.pluginPair.update({
where: { integrationKey },
data: { lastSeenAt: new Date() },
})
return { valid: true, account: { name: pair.user.name, email: pair.user.email }, client: pair.clientSystem }
}
// Lista pares ativos do usuário autenticado
async list(userId: string) {
const pairs = await prisma.pluginPair.findMany({
where: { userId, revokedAt: null },
orderBy: { createdAt: 'desc' },
select: {
id: true,
clientSystem: true,
clientName: true,
clientUrl: true,
lastSeenAt: true,
createdAt: true,
},
})
return pairs
}
// Revoga um par (usuário autenticado só pode revogar os seus)
async revoke(userId: string, pairId: string) {
const pair = await prisma.pluginPair.findFirst({
where: { id: pairId, userId, revokedAt: null },
})
if (!pair) {
throw Object.assign(new Error('Conexão não encontrada'), { statusCode: 404 })
}
await prisma.pluginPair.update({
where: { id: pairId },
data: { revokedAt: new Date() },
})
// Remove do cache imediatamente
await dragonfly.del(intKey(pair.integrationKey))
logger.info(`[pair] Par ${pairId} revogado por ${userId}`)
return { ok: true }
}
}
@@ -0,0 +1,105 @@
import { Router } from 'express'
import { pluginRegistry } from '../../core/plugin-registry'
import { pluginConfig } from '../../core/plugin-config'
import { storageProvider } from '../../core/StorageProvider'
/**
* Rotas de gerenciamento de plugins — exclusivo admin.
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
*/
export const pluginsRouter = Router()
// GET /api/admin/plugins — lista todos os plugins com status e config atual
pluginsRouter.get('/', (_req, res) => {
const plugins = pluginRegistry.list().map((entry) => ({
name: entry.name,
displayName: entry.manifest.displayName,
version: entry.manifest.version,
description: entry.manifest.description,
author: entry.manifest.author,
category: entry.manifest.category,
active: entry.active,
canDisable: entry.manifest.canDisable,
dependencies: entry.manifest.dependencies,
configSchema: entry.manifest.configSchema ?? [],
config: pluginConfig.get(entry.name),
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
}))
res.json(plugins)
})
// POST /api/admin/plugins/:name/enable — ativa plugin
pluginsRouter.post('/:name/enable', async (req, res) => {
const { name } = req.params
try {
await pluginRegistry.activate(name)
res.json({ ok: true, message: `Plugin "${name}" ativado` })
} catch (err: any) {
res.status(400).json({ error: err.message })
}
})
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
pluginsRouter.post('/:name/disable', async (req, res) => {
const { name } = req.params
try {
await pluginRegistry.deactivate(name)
res.json({ ok: true, message: `Plugin "${name}" desativado` })
} catch (err: any) {
res.status(400).json({ error: err.message })
}
})
// GET /api/admin/plugins/:name/config — retorna config atual
pluginsRouter.get('/:name/config', (req, res) => {
const { name } = req.params
const entry = pluginRegistry.getEntry(name)
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
res.json({
name,
config: pluginConfig.get(name),
schema: entry.manifest.configSchema ?? [],
})
})
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
pluginsRouter.get('/storage/health', async (_req, res) => {
if (!storageProvider.isRegistered()) {
const cfg = pluginConfig.get('UnifiedStorageProvider')
const configured = !!(cfg.wasabiAccessKey && cfg.wasabiSecretKey && cfg.wasabiBucket)
return res.json({
healthy: false,
configured,
reason: configured ? 'needs_restart' : 'not_configured',
message: configured
? 'Credenciais salvas mas o plugin ainda não foi inicializado. Reinicie o servidor ou reative o plugin.'
: 'Wasabi não configurado. Acesse Admin > Plugins > UnifiedStorageProvider e insira as credenciais.',
})
}
await storageProvider.updateHealth()
return res.json(storageProvider.getLastHealthStatus())
})
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
pluginsRouter.put('/:name/config', async (req, res) => {
const { name } = req.params
const entry = pluginRegistry.getEntry(name)
if (!entry) return res.status(404).json({ error: `Plugin "${name}" não encontrado` })
const newConfig = req.body as Record<string, unknown>
await pluginConfig.set(name, newConfig)
// Reativa o plugin para aplicar a nova config imediatamente
if (entry.active) {
try {
await pluginRegistry.reactivate(name)
} catch (err: any) {
return res.status(500).json({ error: `Config salva mas falha ao reativar: ${err.message}` })
}
}
res.json({ ok: true, message: `Config do plugin "${name}" salva`, config: newConfig })
})
@@ -0,0 +1,232 @@
/**
* Rotas de Agendamentos de Mensagem
*
* GET /api/scheduled — Lista agendamentos do tenant
* POST /api/scheduled — Cria agendamento
* GET /api/scheduled/:id — Detalhe
* PUT /api/scheduled/:id — Atualiza
* DELETE /api/scheduled/:id — Remove
* POST /api/scheduled/:id/pause — Pausa
* POST /api/scheduled/:id/resume — Retoma
* POST /api/scheduled/:id/run-now — Executa imediatamente
* GET /api/scheduled/:id/logs — Histórico de execuções
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import type { SchedulerService } from './scheduler.service'
const recipientSchema = z.object({
jid: z.string(),
name: z.string().optional(),
birthday: z.string().optional(),
anniversary: z.string().optional(),
signupDate: z.string().optional(),
})
const scheduleSchema = z.object({
instanceId: z.string(),
name: z.string().min(1).max(100),
templateId: z.string().optional(),
payload: z.record(z.unknown()),
recipientMode: z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
recipients: z.array(recipientSchema).default([]),
scheduleType: z.enum(['ONCE', 'RECURRING', 'EVENT']),
cronExpr: z.string().optional(),
scheduledAt: z.string().datetime().optional(),
eventType: z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
timezone: z.string().default('America/Sao_Paulo'),
})
function getId(req: Request): string {
const id = req.params['id']
return Array.isArray(id) ? id[0] : id
}
function calcNextRun(data: Partial<z.infer<typeof scheduleSchema>>): Date | null {
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
return new Date(data.scheduledAt)
}
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
try {
const job = new Cron(data.cronExpr, { paused: true })
return job.nextRun() ?? null
} catch { return null }
}
return null
}
export function buildScheduledRoutes(_scheduler: SchedulerService): Router {
const router = Router()
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'] as string | undefined
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'] as string | undefined
const schedules = await prisma.scheduledMessage.findMany({
where: {
tenantId,
...(status ? { status: status as any } : {}),
...(type ? { scheduleType: type as any } : {}),
},
include: {
template: { select: { id: true, name: true, type: true } },
_count: { select: { logs: true } },
},
orderBy: { createdAt: 'desc' },
})
res.json(schedules)
})
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const parse = scheduleSchema.safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const data = parse.data
const nextRunAt = calcNextRun(data)
const schedule = await prisma.scheduledMessage.create({
data: {
tenantId,
instanceId: data.instanceId,
name: data.name,
templateId: data.templateId,
payload: data.payload as any,
recipientMode: data.recipientMode,
recipients: data.recipients as any,
scheduleType: data.scheduleType,
cronExpr: data.cronExpr,
scheduledAt: data.scheduledAt ? new Date(data.scheduledAt) : null,
eventType: data.eventType ?? null,
timezone: data.timezone,
nextRunAt,
},
})
res.status(201).json(schedule)
})
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sched = await prisma.scheduledMessage.findFirst({
where: { id: getId(req), tenantId },
include: {
template: true,
logs: { orderBy: { runAt: 'desc' }, take: 5 },
},
})
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
res.json(sched)
})
// ── UPDATE ────────────────────────────────────────────────────────────────
router.put('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
const parse = scheduleSchema.partial().safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const data = parse.data
const nextRunAt = calcNextRun({ ...exists, ...data } as any)
const { payload, recipients, scheduledAt, eventType, ...rest } = data
const updated = await prisma.scheduledMessage.update({
where: { id },
data: {
...rest,
...(payload !== undefined ? { payload: payload as any } : {}),
...(recipients !== undefined ? { recipients: recipients as any } : {}),
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt as string) } : {}),
...(eventType !== undefined ? { eventType } : {}),
status: 'ACTIVE',
nextRunAt,
},
})
res.json(updated)
})
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.delete({ where: { id } })
res.status(204).send()
})
// ── PAUSE ─────────────────────────────────────────────────────────────────
router.post('/:id/pause', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } })
res.json({ ok: true })
})
// ── RESUME ────────────────────────────────────────────────────────────────
router.post('/:id/resume', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
await prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } })
res.json({ ok: true })
})
// ── RUN NOW ───────────────────────────────────────────────────────────────
router.post('/:id/run-now', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const sched = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!sched) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
// Marca nextRunAt no passado — o tick do scheduler pega no próximo minuto
await prisma.scheduledMessage.update({
where: { id },
data: { nextRunAt: new Date(Date.now() - 1000), status: 'ACTIVE' },
})
res.json({ ok: true, message: 'Execução agendada para o próximo tick' })
})
// ── LOGS ──────────────────────────────────────────────────────────────────
router.get('/:id/logs', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.scheduledMessage.findFirst({ where: { id, tenantId } })
if (!exists) { res.status(404).json({ error: 'Agendamento não encontrado' }); return }
const logs = await prisma.scheduledMessageLog.findMany({
where: { scheduleId: id },
orderBy: { runAt: 'desc' },
take: 50,
})
res.json(logs)
})
return router
}
@@ -0,0 +1,227 @@
/**
* SchedulerService — executa agendamentos de mensagens ricos.
*
* Tipos suportados:
* ONCE — executa uma vez em scheduledAt
* RECURRING — cron expression (cronExpr)
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
* verificado diariamente às 08:00 contra dados dos contatos
*
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
* Eventos são checados uma vez por dia via job separado.
*
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
* (sem passar pelo HTTP — evita latência e auth overhead).
*/
import { schedule as cronSchedule } from 'node-cron'
import { Cron } from 'croner'
import { prisma } from '../../infra/database/prisma'
import { logger } from '../../config/logger'
import type { WhatsAppConnectionManager } from '../whatsapp/connection/WhatsAppConnectionManager'
type Recipient = { jid: string; name?: string; birthday?: string; anniversary?: string; signupDate?: string }
export class SchedulerService {
private manager: WhatsAppConnectionManager
private minuteJob: ReturnType<typeof cronSchedule> | null = null
private eventJob: ReturnType<typeof cronSchedule> | null = null
constructor(manager: WhatsAppConnectionManager) {
this.manager = manager
}
start() {
// Tick a cada minuto — processa ONCE e RECURRING vencidos
this.minuteJob = cronSchedule('* * * * *', () => {
this.processTimeBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de minuto')
)
})
// Tick diário às 08:00 — processa agendamentos por EVENTO
this.eventJob = cronSchedule('0 8 * * *', () => {
this.processEventBased().catch((err) =>
logger.error({ err }, '[Scheduler] Erro no tick de evento')
)
})
logger.info('[Scheduler] Iniciado (minuto + evento diário)')
}
stop() {
this.minuteJob?.stop()
this.eventJob?.stop()
}
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
private async processTimeBased() {
const now = new Date()
const schedules = await prisma.scheduledMessage.findMany({
where: {
status: 'ACTIVE',
scheduleType: { in: ['ONCE', 'RECURRING'] },
OR: [
{ nextRunAt: { lte: now } },
{ nextRunAt: null, scheduledAt: { lte: now }, scheduleType: 'ONCE' },
],
},
})
for (const sched of schedules) {
await this.executeSchedule(sched)
}
}
// ── Processa EVENT (birthday, anniversary, signup) ─────────────────────────
private async processEventBased() {
const today = new Date()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
const todayMmDd = `${mm}-${dd}`
const schedules = await prisma.scheduledMessage.findMany({
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
})
for (const sched of schedules) {
const recipients = sched.recipients as Recipient[]
const matched: Recipient[] = []
for (const r of recipients) {
if (!r.jid) continue
if (sched.eventType === 'BIRTHDAY' && r.birthday) {
// birthday format: "YYYY-MM-DD" or "MM-DD"
const parts = r.birthday.split('-')
const bMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.birthday
if (bMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'ANNIVERSARY' && r.anniversary) {
const parts = r.anniversary.split('-')
const aMmDd = parts.length >= 3 ? `${parts[1]}-${parts[2]}` : r.anniversary
if (aMmDd === todayMmDd) matched.push(r)
} else if (sched.eventType === 'SIGNUP_DAYS' && r.signupDate) {
// Dispara N dias após o signup — N fica em cronExpr como "30"
const daysAfter = parseInt(sched.cronExpr ?? '0', 10)
const signup = new Date(r.signupDate)
const diffDays = Math.floor((today.getTime() - signup.getTime()) / 86_400_000)
if (diffDays === daysAfter) matched.push(r)
}
}
if (matched.length > 0) {
await this.executeSchedule(sched, matched)
}
}
}
// ── Executa um agendamento ─────────────────────────────────────────────────
private async executeSchedule(sched: any, overrideRecipients?: Recipient[]) {
const recipients: Recipient[] = overrideRecipients ?? (sched.recipients as Recipient[])
const sock = this.manager.getSocket(sched.instanceId)
if (!sock) {
logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando')
return
}
let sentCount = 0
let failedCount = 0
const errors: string[] = []
for (const recipient of recipients) {
if (!recipient.jid) continue
try {
const payload = this.buildPayload(sched.payload as any, recipient)
await sock.sendMessage(recipient.jid, payload)
sentCount++
// Pequeno delay anti-ban
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400))
} catch (err: any) {
failedCount++
errors.push(`${recipient.jid}: ${err?.message ?? err}`)
logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar')
}
}
// Persiste log
await prisma.scheduledMessageLog.create({
data: {
scheduleId: sched.id,
status: failedCount === 0 ? 'SUCCESS' : sentCount === 0 ? 'FAILED' : 'PARTIAL',
sentCount,
failedCount,
error: errors.length ? errors.join('\n') : null,
},
})
// Calcula próximo nextRunAt ou marca como DONE
if (sched.scheduleType === 'ONCE') {
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
status: 'DONE',
lastRunAt: new Date(),
runCount: { increment: 1 },
nextRunAt: null,
},
})
} else if (sched.scheduleType === 'RECURRING' && sched.cronExpr) {
const next = this.nextCronDate(sched.cronExpr)
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
lastRunAt: new Date(),
nextRunAt: next,
runCount: { increment: 1 },
},
})
} else {
// EVENT — apenas registra execução
await prisma.scheduledMessage.update({
where: { id: sched.id },
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
})
}
logger.info({ scheduleId: sched.id, sentCount, failedCount }, '[Scheduler] Agendamento executado')
}
// ── Monta payload Baileys com substituição de variáveis ────────────────────
// Variáveis: {{name}}, {{birthday}}, etc.
private buildPayload(raw: any, recipient: Recipient): any {
const replace = (str: string) =>
str
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '')
const recurse = (node: any): any => {
if (typeof node === 'string') return replace(node)
if (Array.isArray(node)) return node.map(recurse)
if (node && typeof node === 'object') {
const out: any = {}
for (const k of Object.keys(node)) out[k] = recurse(node[k])
return out
}
return node
}
return recurse(raw)
}
// ── Calcula próxima data de um cron ───────────────────────────────────────
private nextCronDate(expr: string): Date | null {
try {
const job = new Cron(expr, { paused: true })
return job.nextRun() ?? null
} catch {
return null
}
}
}
@@ -0,0 +1,136 @@
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
export function buildSectorRoutes(): Router {
const router = Router()
// GET /api/sectors
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectors = await prisma.sector.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { teams: true, protocols: true } } },
})
res.json(sectors)
})
// POST /api/sectors
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const schema = z.object({ name: z.string().min(1).max(100) })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
try {
const sector = await prisma.sector.create({ data: { tenantId, name: body.name } })
res.status(201).json(sector)
} catch {
res.status(409).json({ error: 'Setor com este nome já existe.' })
}
})
// PATCH /api/sectors/:id
router.patch('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const schema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
const updated = await prisma.sector.update({ where: { id }, data: body })
res.json(updated)
})
// DELETE /api/sectors/:id
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = req.params['id'] as string
const existing = await prisma.sector.findFirst({ where: { id, tenantId } })
if (!existing) { res.status(404).json({ error: 'Setor não encontrado' }); return }
await prisma.team.deleteMany({ where: { sectorId: id } })
await prisma.sector.delete({ where: { id } })
res.status(204).send()
})
// GET /api/sectors/:id/teams
router.get('/:id/teams', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectorId = req.params['id'] as string
const teams = await prisma.team.findMany({
where: { sectorId, tenantId },
orderBy: { name: 'asc' },
include: { _count: { select: { protocols: true } } },
})
res.json(teams)
})
// POST /api/sectors/:id/teams
router.post('/:id/teams', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const sectorId = req.params['id'] as string
const schema = z.object({ name: z.string().min(1).max(100) })
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const sector = await prisma.sector.findFirst({ where: { id: sectorId, tenantId } })
if (!sector) { res.status(404).json({ error: 'Setor não encontrado' }); return }
try {
const team = await prisma.team.create({ data: { tenantId, sectorId, name: body.name } })
res.status(201).json(team)
} catch {
res.status(409).json({ error: 'Equipe com este nome já existe neste setor.' })
}
})
// PATCH /api/sectors/:sectorId/teams/:teamId
router.patch('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const teamId = req.params['teamId'] as string
const schema = z.object({
name: z.string().min(1).max(100).optional(),
isActive: z.boolean().optional(),
})
let body: z.infer<typeof schema>
try { body = schema.parse(req.body) }
catch (err: any) { res.status(400).json({ error: err.errors ?? err.message }); return }
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
const updated = await prisma.team.update({ where: { id: teamId }, data: body })
res.json(updated)
})
// DELETE /api/sectors/:sectorId/teams/:teamId
router.delete('/:sectorId/teams/:teamId', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const teamId = req.params['teamId'] as string
const existing = await prisma.team.findFirst({ where: { id: teamId, tenantId } })
if (!existing) { res.status(404).json({ error: 'Equipe não encontrada' }); return }
await prisma.team.delete({ where: { id: teamId } })
res.status(204).send()
})
return router
}
@@ -0,0 +1,134 @@
/**
* Rotas de Templates de Mensagem
*
* GET /api/templates — Lista templates do tenant
* POST /api/templates — Cria template
* GET /api/templates/:id — Detalhe do template
* PUT /api/templates/:id — Atualiza template
* DELETE /api/templates/:id — Remove template
* POST /api/templates/:id/use — Incrementa usageCount (IA chama ao usar)
*/
import { Router, type Request, type Response } from 'express'
import { z } from 'zod'
import { prisma } from '../../infra/database/prisma'
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'] as const
const templateSchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).optional(),
type: z.enum(templateTypeValues),
payload: z.record(z.unknown()),
tags: z.array(z.string()).default([]),
})
function getId(req: Request): string {
const id = req.params['id']
return Array.isArray(id) ? id[0] : id
}
export function buildTemplateRoutes(): Router {
const router = Router()
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type']
const search = Array.isArray(req.query['search']) ? req.query['search'][0] : req.query['search']
const templates = await prisma.messageTemplate.findMany({
where: {
tenantId,
...(type ? { type: type as any } : {}),
...(search ? { name: { contains: search as string, mode: 'insensitive' } } : {}),
},
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
})
res.json(templates)
})
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const parse = templateSchema.safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const { name, description, type, payload, tags } = parse.data
const template = await prisma.messageTemplate.create({
data: { tenantId, name, description, type, payload: payload as any, tags },
})
res.status(201).json(template)
})
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const template = await prisma.messageTemplate.findFirst({
where: { id: getId(req), tenantId },
})
if (!template) { res.status(404).json({ error: 'Template não encontrado' }); return }
res.json(template)
})
// ── UPDATE ────────────────────────────────────────────────────────────────
router.put('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
const parse = templateSchema.partial().safeParse(req.body)
if (!parse.success) {
res.status(400).json({ error: parse.error.errors })
return
}
const { payload, ...rest } = parse.data
const updated = await prisma.messageTemplate.update({
where: { id },
data: { ...rest, ...(payload !== undefined ? { payload: payload as any } : {}) },
})
res.json(updated)
})
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.delete({ where: { id } })
res.status(204).send()
})
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
router.post('/:id/use', async (req: Request, res: Response) => {
const tenantId = req.tenantId!
const id = getId(req)
const exists = await prisma.messageTemplate.findFirst({
where: { id, tenantId },
})
if (!exists) { res.status(404).json({ error: 'Template não encontrado' }); return }
await prisma.messageTemplate.update({
where: { id },
data: { usageCount: { increment: 1 } },
})
res.json({ ok: true })
})
return router
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,60 @@
import * as official from './official'
import * as infinite from './infinite'
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite'
function resolveEngine(type: string) {
return type === 'infinite' ? infinite : official
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
export function getEngine(engineType?: string | null): {
makeWASocket: any
useMultiFileAuthState: any
fetchLatestBaileysVersion: any
makeCacheableSignalKeyStore: any
downloadMediaMessage: any
getContentType: any
generateMessageIDV2: any
generateWAMessageFromContent: any
isJidGroup: any
isJidStatusBroadcast: any
} {
const e = resolveEngine(engineType ?? defaultEngineType)
return {
makeWASocket: e.makeWASocket as any,
useMultiFileAuthState: e.useMultiFileAuthState as any,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion as any,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore as any,
downloadMediaMessage: e.downloadMediaMessage as any,
getContentType: e.getContentType as any,
generateMessageIDV2: e.generateMessageIDV2 as any,
generateWAMessageFromContent: e.generateWAMessageFromContent as any,
isJidGroup: e.isJidGroup as any,
isJidStatusBroadcast: e.isJidStatusBroadcast as any,
}
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType)
export const makeWASocket = selectedEngine.makeWASocket as any
export const useMultiFileAuthState = selectedEngine.useMultiFileAuthState as any
export const DisconnectReason = selectedEngine.DisconnectReason as any
export const fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion as any
export const makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore as any
export const downloadMediaMessage = selectedEngine.downloadMediaMessage as any
export const getContentType = selectedEngine.getContentType as any
export const generateMessageIDV2 = selectedEngine.generateMessageIDV2 as any
export const generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent as any
export const isJidGroup = selectedEngine.isJidGroup as any
export const isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast as any
export { proto } from './official'
// Exportação estática de tipos para garantir segurança em tempo de compilação
export type { WASocket, ConnectionState, WAMessage, Contact, BinaryNode } from './official'
@@ -0,0 +1 @@
export * from 'baileys'
@@ -0,0 +1 @@
export * from '@whiskeysockets/baileys'
@@ -0,0 +1,495 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ContactHandler = void 0;
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
// forem feitas em curto período. Estas constantes controlam o throttling.
/** Quantos avatares buscar em paralelo por lote */
const AVATAR_BATCH_SIZE = 5;
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
const AVATAR_DELAY_MS = 900;
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
const MAX_MISSING_AVATAR_SYNC = 80;
class ContactHandler {
constructor(
/** UUID da instância WhatsApp */
instanceId,
/** UUID do tenant (isolamento multi-tenant) */
tenantId,
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
sock,
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
io) {
this.instanceId = instanceId;
this.tenantId = tenantId;
this.sock = sock;
this.io = io;
/** Fila de JIDs aguardando busca de avatar */
this.avatarQueue = [];
/** Flag de mutex simples — evita processamento concorrente da fila */
this.processingAvatars = false;
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO DE NOME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Regra de ouro para resolução de nome de contato:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
*
* Retorna undefined se nenhum campo estiver disponível.
*/
resolveName(c) {
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
}
// ═══════════════════════════════════════════════════════════════════════════
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cria ou atualiza contatos em massa.
*
* Dispara em dois cenários:
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
* 2. Campo contacts[] do messaging-history.set (history sync)
*
* Para cada contato:
* - Cria ou atualiza no banco com nome resolvido
* - Vincula chats órfãos (Chat sem contactId) ao contato
* - Enfileira busca de avatar se imgUrl não disponível
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
*
* TRATAMENTO DE LID:
* O Baileys pode enviar contatos com c.id no formato @lid.
* Quando o contato do history sync inclui campo `jid` (telefone real),
* usamos esse JID como chave primária e salvamos o LID em lidJid.
* Quando não há campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts) {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar = [];
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
let directAvatarCount = 0;
for (const c of contacts) {
if (!c.id)
continue;
const isLid = c.id.endsWith('@lid');
const isBroadcast = c.id.includes('@broadcast');
if (isBroadcast)
continue;
// imgUrl pode vir do Baileys com valores especiais:
// - URL string: avatar disponível (salva direto)
// - 'changed': avatar foi alterado (precisa buscar novamente)
// - null/undefined: não disponível (precisa buscar via API)
const imgUrl = c.imgUrl;
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
// que associa o LID ao contato com JID real (@s.whatsapp.net)
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = c.lid;
try {
// Para contatos @lid que possuem campo `jid` (vem do history sync),
// usa o JID real como chave primária. Caso contrário, usa c.id como está.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const contactJid = isLid && c.jid ? c.jid : c.id;
const contact = await prisma_1.prisma.contact.upsert({
where: {
tenantId_instanceId_jid: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
},
},
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
// Salva mapeamento LID:
// Se o contato É um LID (c.id termina em @lid): salva c.id como lidJid
// Se o contato TEM campo lid (history sync): salva lid como lidJid
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(isLid ? { lidJid: c.id } : lidFromContact ? { lidJid: lidFromContact } : {}),
},
});
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
await prisma_1.prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
contactId: null,
},
data: { contactId: contact.id },
});
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl (retorna 404 no WA)
if (!isLid) {
if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++;
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid: c.id,
avatarUrl: imgUrl,
});
}
else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(c.id);
}
}
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
}
}
logger_1.logger.debug({
instanceId: this.instanceId,
direct: directAvatarCount,
toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
// Enfileira busca de avatares com cap de 50 por batch
// (randomiza para priorizar contatos diferentes a cada sync)
if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar;
this.enqueueAvatars(toQueue);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UPDATE PARCIAL (contacts.update)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Atualiza campos parciais de contatos existentes.
*
* Dispara no evento contacts.update do Baileys — diferente do upsert,
* aqui só atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts) {
for (const c of contacts) {
if (!c.id)
continue;
const resolvedName = this.resolveName(c);
// Se nenhum campo relevante mudou, pula
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
continue;
try {
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
},
data: {
...(resolvedName !== undefined && { name: resolvedName }),
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
...(c.notify !== undefined && { notify: c.notify }),
},
});
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca e salva a foto de perfil do número WhatsApp da instância.
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
*/
async syncInstanceAvatar(instanceId) {
if (!this.sock?.user?.id)
return null;
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
if (url) {
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
return url;
}
}
catch {
// Avatar privado ou não disponível — normal para contas com privacidade restrita
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos após sync do histórico.
*
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
*
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
* name=null E notify=null, e preenche usando o pushName da mensagem
* recebida mais recente para cada JID.
*
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
*
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
*/
async reconcileNamesFromMessages() {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
name: null,
notify: null,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { endsWith: '@g.us' } },
],
},
select: { id: true, jid: true },
});
if (unnamed.length === 0) {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
return;
}
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
let updated = 0;
for (const contact of unnamed) {
// Busca a mensagem recebida mais recente com pushName para este JID
const msg = await prisma_1.prisma.message.findFirst({
where: {
instanceId: this.instanceId,
remoteJid: contact.jid,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
});
if (msg?.pushName) {
await prisma_1.prisma.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
});
updated++;
}
}
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
// Notifica o frontend para atualizar a lista de chats
if (updated > 0) {
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
instanceId: this.instanceId,
count: updated,
});
}
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FORCE CONTACT SYNC (resyncAppState)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Força re-sync dos contatos via app-state do Baileys.
*
* NOTA: Este método existe mas NÃO é chamado automaticamente.
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
* força o resync das app-state collections que contêm contatos.
*
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
* "tried remove, but no previous op" — erro de estado corrompido
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
*
* Pode ser chamado manualmente via API se necessário.
*/
async forceContactSync() {
if (!this.sock)
return;
try {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
}
catch (err) {
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca avatares ausentes para chats ativos após reconexão.
*
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl.
*
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
*/
async syncMissingAvatars() {
try {
const missing = await prisma_1.prisma.chat.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
contact: { avatarUrl: null },
},
select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: MAX_MISSING_AVATAR_SYNC,
});
if (missing.length === 0)
return;
const jids = missing.map((c) => c.jid);
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
this.enqueueAvatars(jids);
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
}
}
/**
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
* em menos de 48 horas ou já expirou.
*
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars() {
try {
const contacts = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
avatarUrl: { not: null },
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
},
select: { jid: true, avatarUrl: true },
orderBy: { updatedAt: 'asc' },
take: 200,
});
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
const expiring = contacts
.filter(c => {
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
if (!m)
return false;
return parseInt(m[1], 16) < threshold;
})
.map(c => c.jid);
if (expiring.length === 0)
return;
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
// Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: { in: expiring },
},
data: { avatarUrl: null },
});
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FILA DE AVATARES COM RATE-LIMIT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
*
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
*
* Se a fila já está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids) {
const inQueue = new Set(this.avatarQueue);
for (const jid of jids) {
if (!inQueue.has(jid))
this.avatarQueue.push(jid);
}
this.processAvatarQueue();
}
/**
* Processa a fila de avatares com rate-limit.
*
* Usa Promise.allSettled() para que falhas individuais (foto privada,
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
* 1. Salvo no campo avatarUrl do contato no banco
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
*/
async processAvatarQueue() {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0)
return;
this.processingAvatars = true;
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
while (this.avatarQueue.length > 0 && this.sock) {
// Pega o próximo lote (remove da fila)
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
await Promise.allSettled(batch.map(async (jid) => {
try {
const url = await this.sock.profilePictureUrl(jid, 'image');
if (url) {
// Persiste no banco
await prisma_1.prisma.contact.updateMany({
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
data: { avatarUrl: url },
});
// Notifica o frontend para atualizar o avatar na UI
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid,
avatarUrl: url,
});
}
}
catch {
// Foto privada ou JID inválido — ignora silenciosamente
// (não loga para evitar spam com milhares de contatos)
}
}));
// Delay entre lotes para respeitar rate-limit do WhatsApp
if (this.avatarQueue.length > 0) {
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
}
}
this.processingAvatars = false;
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
}
}
exports.ContactHandler = ContactHandler;
//# sourceMappingURL=ContactHandler.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1,559 @@
/**
* ContactHandler Gerencia contatos e avatares do WhatsApp.
*
* Responsabilidades:
* 1. Upsert de contatos em massa (evento contacts.upsert do Baileys)
* 2. Update parcial de contatos (evento contacts.update)
* 3. Sincronização de avatares com rate-limit (fila com batch + delay)
* 4. Reconciliação de nomes via pushName das mensagens (pós history sync)
* 5. Mapeamento LID phone JID (captura do campo `lid` nos contatos)
* 6. Sincronização do avatar da própria instância
*
* CONTEXTO LID:
* O Baileys pode enviar contatos com id no formato @lid (Linked ID).
* Quando o contato inclui o campo `jid` (history sync), usamos o JID real
* e armazenamos o LID em contacts.lidJid para resolução futura.
* @see MessageHandler.resolveLidToPhoneJid()
*
* REGRA DE PRIORIDADE DE NOMES:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
* Essa hierarquia é respeitada tanto no upsert quanto na resolução pelo frontend.
*
* @see WhatsAppConnectionManager registra os event listeners
* @see MessageHandler consome os contatos para vincular a mensagens
*/
import type { Contact, WASocket } from '../engine'
import { prisma } from '../../../infra/database/prisma'
import { logger } from '../../../config/logger'
import type { Server as SocketIOServer } from 'socket.io'
// ─── Configuração de rate-limit para busca de avatares ──────────────────────
// O WhatsApp bloqueia (ban temporário) se muitas requisições profilePictureUrl
// forem feitas em curto período. Estas constantes controlam o throttling.
/** Quantos avatares buscar em paralelo por lote */
const AVATAR_BATCH_SIZE = 5
/** Delay entre lotes de avatares (~1 req/s por JID → abaixo do threshold do WA) */
const AVATAR_DELAY_MS = 900
/** Máximo de avatares a buscar no sync pós-conexão (evita overload em contas grandes) */
const MAX_MISSING_AVATAR_SYNC = 80
export class ContactHandler {
/** Fila de JIDs aguardando busca de avatar */
private avatarQueue: string[] = []
/** Flag de mutex simples — evita processamento concorrente da fila */
private processingAvatars = false
constructor(
/** UUID da instância WhatsApp */
private instanceId: string,
/** UUID do tenant (isolamento multi-tenant) */
private tenantId: string,
/** Socket Baileys — usado para profilePictureUrl e resyncAppState */
private sock?: WASocket,
/** Socket.IO — emite eventos de avatar/reconciliação para o frontend */
private io?: SocketIOServer
) {}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO DE NOME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Regra de ouro para resolução de nome de contato:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
*
* Retorna undefined se nenhum campo estiver disponível.
*/
private resolveName(c: Partial<Contact>): string | undefined {
return c.name ?? c.verifiedName ?? c.notify ?? undefined
}
// ═══════════════════════════════════════════════════════════════════════════
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cria ou atualiza contatos em massa.
*
* Dispara em dois cenários:
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
* 2. Campo contacts[] do messaging-history.set (history sync)
*
* Para cada contato:
* - Cria ou atualiza no banco com nome resolvido
* - Vincula chats órfãos (Chat sem contactId) ao contato
* - Enfileira busca de avatar se imgUrl não disponível
* - Captura mapeamento LID phone JID (campo `lid` do history sync)
*
* TRATAMENTO DE LID:
* O Baileys pode enviar contatos com c.id no formato @lid.
* Quando o contato do history sync inclui campo `jid` (telefone real),
* usamos esse JID como chave primária e salvamos o LID em lidJid.
* Quando não campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts: Contact[]): Promise<void> {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar: string[] = []
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
let directAvatarCount = 0
for (const c of contacts) {
if (!c.id) continue
const isLid = c.id.endsWith('@lid')
const isBroadcast = c.id.includes('@broadcast')
if (isBroadcast) continue
// imgUrl pode vir do Baileys com valores especiais:
// - URL string: avatar disponível (salva direto)
// - 'changed': avatar foi alterado (precisa buscar novamente)
// - null/undefined: não disponível (precisa buscar via API)
const imgUrl = (c as any).imgUrl as string | null | undefined
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
// que associa o LID ao contato com JID real (@s.whatsapp.net)
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = (c as any).lid as string | undefined
try {
// Para contatos @lid que possuem o JID real do telefone:
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const pnJid = (c as any).jid as string | undefined
?? (c as any).phoneNumber as string | undefined
const contactJid = isLid && pnJid ? pnJid : c.id
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined)
const contact = await prisma.contact.upsert({
where: {
tenantId_instanceId_jid: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
},
},
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
})
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
contactId: null,
},
data: { contactId: contact.id },
})
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid')
if (!resolvedIsLid) {
if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid: contactJid,
avatarUrl: imgUrl,
})
} else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(contactJid)
}
}
} catch (err) {
logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato')
}
}
logger.debug({
instanceId: this.instanceId,
direct: directAvatarCount,
toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API')
// Enfileira busca de avatares com cap de 50 por batch
// (randomiza para priorizar contatos diferentes a cada sync)
if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar
this.enqueueAvatars(toQueue)
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UPDATE PARCIAL (contacts.update)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Atualiza campos parciais de contatos existentes.
*
* Dispara no evento contacts.update do Baileys diferente do upsert,
* aqui atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts: Partial<Contact>[]): Promise<void> {
for (const c of contacts) {
if (!c.id) continue
const resolvedName = this.resolveName(c)
// Se nenhum campo relevante mudou, pula
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined) continue
try {
await prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
},
data: {
...(resolvedName !== undefined && { name: resolvedName }),
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
...(c.notify !== undefined && { notify: c.notify }),
},
})
} catch (err) {
logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato')
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca e salva a foto de perfil do número WhatsApp da instância.
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
*/
async syncInstanceAvatar(instanceId: string): Promise<string | null> {
if (!this.sock?.user?.id) return null
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image')
if (url) {
await prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } })
logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado')
return url
}
} catch {
// Avatar privado ou não disponível — normal para contas com privacidade restrita
}
return null
}
// ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos após sync do histórico.
*
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
* sem nome apenas contas business (~22 de ~3000) vêm com verifiedName.
* Os pushNames aparecem nas mensagens, não nos contatos do sync.
*
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
* name=null E notify=null, e preenche usando o pushName da mensagem
* recebida mais recente para cada JID.
*
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
*
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
*/
async reconcileNamesFromMessages(): Promise<void> {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
name: null,
notify: null,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { endsWith: '@g.us' } },
],
},
select: { id: true, jid: true },
})
if (unnamed.length === 0) {
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome')
return
}
logger.info({ instanceId: this.instanceId, count: unnamed.length },
'[ContactHandler] Reconciliação: buscando pushNames nas mensagens')
let updated = 0
for (const contact of unnamed) {
// Busca a mensagem recebida mais recente com pushName para este JID
const msg = await prisma.message.findFirst({
where: {
instanceId: this.instanceId,
remoteJid: contact.jid,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
})
if (msg?.pushName) {
await prisma.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
})
updated++
}
}
logger.info({ instanceId: this.instanceId, total: unnamed.length, updated },
'[ContactHandler] Reconciliação de nomes concluída')
// Notifica o frontend para atualizar a lista de chats
if (updated > 0) {
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
instanceId: this.instanceId,
count: updated,
})
}
} catch (err) {
logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FORCE CONTACT SYNC (resyncAppState)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Força re-sync dos contatos via app-state do Baileys.
*
* NOTA: Este método existe mas NÃO é chamado automaticamente.
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
* força o resync das app-state collections que contêm contatos.
*
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
* "tried remove, but no previous op" erro de estado corrompido
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
*
* Pode ser chamado manualmente via API se necessário.
*/
async forceContactSync(): Promise<void> {
if (!this.sock) return
try {
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state')
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false)
logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído')
} catch (err) {
logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca avatares ausentes para chats ativos após reconexão.
*
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl.
*
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
*/
async syncMissingAvatars(): Promise<void> {
try {
const missing = await prisma.chat.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
contact: { avatarUrl: null },
},
select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: MAX_MISSING_AVATAR_SYNC,
})
if (missing.length === 0) return
const jids = missing.map((c) => c.jid)
logger.info({ instanceId: this.instanceId, count: jids.length },
'[ContactHandler] Sync pós-conexão: buscando avatares ausentes')
this.enqueueAvatars(jids)
} catch (err) {
logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes')
}
}
/**
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
* em menos de 48 horas ou expirou.
*
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars(): Promise<void> {
try {
const contacts = await prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
avatarUrl: { not: null },
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
},
select: { jid: true, avatarUrl: true },
orderBy: { updatedAt: 'asc' },
take: 200,
})
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600 // now + 48h
const expiring = contacts
.filter(c => {
const m = c.avatarUrl!.match(/[?&]oe=([0-9a-f]+)/i)
if (!m) return false
return parseInt(m[1], 16) < threshold
})
.map(c => c.jid)
if (expiring.length === 0) return
logger.info({ instanceId: this.instanceId, count: expiring.length },
'[ContactHandler] Renovando avatares próximos de expirar')
// Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa
await prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: { in: expiring },
},
data: { avatarUrl: null },
})
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC))
} catch (err) {
logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados')
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FILA DE AVATARES COM RATE-LIMIT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
*
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
*
* Se a fila está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids: string[]): void {
const inQueue = new Set(this.avatarQueue)
for (const jid of jids) {
if (!inQueue.has(jid)) this.avatarQueue.push(jid)
}
this.processAvatarQueue()
}
/**
* Processa a fila de avatares com rate-limit.
*
* Usa Promise.allSettled() para que falhas individuais (foto privada,
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
* 1. Salvo no campo avatarUrl do contato no banco
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
*/
private async processAvatarQueue(): Promise<void> {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0) return
this.processingAvatars = true
logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId },
'[ContactHandler] Iniciando sync de avatares')
while (this.avatarQueue.length > 0 && this.sock) {
// Pega o próximo lote (remove da fila)
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE)
await Promise.allSettled(batch.map(async (jid) => {
try {
const url = await this.sock!.profilePictureUrl(jid, 'image')
if (url) {
// Persiste no banco
await prisma.contact.updateMany({
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
data: { avatarUrl: url },
})
// Notifica o frontend para atualizar o avatar na UI
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid,
avatarUrl: url,
})
}
} catch {
// Foto privada ou JID inválido — ignora silenciosamente
// (não loga para evitar spam com milhares de contatos)
}
}))
// Delay entre lotes para respeitar rate-limit do WhatsApp
if (this.avatarQueue.length > 0) {
await new Promise<void>((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE))
}
}
this.processingAvatars = false
logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído')
}
}

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