chore: initial setup with PostgreSQL, DragonflyDB, and Nginx routing configs

This commit is contained in:
Antigravity AI Agent
2026-05-13 05:12:32 +02:00
commit 19dbbae8b8
551 changed files with 308309 additions and 0 deletions
+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
+319
View File
@@ -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).
+9
View File
@@ -0,0 +1,9 @@
node_modules
dist
.git
*.log
.env
sessions
media
storage
uploads
+45
View File
@@ -0,0 +1,45 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
PORT=8018
NODE_ENV=development
JWT_SECRET=scoreodonto_jwt_secret_forte_2026
# ─────────────────────────────────────────────
# PostgreSQL (Prisma)
# ─────────────────────────────────────────────
DATABASE_URL="postgresql://scoreodonto_user:clube67_scoreodonto_pass_9903@10.99.0.3:5432/scoreodonto?schema=public"
# ─────────────────────────────────────────────
# DragonflyDB (compatível com Redis)
# ─────────────────────────────────────────────
DRAGONFLY_URL=redis://:clube67_dragonfly_pass_9903@10.99.0.3:6379/1
DRAGONFLY_TTL_SECONDS=86400
# ─────────────────────────────────────────────
# NATS JetStream
# ─────────────────────────────────────────────
NATS_URL=nats://10.99.0.3:4222
# ─────────────────────────────────────────────
# Temporal.io
# ─────────────────────────────────────────────
TEMPORAL_ADDRESS=10.99.0.3:7233
TEMPORAL_NAMESPACE=default
TEMPORAL_TASK_QUEUE=scoreodonto-queue
# ─────────────────────────────────────────────
# IA (OpenAI + Google Gemini)
# ─────────────────────────────────────────────
OPENAI_API_KEY=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
GEMINI_API_KEY=AIza...
# ─────────────────────────────────────────────
# WhatsApp / Baileys
# ─────────────────────────────────────────────
BAILEYS_SESSIONS_PATH=/home/deploy/scoreodonto-sessions
# ─────────────────────────────────────────────
# Frontend (CORS)
# ─────────────────────────────────────────────
FRONTEND_URL=http://localhost:3013
+45
View File
@@ -0,0 +1,45 @@
# ─────────────────────────────────────────────
# Server
# ─────────────────────────────────────────────
PORT=8008
NODE_ENV=development
JWT_SECRET=newwhats_jwt_secret_forte_2026
# ─────────────────────────────────────────────
# PostgreSQL (Prisma)
# ─────────────────────────────────────────────
DATABASE_URL="postgresql://newwhats:newwhats123@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
# ─────────────────────────────────────────────
BAILEYS_SESSIONS_PATH=/home/deploy/newwhats-sessions
# ─────────────────────────────────────────────
# Frontend (CORS)
# ─────────────────────────────────────────────
FRONTEND_URL=http://newwhats.local:3003
+46
View File
@@ -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"]
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
"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; } });
+26
View File
@@ -0,0 +1,26 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.env = void 0;
const zod_1 = require("zod");
require("dotenv/config");
const envSchema = zod_1.z.object({
PORT: zod_1.z.string().default('8008'),
NODE_ENV: zod_1.z.enum(['development', 'production', 'test']).default('development'),
JWT_SECRET: zod_1.z.string().min(16),
DATABASE_URL: zod_1.z.string().url(),
DRAGONFLY_URL: zod_1.z.string().default('redis://localhost:6379'),
DRAGONFLY_TTL_SECONDS: zod_1.z.string().default('86400'),
NATS_URL: zod_1.z.string().default('nats://localhost:4222'),
TEMPORAL_ADDRESS: zod_1.z.string().default('localhost:7233'),
TEMPORAL_NAMESPACE: zod_1.z.string().default('default'),
TEMPORAL_TASK_QUEUE: zod_1.z.string().default('newwhats-queue'),
BAILEYS_SESSIONS_PATH: zod_1.z.string().default('./sessions'),
FRONTEND_URL: zod_1.z.string().default('http://localhost:3000'),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error('❌ Variáveis de ambiente inválidas:');
console.error(parsed.error.format());
process.exit(1);
}
exports.env = parsed.data;
+14
View File
@@ -0,0 +1,14 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.logger = void 0;
const pino_1 = __importDefault(require("pino"));
const env_1 = require("./env");
exports.logger = (0, pino_1.default)({
level: env_1.env.NODE_ENV === 'production' ? 'info' : 'debug',
transport: env_1.env.NODE_ENV !== 'production'
? { target: 'pino-pretty', options: { colorize: true, translateTime: 'SYS:standard' } }
: undefined,
});
+88
View File
@@ -0,0 +1,88 @@
"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 {
provider = null;
healthy = false;
lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
};
register(provider) {
this.provider = provider;
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
logger_1.logger.info('[StorageProvider] Provider registrado');
}
isRegistered() {
return this.provider !== null;
}
isHealthy() {
return this.healthy;
}
getLastHealthStatus() {
return this.lastHealthStatus;
}
setHealthStatus(status) {
this.lastHealthStatus = status;
this.healthy = status.healthy;
}
async uploadFile(payload) {
if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
}
return this.provider.upload(payload);
}
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
async updateHealth() {
if (!this.provider) {
this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
};
return;
}
try {
const status = await this.provider.checkDetailedHealth();
this.healthy = status.healthy;
this.lastHealthStatus = status;
}
catch {
this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
};
}
}
async getBuffer(path) {
if (!this.provider)
return null;
return this.provider.getBuffer(path);
}
async deletePrefix(prefix) {
if (!this.provider) {
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
return { deleted: 0 };
}
return this.provider.deletePrefix(prefix);
}
unregister() {
this.provider = null;
this.healthy = false;
logger_1.logger.info('[StorageProvider] Provider removido');
}
}
exports.storageProvider = new StorageProviderRegistry();
+55
View File
@@ -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.db = void 0;
exports.ensurePluginTables = ensurePluginTables;
const knex_1 = __importDefault(require("knex"));
const env_1 = require("../config/env");
const logger_1 = require("../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() {
// DATABASE_URL formato: postgresql://user:pass@host:port/db
const connection = env_1.env.DATABASE_URL;
const instance = (0, knex_1.default)({
client: 'pg',
connection,
pool: { min: 1, max: 5 },
acquireConnectionTimeout: 10000,
});
instance.on('query-error', (err, query) => {
logger_1.logger.error({ err, sql: query.sql }, '[Knex] Query error');
});
return instance;
}
exports.db = createKnex();
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
async function ensurePluginTables(knexInstance) {
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);
});
logger_1.logger.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);
});
logger_1.logger.info('[Knex] Tabela nanobana_usage criada');
}
}
+47
View File
@@ -0,0 +1,47 @@
"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 {
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();
+49
View File
@@ -0,0 +1,49 @@
"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 {
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();
+132
View File
@@ -0,0 +1,132 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadPlugins = loadPlugins;
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const logger_1 = require("../config/logger");
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
? '/app/plugins'
: path_1.default.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) {
const byName = new Map(plugins.map((p) => [p.manifest.name, p]));
const inDegree = new Map();
const dependents = new Map();
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)) {
logger_1.logger.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 = [];
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) {
logger_1.logger.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.
*/
async function loadPlugins() {
if (!fs_1.default.existsSync(PLUGINS_DIR)) {
logger_1.logger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado');
return [];
}
const entries = fs_1.default.readdirSync(PLUGINS_DIR, { withFileTypes: true });
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
const loaded = [];
for (const dirName of pluginDirs) {
const pluginDir = path_1.default.join(PLUGINS_DIR, dirName);
const manifestPath = path_1.default.join(pluginDir, 'manifest.json');
const indexTs = path_1.default.join(pluginDir, 'index.ts');
const indexJs = path_1.default.join(pluginDir, 'index.js');
if (!fs_1.default.existsSync(manifestPath)) {
logger_1.logger.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_1.default.existsSync(indexJs) ? indexJs : fs_1.default.existsSync(indexTs) ? indexTs : null;
if (!entryPoint) {
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando');
continue;
}
try {
const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
const mod = await Promise.resolve(`${entryPoint}`).then(s => __importStar(require(s)));
const instance = mod.default ?? mod;
if (typeof instance.activate !== 'function') {
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando');
continue;
}
loaded.push({ instance, manifest, dir: pluginDir });
logger_1.logger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado');
}
catch (err) {
logger_1.logger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin');
}
}
return topoSort(loaded);
}
+111
View File
@@ -0,0 +1,111 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.pluginRegistry = void 0;
const logger_1 = require("../config/logger");
/**
* Registry central dos plugins.
* Gerencia o lifecycle (activate/deactivate) e expõe metadados para a API admin.
*/
class PluginRegistry {
entries = new Map();
ctx;
/** Deve ser chamado uma vez no bootstrap com o contexto completo */
setContext(ctx) {
this.ctx = ctx;
}
/** Registra plugins carregados (sem ativar ainda) */
register(plugins) {
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() {
for (const [name, entry] of this.entries) {
if (!entry.manifest.enabled)
continue;
try {
await this.activate(name);
}
catch (err) {
logger_1.logger.warn({ name, err: err.message }, '[PluginRegistry] Plugin pulado na inicialização');
}
}
}
async activate(name) {
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;
logger_1.logger.info({ name }, '[PluginRegistry] Plugin ativado');
}
catch (err) {
logger_1.logger.error({ err, name }, '[PluginRegistry] Falha ao ativar plugin');
throw err;
}
}
async deactivate(name) {
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;
logger_1.logger.info({ name }, '[PluginRegistry] Plugin desativado');
}
catch (err) {
logger_1.logger.error({ err, name }, '[PluginRegistry] Falha ao desativar plugin');
throw err;
}
}
/** Retorna lista de todos os plugins com status para a API admin */
list() {
return Array.from(this.entries.entries()).map(([name, entry]) => ({ name, ...entry }));
}
getEntry(name) {
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) {
const entry = this.entries.get(name);
if (!entry)
throw new Error(`Plugin "${name}" não encontrado`);
entry.active = false;
await this.activate(name);
logger_1.logger.info({ name }, '[PluginRegistry] Plugin reativado com nova config');
}
/** Retorna menu items de todos os plugins ativos */
getActiveMenuItems() {
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 }));
}
}
exports.pluginRegistry = new PluginRegistry();
+2
View File
@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
+117
View File
@@ -0,0 +1,117 @@
"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 {
client;
ttl;
constructor() {
this.ttl = parseInt(env_1.env.DRAGONFLY_TTL_SECONDS, 10);
this.client = new ioredis_1.default(env_1.env.DRAGONFLY_URL, {
// null = não limita tentativas por comando; a reconexão é gerenciada pelo retryStrategy
maxRetriesPerRequest: null,
enableReadyCheck: false,
lazyConnect: true,
// Falha imediata quando desconectado — evita fila infinita em memória
enableOfflineQueue: false,
// Reconexão exponencial: 200ms → 400ms → ... → 30s
retryStrategy(times) {
const delay = Math.min(200 * 2 ** times, 30_000);
return delay;
},
});
this.client.on('connect', () => logger_1.logger.info('DragonflyDB conectado'));
this.client.on('ready', () => logger_1.logger.info('DragonflyDB pronto'));
this.client.on('error', (err) => logger_1.logger.error({ msg: 'DragonflyDB erro', code: err.code }));
this.client.on('close', () => logger_1.logger.warn('DragonflyDB conexão fechada — reconectando...'));
}
/** true se o cliente está conectado e pronto */
isAlive() {
return this.client.status === 'ready';
}
async connect() {
// Inicia conexão em background — não bloqueia o startup se Redis estiver fora
// retryStrategy cuida da reconexão automática
this.client.connect().catch(() => { });
}
async get(key) {
try {
return await this.client.get(key);
}
catch {
return null;
}
}
async getJson(key) {
try {
const raw = await this.client.get(key);
if (!raw)
return null;
return JSON.parse(raw);
}
catch {
return null;
}
}
async set(key, value, ttlSeconds) {
try {
const ttl = ttlSeconds ?? this.ttl;
if (ttl > 0) {
await this.client.set(key, value, 'EX', ttl);
}
else {
await this.client.set(key, value);
}
}
catch { /* Redis indisponível — ignora silenciosamente */ }
}
async setJson(key, value, ttlSeconds) {
try {
const ttl = ttlSeconds ?? this.ttl;
if (ttl > 0) {
await this.client.set(key, JSON.stringify(value), 'EX', ttl);
}
else {
await this.client.set(key, JSON.stringify(value));
}
}
catch { /* Redis indisponível — ignora silenciosamente */ }
}
async psetex(key, ttlMs, value) {
try {
await this.client.psetex(key, ttlMs, value);
}
catch { }
}
async del(key) {
try {
await this.client.del(key);
}
catch { }
}
async exists(key) {
try {
const count = await this.client.exists(key);
return count > 0;
}
catch {
return false;
}
}
/** Publica em um canal pub/sub (usado para broadcasting de QR/status) */
async publish(channel, message) {
try {
await this.client.publish(channel, message);
}
catch { }
}
raw() {
return this.client;
}
}
exports.dragonfly = new DragonflyClient();
+13
View File
@@ -0,0 +1,13 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.prisma = void 0;
const client_1 = require("@prisma/client");
exports.prisma = global.__prisma ??
new client_1.PrismaClient({
log: process.env.NODE_ENV !== 'production'
? [{ level: 'warn', emit: 'stdout' }, { level: 'error', emit: 'stdout' }]
: [],
});
if (process.env.NODE_ENV !== 'production') {
global.__prisma = exports.prisma;
}
+67
View File
@@ -0,0 +1,67 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSocketServer = buildSocketServer;
const socket_io_1 = require("socket.io");
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const env_1 = require("../../config/env");
const logger_1 = require("../../config/logger");
const dragonfly_1 = require("../cache/dragonfly");
// TTL em segundos: 70 = 2× o heartbeat do frontend (30s) + margem
const PRESENCE_TTL = 70;
function buildSocketServer(httpServer) {
const io = new socket_io_1.Server(httpServer, {
cors: {
origin: env_1.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;
if (!token) {
next(new Error('Token não fornecido'));
return;
}
try {
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_SECRET);
socket.data.userId = payload.sub;
socket.data.role = payload.role;
next();
}
catch {
next(new Error('Token inválido'));
}
});
io.on('connection', (socket) => {
const userId = socket.data.userId;
logger_1.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_1.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) => {
socket.join(`instance:${instanceId}`);
});
socket.on('join:chat', (chatId) => {
socket.join(`chat:${chatId}`);
});
socket.on('leave:chat', (chatId) => {
socket.leave(`chat:${chatId}`);
});
// Heartbeat do frontend a cada 30s — renova TTL de presença
socket.on('user:heartbeat', () => {
dragonfly_1.dragonfly.set(`presence:user:${userId}`, socket.id, PRESENCE_TTL).catch(() => { });
});
socket.on('disconnect', (reason) => {
logger_1.logger.debug({ userId, reason }, 'Socket desconectado');
// Remove presença imediatamente ao desconectar
dragonfly_1.dragonfly.del(`presence:user:${userId}`).catch(() => { });
});
});
return io;
}
+64
View File
@@ -0,0 +1,64 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.liberarSlotAgenda = liberarSlotAgenda;
exports.notificarConflito = notificarConflito;
exports.decrementarScore = decrementarScore;
exports.bloquearAgendamentosAutomaticos = bloquearAgendamentosAutomaticos;
exports.buscarScoreContato = buscarScoreContato;
/**
* Temporal Activities — operações com efeitos colaterais (banco, APIs, I/O).
* Activities são executadas pelo Worker e podem ser re-tentadas automaticamente.
*/
const prisma_1 = require("../database/prisma");
const logger_1 = require("../../config/logger");
// ─── Conflito de Agenda: liberar slot após timeout ────────────────────────────
async function liberarSlotAgenda(input) {
logger_1.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_1.prisma.protocol.updateMany({
where: {
contactId: input.solicitanteId,
status: 'WAITING_CLIENT',
},
data: { status: 'OPEN' },
});
}
async function notificarConflito(input) {
logger_1.logger.info(input, '[Temporal] Notificando pessoa B sobre solicitação de slot');
// Dispara mensagem via NATS → WhatsApp handler
}
// ─── Gestão de Reputação ──────────────────────────────────────────────────────
async function decrementarScore(input) {
const contact = await prisma_1.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_1.prisma.contact.update({
where: { id: input.contactId },
data: {
scoreReputacao: novoScore,
flagRestricao: restrito,
},
});
logger_1.logger.info({ contactId: input.contactId, novoScore, restrito, motivo: input.motivo }, '[Temporal] Score de reputação atualizado');
return { novoScore, restrito };
}
async function bloquearAgendamentosAutomaticos(input) {
await prisma_1.prisma.contact.update({
where: { id: input.contactId },
data: { flagRestricao: true },
});
logger_1.logger.warn({ contactId: input.contactId }, '[Temporal] Contato marcado como RESTRITO — agendamentos automáticos bloqueados');
}
async function buscarScoreContato(contactId) {
const c = await prisma_1.prisma.contact.findUnique({
where: { id: contactId },
select: { scoreReputacao: true, flagRestricao: true },
});
return { score: c?.scoreReputacao ?? 100, restrito: c?.flagRestricao ?? false };
}
+18
View File
@@ -0,0 +1,18 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.getTemporalClient = getTemporalClient;
const client_1 = require("@temporalio/client");
const env_1 = require("../../config/env");
const logger_1 = require("../../config/logger");
let _client = null;
async function getTemporalClient() {
if (_client)
return _client;
const connection = await client_1.Connection.connect({ address: env_1.env.TEMPORAL_ADDRESS });
_client = new client_1.Client({
connection,
namespace: env_1.env.TEMPORAL_NAMESPACE,
});
logger_1.logger.info({ address: env_1.env.TEMPORAL_ADDRESS }, 'Temporal Client conectado');
return _client;
}
+57
View File
@@ -0,0 +1,57 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
/**
* Worker do Temporal — roda como processo separado.
* Uso: npx tsx src/infra/temporal/temporalWorker.ts
*/
const worker_1 = require("@temporalio/worker");
const env_1 = require("../../config/env");
const logger_1 = require("../../config/logger");
const activities = __importStar(require("./activities"));
async function runWorker() {
const worker = await worker_1.Worker.create({
workflowsPath: require.resolve('./workflows'),
activities,
taskQueue: env_1.env.TEMPORAL_TASK_QUEUE,
namespace: env_1.env.TEMPORAL_NAMESPACE,
});
logger_1.logger.info({ taskQueue: env_1.env.TEMPORAL_TASK_QUEUE }, '🕰 Temporal Worker iniciado');
await worker.run();
}
runWorker().catch((err) => {
logger_1.logger.error(err, 'Falha fatal no Temporal Worker');
process.exit(1);
});
@@ -0,0 +1,49 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.confirmarSlotSignal = void 0;
exports.conflitoAgendaWorkflow = conflitoAgendaWorkflow;
/**
* 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.
*/
const workflow_1 = require("@temporalio/workflow");
const { liberarSlotAgenda, notificarConflito, } = (0, workflow_1.proxyActivities)({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
});
// Signal enviado quando B confirma o slot
exports.confirmarSlotSignal = (0, workflow_1.defineSignal)('confirmarSlot');
async function conflitoAgendaWorkflow(input) {
let bConfirmou = false;
// Registra o handler do signal de confirmação de B
(0, workflow_1.setHandler)(exports.confirmarSlotSignal, ({ confirmado }) => {
bConfirmou = confirmado;
workflow_1.log.info('Signal recebido de B', { confirmado });
});
// Notifica B sobre a disputa
await notificarConflito({
chatIdB: input.chatIdDetentor,
solicitanteNome: input.solicitanteId,
tituloSlot: input.tituloSlot,
});
workflow_1.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 (0, workflow_1.condition)(() => bConfirmou !== false, '1 hour');
if (bRespondeu && bConfirmou) {
workflow_1.log.info('B confirmou o slot — slot mantido com B');
return { resultado: 'MANTIDO_B' };
}
// B não respondeu ou recusou → libera para A
workflow_1.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' };
}
+10
View File
@@ -0,0 +1,10 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.encerrarMonitoramentoSignal = exports.registrarInfracaoSignal = exports.reputacaoWorkflow = exports.confirmarSlotSignal = exports.conflitoAgendaWorkflow = void 0;
var conflitoAgendaWorkflow_1 = require("./conflitoAgendaWorkflow");
Object.defineProperty(exports, "conflitoAgendaWorkflow", { enumerable: true, get: function () { return conflitoAgendaWorkflow_1.conflitoAgendaWorkflow; } });
Object.defineProperty(exports, "confirmarSlotSignal", { enumerable: true, get: function () { return conflitoAgendaWorkflow_1.confirmarSlotSignal; } });
var reputacaoWorkflow_1 = require("./reputacaoWorkflow");
Object.defineProperty(exports, "reputacaoWorkflow", { enumerable: true, get: function () { return reputacaoWorkflow_1.reputacaoWorkflow; } });
Object.defineProperty(exports, "registrarInfracaoSignal", { enumerable: true, get: function () { return reputacaoWorkflow_1.registrarInfracaoSignal; } });
Object.defineProperty(exports, "encerrarMonitoramentoSignal", { enumerable: true, get: function () { return reputacaoWorkflow_1.encerrarMonitoramentoSignal; } });
@@ -0,0 +1,56 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.encerrarMonitoramentoSignal = exports.registrarInfracaoSignal = void 0;
exports.reputacaoWorkflow = reputacaoWorkflow;
/**
* 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).
*/
const workflow_1 = require("@temporalio/workflow");
const { decrementarScore, bloquearAgendamentosAutomaticos, buscarScoreContato, } = (0, workflow_1.proxyActivities)({
startToCloseTimeout: '30 seconds',
retry: { maximumAttempts: 3 },
});
// Signal para registrar uma nova infração
exports.registrarInfracaoSignal = (0, workflow_1.defineSignal)('registrarInfracao');
// Signal para encerrar o monitoramento (contato reabilitado manualmente)
exports.encerrarMonitoramentoSignal = (0, workflow_1.defineSignal)('encerrarMonitoramento');
async function reputacaoWorkflow(input) {
let encerrado = false;
const infracoesPendentes = [];
(0, workflow_1.setHandler)(exports.registrarInfracaoSignal, (payload) => {
workflow_1.log.info('Infração registrada via signal', payload);
infracoesPendentes.push(payload);
});
(0, workflow_1.setHandler)(exports.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 (0, workflow_1.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,
});
workflow_1.log.info('Score atualizado', { novoScore, restrito, contactId: input.contactId });
if (restrito) {
await bloquearAgendamentosAutomaticos({ contactId: input.contactId });
workflow_1.log.warn('Contato RESTRITO — agendamentos automáticos bloqueados', {
contactId: input.contactId,
scoreAtual: novoScore,
});
// Continua monitorando (restrição é revertida manualmente pelo agente)
}
}
workflow_1.log.info('Monitoramento de reputação encerrado', { contactId: input.contactId });
}
+8
View File
@@ -0,0 +1,8 @@
"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; } });
+7
View File
@@ -0,0 +1,7 @@
"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();
}
+100
View File
@@ -0,0 +1,100 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildApiKeyRoutes = buildApiKeyRoutes;
const express_1 = require("express");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
const crypto_1 = __importDefault(require("crypto"));
function buildApiKeyRoutes() {
const router = (0, express_1.Router)();
// GET /api/api-keys
router.get('/', async (req, res) => {
const keys = await prisma_1.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, res) => {
const schema = zod_1.z.object({
name: zod_1.z.string().min(1).max(80),
expiresAt: zod_1.z.string().datetime().optional(),
});
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const key = `nw_${crypto_1.default.randomBytes(24).toString('hex')}`;
const created = await prisma_1.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, res) => {
const id = req.params['id'];
const schema = zod_1.z.object({ isActive: zod_1.z.boolean() });
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const existing = await prisma_1.prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId } });
if (!existing) {
res.status(404).json({ error: 'Chave não encontrada' });
return;
}
const updated = await prisma_1.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, res) => {
const id = req.params['id'];
const existing = await prisma_1.prisma.apiKey.findFirst({ where: { id, tenantId: req.tenantId } });
if (!existing) {
res.status(404).json({ error: 'Chave não encontrada' });
return;
}
await prisma_1.prisma.apiKey.delete({ where: { id } });
res.status(204).send();
});
return router;
}
+385
View File
@@ -0,0 +1,385 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.adminRouter = void 0;
/**
* Rotas exclusivas do ADMIN (dono do SaaS).
* Prefixo: /api/admin/
*/
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
const express_1 = require("express");
const zod_1 = require("zod");
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const multer_1 = __importDefault(require("multer"));
const systeminformation_1 = __importDefault(require("systeminformation"));
const prisma_1 = require("../../infra/database/prisma");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const plugins_routes_1 = require("../plugins/plugins.routes");
const SETTINGS_KEY = 'system:settings';
const DEFAULT_SETTINGS = {
systemName: 'NewWhats',
timezone: 'America/Sao_Paulo',
logoUrl: null,
faviconUrl: 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', defaultPlanId: null },
maintenance: { enabled: false, message: 'Sistema em manutenção. Voltamos em breve.' },
webhook: { url: '', secret: '' },
};
async function getSettings() {
const stored = await dragonfly_1.dragonfly.getJson(SETTINGS_KEY);
return { ...DEFAULT_SETTINGS, ...stored };
}
const uploadAsset = (0, multer_1.default)({
storage: multer_1.default.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'));
},
});
exports.adminRouter = (0, express_1.Router)();
// ── Plugins ───────────────────────────────────────────────────────────────────
exports.adminRouter.use('/plugins', plugins_routes_1.pluginsRouter);
// ── Plans ─────────────────────────────────────────────────────────────────────
exports.adminRouter.get('/plans', async (_req, res) => {
try {
const plans = await prisma_1.prisma.plan.findMany({
select: { id: true, name: true }
});
res.json(plans);
}
catch {
res.status(500).json({ error: 'Erro ao listar planos' });
}
});
// ── Settings ──────────────────────────────────────────────────────────────────
exports.adminRouter.get('/settings', async (_req, res) => {
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' });
}
});
exports.adminRouter.patch('/settings', async (req, res) => {
try {
const current = await getSettings();
const body = req.body;
// 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_1.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
exports.adminRouter.post('/settings/logo', uploadAsset.single('file'), async (req, res) => {
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_1.default.resolve('./media/system/logo.' + ext);
await promises_1.default.writeFile(dest, req.file.buffer);
const url = `/media/system/logo.${ext}`;
const settings = await getSettings();
await dragonfly_1.dragonfly.setJson(SETTINGS_KEY, { ...settings, logoUrl: url }, 0);
res.json({ url });
}
catch {
res.status(500).json({ error: 'Erro ao salvar logo' });
}
});
// Upload de favicon
exports.adminRouter.post('/settings/favicon', uploadAsset.single('file'), async (req, res) => {
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_1.default.resolve('./media/system/favicon.' + ext);
await promises_1.default.writeFile(dest, req.file.buffer);
const url = `/media/system/favicon.${ext}`;
const settings = await getSettings();
await dragonfly_1.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
exports.adminRouter.get('/settings/public', async (_req, res) => {
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 ───────────────────────────────────────────────────────
exports.adminRouter.get('/metrics', async (_req, res) => {
try {
const [cpuLoad, mem, disk] = await Promise.all([
systeminformation_1.default.currentLoad(),
systeminformation_1.default.mem(),
systeminformation_1.default.fsSize(),
]);
const rootDisk = disk.find((d) => d.mount === '/') ?? disk[0];
const [connectedInstances, totalUsers] = await Promise.all([
prisma_1.prisma.instance.count({ where: { status: 'CONNECTED' } }),
prisma_1.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
exports.adminRouter.get('/users', async (_req, res) => {
try {
const [users, presenceKeys] = await Promise.all([
prisma_1.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_1.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
exports.adminRouter.get('/users/:id', async (req, res) => {
try {
const id = req.params['id'];
const user = await prisma_1.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
exports.adminRouter.patch('/users/:id/status', async (req, res) => {
try {
const id = req.params['id'];
const { isActive } = zod_1.z.object({ isActive: zod_1.z.boolean() }).parse(req.body);
const user = await prisma_1.prisma.user.update({
where: { id },
data: { isActive },
select: { id: true, name: true, email: true, isActive: true },
});
res.json(user);
}
catch (err) {
if (err instanceof zod_1.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
exports.adminRouter.patch('/users/:id/plan', async (req, res) => {
try {
const id = req.params['id'];
const { planId } = zod_1.z.object({ planId: zod_1.z.string().uuid() }).parse(req.body);
const user = await prisma_1.prisma.user.update({
where: { id },
data: { planId },
select: { id: true, name: true, planId: true },
});
res.json(user);
}
catch (err) {
if (err instanceof zod_1.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
exports.adminRouter.patch('/users/:id/trial', async (req, res) => {
try {
const id = req.params['id'];
const { days } = zod_1.z.object({ days: zod_1.z.number().int().min(1).max(365) }).parse(req.body);
const user = await prisma_1.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 zod_1.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)
exports.adminRouter.patch('/users/:id/reset-password', async (req, res) => {
try {
const id = req.params['id'];
const { newPassword } = zod_1.z
.object({ newPassword: zod_1.z.string().min(8).max(128) })
.parse(req.body);
const passwordHash = await bcryptjs_1.default.hash(newPassword, 12);
await prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
res.json({ message: 'Senha redefinida com sucesso' });
}
catch (err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: 'newPassword inválido' });
return;
}
res.status(500).json({ error: 'Erro ao redefinir senha' });
}
});
// Obter engine ativa
exports.adminRouter.get('/engine', async (_req, res) => {
try {
const settings = await getSettings();
const storedEngine = settings.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)
exports.adminRouter.post('/engine', async (req, res) => {
try {
const { engine } = zod_1.z.object({ engine: zod_1.z.enum(['infinite', 'official']) }).parse(req.body);
// 1. Persiste nas configurações globais
const settings = await getSettings();
await dragonfly_1.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_1.default.resolve('./.env');
try {
let content = await promises_1.default.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 promises_1.default.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) => {
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 zod_1.z.ZodError) {
res.status(400).json({ error: 'Engine inválida. Use "infinite" ou "official"' });
return;
}
res.status(500).json({ error: 'Erro ao salvar a engine' });
}
});
// ── Rota para Disparo Manual do Deploy ──
exports.adminRouter.post('/deploys/trigger', async (_req, res) => {
try {
const response = await fetch('http://10.99.0.4:9000/hooks/clube67-deploy-hook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ trigger: 'admin_panel' })
});
if (!response.ok)
throw new Error(`Status ${response.status}`);
res.json({ ok: true, message: 'Deploy automático iniciado na VPS 4.' });
}
catch (err) {
console.error('[Admin] Falha ao disparar webhook de deploy:', err.message);
res.status(502).json({ error: 'Não foi possível se conectar ao servidor de build' });
}
});
+52
View File
@@ -0,0 +1,52 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuthRepository = void 0;
const prisma_1 = require("../../infra/database/prisma");
class AuthRepository {
async findByEmail(email) {
return prisma_1.prisma.user.findUnique({ where: { email } });
}
async findById(id) {
return prisma_1.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) {
return prisma_1.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, passwordHash) {
return prisma_1.prisma.user.update({ where: { id }, data: { passwordHash } });
}
async setActive(id, isActive) {
return prisma_1.prisma.user.update({ where: { id }, data: { isActive } });
}
}
exports.AuthRepository = AuthRepository;
+120
View File
@@ -0,0 +1,120 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.authRouter = void 0;
const express_1 = require("express");
const zod_1 = require("zod");
const auth_service_1 = require("./auth.service");
const auth_middleware_1 = require("../../shared/middlewares/auth.middleware");
const service = new auth_service_1.AuthService();
// ─── Schemas de validação ─────────────────────────────────────────────────────
const registerSchema = zod_1.z.object({
name: zod_1.z.string().min(2).max(100),
email: zod_1.z.string().email(),
password: zod_1.z.string().min(8).max(128),
planId: zod_1.z.string().uuid().optional(),
});
const loginSchema = zod_1.z.object({
email: zod_1.z.string().email(),
password: zod_1.z.string().min(1),
});
const refreshSchema = zod_1.z.object({
refreshToken: zod_1.z.string().min(1),
});
const changePasswordSchema = zod_1.z.object({
currentPassword: zod_1.z.string().min(1),
newPassword: zod_1.z.string().min(8).max(128),
});
// ─── Helper para erros de validação / serviço ─────────────────────────────────
function handleError(res, err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
return;
}
const e = err;
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' });
}
// ─── Router ───────────────────────────────────────────────────────────────────
exports.authRouter = (0, express_1.Router)();
/**
* POST /api/auth/register
* Cria novo tenant (usuário com role USER).
* Retorna access + refresh token e dados do usuário.
*/
exports.authRouter.post('/register', async (req, res) => {
try {
const body = registerSchema.parse(req.body);
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.
*/
exports.authRouter.post('/login', async (req, res) => {
try {
const body = loginSchema.parse(req.body);
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.
*/
exports.authRouter.post('/refresh', async (req, res) => {
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.
*/
exports.authRouter.post('/logout', auth_middleware_1.authMiddleware, async (req, res) => {
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.
*/
exports.authRouter.get('/me', auth_middleware_1.authMiddleware, async (req, res) => {
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.
*/
exports.authRouter.patch('/password', auth_middleware_1.authMiddleware, async (req, res) => {
try {
const body = changePasswordSchema.parse(req.body);
await service.changePassword(req.tenantId, body);
res.json({ message: 'Senha alterada com sucesso' });
}
catch (err) {
handleError(res, err);
}
});
+121
View File
@@ -0,0 +1,121 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuthService = void 0;
const bcryptjs_1 = __importDefault(require("bcryptjs"));
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const env_1 = require("../../config/env");
const auth_repository_1 = require("./auth.repository");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const BCRYPT_ROUNDS = 12;
const ACCESS_EXPIRES = '8h';
const REFRESH_EXPIRES = '30d';
const REFRESH_TTL_SECONDS = 30 * 24 * 60 * 60;
class AuthService {
repo = new auth_repository_1.AuthRepository();
// ─── Register ─────────────────────────────────────────────────────────────
async register(input) {
const existing = await this.repo.findByEmail(input.email);
if (existing) {
throw Object.assign(new Error('E-mail já cadastrado'), { statusCode: 409 });
}
const passwordHash = await bcryptjs_1.default.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) {
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 bcryptjs_1.default.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) {
let payload;
try {
payload = jsonwebtoken_1.default.verify(refreshToken, env_1.env.JWT_SECRET);
}
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_1.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) {
await dragonfly_1.dragonfly.del(this.refreshKey(userId));
}
// ─── Me ───────────────────────────────────────────────────────────────────
async me(userId) {
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, input) {
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 bcryptjs_1.default.compare(input.currentPassword, user.passwordHash);
if (!valid) {
throw Object.assign(new Error('Senha atual incorreta'), { statusCode: 400 });
}
const newHash = await bcryptjs_1.default.hash(input.newPassword, BCRYPT_ROUNDS);
await this.repo.updatePassword(userId, newHash);
await this.logout(userId); // invalida todas as sessões
}
// ─── Helpers ──────────────────────────────────────────────────────────────
issueTokens(userId, role) {
const base = { sub: userId, role };
const accessToken = jsonwebtoken_1.default.sign({ ...base, type: 'access' }, env_1.env.JWT_SECRET, { expiresIn: ACCESS_EXPIRES });
const refreshToken = jsonwebtoken_1.default.sign({ ...base, type: 'refresh' }, env_1.env.JWT_SECRET, { expiresIn: REFRESH_EXPIRES });
return { accessToken, refreshToken };
}
async saveRefreshToken(userId, token) {
await dragonfly_1.dragonfly.set(this.refreshKey(userId), token, REFRESH_TTL_SECONDS);
}
refreshKey(userId) {
return `auth:refresh:${userId}`;
}
}
exports.AuthService = AuthService;
+67
View File
@@ -0,0 +1,67 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.chatBotState = exports.botRepo = exports.credentialRepo = void 0;
/**
* chatbot.repository.ts — CRUD para AICredential e AIBot.
*/
const prisma_1 = require("../../infra/database/prisma");
// ─── Credentials ─────────────────────────────────────────────────────────────
exports.credentialRepo = {
findAll: (tenantId, instanceId) => prisma_1.prisma.aICredential.findMany({
where: { tenantId, instanceId },
select: { id: true, name: true, provider: true, createdAt: true },
orderBy: { createdAt: 'desc' },
}),
create: (data) => prisma_1.prisma.aICredential.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
name: data.name,
provider: data.provider ?? 'GEMINI',
apiKey: data.apiKey,
},
select: { id: true, name: true, provider: true, createdAt: true },
}),
delete: (id, tenantId) => prisma_1.prisma.aICredential.deleteMany({ where: { id, tenantId } }),
};
// ─── Bots ─────────────────────────────────────────────────────────────────────
exports.botRepo = {
findAll: (tenantId, instanceId) => prisma_1.prisma.aIBot.findMany({
where: { tenantId, instanceId },
include: { credential: { select: { id: true, name: true, provider: true } } },
orderBy: { createdAt: 'desc' },
}),
findEnabled: (tenantId, instanceId) => prisma_1.prisma.aIBot.findFirst({
where: { tenantId, instanceId, enabled: true },
include: { credential: true },
}),
findById: (id, tenantId) => prisma_1.prisma.aIBot.findFirst({
where: { id, tenantId },
include: { credential: true },
}),
create: (data) => prisma_1.prisma.aIBot.create({
data: {
tenantId: data.tenantId,
instanceId: data.instanceId,
credentialId: data.credentialId,
name: data.name,
systemPrompt: data.systemPrompt,
model: data.model ?? 'gemini-1.5-flash',
enabled: data.enabled ?? false,
triggerMode: data.triggerMode ?? 'ALL',
keywords: data.keywords ?? [],
},
}),
update: (id, tenantId, data) => prisma_1.prisma.aIBot.updateMany({ where: { id, tenantId }, data }),
delete: (id, tenantId) => prisma_1.prisma.aIBot.deleteMany({ where: { id, tenantId } }),
};
// ─── Chat bot state ───────────────────────────────────────────────────────────
exports.chatBotState = {
pauseBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: true } }),
resumeBot: (chatId) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botPaused: false } }),
updateSummary: (chatId, botSummary) => prisma_1.prisma.chat.update({ where: { id: chatId }, data: { botSummary } }),
getState: (chatId) => prisma_1.prisma.chat.findUnique({
where: { id: chatId },
select: { botPaused: true, botSummary: true },
}),
};
+202
View File
@@ -0,0 +1,202 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildChatbotRoutes = buildChatbotRoutes;
/**
* chatbot.routes.ts — CRUD de credenciais e bots IA por instância.
*
* Montado em /api/chatbot (ver server.ts)
* Todos os endpoints requerem authMiddleware.
*/
const express_1 = require("express");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
const chatbot_repository_1 = require("./chatbot.repository");
const logger_1 = require("../../config/logger");
function buildChatbotRoutes() {
const router = (0, express_1.Router)();
// ── Helpers ──────────────────────────────────────────────────────────────
const validateInstance = async (instanceId, tenantId) => {
return prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
};
// ─────────────────────────────────────────────────────────────────────────
// CREDENTIALS
// ─────────────────────────────────────────────────────────────────────────
// GET /api/chatbot/credentials/:instanceId
router.get('/credentials/:instanceId', async (req, res) => {
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
const creds = await chatbot_repository_1.credentialRepo.findAll(tenantId, instanceId);
res.json(creds);
}
catch (err) {
logger_1.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, res) => {
const schema = zod_1.z.object({
name: zod_1.z.string().min(1).max(80),
apiKey: zod_1.z.string().min(10),
provider: zod_1.z.enum(['GEMINI', 'OPENAI']).default('GEMINI'),
});
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
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 chatbot_repository_1.credentialRepo.create({ tenantId, instanceId, ...body });
res.status(201).json(cred);
}
catch (err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: err.errors });
return;
}
logger_1.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, res) => {
try {
const tenantId = req.tenantId;
const credId = req.params['credId'];
await chatbot_repository_1.credentialRepo.delete(credId, tenantId);
res.status(204).send();
}
catch (err) {
logger_1.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, res) => {
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
if (!await validateInstance(instanceId, tenantId)) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
const bots = await chatbot_repository_1.botRepo.findAll(tenantId, instanceId);
res.json(bots);
}
catch (err) {
logger_1.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, res) => {
const schema = zod_1.z.object({
credentialId: zod_1.z.string().uuid(),
name: zod_1.z.string().min(1).max(80),
systemPrompt: zod_1.z.string().min(10),
model: zod_1.z.string().default('gemini-1.5-flash'),
enabled: zod_1.z.boolean().default(false),
triggerMode: zod_1.z.enum(['ALL', 'KEYWORD']).default('ALL'),
keywords: zod_1.z.array(zod_1.z.string()).default([]),
});
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
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 chatbot_repository_1.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 chatbot_repository_1.botRepo.create({ tenantId, instanceId, ...body });
res.status(201).json(bot);
}
catch (err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: err.errors });
return;
}
logger_1.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, res) => {
const schema = zod_1.z.object({
credentialId: zod_1.z.string().uuid().optional(),
name: zod_1.z.string().min(1).max(80).optional(),
systemPrompt: zod_1.z.string().min(10).optional(),
model: zod_1.z.string().optional(),
enabled: zod_1.z.boolean().optional(),
triggerMode: zod_1.z.enum(['ALL', 'KEYWORD']).optional(),
keywords: zod_1.z.array(zod_1.z.string()).optional(),
});
try {
const tenantId = req.tenantId;
const botId = req.params['botId'];
const body = schema.parse(req.body);
await chatbot_repository_1.botRepo.update(botId, tenantId, body);
const updated = await chatbot_repository_1.botRepo.findById(botId, tenantId);
res.json(updated);
}
catch (err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: err.errors });
return;
}
logger_1.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, res) => {
try {
const tenantId = req.tenantId;
const botId = req.params['botId'];
await chatbot_repository_1.botRepo.delete(botId, tenantId);
res.status(204).send();
}
catch (err) {
logger_1.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, res) => {
try {
await chatbot_repository_1.chatBotState.pauseBot(req.params['chatId']);
res.json({ paused: true });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
// POST /api/chatbot/chats/:chatId/resume — retoma o bot
router.post('/chats/:chatId/resume', async (req, res) => {
try {
await chatbot_repository_1.chatBotState.resumeBot(req.params['chatId']);
res.json({ paused: false });
}
catch (err) {
res.status(500).json({ error: err.message });
}
});
return router;
}
+165
View File
@@ -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 {
io;
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;
+43
View File
@@ -0,0 +1,43 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.invalidateChatListCache = invalidateChatListCache;
/**
* chat.cache.ts — Invalidação centralizada do cache da listagem de chats.
*
* O cache é populado em GET /api/chats com TTL de 30s.
* Esta função invalida todas as variações (archived, limit) para um par
* tenant+instance — uma nova mensagem ou alteração no chat força refetch.
*
* Usa SCAN ao invés de KEYS para não bloquear o DragonflyDB em prod.
*/
const dragonfly_1 = require("../../infra/cache/dragonfly");
const logger_1 = require("../../config/logger");
/**
* Remove todas as chaves de cache da listagem de chats para um instance.
* Pattern: chats:list:{tenantId}:{instanceId}:*
* Operação fire-and-forget: erros são logados mas não propagados.
*/
async function invalidateChatListCache(tenantId, instanceId) {
if (!dragonfly_1.dragonfly.isAlive())
return;
try {
const client = dragonfly_1.dragonfly.raw();
const pattern = `chats:list:${tenantId}:${instanceId}:*`;
const stream = client.scanStream({ match: pattern, count: 100 });
const keys = [];
await new Promise((resolve, reject) => {
stream.on('data', (batch) => {
for (const k of batch)
keys.push(k);
});
stream.on('end', () => resolve());
stream.on('error', (err) => reject(err));
});
if (keys.length > 0) {
await client.del(...keys);
}
}
catch (err) {
logger_1.logger.warn({ err, tenantId, instanceId }, '[chat.cache] Falha ao invalidar cache (ignorado)');
}
}
+167
View File
@@ -0,0 +1,167 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.ChatRepository = void 0;
const prisma_1 = require("../../infra/database/prisma");
class ChatRepository {
async findAllByInstance(tenantId, instanceId, opts = {}) {
const { limit = 200, search, archived = false } = opts;
// ── Q1: chats (sem includes — bate o N+1) ────────────────────────────────
const chats = await prisma_1.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' } },
{ contact: { name: { contains: search, mode: 'insensitive' } } },
{ contact: { notify: { contains: search, mode: 'insensitive' } } },
{ contact: { phone: { contains: search, mode: 'insensitive' } } },
],
}
: {}),
},
orderBy: [
{ isPinned: 'desc' },
{ lastMessageAt: { sort: 'desc', nulls: 'last' } },
{ createdAt: 'desc' },
],
take: limit,
});
if (chats.length === 0) {
return [];
}
const chatIds = chats.map((c) => c.id);
const contactIds = Array.from(new Set(chats.map((c) => c.contactId).filter((x) => !!x)));
// ── Q2/Q3/Q4 em paralelo: contatos, protocols (1 por chat), last msg (1 por chat) ──
const [contacts, protocols, lastMsgs] = await Promise.all([
contactIds.length > 0
? prisma_1.prisma.contact.findMany({
where: { id: { in: contactIds } },
select: {
id: true,
name: true,
notify: true,
verifiedName: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
})
: Promise.resolve([]),
// DISTINCT ON: 1 query batch — pega o protocolo aberto mais recente por chat
prisma_1.prisma.$queryRaw `
SELECT DISTINCT ON ("chatId") "chatId", number, status::text AS status
FROM protocols
WHERE "chatId" = ANY(${chatIds}::text[])
AND status IN ('OPEN','IN_PROGRESS','WAITING_CLIENT','WAITING_AGENT')
ORDER BY "chatId", "createdAt" DESC
`,
// DISTINCT ON: 1 query batch — pega a última msg por chat
prisma_1.prisma.$queryRaw `
SELECT DISTINCT ON ("chatId") "chatId", body, "fromMe", status::text AS status, timestamp, type::text AS type, "pushName"
FROM messages
WHERE "chatId" = ANY(${chatIds}::text[])
ORDER BY "chatId", timestamp DESC
`,
]);
// ── Indexa para lookup O(1) ──────────────────────────────────────────────
const contactById = new Map(contacts.map((c) => [c.id, c]));
const protocolByChat = new Map(protocols.map((p) => [p.chatId, p]));
const lastMsgByChat = new Map(lastMsgs.map((m) => [m.chatId, m]));
// ── Re-monta a estrutura no formato esperado pelo chat.routes.ts ─────────
return chats.map((c) => {
const contact = c.contactId ? contactById.get(c.contactId) ?? null : null;
const protocol = protocolByChat.get(c.id);
const lastMsg = lastMsgByChat.get(c.id);
return {
...c,
contact: contact
? {
name: contact.name,
notify: contact.notify,
verifiedName: contact.verifiedName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: null,
protocols: protocol
? [{ number: protocol.number, status: protocol.status }]
: [],
messages: lastMsg
? [{
body: lastMsg.body,
fromMe: lastMsg.fromMe,
status: lastMsg.status,
timestamp: lastMsg.timestamp,
type: lastMsg.type,
pushName: lastMsg.pushName,
}]
: [],
};
});
}
async findById(id, tenantId) {
return prisma_1.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, instanceId, jid) {
return prisma_1.prisma.chat.findUnique({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
include: { contact: true },
});
}
async clearUnread(id, tenantId) {
return prisma_1.prisma.chat.updateMany({
where: { id, tenantId },
data: { unreadCount: 0 },
});
}
async setArchived(id, tenantId, archived) {
return prisma_1.prisma.chat.updateMany({
where: { id, tenantId },
data: { isArchived: archived },
});
}
async setPinned(id, tenantId, pinned) {
return prisma_1.prisma.chat.updateMany({
where: { id, tenantId },
data: { isPinned: pinned },
});
}
async countUnreadByInstance(tenantId, instanceId) {
const result = await prisma_1.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;
}
}
exports.ChatRepository = ChatRepository;
+403
View File
@@ -0,0 +1,403 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildChatRoutes = buildChatRoutes;
/**
* Rotas de Chats — API REST para a inbox do frontend.
*
* Endpoints:
* GET /api/chats — Lista chats (com filtros e último msg)
* GET /api/chats/search/messages — Busca full-text em mensagens
* GET /api/chats/stats/overview — Contagens para o dashboard
* GET /api/chats/:id — Detalhe de um chat
* POST /api/chats/:id/read — Zera unread_count
* PATCH /api/chats/:id/archive — Arquiva/desarquiva
* PATCH /api/chats/:id/pin — Fixa/desfixa
* DELETE /api/chats/:id — Remove chat e mensagens
* GET /api/chats/:id/messages — Histórico paginado por cursor
* GET /api/chats/:id/protocol — Protocolos do chat
* POST /api/chats/:id/protocol — Abre novo protocolo
* PATCH /api/chats/:chatId/protocol/:protocolId — Atualiza protocolo
*
* @see ChatRepository — queries otimizadas com batch DISTINCT ON
* @see MessageRepository — busca e paginação de mensagens
*/
const express_1 = require("express");
const crypto_1 = require("crypto");
const zod_1 = require("zod");
const chat_repository_1 = require("./chat.repository");
const message_repository_1 = require("./message.repository");
const prisma_1 = require("../../infra/database/prisma");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const chat_cache_1 = require("./chat.cache");
const chatRepo = new chat_repository_1.ChatRepository();
const msgRepo = new message_repository_1.MessageRepository();
/** TTL do cache de listagem de chats — longo para estabilidade, invalidado por eventos e mutações */
const CHAT_LIST_CACHE_TTL_S = 86400;
/** Handler genérico de erros: diferencia ZodError (400) de erros internos (500) */
function handleError(res, err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
return;
}
res.status(500).json({ error: err.message ?? 'Erro interno' });
}
/** Constrói a chave de cache da listagem. Inclui search/archived/limit. */
function chatListCacheKey(tenantId, instanceId, archived, limit) {
return `chats:list:${tenantId}:${instanceId}:${archived ? '1' : '0'}:${limit}`;
}
function buildChatRoutes(manager) {
const router = (0, express_1.Router)();
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats — Lista chats com snapshot da última mensagem
// ═══════════════════════════════════════════════════════════════════════════
router.get('/', async (req, res) => {
try {
const { instanceId, search, archived, limit } = zod_1.z.object({
instanceId: zod_1.z.string().uuid(),
search: zod_1.z.string().optional(),
archived: zod_1.z.coerce.boolean().optional(),
limit: zod_1.z.coerce.number().int().min(1).max(200).optional(),
}).parse(req.query);
const archivedFlag = archived ?? false;
const limitVal = limit ?? 200;
const tenantId = req.tenantId;
// ── Cache hit: só quando não houver search (busca tem termos únicos) ──
const useCache = !search;
let cacheKey = null;
if (useCache) {
cacheKey = chatListCacheKey(tenantId, instanceId, archivedFlag, limitVal);
const cached = await dragonfly_1.dragonfly.getJson(cacheKey);
if (cached) {
res.setHeader('X-Cache', 'HIT');
res.json(cached);
return;
}
}
const chats = await chatRepo.findAllByInstance(tenantId, instanceId, {
search,
archived,
limit,
});
const result = chats.map((c) => {
const contact = c.contact;
const isGroup = c.jid.endsWith('@g.us');
const lastMsg = c.messages[0];
const activeProtocol = c.protocols[0] ?? null;
const resolvedName = isGroup
? (c.name ?? null)
: (contact?.name ?? contact?.verifiedName ?? contact?.notify
?? (lastMsg && !lastMsg.fromMe ? lastMsg.pushName : null)
?? null);
return {
id: c.id,
instanceId: c.instanceId,
jid: c.jid,
name: c.name ?? null,
isPinned: c.isPinned,
isArchived: c.isArchived,
unreadCount: c.unreadCount,
lastMessageAt: c.lastMessageAt,
createdAt: c.createdAt,
contact: contact
? {
name: resolvedName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
// Hash curto da URL armazenada — muda quando o avatar é atualizado no banco.
// Usado pelo frontend como chave de cache IndexedDB (invalida ao trocar foto).
avatarVersion: contact.avatarUrl
? (0, crypto_1.createHash)('md5').update(contact.avatarUrl).digest('hex').slice(0, 8)
: null,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
}
: isGroup
? { name: resolvedName, phone: null, avatarUrl: null, avatarVersion: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage: lastMsg
? {
body: lastMsg.body,
fromMe: lastMsg.fromMe,
status: lastMsg.status,
type: lastMsg.type,
timestamp: lastMsg.timestamp,
pushName: lastMsg.fromMe ? null : (lastMsg.pushName ?? null),
}
: null,
protocol: activeProtocol
? { number: activeProtocol.number, status: activeProtocol.status }
: null,
};
});
if (useCache && cacheKey) {
// fire-and-forget — não bloqueia a resposta
dragonfly_1.dragonfly.setJson(cacheKey, result, CHAT_LIST_CACHE_TTL_S).catch(() => { });
}
res.setHeader('X-Cache', 'MISS');
res.json(result);
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/search/messages
// ═══════════════════════════════════════════════════════════════════════════
router.get('/search/messages', async (req, res) => {
try {
const { instanceId, q, limit } = zod_1.z.object({
instanceId: zod_1.z.string().uuid(),
q: zod_1.z.string().min(2),
limit: zod_1.z.coerce.number().int().min(1).max(50).optional(),
}).parse(req.query);
const results = await msgRepo.search(req.tenantId, instanceId, q, limit);
res.json(results);
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/stats/overview
// ═══════════════════════════════════════════════════════════════════════════
router.get('/stats/overview', async (req, res) => {
try {
const { instanceId } = zod_1.z.object({ instanceId: zod_1.z.string().uuid() }).parse(req.query);
const cacheKey = `stats:${req.tenantId}:${instanceId}`;
const cached = await dragonfly_1.dragonfly.getJson(cacheKey);
if (cached) {
res.json(cached);
return;
}
const [totalUnread, openProtocols, totalChats] = await Promise.all([
chatRepo.countUnreadByInstance(req.tenantId, instanceId),
prisma_1.prisma.protocol.count({
where: {
tenantId: req.tenantId,
status: { in: ['OPEN', 'IN_PROGRESS'] },
},
}),
prisma_1.prisma.chat.count({
where: {
tenantId: req.tenantId,
instanceId,
isArchived: false,
NOT: { jid: { endsWith: '@lid' } }
},
}),
]);
const stats = { totalUnread, openProtocols, totalChats };
await dragonfly_1.dragonfly.setJson(cacheKey, stats, 30);
res.json(stats);
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id', async (req, res) => {
try {
const id = req.params['id'];
const chat = await chatRepo.findById(id, req.tenantId);
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
res.json(chat);
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/chats/:id/read
// ═══════════════════════════════════════════════════════════════════════════
router.post('/:id/read', async (req, res) => {
try {
const id = req.params['id'];
const tenantId = req.tenantId;
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
await chatRepo.clearUnread(id, tenantId);
if (chat)
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
res.json({ ok: true });
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/archive
// ═══════════════════════════════════════════════════════════════════════════
router.patch('/:id/archive', async (req, res) => {
try {
const id = req.params['id'];
const { archived } = zod_1.z.object({ archived: zod_1.z.boolean() }).parse(req.body);
const tenantId = req.tenantId;
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
await chatRepo.setArchived(id, tenantId, archived);
if (chat)
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
res.json({ ok: true });
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// PATCH /api/chats/:id/pin
// ═══════════════════════════════════════════════════════════════════════════
router.patch('/:id/pin', async (req, res) => {
try {
const id = req.params['id'];
const { pinned } = zod_1.z.object({ pinned: zod_1.z.boolean() }).parse(req.body);
const tenantId = req.tenantId;
const chat = await prisma_1.prisma.chat.findFirst({ where: { id, tenantId }, select: { instanceId: true } });
await chatRepo.setPinned(id, tenantId, pinned);
if (chat)
(0, chat_cache_1.invalidateChatListCache)(tenantId, chat.instanceId).catch(() => { });
res.json({ ok: true });
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// DELETE /api/chats/:id
// ═══════════════════════════════════════════════════════════════════════════
router.delete('/:id', async (req, res) => {
try {
const id = req.params['id'];
const chat = await chatRepo.findById(id, req.tenantId);
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
await prisma_1.prisma.message.deleteMany({ where: { chatId: id } });
await prisma_1.prisma.chat.delete({ where: { id } });
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
res.status(204).send();
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/chats/:id/messages
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id/messages', async (req, res) => {
try {
const id = req.params['id'];
const { before, after, limit } = zod_1.z.object({
before: zod_1.z.string().datetime().optional(),
after: zod_1.z.string().datetime().optional(),
limit: zod_1.z.coerce.number().int().min(1).max(100).optional(),
}).parse(req.query);
const chat = await chatRepo.findById(id, req.tenantId);
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
const { messages, hasMore } = await msgRepo.findByChatPaginated(id, {
before,
after,
limit,
});
res.json({ messages, hasMore });
}
catch (err) {
handleError(res, err);
}
});
// ═══════════════════════════════════════════════════════════════════════════
// PROTOCOLOS
// ═══════════════════════════════════════════════════════════════════════════
router.get('/:id/protocol', async (req, res) => {
try {
const id = req.params['id'];
const chat = await chatRepo.findById(id, req.tenantId);
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
const protocols = await prisma_1.prisma.protocol.findMany({
where: { chatId: id },
orderBy: { createdAt: 'desc' },
take: 10,
include: {
sector: { select: { name: true } },
team: { select: { name: true } },
},
});
res.json(protocols);
}
catch (err) {
handleError(res, err);
}
});
router.post('/:id/protocol', async (req, res) => {
try {
const id = req.params['id'];
const { sectorId, teamId } = zod_1.z.object({
sectorId: zod_1.z.string().uuid().optional(),
teamId: zod_1.z.string().uuid().optional(),
}).parse(req.body);
const chat = await chatRepo.findById(id, req.tenantId);
if (!chat) {
res.status(404).json({ error: 'Chat não encontrado' });
return;
}
if (!chat.contactId) {
res.status(400).json({ error: 'Chat sem contato associado' });
return;
}
const count = await prisma_1.prisma.protocol.count({ where: { chatId: id } });
const year = new Date().getFullYear();
const number = `#${String(count + 1).padStart(4, '0')}-${year}`;
const protocol = await prisma_1.prisma.protocol.create({
data: {
tenantId: req.tenantId,
number,
chatId: id,
contactId: chat.contactId,
sectorId,
teamId,
status: 'OPEN',
},
});
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
res.status(201).json(protocol);
}
catch (err) {
handleError(res, err);
}
});
router.patch('/:chatId/protocol/:protocolId', async (req, res) => {
try {
const chatId = req.params['chatId'];
const protocolId = req.params['protocolId'];
const { status, summary } = zod_1.z.object({
status: zod_1.z.enum(['OPEN', 'IN_PROGRESS', 'WAITING_CLIENT', 'WAITING_AGENT', 'RESOLVED', 'CANCELLED']).optional(),
summary: zod_1.z.string().max(500).optional(),
}).parse(req.body);
const protocol = await prisma_1.prisma.protocol.updateMany({
where: { id: protocolId, chatId, tenantId: req.tenantId },
data: {
...(status && { status }),
...(summary && { summary }),
...(status === 'RESOLVED' && { resolvedAt: new Date() }),
},
});
const chat = await prisma_1.prisma.chat.findFirst({ where: { id: chatId, tenantId: req.tenantId }, select: { instanceId: true } });
if (chat)
(0, chat_cache_1.invalidateChatListCache)(req.tenantId, chat.instanceId).catch(() => { });
res.json({ updated: protocol.count });
}
catch (err) {
handleError(res, err);
}
});
return router;
}
+77
View File
@@ -0,0 +1,77 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MessageRepository = void 0;
const prisma_1 = require("../../infra/database/prisma");
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, opts = {}) {
const { limit = 40, before, after } = opts;
const where = { 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_1.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) {
return prisma_1.prisma.message.findUnique({ where: { id } });
}
async search(tenantId, instanceId, query, limit = 20) {
return prisma_1.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, instanceId, status) {
return prisma_1.prisma.message.updateMany({
where: { messageId, instanceId },
data: { status: status },
});
}
async countByChat(chatId) {
return prisma_1.prisma.message.count({ where: { chatId } });
}
}
exports.MessageRepository = MessageRepository;
+152
View File
@@ -0,0 +1,152 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildContactRoutes = buildContactRoutes;
const express_1 = require("express");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
const whatsapp_1 = require("../../shared/utils/whatsapp");
const chat_cache_1 = require("../chats/chat.cache");
function buildContactRoutes() {
const router = (0, express_1.Router)();
// ── GET /api/contacts/stats/overview — deve vir ANTES de /:id ─────────────
router.get('/stats/overview', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.query['instanceId'];
const where = { tenantId };
if (instanceId)
where['instanceId'] = instanceId;
const [total, blocked, flagged] = await Promise.all([
prisma_1.prisma.contact.count({ where }),
prisma_1.prisma.contact.count({ where: { ...where, isBlocked: true } }),
prisma_1.prisma.contact.count({ where: { ...where, flagRestricao: true } }),
]);
res.json({ total, blocked, flagged, active: total - blocked });
});
// ── GET /api/contacts — lista paginada ────────────────────────────────────
router.get('/', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.query['instanceId'];
const search = req.query['search'];
const blocked = req.query['blocked'];
const limit = Math.min(Number(req.query['limit'] ?? 50), 200);
const offset = Number(req.query['offset'] ?? 0);
const where = { 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_1.prisma.contact.count({ where }),
prisma_1.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}` : (0, whatsapp_1.normalizeJid)(c.jid).split('@')[0])
}))
});
});
// ── GET /api/contacts/:id — detalhe ──────────────────────────────────────
router.get('/:id', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const contact = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const schema = zod_1.z.object({
name: zod_1.z.string().min(1).max(255).optional(),
isBlocked: zod_1.z.boolean().optional(),
});
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const existing = await prisma_1.prisma.contact.findFirst({ where: { id, tenantId } });
if (!existing) {
res.status(404).json({ error: 'Contato não encontrado' });
return;
}
const updated = await prisma_1.prisma.contact.update({ where: { id }, data: body });
if (body.name && existing.instanceId) {
(0, chat_cache_1.invalidateChatListCache)(tenantId, existing.instanceId).catch(() => { });
}
res.json(updated);
});
// ── DELETE /api/contacts/:id ──────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const existing = await prisma_1.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_1.prisma.chat.updateMany({
where: { contactId: id },
data: { contactId: null },
});
await prisma_1.prisma.contact.delete({ where: { id } });
if (existing.instanceId) {
(0, chat_cache_1.invalidateChatListCache)(tenantId, existing.instanceId).catch(() => { });
}
res.status(204).send();
});
return router;
}
+116
View File
@@ -0,0 +1,116 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.pairRouter = void 0;
const express_1 = require("express");
const zod_1 = require("zod");
const pair_service_1 = require("./pair.service");
const auth_middleware_1 = require("../../shared/middlewares/auth.middleware");
const service = new pair_service_1.PairService();
function handleError(res, err) {
if (err instanceof zod_1.z.ZodError) {
res.status(400).json({ error: 'Dados inválidos', details: err.flatten().fieldErrors });
return;
}
const e = err;
res.status(e.statusCode ?? 500).json({ error: e.message ?? 'Erro interno' });
}
// ─── Schemas ──────────────────────────────────────────────────────────────────
const requestSchema = zod_1.z.object({
email: zod_1.z.string().email(),
clientSystem: zod_1.z.string().min(1).max(100),
clientName: zod_1.z.string().min(1).max(200),
clientUrl: zod_1.z.string().url().nullable().optional(),
});
const confirmSchema = zod_1.z.object({
email: zod_1.z.string().email(),
code: zod_1.z.string().length(6),
clientSystem: zod_1.z.string().min(1).max(100),
clientName: zod_1.z.string().min(1).max(200),
clientUrl: zod_1.z.string().url().nullable().optional(),
});
// ─── Router ───────────────────────────────────────────────────────────────────
exports.pairRouter = (0, express_1.Router)();
/**
* POST /api/pair/request
* Inicia pareamento: gera OTP e envia por e-mail.
* Público (chamado pelo sistema cliente).
*/
exports.pairRouter.post('/request', async (req, res) => {
try {
const body = requestSchema.parse(req.body);
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).
*/
exports.pairRouter.post('/confirm', async (req, res) => {
try {
const body = confirmSchema.parse(req.body);
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.
*/
exports.pairRouter.get('/verify', async (req, res) => {
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.
*/
exports.pairRouter.get('/connections', auth_middleware_1.authMiddleware, async (req, res) => {
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.
*/
exports.pairRouter.delete('/connections/:id', auth_middleware_1.authMiddleware, async (req, res) => {
try {
const result = await service.revoke(req.tenantId, String(req.params.id));
res.json(result);
}
catch (err) {
handleError(res, err);
}
});
+204
View File
@@ -0,0 +1,204 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.PairService = void 0;
const crypto_1 = __importDefault(require("crypto"));
const nodemailer_1 = __importDefault(require("nodemailer"));
const prisma_1 = require("../../infra/database/prisma");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const logger_1 = require("../../config/logger");
const OTP_TTL = 10 * 60; // 10 minutos
const KEY_TTL = 90 * 24 * 3600; // 90 dias
// ─── Keys do Dragonfly ────────────────────────────────────────────────────────
const otpKey = (email) => `pair:otp:${email}`;
const intKey = (integrationKey) => `pair:key:${integrationKey}`;
// ─── Email sender ─────────────────────────────────────────────────────────────
async function sendOtpEmail(to, code, clientName) {
// Lê config SMTP do admin settings
const raw = await dragonfly_1.dragonfly.getJson('system:settings');
const smtp = raw?.smtp ?? {};
if (!smtp.host || !smtp.user) {
logger_1.logger.warn('[pair] SMTP não configurado — email de OTP não enviado. Configure em Admin → Configurações.');
logger_1.logger.info(`[pair] OTP para ${to}: ${code}`); // fallback: loga localmente
return false;
}
const transporter = nodemailer_1.default.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_1.logger.info(`[pair] OTP enviado para ${to}`);
return true;
}
// ─── PairService ──────────────────────────────────────────────────────────────
class PairService {
// Etapa 1 — solicitar pareamento
async request(input) {
const user = await prisma_1.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 = {
code,
clientSystem: input.clientSystem,
clientName: input.clientName,
clientUrl: input.clientUrl,
};
await dragonfly_1.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) {
const stored = await dragonfly_1.dragonfly.getJson(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_1.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_1.prisma.pluginPair.updateMany({
where: { userId: user.id, clientSystem: input.clientSystem, revokedAt: null },
data: { revokedAt: new Date() },
});
// Gera integration_key exclusiva
const integrationKey = `nwk_${crypto_1.default.randomUUID().replace(/-/g, '')}`;
const pair = await prisma_1.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 = {
userId: user.id,
email: user.email,
clientSystem: input.clientSystem,
clientName: input.clientName,
};
await dragonfly_1.dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL);
// Limpa OTP usado
await dragonfly_1.dragonfly.del(otpKey(input.email));
logger_1.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) {
// Fast path — Dragonfly
const cached = await dragonfly_1.dragonfly.getJson(intKey(integrationKey));
if (cached) {
// Atualiza lastSeenAt de forma assíncrona (sem bloquear a resposta)
prisma_1.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_1.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 = {
userId: pair.userId,
email: pair.user.email,
clientSystem: pair.clientSystem,
clientName: pair.clientName,
};
await dragonfly_1.dragonfly.setJson(intKey(integrationKey), keyPayload, KEY_TTL);
await prisma_1.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) {
const pairs = await prisma_1.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, pairId) {
const pair = await prisma_1.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_1.prisma.pluginPair.update({
where: { id: pairId },
data: { revokedAt: new Date() },
});
// Remove do cache imediatamente
await dragonfly_1.dragonfly.del(intKey(pair.integrationKey));
logger_1.logger.info(`[pair] Par ${pairId} revogado por ${userId}`);
return { ok: true };
}
}
exports.PairService = PairService;
+100
View File
@@ -0,0 +1,100 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.pluginsRouter = void 0;
const express_1 = require("express");
const plugin_registry_1 = require("../../core/plugin-registry");
const plugin_config_1 = require("../../core/plugin-config");
const StorageProvider_1 = require("../../core/StorageProvider");
/**
* Rotas de gerenciamento de plugins — exclusivo admin.
* Montado em /api/admin/plugins pelo server.ts via adminRouter.
*/
exports.pluginsRouter = (0, express_1.Router)();
// GET /api/admin/plugins — lista todos os plugins com status e config atual
exports.pluginsRouter.get('/', (_req, res) => {
const plugins = plugin_registry_1.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: plugin_config_1.pluginConfig.get(entry.name),
hooks: entry.manifest.hooks ?? { subscribes: [], emits: [] },
}));
res.json(plugins);
});
// POST /api/admin/plugins/:name/enable — ativa plugin
exports.pluginsRouter.post('/:name/enable', async (req, res) => {
const { name } = req.params;
try {
await plugin_registry_1.pluginRegistry.activate(name);
res.json({ ok: true, message: `Plugin "${name}" ativado` });
}
catch (err) {
res.status(400).json({ error: err.message });
}
});
// POST /api/admin/plugins/:name/disable — desativa plugin (se canDisable=true)
exports.pluginsRouter.post('/:name/disable', async (req, res) => {
const { name } = req.params;
try {
await plugin_registry_1.pluginRegistry.deactivate(name);
res.json({ ok: true, message: `Plugin "${name}" desativado` });
}
catch (err) {
res.status(400).json({ error: err.message });
}
});
// GET /api/admin/plugins/:name/config — retorna config atual
exports.pluginsRouter.get('/:name/config', (req, res) => {
const { name } = req.params;
const entry = plugin_registry_1.pluginRegistry.getEntry(name);
if (!entry)
return res.status(404).json({ error: `Plugin "${name}" não encontrado` });
res.json({
name,
config: plugin_config_1.pluginConfig.get(name),
schema: entry.manifest.configSchema ?? [],
});
});
// GET /api/admin/plugins/storage/health — retorna status detalhado do storage
exports.pluginsRouter.get('/storage/health', async (_req, res) => {
if (!StorageProvider_1.storageProvider.isRegistered()) {
const cfg = plugin_config_1.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_1.storageProvider.updateHealth();
return res.json(StorageProvider_1.storageProvider.getLastHealthStatus());
});
// PUT /api/admin/plugins/:name/config — salva config e reativa plugin se necessário
exports.pluginsRouter.put('/:name/config', async (req, res) => {
const { name } = req.params;
const entry = plugin_registry_1.pluginRegistry.getEntry(name);
if (!entry)
return res.status(404).json({ error: `Plugin "${name}" não encontrado` });
const newConfig = req.body;
await plugin_config_1.pluginConfig.set(name, newConfig);
// Reativa o plugin para aplicar a nova config imediatamente
if (entry.active) {
try {
await plugin_registry_1.pluginRegistry.reactivate(name);
}
catch (err) {
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 });
});
+230
View File
@@ -0,0 +1,230 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildScheduledRoutes = buildScheduledRoutes;
/**
* 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
*/
const express_1 = require("express");
const zod_1 = require("zod");
const croner_1 = require("croner");
const prisma_1 = require("../../infra/database/prisma");
const recipientSchema = zod_1.z.object({
jid: zod_1.z.string(),
name: zod_1.z.string().optional(),
birthday: zod_1.z.string().optional(),
anniversary: zod_1.z.string().optional(),
signupDate: zod_1.z.string().optional(),
});
const scheduleSchema = zod_1.z.object({
instanceId: zod_1.z.string(),
name: zod_1.z.string().min(1).max(100),
templateId: zod_1.z.string().optional(),
payload: zod_1.z.record(zod_1.z.unknown()),
recipientMode: zod_1.z.enum(['MANUAL', 'ALL_CONTACTS', 'TAG_FILTER']).default('MANUAL'),
recipients: zod_1.z.array(recipientSchema).default([]),
scheduleType: zod_1.z.enum(['ONCE', 'RECURRING', 'EVENT']),
cronExpr: zod_1.z.string().optional(),
scheduledAt: zod_1.z.string().datetime().optional(),
eventType: zod_1.z.enum(['BIRTHDAY', 'ANNIVERSARY', 'SIGNUP_DAYS']).optional(),
timezone: zod_1.z.string().default('America/Sao_Paulo'),
});
function getId(req) {
const id = req.params['id'];
return Array.isArray(id) ? id[0] : id;
}
function calcNextRun(data) {
if (data.scheduleType === 'ONCE' && data.scheduledAt) {
return new Date(data.scheduledAt);
}
if (data.scheduleType === 'RECURRING' && data.cronExpr) {
try {
const job = new croner_1.Cron(data.cronExpr, { paused: true });
return job.nextRun() ?? null;
}
catch {
return null;
}
}
return null;
}
function buildScheduledRoutes(_scheduler) {
const router = (0, express_1.Router)();
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req, res) => {
const tenantId = req.tenantId;
const status = Array.isArray(req.query['status']) ? req.query['status'][0] : req.query['status'];
const type = Array.isArray(req.query['type']) ? req.query['type'][0] : req.query['type'];
const schedules = await prisma_1.prisma.scheduledMessage.findMany({
where: {
tenantId,
...(status ? { status: status } : {}),
...(type ? { scheduleType: type } : {}),
},
include: {
template: { select: { id: true, name: true, type: true } },
_count: { select: { logs: true } },
},
orderBy: { createdAt: 'desc' },
});
res.json(schedules);
});
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req, res) => {
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_1.prisma.scheduledMessage.create({
data: {
tenantId,
instanceId: data.instanceId,
name: data.name,
templateId: data.templateId,
payload: data.payload,
recipientMode: data.recipientMode,
recipients: data.recipients,
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, res) => {
const tenantId = req.tenantId;
const sched = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.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 });
const { payload, recipients, scheduledAt, eventType, ...rest } = data;
const updated = await prisma_1.prisma.scheduledMessage.update({
where: { id },
data: {
...rest,
...(payload !== undefined ? { payload: payload } : {}),
...(recipients !== undefined ? { recipients: recipients } : {}),
...(scheduledAt !== undefined ? { scheduledAt: new Date(scheduledAt) } : {}),
...(eventType !== undefined ? { eventType } : {}),
status: 'ACTIVE',
nextRunAt,
},
});
res.json(updated);
});
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.prisma.scheduledMessage.findFirst({
where: { id, tenantId },
});
if (!exists) {
res.status(404).json({ error: 'Agendamento não encontrado' });
return;
}
await prisma_1.prisma.scheduledMessage.delete({ where: { id } });
res.status(204).send();
});
// ── PAUSE ─────────────────────────────────────────────────────────────────
router.post('/:id/pause', async (req, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
if (!exists) {
res.status(404).json({ error: 'Agendamento não encontrado' });
return;
}
await prisma_1.prisma.scheduledMessage.update({ where: { id }, data: { status: 'PAUSED' } });
res.json({ ok: true });
});
// ── RESUME ────────────────────────────────────────────────────────────────
router.post('/:id/resume', async (req, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
if (!exists) {
res.status(404).json({ error: 'Agendamento não encontrado' });
return;
}
await prisma_1.prisma.scheduledMessage.update({ where: { id }, data: { status: 'ACTIVE' } });
res.json({ ok: true });
});
// ── RUN NOW ───────────────────────────────────────────────────────────────
router.post('/:id/run-now', async (req, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const sched = await prisma_1.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_1.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, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.prisma.scheduledMessage.findFirst({ where: { id, tenantId } });
if (!exists) {
res.status(404).json({ error: 'Agendamento não encontrado' });
return;
}
const logs = await prisma_1.prisma.scheduledMessageLog.findMany({
where: { scheduleId: id },
orderBy: { runAt: 'desc' },
take: 50,
});
res.json(logs);
});
return router;
}
+206
View File
@@ -0,0 +1,206 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SchedulerService = void 0;
/**
* SchedulerService — executa agendamentos de mensagens ricos.
*
* Tipos suportados:
* ONCE — executa uma vez em scheduledAt
* RECURRING — cron expression (cronExpr)
* EVENT — dispara por evento (BIRTHDAY, ANNIVERSARY, SIGNUP_DAYS)
* verificado diariamente às 08:00 contra dados dos contatos
*
* O serviço verifica a cada minuto agendamentos ONCE/RECURRING vencidos.
* Eventos são checados uma vez por dia via job separado.
*
* Para enviar, chama o socket Baileys diretamente via WhatsAppConnectionManager
* (sem passar pelo HTTP — evita latência e auth overhead).
*/
const node_cron_1 = require("node-cron");
const croner_1 = require("croner");
const prisma_1 = require("../../infra/database/prisma");
const logger_1 = require("../../config/logger");
class SchedulerService {
manager;
minuteJob = null;
eventJob = null;
constructor(manager) {
this.manager = manager;
}
start() {
// Tick a cada minuto — processa ONCE e RECURRING vencidos
this.minuteJob = (0, node_cron_1.schedule)('* * * * *', () => {
this.processTimeBased().catch((err) => logger_1.logger.error({ err }, '[Scheduler] Erro no tick de minuto'));
});
// Tick diário às 08:00 — processa agendamentos por EVENTO
this.eventJob = (0, node_cron_1.schedule)('0 8 * * *', () => {
this.processEventBased().catch((err) => logger_1.logger.error({ err }, '[Scheduler] Erro no tick de evento'));
});
logger_1.logger.info('[Scheduler] Iniciado (minuto + evento diário)');
}
stop() {
this.minuteJob?.stop();
this.eventJob?.stop();
}
// ── Processa ONCE e RECURRING ──────────────────────────────────────────────
async processTimeBased() {
const now = new Date();
const schedules = await prisma_1.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) ─────────────────────────
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_1.prisma.scheduledMessage.findMany({
where: { status: 'ACTIVE', scheduleType: 'EVENT' },
});
for (const sched of schedules) {
const recipients = sched.recipients;
const matched = [];
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 ─────────────────────────────────────────────────
async executeSchedule(sched, overrideRecipients) {
const recipients = overrideRecipients ?? sched.recipients;
const sock = this.manager.getSocket(sched.instanceId);
if (!sock) {
logger_1.logger.warn({ scheduleId: sched.id }, '[Scheduler] Socket não disponível — adiando');
return;
}
let sentCount = 0;
let failedCount = 0;
const errors = [];
for (const recipient of recipients) {
if (!recipient.jid)
continue;
try {
const payload = this.buildPayload(sched.payload, recipient);
await sock.sendMessage(recipient.jid, payload);
sentCount++;
// Pequeno delay anti-ban
await new Promise((r) => setTimeout(r, 800 + Math.random() * 400));
}
catch (err) {
failedCount++;
errors.push(`${recipient.jid}: ${err?.message ?? err}`);
logger_1.logger.warn({ err, jid: recipient.jid, scheduleId: sched.id }, '[Scheduler] Falha ao enviar');
}
}
// Persiste log
await prisma_1.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_1.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_1.prisma.scheduledMessage.update({
where: { id: sched.id },
data: {
lastRunAt: new Date(),
nextRunAt: next,
runCount: { increment: 1 },
},
});
}
else {
// EVENT — apenas registra execução
await prisma_1.prisma.scheduledMessage.update({
where: { id: sched.id },
data: { lastRunAt: new Date(), runCount: { increment: 1 } },
});
}
logger_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.
buildPayload(raw, recipient) {
const replace = (str) => str
.replace(/\{\{name\}\}/gi, recipient.name ?? '')
.replace(/\{\{birthday\}\}/gi, recipient.birthday ?? '')
.replace(/\{\{anniversary\}\}/gi, recipient.anniversary ?? '');
const recurse = (node) => {
if (typeof node === 'string')
return replace(node);
if (Array.isArray(node))
return node.map(recurse);
if (node && typeof node === 'object') {
const out = {};
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 ───────────────────────────────────────
nextCronDate(expr) {
try {
const job = new croner_1.Cron(expr, { paused: true });
return job.nextRun() ?? null;
}
catch {
return null;
}
}
}
exports.SchedulerService = SchedulerService;
+150
View File
@@ -0,0 +1,150 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSectorRoutes = buildSectorRoutes;
const express_1 = require("express");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
function buildSectorRoutes() {
const router = (0, express_1.Router)();
// GET /api/sectors
router.get('/', async (req, res) => {
const tenantId = req.tenantId;
const sectors = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const schema = zod_1.z.object({ name: zod_1.z.string().min(1).max(100) });
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
try {
const sector = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const schema = zod_1.z.object({
name: zod_1.z.string().min(1).max(100).optional(),
isActive: zod_1.z.boolean().optional(),
});
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const existing = await prisma_1.prisma.sector.findFirst({ where: { id, tenantId } });
if (!existing) {
res.status(404).json({ error: 'Setor não encontrado' });
return;
}
const updated = await prisma_1.prisma.sector.update({ where: { id }, data: body });
res.json(updated);
});
// DELETE /api/sectors/:id
router.delete('/:id', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const existing = await prisma_1.prisma.sector.findFirst({ where: { id, tenantId } });
if (!existing) {
res.status(404).json({ error: 'Setor não encontrado' });
return;
}
await prisma_1.prisma.team.deleteMany({ where: { sectorId: id } });
await prisma_1.prisma.sector.delete({ where: { id } });
res.status(204).send();
});
// GET /api/sectors/:id/teams
router.get('/:id/teams', async (req, res) => {
const tenantId = req.tenantId;
const sectorId = req.params['id'];
const teams = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const sectorId = req.params['id'];
const schema = zod_1.z.object({ name: zod_1.z.string().min(1).max(100) });
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const sector = await prisma_1.prisma.sector.findFirst({ where: { id: sectorId, tenantId } });
if (!sector) {
res.status(404).json({ error: 'Setor não encontrado' });
return;
}
try {
const team = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const teamId = req.params['teamId'];
const schema = zod_1.z.object({
name: zod_1.z.string().min(1).max(100).optional(),
isActive: zod_1.z.boolean().optional(),
});
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const existing = await prisma_1.prisma.team.findFirst({ where: { id: teamId, tenantId } });
if (!existing) {
res.status(404).json({ error: 'Equipe não encontrada' });
return;
}
const updated = await prisma_1.prisma.team.update({ where: { id: teamId }, data: body });
res.json(updated);
});
// DELETE /api/sectors/:sectorId/teams/:teamId
router.delete('/:sectorId/teams/:teamId', async (req, res) => {
const tenantId = req.tenantId;
const teamId = req.params['teamId'];
const existing = await prisma_1.prisma.team.findFirst({ where: { id: teamId, tenantId } });
if (!existing) {
res.status(404).json({ error: 'Equipe não encontrada' });
return;
}
await prisma_1.prisma.team.delete({ where: { id: teamId } });
res.status(204).send();
});
return router;
}
+91
View File
@@ -0,0 +1,91 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildStickerRoutes = buildStickerRoutes;
/**
* Rotas de Figurinhas (Stickers)
*
* GET /api/stickers — Lista stickers do tenant (com listVersion)
* GET /api/stickers/recent — Últimos 30 stickers usados
* PATCH /api/stickers/:id/favorite — Toggle isFavorite
*/
const express_1 = require("express");
const crypto_1 = require("crypto");
const prisma_1 = require("../../infra/database/prisma");
function buildStickerRoutes() {
const router = (0, express_1.Router)();
// ── LIST (com listVersion para cache invalidation) ────────────────────────
router.get('/', async (req, res) => {
try {
const tenantId = req.tenantId;
const stickers = await prisma_1.prisma.sticker.findMany({
where: { tenantId },
orderBy: [{ isFavorite: 'desc' }, { lastSeenAt: 'desc' }],
select: {
id: true,
sha256: true,
wasabiPath: true,
isAnimated: true,
isFavorite: true,
useCount: true,
lastSeenAt: true,
},
});
const versionSource = stickers.length === 0
? 'empty'
: `${stickers.length}:${stickers[0].sha256}:${stickers[0].lastSeenAt.getTime()}:${stickers.filter(s => s.isFavorite).length}`;
const listVersion = (0, crypto_1.createHash)('md5').update(versionSource).digest('hex').slice(0, 8);
res.json({ listVersion, stickers });
}
catch (err) {
res.status(500).json({ error: 'Erro ao listar stickers' });
}
});
// ── RECENT (últimos 30) ───────────────────────────────────────────────────
router.get('/recent', async (req, res) => {
try {
const tenantId = req.tenantId;
const stickers = await prisma_1.prisma.sticker.findMany({
where: { tenantId },
orderBy: { lastSeenAt: 'desc' },
take: 30,
select: {
id: true,
sha256: true,
wasabiPath: true,
isAnimated: true,
isFavorite: true,
},
});
res.json({ stickers });
}
catch (err) {
res.status(500).json({ error: 'Erro ao listar stickers recentes' });
}
});
// ── TOGGLE FAVORITE ───────────────────────────────────────────────────────
router.patch('/:id/favorite', async (req, res) => {
try {
const tenantId = req.tenantId;
const idParam = req.params['id'];
const id = Array.isArray(idParam) ? idParam[0] : idParam;
const sticker = await prisma_1.prisma.sticker.findFirst({
where: { id, tenantId },
select: { id: true, isFavorite: true },
});
if (!sticker) {
res.status(404).json({ error: 'Sticker não encontrado' });
return;
}
const updated = await prisma_1.prisma.sticker.update({
where: { id: sticker.id },
data: { isFavorite: !sticker.isFavorite },
select: { id: true, isFavorite: true },
});
res.json(updated);
}
catch (err) {
res.status(500).json({ error: 'Erro ao atualizar favorito' });
}
});
return router;
}
+127
View File
@@ -0,0 +1,127 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildTemplateRoutes = buildTemplateRoutes;
/**
* 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)
*/
const express_1 = require("express");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
const templateTypeValues = ['TEXT', 'BUTTONS', 'INTERACTIVE', 'LIST', 'POLL', 'CAROUSEL'];
const templateSchema = zod_1.z.object({
name: zod_1.z.string().min(1).max(100),
description: zod_1.z.string().max(500).optional(),
type: zod_1.z.enum(templateTypeValues),
payload: zod_1.z.record(zod_1.z.unknown()),
tags: zod_1.z.array(zod_1.z.string()).default([]),
});
function getId(req) {
const id = req.params['id'];
return Array.isArray(id) ? id[0] : id;
}
function buildTemplateRoutes() {
const router = (0, express_1.Router)();
// ── LIST ──────────────────────────────────────────────────────────────────
router.get('/', async (req, res) => {
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_1.prisma.messageTemplate.findMany({
where: {
tenantId,
...(type ? { type: type } : {}),
...(search ? { name: { contains: search, mode: 'insensitive' } } : {}),
},
orderBy: [{ usageCount: 'desc' }, { createdAt: 'desc' }],
});
res.json(templates);
});
// ── CREATE ────────────────────────────────────────────────────────────────
router.post('/', async (req, res) => {
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_1.prisma.messageTemplate.create({
data: { tenantId, name, description, type, payload: payload, tags },
});
res.status(201).json(template);
});
// ── GET ONE ───────────────────────────────────────────────────────────────
router.get('/:id', async (req, res) => {
const tenantId = req.tenantId;
const template = await prisma_1.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, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.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_1.prisma.messageTemplate.update({
where: { id },
data: { ...rest, ...(payload !== undefined ? { payload: payload } : {}) },
});
res.json(updated);
});
// ── DELETE ────────────────────────────────────────────────────────────────
router.delete('/:id', async (req, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.prisma.messageTemplate.findFirst({
where: { id, tenantId },
});
if (!exists) {
res.status(404).json({ error: 'Template não encontrado' });
return;
}
await prisma_1.prisma.messageTemplate.delete({ where: { id } });
res.status(204).send();
});
// ── USE (IA incrementa uso) ────────────────────────────────────────────────
router.post('/:id/use', async (req, res) => {
const tenantId = req.tenantId;
const id = getId(req);
const exists = await prisma_1.prisma.messageTemplate.findFirst({
where: { id, tenantId },
});
if (!exists) {
res.status(404).json({ error: 'Template não encontrado' });
return;
}
await prisma_1.prisma.messageTemplate.update({
where: { id },
data: { usageCount: { increment: 1 } },
});
res.json({ ok: true });
});
return router;
}
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.proto = exports.isJidStatusBroadcast = exports.isJidGroup = exports.generateWAMessageFromContent = exports.generateMessageIDV2 = exports.getContentType = exports.downloadMediaMessage = exports.makeCacheableSignalKeyStore = exports.fetchLatestBaileysVersion = exports.DisconnectReason = exports.useMultiFileAuthState = exports.makeWASocket = void 0;
exports.getEngine = getEngine;
const official = __importStar(require("./official"));
const infinite = __importStar(require("./infinite"));
// Engine padrão global: lida de BAILEYS_ENGINE (fallback para 'infinite')
const defaultEngineType = process.env.BAILEYS_ENGINE ?? 'infinite';
function resolveEngine(type) {
return type === 'infinite' ? infinite : official;
}
/**
* Retorna os exports da engine selecionada para uma instância específica.
* Permite que cada instância use uma engine diferente (infinite ou official).
* Se não informado, usa BAILEYS_ENGINE do ambiente (padrão: 'infinite').
*/
function getEngine(engineType) {
const e = resolveEngine(engineType ?? defaultEngineType);
return {
makeWASocket: e.makeWASocket,
useMultiFileAuthState: e.useMultiFileAuthState,
fetchLatestBaileysVersion: e.fetchLatestBaileysVersion,
makeCacheableSignalKeyStore: e.makeCacheableSignalKeyStore,
downloadMediaMessage: e.downloadMediaMessage,
getContentType: e.getContentType,
generateMessageIDV2: e.generateMessageIDV2,
generateWAMessageFromContent: e.generateWAMessageFromContent,
isJidGroup: e.isJidGroup,
isJidStatusBroadcast: e.isJidStatusBroadcast,
};
}
// Exports estáticos: usam a engine global (retrocompatibilidade com código existente)
const selectedEngine = resolveEngine(defaultEngineType);
exports.makeWASocket = selectedEngine.makeWASocket;
exports.useMultiFileAuthState = selectedEngine.useMultiFileAuthState;
exports.DisconnectReason = selectedEngine.DisconnectReason;
exports.fetchLatestBaileysVersion = selectedEngine.fetchLatestBaileysVersion;
exports.makeCacheableSignalKeyStore = selectedEngine.makeCacheableSignalKeyStore;
exports.downloadMediaMessage = selectedEngine.downloadMediaMessage;
exports.getContentType = selectedEngine.getContentType;
exports.generateMessageIDV2 = selectedEngine.generateMessageIDV2;
exports.generateWAMessageFromContent = selectedEngine.generateWAMessageFromContent;
exports.isJidGroup = selectedEngine.isJidGroup;
exports.isJidStatusBroadcast = selectedEngine.isJidStatusBroadcast;
var official_1 = require("./official");
Object.defineProperty(exports, "proto", { enumerable: true, get: function () { return official_1.proto; } });
+17
View File
@@ -0,0 +1,17 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("baileys"), exports);
+17
View File
@@ -0,0 +1,17 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("@whiskeysockets/baileys"), exports);
+508
View File
@@ -0,0 +1,508 @@
"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 {
instanceId;
tenantId;
sock;
io;
/** Fila de JIDs aguardando busca de avatar */
avatarQueue = [];
/** Flag de mutex simples — evita processamento concorrente da fila */
processingAvatars = false;
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;
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO DE NOME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Regra de ouro para resolução de nome de contato:
* name (agenda do telefone) > verifiedName (conta business) > notify (pushname)
*
* Retorna undefined se nenhum campo estiver disponível.
*/
resolveName(c) {
return c.name ?? c.verifiedName ?? c.notify ?? undefined;
}
// ═══════════════════════════════════════════════════════════════════════════
// UPSERT EM MASSA (contacts.upsert + messaging-history.set)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Cria ou atualiza contatos em massa.
*
* Dispara em dois cenários:
* 1. Evento contacts.upsert do Baileys (conexão inicial ou reconexão)
* 2. Campo contacts[] do messaging-history.set (history sync)
*
* Para cada contato:
* - Cria ou atualiza no banco com nome resolvido
* - Vincula chats órfãos (Chat sem contactId) ao contato
* - Enfileira busca de avatar se imgUrl não disponível
* - Captura mapeamento LID → phone JID (campo `lid` do history sync)
*
* TRATAMENTO DE LID:
* O Baileys pode enviar contatos com c.id no formato @lid.
* Quando o contato do history sync inclui campo `jid` (telefone real),
* usamos esse JID como chave primária e salvamos o LID em lidJid.
* Quando não há campo `jid`, o contato é criado com o LID como JID
* (será corrigido quando phoneNumberShare ou senderPn revelar o telefone).
*/
async upsert(contacts) {
/** JIDs que precisam buscar avatar via profilePictureUrl */
const needFetchAvatar = [];
/** Contador de avatares que já vieram prontos do Baileys (imgUrl direto) */
let directAvatarCount = 0;
for (const c of contacts) {
if (!c.id)
continue;
const isLid = c.id.endsWith('@lid');
const isBroadcast = c.id.includes('@broadcast');
if (isBroadcast)
continue;
// imgUrl pode vir do Baileys com valores especiais:
// - URL string: avatar disponível (salva direto)
// - 'changed': avatar foi alterado (precisa buscar novamente)
// - null/undefined: não disponível (precisa buscar via API)
const imgUrl = c.imgUrl;
// LID mapping: o history sync do Baileys traz campo `lid` nos contatos
// que associa o LID ao contato com JID real (@s.whatsapp.net)
// Ex: { id: "5511999999999@s.whatsapp.net", lid: "123456789@lid" }
const lidFromContact = c.lid;
try {
// Para contatos @lid que possuem o JID real do telefone:
// - Baileys oficial envia o campo `jid` (@s.whatsapp.net)
// - InfiniteAPI v7+ envia o campo `phoneNumber` (@s.whatsapp.net)
// Ambos são suportados — o primeiro disponível vence.
// Isso evita criar contatos duplicados (um com LID, outro com phone).
const pnJid = c.jid
?? c.phoneNumber;
const contactJid = isLid && pnJid ? pnJid : c.id;
// Quando o contato É um @lid mas foi resolvido para phoneJid:
// lidJid = c.id (o @lid original) para manter o mapeamento no banco
// Quando o contato TEM campo `lid` (history sync Baileys oficial):
// lidJid = lidFromContact
const finalLidJid = isLid && pnJid ? c.id : (lidFromContact ?? undefined);
const contact = await prisma_1.prisma.contact.upsert({
where: {
tenantId_instanceId_jid: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
},
},
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
phone: contactJid.endsWith('@lid') ? null : contactJid.split('@')[0],
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
update: {
name: this.resolveName(c) ?? null,
verifiedName: c.verifiedName ?? null,
notify: c.notify ?? null,
...(imgUrl && imgUrl !== 'changed' ? { avatarUrl: imgUrl } : {}),
...(finalLidJid ? { lidJid: finalLidJid } : {}),
},
});
// Garante vínculo Chat ↔ Contact para chats órfãos
// (chats criados antes do contato existir ficam com contactId=null)
// Usa contactJid (JID resolvido) porque chats @lid são salvos com phone JID.
// Quando c.id é @lid mas foi resolvido para pnJid, o chat no banco tem pnJid.
await prisma_1.prisma.chat.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: contactJid,
contactId: null,
},
data: { contactId: contact.id },
});
// ── Lógica de avatar ──────────────────────────────────────────────
// JIDs @lid não suportam profilePictureUrl. Usa contactJid para verificar
// pois @lid resolvido para phone JID deve buscar avatar normalmente.
const resolvedIsLid = contactJid.endsWith('@lid');
if (!resolvedIsLid) {
if (imgUrl && imgUrl !== 'changed') {
// Avatar veio pronto do Baileys — notifica frontend imediatamente
directAvatarCount++;
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid: contactJid,
avatarUrl: imgUrl,
});
}
else if (imgUrl === 'changed' || imgUrl === undefined) {
// Precisa buscar via API — adiciona à fila com rate-limit
needFetchAvatar.push(contactJid);
}
}
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao upsert contato');
}
}
logger_1.logger.debug({
instanceId: this.instanceId,
direct: directAvatarCount,
toFetch: needFetchAvatar.length,
}, '[ContactHandler] Avatares: diretos vs a buscar via API');
// Enfileira busca de avatares com cap de 50 por batch
// (randomiza para priorizar contatos diferentes a cada sync)
if (this.sock && needFetchAvatar.length > 0) {
const toQueue = needFetchAvatar.length > 50
? needFetchAvatar.sort(() => Math.random() - 0.5).slice(0, 50)
: needFetchAvatar;
this.enqueueAvatars(toQueue);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// UPDATE PARCIAL (contacts.update)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Atualiza campos parciais de contatos existentes.
*
* Dispara no evento contacts.update do Baileys — diferente do upsert,
* aqui só atualizamos os campos que foram explicitamente alterados.
* Não cria contatos novos (se não existir, ignora silenciosamente).
*/
async update(contacts) {
for (const c of contacts) {
if (!c.id)
continue;
const resolvedName = this.resolveName(c);
// Se nenhum campo relevante mudou, pula
if (!resolvedName && c.verifiedName === undefined && c.notify === undefined)
continue;
try {
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: c.id,
},
data: {
...(resolvedName !== undefined && { name: resolvedName }),
...(c.verifiedName !== undefined && { verifiedName: c.verifiedName }),
...(c.notify !== undefined && { notify: c.notify }),
},
});
}
catch (err) {
logger_1.logger.error({ err, jid: c.id }, '[ContactHandler] Erro ao atualizar contato');
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// AVATAR DA INSTÂNCIA (foto de perfil do número da sessão)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca e salva a foto de perfil do número WhatsApp da instância.
* Chamado pelo WhatsAppConnectionManager após connection 'open'.
*/
async syncInstanceAvatar(instanceId) {
if (!this.sock?.user?.id)
return null;
try {
const url = await this.sock.profilePictureUrl(this.sock.user.id, 'image');
if (url) {
await prisma_1.prisma.instance.update({ where: { id: instanceId }, data: { avatar: url } });
logger_1.logger.info({ instanceId }, '[ContactHandler] Avatar da instância atualizado');
return url;
}
}
catch {
// Avatar privado ou não disponível — normal para contas com privacidade restrita
}
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// RECONCILIAÇÃO DE NOMES VIA PUSHNAME
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos após sync do histórico.
*
* PROBLEMA: O history sync do Baileys (messaging-history.set) traz contatos
* sem nome — apenas contas business (~22 de ~3000) vêm com verifiedName.
* Os pushNames só aparecem nas mensagens, não nos contatos do sync.
*
* SOLUÇÃO: Após o sync completo (isLatest=true), busca contatos com
* name=null E notify=null, e preenche usando o pushName da mensagem
* recebida mais recente para cada JID.
*
* FILTROS: Ignora @lid, @broadcast, @newsletter, @g.us (grupos usam subject)
*
* Emite evento `contacts:reconciled` para o frontend re-buscar a lista de chats.
*/
async reconcileNamesFromMessages() {
try {
// Busca contatos "sem nome" — candidatos à reconciliação
const unnamed = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
name: null,
notify: null,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
{ jid: { endsWith: '@newsletter' } },
{ jid: { endsWith: '@g.us' } },
],
},
select: { id: true, jid: true },
});
if (unnamed.length === 0) {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Reconciliação: todos os contatos já possuem nome');
return;
}
logger_1.logger.info({ instanceId: this.instanceId, count: unnamed.length }, '[ContactHandler] Reconciliação: buscando pushNames nas mensagens');
let updated = 0;
for (const contact of unnamed) {
// Busca a mensagem recebida mais recente com pushName para este JID
const msg = await prisma_1.prisma.message.findFirst({
where: {
instanceId: this.instanceId,
remoteJid: contact.jid,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
});
if (msg?.pushName) {
await prisma_1.prisma.contact.update({
where: { id: contact.id },
data: { name: msg.pushName, notify: msg.pushName },
});
updated++;
}
}
logger_1.logger.info({ instanceId: this.instanceId, total: unnamed.length, updated }, '[ContactHandler] Reconciliação de nomes concluída');
// Notifica o frontend para atualizar a lista de chats
if (updated > 0) {
this.io?.to(`tenant:${this.tenantId}`).emit('contacts:reconciled', {
instanceId: this.instanceId,
count: updated,
});
}
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro na reconciliação de nomes');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FORCE CONTACT SYNC (resyncAppState)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Força re-sync dos contatos via app-state do Baileys.
*
* NOTA: Este método existe mas NÃO é chamado automaticamente.
* Em reconexões, o Baileys não re-envia contacts.upsert. Este método
* força o resync das app-state collections que contêm contatos.
*
* PROBLEMA CONHECIDO: a collection 'regular_low' pode falhar com
* "tried remove, but no previous op" — erro de estado corrompido
* no Baileys. Por isso foi removido do trigger automático pós-conexão.
*
* Pode ser chamado manualmente via API se necessário.
*/
async forceContactSync() {
if (!this.sock)
return;
try {
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Forçando resync de contatos via app-state');
await this.sock.resyncAppState(['critical_unblock_low', 'regular_low', 'regular'], false);
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] resyncAppState concluído');
}
catch (err) {
logger_1.logger.warn({ err, instanceId: this.instanceId }, '[ContactHandler] Erro no resyncAppState (não-fatal)');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SYNC PÓS-CONEXÃO: AVATARES AUSENTES
// ═══════════════════════════════════════════════════════════════════════════
/**
* Busca avatares ausentes para chats ativos após reconexão.
*
* Chamado pelo WhatsAppConnectionManager 5s após connection 'open'.
* Cobre o caso em que contacts.upsert não disparou (history sync timeout)
* ou contatos vieram sem imgUrl.
*
* Prioriza chats não-arquivados ordenados por lastMessageAt (mais recentes primeiro).
* Limitado a MAX_MISSING_AVATAR_SYNC (80) para não sobrecarregar a API do WA.
*/
async syncMissingAvatars() {
try {
const missing = await prisma_1.prisma.chat.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
isArchived: false,
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
contact: { avatarUrl: null },
},
select: { jid: true },
orderBy: { lastMessageAt: { sort: 'desc', nulls: 'last' } },
take: MAX_MISSING_AVATAR_SYNC,
});
if (missing.length === 0)
return;
const jids = missing.map((c) => c.jid);
logger_1.logger.info({ instanceId: this.instanceId, count: jids.length }, '[ContactHandler] Sync pós-conexão: buscando avatares ausentes');
this.enqueueAvatars(jids);
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares ausentes');
}
}
/**
* Re-faz profilePictureUrl para contatos cuja URL CDN do WhatsApp expira
* em menos de 48 horas ou já expirou.
*
* URLs do WA têm parâmetro `oe=<hex epoch>` que indica a expiração.
* Sem renovação, <img> quebra silenciosamente no browser após o TTL.
*/
async syncExpiredAvatars() {
try {
const contacts = await prisma_1.prisma.contact.findMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
avatarUrl: { not: null },
NOT: [
{ jid: { endsWith: '@lid' } },
{ jid: { contains: '@broadcast' } },
],
},
select: { jid: true, avatarUrl: true },
orderBy: { updatedAt: 'asc' },
take: 200,
});
const threshold = Math.floor(Date.now() / 1000) + 48 * 3600; // now + 48h
const expiring = contacts
.filter(c => {
const m = c.avatarUrl.match(/[?&]oe=([0-9a-f]+)/i);
if (!m)
return false;
return parseInt(m[1], 16) < threshold;
})
.map(c => c.jid);
if (expiring.length === 0)
return;
logger_1.logger.info({ instanceId: this.instanceId, count: expiring.length }, '[ContactHandler] Renovando avatares próximos de expirar');
// Limpa as URLs expiradas antes de re-buscar, para que o browser
// não use URLs inválidas enquanto a fila processa
await prisma_1.prisma.contact.updateMany({
where: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: { in: expiring },
},
data: { avatarUrl: null },
});
this.enqueueAvatars(expiring.slice(0, MAX_MISSING_AVATAR_SYNC));
}
catch (err) {
logger_1.logger.error({ err }, '[ContactHandler] Erro no sync de avatares expirados');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FILA DE AVATARES COM RATE-LIMIT
// ═══════════════════════════════════════════════════════════════════════════
/**
* Adiciona JIDs à fila de busca de avatares (com deduplicação).
*
* A fila é processada em lotes de AVATAR_BATCH_SIZE com delay de
* AVATAR_DELAY_MS entre cada lote para respeitar o rate-limit do WhatsApp.
*
* Se a fila já está sendo processada, os novos JIDs são adicionados
* e serão processados na próxima iteração do loop.
*/
enqueueAvatars(jids) {
const inQueue = new Set(this.avatarQueue);
for (const jid of jids) {
if (!inQueue.has(jid))
this.avatarQueue.push(jid);
}
this.processAvatarQueue();
}
/**
* Processa a fila de avatares com rate-limit.
*
* Usa Promise.allSettled() para que falhas individuais (foto privada,
* JID inválido) não interrompam o lote. Cada avatar encontrado é:
* 1. Salvo no campo avatarUrl do contato no banco
* 2. Emitido via Socket.IO para atualizar o frontend em tempo real
*/
async processAvatarQueue() {
// Mutex simples: apenas uma execução por vez
if (this.processingAvatars || this.avatarQueue.length === 0)
return;
this.processingAvatars = true;
logger_1.logger.info({ count: this.avatarQueue.length, instanceId: this.instanceId }, '[ContactHandler] Iniciando sync de avatares');
while (this.avatarQueue.length > 0 && this.sock) {
// Pega o próximo lote (remove da fila)
const batch = this.avatarQueue.splice(0, AVATAR_BATCH_SIZE);
await Promise.allSettled(batch.map(async (jid) => {
try {
const url = await this.sock.profilePictureUrl(jid, 'image');
if (url) {
// Persiste no banco
await prisma_1.prisma.contact.updateMany({
where: { tenantId: this.tenantId, instanceId: this.instanceId, jid },
data: { avatarUrl: url },
});
// Notifica o frontend para atualizar o avatar na UI
this.io?.to(`tenant:${this.tenantId}`).emit('contact:avatar', {
instanceId: this.instanceId,
jid,
avatarUrl: url,
});
}
}
catch {
// Foto privada ou JID inválido — ignora silenciosamente
// (não loga para evitar spam com milhares de contatos)
}
}));
// Delay entre lotes para respeitar rate-limit do WhatsApp
if (this.avatarQueue.length > 0) {
await new Promise((r) => setTimeout(r, AVATAR_DELAY_MS * AVATAR_BATCH_SIZE));
}
}
this.processingAvatars = false;
logger_1.logger.info({ instanceId: this.instanceId }, '[ContactHandler] Sync de avatares concluído');
}
}
exports.ContactHandler = ContactHandler;
+969
View File
@@ -0,0 +1,969 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.MessageHandler = void 0;
/**
* MessageHandler Processa mensagens recebidas do Baileys (tempo real e histórico).
*
* Responsabilidades:
* 1. Receber eventos `messages.upsert` do Baileys (tipo 'notify' ou 'append')
* 2. Resolver JIDs LID telefone (@lid @s.whatsapp.net)
* 3. Criar/atualizar Contact, Chat e Message no banco
* 4. Emitir eventos Socket.IO para o frontend (message:new, chat:upsert)
* 5. Download assíncrono de mídia (imagens, vídeos, docs)
* 6. Disparar chatbot para mensagens recebidas com texto
*
* CONTEXTO LID (Linked ID):
* A partir de 2024, o WhatsApp passou a usar LID como identificador primário
* nas mensagens peer-to-peer. O remoteJid chega no formato "123456@lid" em vez
* de "5511999999999@s.whatsapp.net". O telefone real está disponível em:
* - key.senderPn: campo do protocolo WA contendo o JID com telefone
* - key.participantPn: idem, usado em contextos de grupo
* - DB lookup: tabela contacts.lidJid (mapeamento persistido anteriormente)
*
* @see WhatsAppConnectionManager registra os event listeners do Baileys
* @see ContactHandler gerencia contatos e avatares
*/
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
const engine_1 = require("../engine");
const client_1 = require("@prisma/client");
const prisma_1 = require("../../../infra/database/prisma");
const logger_1 = require("../../../config/logger");
const whatsapp_1 = require("../../../shared/utils/whatsapp");
const StorageProvider_1 = require("../../../core/StorageProvider");
const hook_bus_1 = require("../../../core/hook-bus");
const chat_cache_1 = require("../../chats/chat.cache");
/** Diretório raiz onde os arquivos de mídia são salvos (organizados por instanceId) */
const MEDIA_DIR = path_1.default.resolve('./media');
/**
* Tipos de mensagem do protocolo WhatsApp que NÃO representam conteúdo real para o usuário.
* Mensagens com estes contentTypes são descartadas silenciosamente sem criar Chat nem Message.
*/
const PROTOCOL_CONTENT_TYPES = new Set([
'senderKeyDistributionMessage', // distribuição de chaves de grupo (protocolo)
'protocolMessage', // revogações, timers de desaparecimento (protocolo)
'ephemeralMessage', // wrapper de mensagem efêmera (o conteúdo real está dentro)
'deviceSentMessage', // eco de mensagem enviada de outro dispositivo (protocolo)
'messageContextInfo', // só metadados, sem conteúdo
'appStateSyncKeyShare', // sync de estado do app (protocolo)
'appStateFatalExceptionNotification', // notificação interna (protocolo)
'keepInChatMessage', // manter no chat (protocolo)
]);
/**
* Wrappers compostos do WhatsApp que aninham a mensagem real em `.message`.
* Sem desembrulhar, getContentType retorna o nome do wrapper (ex:
* `documentWithCaptionMessage` é o que o WhatsApp usa quando o usuário envia
* um PDF com legenda) e mapContentType cai em UNSUPPORTED a msg é
* descartada silenciosamente. Itera enquanto houver wrapper aninhado.
*/
const COMPOSITE_WRAPPER_TYPES = new Set([
'documentWithCaptionMessage',
'viewOnceMessage',
'viewOnceMessageV2',
'viewOnceMessageV2Extension',
'editedMessage',
'botInvokeMessage',
]);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function unwrapInnerMessage(msg) {
let cur = msg;
let depth = 0;
while (cur && depth < 5) {
const keys = Object.keys(cur).filter(k => k !== 'messageContextInfo');
const wrapperKey = keys.find(k => COMPOSITE_WRAPPER_TYPES.has(k));
if (!wrapperKey)
return cur;
const inner = cur[wrapperKey]?.message;
if (!inner || typeof inner !== 'object')
return cur;
cur = inner;
depth++;
}
return cur;
}
class MessageHandler {
sock;
instanceId;
tenantId;
io;
chatbotService;
/**
* Cache de dicas de `peer_recipient_pn` extraídas das stanzas brutas.
*
* Mapa: msgId phone JID do destinatário (ex: "556799138694@s.whatsapp.net").
*
* Contexto: quando o usuário envia uma mensagem pelo celular (fromMe=true)
* para um contato que usa LID, o WhatsApp sincroniza a mensagem pra nossa
* sessão com remoteJid=@lid. Nesse caso, `key.senderPn` descreve o DONO da
* sessão (não o destinatário) inútil pra resolução. A ÚNICA pista do
* telefone real é o atributo `peer_recipient_pn` na stanza bruta, que o
* Baileys NÃO expõe em `decode-wa-message.js`.
*
* Por isso, o WhatsAppConnectionManager registra um listener extra em
* `sock.ws.on('CB:message')` que extrai esse atributo e popula este mapa
* ANTES do Baileys emitir `messages.upsert`. Depois, `resolveLidToPhoneJid`
* consome a dica e remove a entrada (mapa efêmero, não cresce).
*/
peerRecipientHints = new Map();
constructor(
/** Socket Baileys ativo — usado para download de mídia e reupload */
sock,
/** UUID da instância WhatsApp (1 instância = 1 número conectado) */
instanceId,
/** UUID do tenant (empresa/cliente) — isolamento multi-tenant */
tenantId,
/** Socket.IO server — emite eventos em tempo real para o frontend */
io,
/** Serviço de chatbot (opcional) — processa mensagens recebidas com IA */
chatbotService) {
this.sock = sock;
this.instanceId = instanceId;
this.tenantId = tenantId;
this.io = io;
this.chatbotService = chatbotService;
}
/**
* Registra uma dica de destinatário extraída da stanza bruta.
*
* Chamado pelo listener `sock.ws.on('CB:message')` no
* WhatsAppConnectionManager. Entradas expiram em 60s como garantia
* contra vazamento caso o `messages.upsert` correspondente nunca chegue.
*/
setPeerRecipientHint(msgId, peerRecipientPn) {
this.peerRecipientHints.set(msgId, peerRecipientPn);
setTimeout(() => this.peerRecipientHints.delete(msgId), 60_000).unref();
}
/**
* Ponto de entrada principal chamado pelo event listener `messages.upsert`.
*
* @param type - 'notify' = mensagem em tempo real; 'append' = histórico sendo sincronizado
*
* Diferença entre os modos:
* - notify: processa completo (salva, emite socket, dispara chatbot, baixa mídia)
* - append: apenas salva no banco em lote (bulk insert), sem emitir eventos
*/
async handle({ messages, type }) {
if (type === 'notify') {
for (const msg of messages) {
try {
await this.processMessage(msg);
}
catch (err) {
logger_1.logger.error({ err, msgId: msg.key.id }, 'Erro ao processar mensagem');
}
}
}
else if (type === 'append') {
await this.handleHistory(messages);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// HISTÓRICO — Salva mensagens em lote durante sync inicial (sem socket/chatbot)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa mensagens históricas (history sync / append).
*
* Estratégia: agrupa mensagens por JID e faz 1 upsert de Contact + Chat + createMany
* por grupo, minimizando round-trips ao banco.
*
* Mensagens @lid são resolvidas para o JID real via senderPn ou DB lookup.
* Se não for possível resolver, a mensagem é descartada silenciosamente
* (será capturada quando o mapeamento LID for descoberto via phoneNumberShare).
*/
async handleHistory(messages) {
if (messages.length === 0)
return;
// Define o limite de 2 dias atrás em segundos (timestamp do WhatsApp usa segundos)
const doisDiasAtrasEmSegundos = Math.floor((Date.now() - 2 * 24 * 60 * 60 * 1000) / 1000);
const mensagensRecentes = [];
const mensagensAntigas = [];
// Separa as mensagens por idade
for (const msg of messages) {
const ts = Number(msg.messageTimestamp ?? 0);
if (ts >= doisDiasAtrasEmSegundos) {
mensagensRecentes.push(msg);
}
else {
mensagensAntigas.push(msg);
}
}
// 1. PROCESSAMENTO DE ALTA PRIORIDADE (Imediato)
if (mensagensRecentes.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensRecentes.length
}, '[History] Sincronizando conversas recentes (últimos 2 dias) com prioridade máxima!');
await this.processHistoryBatch(mensagensRecentes);
}
// 2. PROCESSAMENTO DE BAIXA PRIORIDADE (Gradual em segundo plano)
if (mensagensAntigas.length > 0) {
logger_1.logger.info({
instanceId: this.instanceId,
count: mensagensAntigas.length
}, '[History] Agendando histórico antigo para sincronização gradual em background...');
this.processOlderHistoryGradually(mensagensAntigas);
}
}
/**
* Executa o processamento gradual do histórico antigo em lotes de 100 msgs a cada 3 segundos
*/
processOlderHistoryGradually(messages) {
const tamanhoDoLote = 100;
const intervaloMs = 3000; // 3 segundos de folga para a CPU/Postgres respirar
let offset = 0;
const processarProximoLote = async () => {
// Se processou tudo, encerra o ciclo
if (offset >= messages.length) {
logger_1.logger.info({ instanceId: this.instanceId }, '[History] Sincronização completa de histórico antigo concluída com sucesso!');
return;
}
// Corta o próximo pedaço do histórico
const lote = messages.slice(offset, offset + tamanhoDoLote);
offset += tamanhoDoLote;
try {
await this.processHistoryBatch(lote);
}
catch (err) {
logger_1.logger.error({ err, instanceId: this.instanceId }, '[History] Erro ao processar lote gradual de histórico antigo');
}
// Agenda o próximo lote de forma não bloqueante
setTimeout(processarProximoLote, intervaloMs).unref();
};
// Inicia o sincronismo em background após 5 segundos, garantindo que
// a conexão inicial e o frontend já estejam 100% carregados e estáveis
setTimeout(processarProximoLote, 5000).unref();
}
/**
* Lógica original de agrupamento e escrita no banco (Refatorada em método auxiliar)
*/
async processHistoryBatch(messages) {
if (messages.length === 0)
return;
// JID próprio da sessão (ex: "5511999@s.whatsapp.net") — usado para filtrar self-chat
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
// Agrupa por JID para minimizar queries (1 chat upsert + 1 createMany por JID)
const byJid = new Map();
for (const msg of messages) {
let jidRaw = msg.key.remoteJid;
if (!jidRaw || jidRaw.includes('@broadcast') || jidRaw.endsWith('@newsletter') || jidRaw.startsWith('0@') || !msg.message)
continue;
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const normalizedRaw = (0, whatsapp_1.normalizeJid)(jidRaw);
if (ownJid && normalizedRaw === ownJid)
continue;
// ── Resolução LID → phone JID para mensagens históricas ──────────
// O history sync do WhatsApp pode enviar mensagens com remoteJid @lid.
// Precisamos converter para @s.whatsapp.net antes de persistir,
// caso contrário o chat ficaria duplicado (um com LID, outro com phone).
//
// Fontes de resolução (ordem de prioridade):
// 1. key.remoteJidAlt — InfiniteAPI v7+ inclui o JID alternativo (phone ↔ LID)
// 2. key.senderPn / participantPn — campo do protocolo WA
// 3. DB lookup — contacts.lidJid salvo por sessões anteriores
if (jidRaw.endsWith('@lid')) {
// 1. remoteJidAlt: InfiniteAPI preenche o JID alternativo em mensagens históricas.
// Quando remoteJid=@lid, remoteJidAlt contém @s.whatsapp.net (e vice-versa).
const remoteJidAlt = msg.key.remoteJidAlt;
if (remoteJidAlt && remoteJidAlt.endsWith('@s.whatsapp.net')) {
jidRaw = (0, whatsapp_1.normalizeJid)(remoteJidAlt);
}
else {
// 2. senderPn / participantPn
const senderPn = msg.key.senderPn || msg.key.participantPn;
if (senderPn) {
jidRaw = (0, whatsapp_1.normalizeJid)(senderPn);
if (!jidRaw.endsWith('@s.whatsapp.net'))
continue;
}
else {
// 3. DB lookup — mapeamento LID → phone salvo pelo ContactHandler
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid: jidRaw },
select: { jid: true },
});
if (contact?.jid && !contact.jid.endsWith('@lid')) {
jidRaw = contact.jid;
}
else {
// Sem resolução possível — descarta (será capturada via phoneNumberShare futuro)
continue;
}
}
}
}
const jid = (0, whatsapp_1.normalizeJid)(jidRaw);
const list = byJid.get(jid) ?? [];
list.push(msg);
byJid.set(jid, list);
}
let totalSaved = 0;
for (const [jid, msgs] of byJid) {
try {
// Calcula o timestamp mais recente para atualizar lastMessageAt do chat
const latestTs = msgs.reduce((max, m) => Math.max(max, Number(m.messageTimestamp ?? 0)), 0);
const lastAt = latestTs ? new Date(latestTs * 1000) : null;
// 1. Garantir que o Contato existe (cria sem nome — será preenchido pela reconciliação)
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
phone: jid.endsWith('@g.us') ? null : jid.split('@')[0],
},
update: {},
});
// 2. Garantir que o Chat existe e está vinculado ao Contact
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid,
contactId: contact.id,
lastMessageAt: lastAt,
unreadCount: 0
},
update: {
contactId: contact.id,
...(lastAt && { lastMessageAt: lastAt })
},
});
// 3. Prepara registros para bulk insert (createMany com skipDuplicates)
const rows = msgs.flatMap((msg) => {
const { key, messageTimestamp } = msg;
let message = msg.message;
if (!message || !key.id)
return [];
message = unwrapInnerMessage(message);
const contentType = (0, engine_1.getContentType)(message);
if (!contentType)
return [];
// Descarta tipos de protocolo/sistema que não representam conteúdo real
if (PROTOCOL_CONTENT_TYPES.has(contentType))
return [];
const msgType = this.mapContentType(contentType);
if (msgType === client_1.MessageType.UNSUPPORTED)
return [];
const ts = messageTimestamp ? new Date(Number(messageTimestamp) * 1000) : new Date();
// Extrai o corpo textual — tenta vários campos em ordem de prioridade
const body = message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
null;
const isGroup = jid.endsWith('@g.us');
return [{
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid: jid,
messageId: key.id,
fromMe: key.fromMe ?? false,
type: msgType,
body,
// pushName: nome público do remetente (só confiável em msgs recebidas)
pushName: (key.fromMe ? null : msg.pushName) ?? null,
// senderJid: em grupos, identifica quem enviou (JID do participante)
senderJid: isGroup ? (key.participant ?? null) : null,
status: 'READ', // históricas são consideradas já lidas
timestamp: ts,
}];
});
if (rows.length > 0) {
const { count } = await prisma_1.prisma.message.createMany({ data: rows, skipDuplicates: true });
totalSaved += count;
}
}
catch (err) {
logger_1.logger.error({ err, jid }, '[History] Erro ao salvar mensagens do chat');
}
}
if (totalSaved > 0) {
logger_1.logger.info({ totalSaved, chats: byJid.size, instanceId: this.instanceId }, `[History] Lote de ${totalSaved} mensagens históricas salvas`);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// TEMPO REAL — Processa uma única mensagem (notify)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Processa uma mensagem recebida em tempo real.
*
* Fluxo completo:
* 1. Filtra broadcasts, newsletters
* 2. Resolve LID telefone (se @lid)
* 3. Cria/atualiza Contact com pushName (se aplicável)
* 4. Cria/atualiza Chat vinculado ao Contact
* 5. Persiste a Message no banco
* 6. Emite socket events (message:new, chat:upsert)
* 7. Agenda download de mídia (assíncrono)
* 8. Dispara chatbot (assíncrono)
*/
async processMessage(msg) {
const { key, messageTimestamp, pushName } = msg;
let message = msg.message;
if (!message || !key.remoteJid)
return;
// Desembrulha wrappers compostos (documentWithCaptionMessage, viewOnceMessage, etc).
// Sem isso, getContentType retornaria o nome do wrapper e cairia em UNSUPPORTED.
message = unwrapInnerMessage(message);
// Ignora mensagens de status broadcast, newsletters e conta PSA do WhatsApp
if (key.remoteJid === 'status@broadcast')
return;
if (key.remoteJid.endsWith('@newsletter'))
return;
if (key.remoteJid.startsWith('0@'))
return;
// ── Resolução LID → telefone ──────────────────────────────────────────
// WhatsApp moderno usa LID (Linked ID) como remoteJid em vez de @s.whatsapp.net.
// O telefone real está em key.senderPn (quando disponível) ou no mapeamento DB.
//
// SEM esta resolução, mensagens de contatos que usam LID seriam descartadas
// e o chat apareceria com a foto/número errado no frontend (bug crítico).
//
// Prioridade de resolução:
// 1. key.senderPn — campo do protocolo WA (mais confiável, disponível ~95% dos casos)
// 2. key.participantPn — idem, usado em contextos de grupo
// 3. DB lookup — contacts.lidJid (mapeamento salvo por processamento anterior ou phoneNumberShare)
/**
* Guarda o JID @lid original quando a resolução é bem-sucedida, para
* persistir em `contacts.lidJid` mais abaixo (após o upsert do Contact).
* Tentar salvar ANTES do upsert via `updateMany` afeta 0 rows para
* contatos novos bug silencioso que fazia o mapeamento ser perdido.
*/
let originalLidJid = null;
if (key.remoteJid.endsWith('@lid')) {
const phoneJid = await this.resolveLidToPhoneJid(key.remoteJid, key);
if (!phoneJid) {
// Sem resolução: não há como saber o número real do contato.
// A mensagem será perdida, mas o mapeamento será capturado no futuro
// via chats.phoneNumberShare e as próximas mensagens serão processadas.
logger_1.logger.warn({ remoteJid: key.remoteJid, instanceId: this.instanceId, fromMe: key.fromMe }, '[MessageHandler] @lid sem resolução de telefone — descartando');
return;
}
originalLidJid = key.remoteJid;
key.remoteJid = phoneJid;
}
const contentType = (0, engine_1.getContentType)(message);
if (!contentType)
return;
// Descarta tipos de protocolo/sistema (sem conteúdo real para o usuário)
if (PROTOCOL_CONTENT_TYPES.has(contentType))
return;
const msgType = this.mapContentType(contentType);
// Descarta tipos completamente desconhecidos — evita poluir o DB com UNSUPPORTED
if (msgType === client_1.MessageType.UNSUPPORTED)
return;
const fromMe = key.fromMe ?? false;
const remoteJidRaw = key.remoteJid;
/** JID normalizado (sem sufixo de dispositivo :1, :2 etc) */
const remoteJid = (0, whatsapp_1.normalizeJid)(remoteJidRaw);
const messageId = key.id;
// Ignora mensagens de/para o próprio número da sessão (self-chat / notas pessoais)
const ownJid = (0, whatsapp_1.normalizeJid)(this.sock.user?.id ?? '');
if (ownJid && remoteJid === ownJid)
return;
// ── 1. Contact: criar ou atualizar ────────────────────────────────────
// REGRA IMPORTANTE sobre pushName:
// - pushName é o "nome público" que o remetente escolheu no WhatsApp
// - Para mensagens RECEBIDAS (fromMe=false) de contatos individuais: é confiável
// - Para mensagens ENVIADAS (fromMe=true): pushName é o nome da NOSSA sessão
// → NUNCA sobrescrever o nome do contato destinatário com isso ("efeito espelho")
// - Para GRUPOS: pushName é do remetente individual, não do grupo
// → O nome do grupo vem de groups.upsert → Chat.name (subject)
const isGroupMessage = remoteJid.endsWith('@g.us');
const participantJid = key.participant ?? msg.participant ?? null;
const safePushName = (!fromMe && !isGroupMessage && pushName) ? pushName : null;
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
name: safePushName,
notify: safePushName,
phone: isGroupMessage ? null : remoteJid.split('@')[0],
// Se resolvemos a partir de @lid, persiste o mapeamento já na criação.
// Sem isso, o próximo @lid do mesmo contato falharia o DB lookup.
...(originalLidJid && { lidJid: originalLidJid }),
},
update: {
// Atualiza apenas pushname (notify) — preserva name se já veio de contacts.upsert
...(safePushName && { notify: safePushName }),
// Garante que o lidJid fique salvo mesmo em contatos pré-existentes
// que ainda não tinham o mapeamento (ex: criados via history sync).
...(originalLidJid && { lidJid: originalLidJid }),
},
});
// ── 2. Chat: criar ou atualizar, vinculando ao Contact ────────────────
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId: this.tenantId, instanceId: this.instanceId, jid: remoteJid } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
jid: remoteJid,
contactId: contact.id,
unreadCount: fromMe ? 0 : 1,
lastMessageAt: new Date(messageTimestamp * 1000),
},
update: {
contactId: contact.id, // Garante o vínculo (corrige chats órfãos antigos)
lastMessageAt: new Date(messageTimestamp * 1000),
// fromMe: zera unread (estamos respondendo); !fromMe: incrementa
unreadCount: fromMe ? { set: 0 } : { increment: 1 },
},
});
// ── 3. Extrai corpo textual da mensagem ───────────────────────────────
// nativeFlowResponseMessage: resposta ao clique de botão/lista
let nativeFlowBody = null;
if (message.interactiveResponseMessage?.nativeFlowResponseMessage) {
try {
const params = JSON.parse(message.interactiveResponseMessage.nativeFlowResponseMessage.paramsJson ?? '{}');
nativeFlowBody = params.display_text ?? params.title ?? params.id ?? null;
}
catch { /* ignora JSON inválido */ }
}
const body = message.conversation ??
message.extendedTextMessage?.text ??
message.imageMessage?.caption ??
message.videoMessage?.caption ??
message.documentMessage?.caption ??
nativeFlowBody ??
null;
// ── 4. Contexto de reply (se for resposta a outra mensagem) ───────────
const replyToWaId = message.extendedTextMessage?.contextInfo?.stanzaId ?? null;
let replyToId = null;
if (replyToWaId) {
const replyMsg = await prisma_1.prisma.message.findUnique({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId: replyToWaId } },
});
replyToId = replyMsg?.id ?? null;
}
// ── 5. Persiste a mensagem (mídia será baixada depois) ────────────────
const saved = await prisma_1.prisma.message.upsert({
where: { instanceId_messageId: { instanceId: this.instanceId, messageId } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
remoteJid,
messageId,
fromMe,
type: msgType,
body,
pushName: fromMe ? null : (pushName ?? null),
senderJid: isGroupMessage ? participantJid : null,
replyToId,
status: fromMe ? 'SENT' : 'PENDING',
timestamp: new Date(messageTimestamp * 1000),
// Armazena payload da mídia para permitir re-download posterior
...(this.isMediaMessage(contentType) ? {
mediaPayload: this.buildMediaPayload(key, message),
} : {}),
},
update: {}, // Se já existe (duplicata), não sobrescreve
});
// ── 6. Emite eventos Socket.IO ────────────────────────────────────────
// 6a. Para quem está com o chat aberto (sala chat:{id})
// Inclui replyTo quando é uma resposta — o frontend precisa para exibir
// a caixa de citação (quoted block) em tempo real, sem precisar recarregar.
const emitMsg = replyToId
? await prisma_1.prisma.message.findUnique({
where: { id: saved.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? saved
: saved;
this.io.to(`chat:${chat.id}`).emit('message:new', {
...emitMsg,
pushName,
senderJid: isGroupMessage ? participantJid : null,
});
// ext: bridge — satélites externos recebem via WS /api/ext/v1/stream
hook_bus_1.hookBus.emit('ext:message.new', {
instanceId: this.instanceId,
tenantId: this.tenantId,
chatId: chat.id,
message: { ...emitMsg, pushName, senderJid: isGroupMessage ? participantJid : null },
timestamp: Date.now(),
}).catch(() => { });
// 6b. Para TODOS os clientes do tenant (atualiza a lista de chats)
// Necessário para: novos chats, chats não abertos, badge de unread
this.emitChatUpsert(chat.id, { body, fromMe, status: saved.status, type: msgType, timestamp: saved.timestamp });
// ── 7. Download de mídia assíncrono (não bloqueia o event loop) ────────
if (this.isMediaMessage(contentType)) {
// Passa msg com message desembrulhada (necessário p/ documentWithCaptionMessage etc).
const msgUnwrapped = { ...msg, message };
setImmediate(() => this.downloadAndPersistMedia(msgUnwrapped, saved.id, chat.id, contentType));
}
// ── 8. Dispara chatbot para mensagens recebidas com texto ─────────────
if (!fromMe && body && this.chatbotService) {
setImmediate(() => this.chatbotService.handleIncoming({
tenantId: this.tenantId,
instanceId: this.instanceId,
chatId: chat.id,
jid: remoteJid,
text: body,
sock: this.sock,
}).catch((err) => logger_1.logger.error({ err }, '[Chatbot] Erro assíncrono')));
}
}
// ═══════════════════════════════════════════════════════════════════════════
// RESOLUÇÃO LID → TELEFONE
// ═══════════════════════════════════════════════════════════════════════════
/**
* Converte um JID LID (ex: "123456789@lid") para o JID com telefone
* (ex: "5511999999999@s.whatsapp.net").
*
* O WhatsApp moderno usa LID (Linked ID) como identificador interno.
* Para exibir o contato corretamente, precisamos mapear para o telefone real.
*
* Fontes de resolução (em ordem de prioridade):
* 1. key.senderPn / key.participantPn campo do protocolo WA.
* Útil para mensagens RECEBIDAS e contextos de grupo.
* 2. peerRecipientHints[msgId] dica extraída da stanza bruta
* (atributo `peer_recipient_pn`, não exposto pelo Baileys).
* Única fonte confiável para mensagens fromMe=true enviadas pelo
* celular pra um contato @lid que ainda não temos mapeado.
* 3. DB lookup contacts.lidJid (mapeamento persistido anteriormente).
* Populado por: processamento anterior, chats.phoneNumberShare,
* ou contacts.upsert do history sync.
*
* A persistência do mapeamento LIDphone foi movida para processMessage
* (após o upsert do Contact), para evitar o bug anterior onde o
* `updateMany` disparado aqui afetava 0 rows em contatos inexistentes.
*
* @param lidJid - O JID no formato @lid (ex: "123456789@lid")
* @param key - WAMessageKey (id, fromMe, senderPn, participantPn)
* @returns O JID @s.whatsapp.net correspondente, ou null se não resolver
*/
async resolveLidToPhoneJid(lidJid, key) {
// 1. senderPn vem direto do protocolo WhatsApp (mais confiável)
// OBS: em mensagens fromMe=true sincronizadas do celular, senderPn
// descreve o DONO da sessão, não o destinatário — inútil aqui.
const senderPn = key.senderPn || key.participantPn;
if (senderPn && !key.fromMe) {
const phoneJid = (0, whatsapp_1.normalizeJid)(senderPn);
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid;
}
}
// 2. Dica do peer_recipient_pn (stanza bruta) — fonte primária para
// mensagens fromMe=true que chegam via sync de outro dispositivo.
if (key.fromMe && key.id) {
const hint = this.peerRecipientHints.get(key.id);
this.peerRecipientHints.delete(key.id); // uso único
if (hint) {
const phoneJid = (0, whatsapp_1.normalizeJid)(hint);
if (phoneJid && phoneJid.endsWith('@s.whatsapp.net')) {
return phoneJid;
}
}
}
// 3. Busca no DB por mapeamento LID salvo anteriormente
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId: this.tenantId, instanceId: this.instanceId, lidJid },
select: { jid: true },
});
if (contact?.jid)
return contact.jid;
return null;
}
// ═══════════════════════════════════════════════════════════════════════════
// EMISSÃO DE EVENTOS SOCKET.IO
// ═══════════════════════════════════════════════════════════════════════════
/**
* Emite evento `chat:upsert` para todos os clientes do tenant.
*
* Usado para atualizar a lista de chats no frontend em tempo real:
* - Novos chats aparecem instantaneamente
* - Chats existentes atualizam lastMessage, unreadCount, etc.
* - Chats @lid são filtrados (não devem aparecer no frontend)
*
* O frontend usa este evento para reordenar a lista por lastMessageAt
* e exibir o preview da última mensagem.
*/
async emitChatUpsert(chatId, lastMessage) {
try {
const chat = await prisma_1.prisma.chat.findUnique({
where: { id: chatId },
include: {
contact: {
select: {
name: true,
verifiedName: true,
notify: true,
phone: true,
avatarUrl: true,
scoreReputacao: true,
flagRestricao: true,
},
},
},
});
if (!chat)
return;
// Chats @lid são artefatos internos — nunca expor ao frontend
if (chat.jid.endsWith('@lid'))
return;
const c = chat.contact;
const isGroup = chat.jid.endsWith('@g.us');
// Regra de resolução de nome:
// Grupos: usa Chat.name (subject do grupo, vem de groups.upsert)
// Individuais: name (agenda) > verifiedName (business) > notify (pushname)
// NUNCA usar pushName do remetente como nome de grupo
const displayName = isGroup
? (chat.name ?? null)
: (c?.name ?? c?.verifiedName ?? c?.notify ?? null);
this.io.to(`tenant:${this.tenantId}`).emit('chat:upsert', {
id: chat.id,
tenantId: chat.tenantId,
instanceId: chat.instanceId,
jid: chat.jid,
name: chat.name ?? null,
unreadCount: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
isPinned: chat.isPinned,
isArchived: chat.isArchived,
botPaused: chat.botPaused,
contact: c
? {
name: displayName,
phone: c.phone,
avatarUrl: c.avatarUrl,
scoreReputacao: c.scoreReputacao,
flagRestricao: c.flagRestricao,
}
: isGroup
? { name: displayName, phone: null, avatarUrl: null, scoreReputacao: 0, flagRestricao: false }
: null,
lastMessage,
});
// Invalida cache da listagem — força refetch na próxima request da inbox
(0, chat_cache_1.invalidateChatListCache)(this.tenantId, this.instanceId).catch(() => { });
}
catch (err) {
logger_1.logger.error({ err, chatId }, '[MessageHandler] Falha ao emitir chat:upsert');
}
}
// ═══════════════════════════════════════════════════════════════════════════
// DOWNLOAD DE MÍDIA
// ═══════════════════════════════════════════════════════════════════════════
/**
* Baixa o conteúdo de mídia de uma mensagem e salva no disco.
*
* Executado via setImmediate() para não bloquear o event loop.
* Após salvar, atualiza o registro da mensagem com mediaPath/mediaUrl
* e emite `message:media_ready` para o frontend renderizar o preview.
*
* Organização: /media/{instanceId}/{messageId}.{ext}
*/
async downloadAndPersistMedia(msg, savedMsgId, chatId, contentType) {
try {
const buffer = await (0, engine_1.downloadMediaMessage)(msg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: this.sock.updateMediaMessage });
if (!buffer)
return;
// Para documentos: preserva nome e extensão originais do arquivo
const docMsg = msg.message?.documentMessage;
const originalFileName = docMsg?.fileName?.trim() || null;
const originalMimetype = docMsg?.mimetype || this.mimetypeForType(contentType);
let ext;
if (contentType === 'documentMessage' && originalFileName) {
ext = path_1.default.extname(originalFileName).replace('.', '') || 'bin';
}
else {
ext = this.extensionForType(contentType);
}
const fileName = `${savedMsgId}.${ext}`;
const filePath = path_1.default.join(MEDIA_DIR, this.instanceId, fileName);
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
await promises_1.default.writeFile(filePath, buffer);
// Tenta upload para Wasabi; se falhar usa path local como fallback
let mediaUrl = `/media/${this.instanceId}/${fileName}`;
let mediaPath = filePath;
let uploadedToRemote = false;
if (StorageProvider_1.storageProvider.isRegistered()) {
try {
const result = await StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: this.tenantId,
sessionJID: this.instanceId,
file: {
originalname: originalFileName ?? fileName,
buffer: buffer,
mimetype: originalMimetype,
},
});
mediaUrl = result.path;
if (result.provider !== 'local_fallback') {
mediaPath = null;
uploadedToRemote = true;
}
logger_1.logger.debug({ savedMsgId, path: result.path, provider: result.provider }, 'Mídia enviada ao storage');
}
catch (uploadErr) {
logger_1.logger.warn({ uploadErr, savedMsgId }, 'Upload Wasabi falhou, usando path local');
}
}
await prisma_1.prisma.message.update({
where: { id: savedMsgId },
data: {
mediaPath,
mediaUrl,
// Preserva o nome original do documento para exibição no frontend
...(contentType === 'documentMessage' && originalFileName
? { fileName: originalFileName }
: {}),
},
});
if (uploadedToRemote) {
promises_1.default.unlink(filePath).catch((err) => {
logger_1.logger.error({ err, filePath }, 'Erro ao deletar arquivo local após upload remoto');
});
}
// Notifica frontend que a mídia está pronta para exibição
this.io.to(`chat:${chatId}`).emit('message:media_ready', {
messageId: savedMsgId,
mediaUrl,
});
// Persiste sticker na biblioteca (deduplicado por sha256)
if (contentType === 'stickerMessage') {
this.persistSticker(msg, mediaUrl).catch(() => { });
}
logger_1.logger.debug({ savedMsgId, filePath }, 'Mídia salva');
}
catch (err) {
logger_1.logger.error({ err, savedMsgId }, 'Falha ao baixar mídia');
}
}
async persistSticker(msg, wasabiPath) {
const stickerMsg = msg.message?.stickerMessage;
if (!stickerMsg)
return;
const sha256Bytes = stickerMsg.fileSha256;
if (!sha256Bytes || sha256Bytes.length === 0)
return;
const sha256 = Buffer.from(sha256Bytes).toString('hex');
const isAnimated = stickerMsg.isAnimated ?? false;
await prisma_1.prisma.sticker.upsert({
where: { tenantId_sha256: { tenantId: this.tenantId, sha256 } },
create: {
tenantId: this.tenantId,
instanceId: this.instanceId,
sha256,
wasabiPath,
isAnimated,
useCount: 1,
lastSeenAt: new Date(),
},
update: {
useCount: { increment: 1 },
lastSeenAt: new Date(),
instanceId: this.instanceId,
},
});
}
// ═══════════════════════════════════════════════════════════════════════════
// PAYLOAD DE MÍDIA (serialização para re-download)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Constrói o payload serializado da mensagem de mídia para armazenamento.
* Uint8Array e Buffer são convertidos para { _b64: base64 } para sobreviver ao JSON.
* jpegThumbnail é descartado para economizar espaço (não necessário para re-download).
*/
buildMediaPayload(key, innerMessage) {
const stripped = this.stripThumbnails(innerMessage);
return {
key: { remoteJid: key.remoteJid, fromMe: key.fromMe, id: key.id },
message: this.serializeForStorage(stripped),
};
}
stripThumbnails(obj) {
if (!obj || typeof obj !== 'object')
return obj;
const result = {};
for (const [k, v] of Object.entries(obj)) {
if (k === 'jpegThumbnail' || k === 'thumbnailDirectPath' || k === 'thumbnailSha256' || k === 'thumbnailEncSha256')
continue;
result[k] = v;
}
return result;
}
serializeForStorage(val) {
if (val instanceof Uint8Array || Buffer.isBuffer(val)) {
return { _b64: Buffer.from(val).toString('base64') };
}
if (Array.isArray(val))
return val.map(v => this.serializeForStorage(v));
if (val !== null && typeof val === 'object') {
// Long de protobuf — converte para número
if (typeof val.toNumber === 'function')
return val.toNumber();
return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, this.serializeForStorage(v)]));
}
return val;
}
// ═══════════════════════════════════════════════════════════════════════════
// UTILITÁRIOS
// ═══════════════════════════════════════════════════════════════════════════
/** Verifica se o contentType é de mídia (precisa de download) */
isMediaMessage(contentType) {
return ['imageMessage', 'videoMessage', 'audioMessage', 'documentMessage', 'stickerMessage'].includes(contentType);
}
/**
* Mapeia o contentType do Baileys para o enum MessageType do Prisma.
* Tipos não reconhecidos são mapeados para UNSUPPORTED.
*/
mapContentType(contentType) {
const map = {
conversation: client_1.MessageType.TEXT,
extendedTextMessage: client_1.MessageType.TEXT,
imageMessage: client_1.MessageType.IMAGE,
videoMessage: client_1.MessageType.VIDEO,
audioMessage: client_1.MessageType.AUDIO,
documentMessage: client_1.MessageType.DOCUMENT,
stickerMessage: client_1.MessageType.STICKER,
locationMessage: client_1.MessageType.LOCATION,
contactMessage: client_1.MessageType.CONTACT,
reactionMessage: client_1.MessageType.REACTION,
pollCreationMessage: client_1.MessageType.POLL,
pollCreationMessageV2: client_1.MessageType.POLL, // poll multi-select grupos
pollCreationMessageV3: client_1.MessageType.POLL, // poll single-select
// Resposta ao clique de botão / seleção de item de lista
interactiveResponseMessage: client_1.MessageType.INTERACTIVE,
// Mensagem interativa recebida de outro remetente (botões/lista/carrossel)
interactiveMessage: client_1.MessageType.BUTTONS,
};
return map[contentType] ?? client_1.MessageType.UNSUPPORTED;
}
/** Retorna a extensão de arquivo adequada para o tipo de mídia */
extensionForType(contentType) {
const map = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
};
return map[contentType] ?? 'bin';
}
/** Retorna o mimetype adequado para o tipo de mídia */
mimetypeForType(contentType) {
const map = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
};
return map[contentType] ?? 'application/octet-stream';
}
}
exports.MessageHandler = MessageHandler;
@@ -0,0 +1,27 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.InstanceRepository = void 0;
const prisma_1 = require("../../../infra/database/prisma");
class InstanceRepository {
async findAllByTenant(tenantId) {
return prisma_1.prisma.instance.findMany({
where: { tenantId },
orderBy: { createdAt: 'asc' },
});
}
async findById(id) {
return prisma_1.prisma.instance.findUnique({ where: { id } });
}
async create(tenantId, name) {
return prisma_1.prisma.instance.create({
data: { tenantId, name },
});
}
async updateStatus(id, status) {
return prisma_1.prisma.instance.update({ where: { id }, data: { status } });
}
async delete(id) {
return prisma_1.prisma.instance.delete({ where: { id } });
}
}
exports.InstanceRepository = InstanceRepository;
+308
View File
@@ -0,0 +1,308 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.sendRichMessage = sendRichMessage;
/**
* rich-message.ts Construção e envio de mensagens ricas via Baileys padrão
*
* O pacote @whiskeysockets/baileys não suporta os campos `nativeButtons`,
* `nativeList` e `nativeCarousel` que são específicos do fork InfiniteAPI.
* Este módulo replica a lógica de transformação do InfiniteAPI para que
* o motor newwhats possa enviar botões, listas e carrosséis usando o
* sock.relayMessage() com o proto correto.
*
* Referência: github:rsalcara/InfiniteAPI lib/Socket/messages-send.js
*
* IMPORTANTE: O WhatsApp Web NÃO suporta viewOnceMessage para mensagens interativas.
* Por isso usamos interactiveMessage DIRETO (sem wrapper viewOnce) + additionalNodes.
*
* O que é necessário para botões/listas/carrosséis funcionarem no WhatsApp
* (celular E web):
*
* 1. interactiveMessage direto (NÃO viewOnceMessage) viewOnce não carrega no Web
*
* 2. adicional <biz><interactive type="native_flow" v="1"><native_flow v="9" name="mixed"/></interactive></biz>
* injetado no stanza via relayMessage(additionalNodes)
* (sem isso o WhatsApp Web exibe "Não foi possível carregar")
*
* 3. <bot biz_bot="1"/> para chats 1-a-1
* (necessário para interactive messages em conversas privadas)
*
* Lista (single_select) também usa o mesmo fluxo InfiniteAPI converte
* o viewOnceMessage > nativeFlowMessage de volta para listMessage no relayMessage,
* mas testamos que a forma direta com biz > list também funciona.
*/
const engine_1 = require("./engine");
// ─── Converte um botão nativo para o formato buttonParamsJson do WhatsApp ─────
function formatNativeFlowButton(btn) {
switch (btn.type) {
case 'url':
return {
name: 'cta_url',
buttonParamsJson: JSON.stringify({
display_text: btn.text,
url: btn.url,
merchant_url: btn.url,
}),
};
case 'copy':
return {
name: 'cta_copy',
buttonParamsJson: JSON.stringify({
display_text: btn.text,
copy_code: btn.copyText,
}),
};
case 'reply':
return {
name: 'quick_reply',
buttonParamsJson: JSON.stringify({
display_text: btn.text,
id: btn.id,
}),
};
case 'call':
return {
name: 'cta_call',
buttonParamsJson: JSON.stringify({
display_text: btn.text,
phone_number: btn.phoneNumber,
}),
};
}
}
// ─── Nó biz > interactive > native_flow (obrigatório para renderizar no Web) ──
const BIZ_NATIVE_FLOW_NODE = {
tag: 'biz',
attrs: {},
content: [
{
tag: 'interactive',
attrs: { type: 'native_flow', v: '1' },
content: [
{
tag: 'native_flow',
attrs: { v: '9', name: 'mixed' },
},
],
},
],
};
// ─── Nó biz para listMessage legado (formato que renderiza no Web) ───────────
// InfiniteAPI usa <biz><list type="product_list" v="2"/></biz> com proto.listMessage
// (não interactiveMessage). Comentário do fork: "Modern format causes rejection
// (error 479) — Legacy format works on all platforms".
const BIZ_LIST_NODE = {
tag: 'biz',
attrs: {},
content: [
{
tag: 'list',
attrs: { type: 'product_list', v: '2' },
},
],
};
// ─── Nó bot (necessário para chats 1-a-1 com interactive messages) ────────────
const BOT_NODE = {
tag: 'bot',
attrs: { biz_bot: '1' },
};
// ─── Helper: jid é chat privado (não grupo, não status, não bot) ─────────────
function isPrivateChat(jid) {
return (!(0, engine_1.isJidGroup)(jid) &&
!(0, engine_1.isJidStatusBroadcast)(jid) &&
!jid.includes('newsletter') &&
!jid.includes('broadcast'));
}
// ─── Helper: envia proto pré-construído pelo MESMO caminho do sendMessage ────
// Diferença crítica vs `sock.relayMessage` direto:
// sendMessage() → generateWAMessage() → WAProto.Message.fromObject() → relayMessage()
// ↑ normalização proto que o WA Web exige
// Sem esse passo, o Web descarta a mensagem ("Não foi possível carregar").
// É o mesmo motivo pelo qual nossas enquetes (via sendMessage) funcionam no Web
// e os botões (via relayMessage direto) não funcionavam.
async function sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted) {
const fullMsg = (0, engine_1.generateWAMessageFromContent)(jid, protoMsg, {
userJid: sock.user?.id,
messageId: msgId,
quoted,
});
await sock.relayMessage(jid, fullMsg.message, {
messageId: fullMsg.key.id,
additionalNodes,
});
return fullMsg.key.id;
}
// ─── Helper: monta interactiveMessage DIRETO (sem viewOnceMessage wrapper) ────
// viewOnceMessage wrapper faz o WhatsApp Web exibir "Não foi possível carregar":
// o Web trata viewOnce como mensagem efêmera e recusa conteúdo interativo dentro.
// Baileys padrão não adiciona messageContextInfo para interactiveMessage
// (só faz isso para poll, com messageSecret). Usar interactiveMessage direto
// + nó <biz> no additionalNodes é o caminho correto com @whiskeysockets/baileys.
function makeInteractiveProto(interactiveMsg) {
return {
interactiveMessage: interactiveMsg,
};
}
/**
* Envia uma mensagem rica pelo socket Baileys padrão.
*
* - Buttons / CTAs: interactiveMessage direto + biz>interactive>native_flow + bot
* - Carousel: interactiveMessage direto + biz>interactive>native_flow
* - List: interactiveMessage direto + biz>interactive>native_flow + bot
* - Poll: sock.sendMessage (requer encriptação especial do Baileys)
* - Text: sock.sendMessage normal
*
* Retorna o messageId gerado.
*/
async function sendRichMessage(sock, jid, payload, quoted, engineType) {
const { text, footer, nativeButtons, nativeList, nativeCarousel, poll } = payload;
const engine = engineType ?? process.env.BAILEYS_ENGINE ?? 'infinite';
// Se a engine ativa for 'infinite', utilizamos o suporte nativo do fork InfiniteAPI
if (engine === 'infinite') {
// ── Poll: delegado ao sendMessage padrão (requer encriptação especial do Baileys) ──
if (poll) {
const sent = await sock.sendMessage(jid, {
poll: {
name: poll.name,
values: poll.values,
selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length),
},
}, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `poll-${Date.now()}`;
}
// ── Botões / CTAs nativos (InfiniteAPI) ──
if (nativeButtons) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeButtons,
}, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `btn-${Date.now()}`;
}
// ── Carrossel nativo (InfiniteAPI) ──
if (nativeCarousel) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeCarousel,
}, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `carousel-${Date.now()}`;
}
// ── Lista nativa (InfiniteAPI) ──
if (nativeList) {
const sent = await sock.sendMessage(jid, {
text: text ?? '',
footer,
nativeList,
}, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `list-${Date.now()}`;
}
// ── Texto simples (InfiniteAPI) ──
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `text-${Date.now()}`;
}
// FALLBACK: Lógica de montagem manual de nós binários para o Baileys oficial
// ── Poll: delegado ao sendMessage padrão (requer messageSecret) ───────────
if (poll) {
const sent = await sock.sendMessage(jid, {
poll: {
name: poll.name,
values: poll.values,
selectableCount: Math.min(Math.max(1, poll.selectableCount ?? 1), poll.values.length),
},
}, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `poll-${Date.now()}`;
}
// ── Texto simples: delegado ao sendMessage padrão ─────────────────────────
if (!nativeButtons && !nativeList && !nativeCarousel) {
const sent = await sock.sendMessage(jid, { text: text ?? '' }, quoted ? { quoted } : undefined);
return sent?.key?.id ?? `text-${Date.now()}`;
}
const msgId = (0, engine_1.generateMessageIDV2)(sock.user?.id);
const isPrivate = isPrivateChat(jid);
// ── Botões / CTAs: interactiveMessage + nativeFlowMessage ────────────────────
if (nativeButtons) {
const formattedButtons = nativeButtons.map(formatNativeFlowButton);
const protoMsg = makeInteractiveProto({
body: { text: text ?? '' },
footer: footer ? { text: footer } : undefined,
header: { title: '', subtitle: '', hasMediaAttachment: false },
nativeFlowMessage: {
buttons: formattedButtons,
messageParamsJson: JSON.stringify({}),
messageVersion: 2,
},
});
const additionalNodes = [BIZ_NATIVE_FLOW_NODE];
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
}
// ── Carrossel: viewOnceMessage > interactiveMessage > carouselMessage ─────
if (nativeCarousel) {
const cards = nativeCarousel.cards;
if (cards.length < 2)
throw new Error('Carrossel requer no mínimo 2 cards');
if (cards.length > 10)
throw new Error('Carrossel suporta no máximo 10 cards');
const carouselCards = cards.map((card) => {
const rawButtons = card.buttons ?? [];
const cardButtons = rawButtons.length > 0
? rawButtons.map((btn) => formatNativeFlowButton({
type: btn.type ?? 'reply',
id: btn.id ?? btn.text.toLowerCase().replace(/\s+/g, '_'),
text: btn.text,
}))
: [{ name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: 'Ver mais', id: 'see_more' }) }];
return {
header: {
title: card.title ?? '',
subtitle: '',
hasMediaAttachment: !!(card.image?.url),
},
body: { text: card.body ?? '' },
footer: card.footer ? { text: card.footer } : undefined,
nativeFlowMessage: {
buttons: cardButtons,
messageParamsJson: JSON.stringify({}),
},
};
});
const protoMsg = makeInteractiveProto({
body: { text: text ?? '' },
footer: footer ? { text: footer } : undefined,
header: { title: '', subtitle: '', hasMediaAttachment: false },
carouselMessage: {
cards: carouselCards,
messageVersion: 1,
},
});
const additionalNodes = [BIZ_NATIVE_FLOW_NODE];
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
}
// ── Lista: proto.listMessage LEGADO + nó <biz><list type="product_list" v="2"/></biz> ──
if (nativeList) {
const protoMsg = {
listMessage: {
title: '',
description: text ?? '',
buttonText: nativeList.buttonText,
footerText: footer ?? '',
listType: 2,
sections: nativeList.sections.map((s) => ({
title: s.title,
rows: s.rows.map((r) => ({
rowId: r.id,
title: r.title,
description: r.description ?? '',
})),
})),
},
};
const additionalNodes = [BIZ_LIST_NODE];
if (isPrivate)
additionalNodes.push(BOT_NODE);
return await sendBuiltProto(sock, jid, protoMsg, msgId, additionalNodes, quoted);
}
// Fallback (não deve chegar aqui)
const sent = await sock.sendMessage(jid, { text: text ?? '' });
return sent?.key?.id ?? `fallback-${Date.now()}`;
}
+107
View File
@@ -0,0 +1,107 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.WhatsAppWarmer = void 0;
/**
* WhatsApp Warmer aquecimento gradual de número novo.
*
* Envia mensagens em intervalos aleatórios crescentes para simular
* uso humano e reduzir risco de ban em números recém-criados.
*/
const logger_1 = require("../../../config/logger");
const MESSAGES = [
'Olá! Tudo bem?',
'Oi, como vai?',
'Boa tarde!',
'Só passando para dizer oi 😊',
'Tudo certo por aí?',
'Bom dia! 🌤',
'E aí, tudo bem?',
'Boa noite!',
];
function randomBetween(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function randomMessage() {
return MESSAGES[Math.floor(Math.random() * MESSAGES.length)];
}
class WhatsAppWarmer {
manager;
sessions = new Map();
constructor(manager) {
this.manager = manager;
}
start(config) {
if (this.sessions.has(config.instanceId)) {
logger_1.logger.warn({ instanceId: config.instanceId }, 'Warmer já em execução para esta instância');
return;
}
const session = {
config,
dayCount: 0,
sentToday: 0,
timer: null,
};
this.sessions.set(config.instanceId, session);
logger_1.logger.info({ instanceId: config.instanceId, totalDays: config.totalDays }, '🔥 Warmer iniciado');
this.scheduleNext(config.instanceId);
}
stop(instanceId) {
const session = this.sessions.get(instanceId);
if (!session)
return;
if (session.timer)
clearTimeout(session.timer);
this.sessions.delete(instanceId);
logger_1.logger.info({ instanceId }, 'Warmer encerrado');
}
scheduleNext(instanceId) {
const session = this.sessions.get(instanceId);
if (!session)
return;
const { config, dayCount, sentToday } = session;
// Limite diário cresce linearmente: day 1 = 10%, day N = 100%
const progressRatio = Math.min(1, (dayCount + 1) / config.totalDays);
const limitHoje = Math.ceil(config.dailyMessageLimit * progressRatio);
if (sentToday >= limitHoje) {
// Dia completo — aguarda até o próximo dia (24h + jitter)
const nextDayMs = 24 * 60 * 60 * 1000 + randomBetween(0, 30 * 60 * 1000);
logger_1.logger.debug({ instanceId, dayCount, sentToday }, 'Limite diário atingido — aguardando próximo dia');
if (dayCount + 1 >= config.totalDays) {
logger_1.logger.info({ instanceId }, '✅ Aquecimento concluído!');
this.sessions.delete(instanceId);
return;
}
session.dayCount += 1;
session.sentToday = 0;
session.timer = setTimeout(() => this.scheduleNext(instanceId), nextDayMs);
return;
}
// Intervalo aleatório entre mensagens (15 minutos com jitter)
const delayMs = randomBetween(60_000, 5 * 60_000);
session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), delayMs);
}
async sendWarmerMessage(instanceId) {
const session = this.sessions.get(instanceId);
if (!session)
return;
const sock = this.manager.getSocket(instanceId);
if (!sock) {
logger_1.logger.warn({ instanceId }, 'Warmer: socket não encontrado — pausando');
session.timer = setTimeout(() => this.sendWarmerMessage(instanceId), 60_000);
return;
}
const targets = session.config.targets;
const target = targets[Math.floor(Math.random() * targets.length)];
const text = randomMessage();
try {
await sock.sendMessage(target, { text });
session.sentToday += 1;
logger_1.logger.debug({ instanceId, target, sentToday: session.sentToday }, 'Warmer: mensagem enviada');
}
catch (err) {
logger_1.logger.error({ err, instanceId, target }, 'Warmer: falha ao enviar mensagem');
}
this.scheduleNext(instanceId);
}
}
exports.WhatsAppWarmer = WhatsAppWarmer;
+372
View File
@@ -0,0 +1,372 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildMediaRoutes = buildMediaRoutes;
const path_1 = __importDefault(require("path"));
const promises_1 = __importDefault(require("fs/promises"));
const express_1 = require("express");
const multer_1 = __importDefault(require("multer"));
const engine_1 = require("./engine");
const prisma_1 = require("../../infra/database/prisma");
const logger_1 = require("../../config/logger");
const StorageProvider_1 = require("../../core/StorageProvider");
// ─── Tipos de mídia suportados ────────────────────────────────────────────────
const MIME_MAP = {
'image/jpeg': 'image',
'image/png': 'image',
'image/webp': 'sticker',
'image/gif': 'video', // GIFs são enviados como video no Baileys
'video/mp4': 'video',
'video/3gpp': 'video',
'video/quicktime': 'video',
'audio/ogg': 'audio',
'audio/mpeg': 'audio',
'audio/mp4': 'audio',
'audio/wav': 'audio',
'audio/webm': 'audio',
'application/pdf': 'document',
'application/msword': 'document',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'document',
'application/vnd.ms-excel': 'document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'document',
'application/zip': 'document',
'text/plain': 'document',
};
const MAX_FILE_SIZE = 64 * 1024 * 1024; // 64 MB
// ─── Multer: armazena em memória (buffer) ─────────────────────────────────────
// Buffers são passados diretamente ao Baileys sem gravar arquivo temporário
const upload = (0, multer_1.default)({
storage: multer_1.default.memoryStorage(),
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (_req, file, cb) => {
if (MIME_MAP[file.mimetype]) {
cb(null, true);
}
else {
cb(new Error(`Tipo de arquivo não suportado: ${file.mimetype}`));
}
},
});
// ─── Helper: salva mídia enviada no disco (para histórico local) ──────────────
async function saveMediaLocally(instanceId, messageId, buffer, originalName) {
const ext = path_1.default.extname(originalName) || '.bin';
const fileName = `${messageId}${ext}`;
const filePath = path_1.default.resolve('./media', instanceId, 'sent', fileName);
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
await promises_1.default.writeFile(filePath, buffer);
return `/media/${instanceId}/sent/${fileName}`;
}
// ─── Router ───────────────────────────────────────────────────────────────────
function buildMediaRoutes(manager) {
const router = (0, express_1.Router)({ mergeParams: true });
// ── POST /api/instances/:instanceId/send-media ─────────────────────────────
// Body: multipart/form-data { file, jid, caption?, replyToMessageId? }
router.post('/send-media', upload.single('file'), async (req, res) => {
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const { jid, caption, replyToMessageId } = req.body;
if (!req.file) {
res.status(400).json({ error: 'Arquivo não enviado' });
return;
}
if (!jid) {
res.status(400).json({ error: 'jid é obrigatório' });
return;
}
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
const sock = manager.getSocket(instanceId);
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' });
return;
}
const { buffer, mimetype, originalname, size } = req.file;
const mediaType = MIME_MAP[mimetype] ?? 'document';
// ── Monta o content para o Baileys ────────────────────────────────────
let content;
switch (mediaType) {
case 'image':
content = { image: buffer, mimetype, caption: caption ?? '' };
break;
case 'video':
content = {
video: buffer,
mimetype,
caption: caption ?? '',
gifPlayback: mimetype === 'image/gif',
};
break;
case 'audio':
content = { audio: buffer, mimetype, ptt: false };
break;
case 'sticker':
content = { sticker: buffer, mimetype };
break;
default:
content = { document: buffer, mimetype, fileName: originalname, caption: caption ?? '' };
}
// ── Reply context ─────────────────────────────────────────────────────
let quotedMsg;
if (replyToMessageId) {
const reply = await prisma_1.prisma.message.findUnique({
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
});
if (reply?.body) {
quotedMsg = {
key: { remoteJid: jid, fromMe: reply.fromMe, id: reply.messageId },
message: { conversation: reply.body },
};
}
}
// ── Envia via Baileys ─────────────────────────────────────────────────
const sent = await sock.sendMessage(jid, content, quotedMsg ? { quoted: quotedMsg } : undefined);
// ── Salva arquivo e faz upload para Wasabi ────────────────────────────
const messageId = sent?.key.id ?? `local-${Date.now()}`;
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname);
let mediaUrl = localUrl;
if (StorageProvider_1.storageProvider.isRegistered()) {
try {
const result = await StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: tenantId,
sessionJID: instanceId,
file: { originalname, buffer, mimetype },
});
mediaUrl = result.path;
}
catch (uploadErr) {
logger_1.logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (mídia enviada), usando path local');
}
}
// ── Garante chat + persiste mensagem ─────────────────────────────────
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
create: { tenantId, instanceId, jid, lastMessageAt: new Date() },
update: { lastMessageAt: new Date() },
});
const messageType = mediaType.toUpperCase();
// Usa upsert para evitar P2002 — o MessageHandler pode ter inserido antes
const msg = await prisma_1.prisma.message.upsert({
where: { instanceId_messageId: { instanceId, messageId } },
create: {
tenantId,
instanceId,
chatId: chat.id,
remoteJid: jid,
messageId,
fromMe: true,
type: messageType,
body: caption ?? null,
mediaUrl,
mimeType: mimetype,
fileName: mediaType === 'document' ? originalname : null,
status: 'SENT',
timestamp: new Date(),
},
update: {
mediaUrl,
mimeType: mimetype,
status: 'SENT',
},
});
logger_1.logger.info({ instanceId, jid, mediaType, size }, 'Mídia enviada');
res.status(201).json(msg);
}
catch (err) {
logger_1.logger.error({ err }, 'Erro ao enviar mídia');
res.status(500).json({ error: err.message ?? 'Erro ao enviar mídia' });
}
});
// ── POST /api/instances/:instanceId/send-audio ─────────────────────────────
// Áudio gravado pelo InputBar (PTT — Push to Talk)
// Body: multipart/form-data { audio (blob ogg/webm), jid }
router.post('/send-audio', upload.single('audio'), async (req, res) => {
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const { jid } = req.body;
if (!req.file) {
res.status(400).json({ error: 'Áudio não enviado' });
return;
}
if (!jid) {
res.status(400).json({ error: 'jid é obrigatório' });
return;
}
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
const sock = manager.getSocket(instanceId);
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' });
return;
}
const { buffer, mimetype, originalname } = req.file;
// PTT (push-to-talk) — aparece como nota de voz no WhatsApp
const sent = await sock.sendMessage(jid, {
audio: buffer,
mimetype: mimetype || 'audio/ogg; codecs=opus',
ptt: true,
});
const messageId = sent?.key.id ?? `local-${Date.now()}`;
const localUrl = await saveMediaLocally(instanceId, messageId, buffer, originalname || 'audio.ogg');
let mediaUrl = localUrl;
if (StorageProvider_1.storageProvider.isRegistered()) {
try {
const result = await StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: tenantId,
sessionJID: instanceId,
file: { originalname: originalname || 'audio.ogg', buffer, mimetype: mimetype || 'audio/ogg' },
});
mediaUrl = result.path;
}
catch (uploadErr) {
logger_1.logger.warn({ uploadErr, messageId }, 'Upload Wasabi falhou (áudio enviado), usando path local');
}
}
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid } },
create: { tenantId, instanceId, jid, lastMessageAt: new Date() },
update: { lastMessageAt: new Date() },
});
// upsert evita colisão com o registro criado pelo MessageHandler via messages.upsert
const msg = await prisma_1.prisma.message.upsert({
where: { instanceId_messageId: { instanceId, messageId } },
create: {
tenantId,
instanceId,
chatId: chat.id,
remoteJid: jid,
messageId,
fromMe: true,
type: 'AUDIO',
mimeType: mimetype || 'audio/ogg',
mediaUrl,
status: 'SENT',
timestamp: new Date(),
},
update: { mediaUrl, status: 'SENT' },
});
logger_1.logger.info({ instanceId, jid }, 'Áudio PTT enviado');
res.status(201).json(msg);
}
catch (err) {
logger_1.logger.error({ err }, 'Erro ao enviar áudio');
res.status(500).json({ error: err.message ?? 'Erro ao enviar áudio' });
}
});
// ── POST /api/instances/:instanceId/redownload-media/:messageDbId ─────────
// Re-solicita ao WhatsApp a mídia de uma mensagem cujo download falhou.
// Usa o payload WAMessage salvo em messages.mediaPayload para reconstruir
// a requisição com updateMediaMessage (renova URL expirada se necessário).
router.post('/redownload-media/:messageDbId', async (req, res) => {
try {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const messageDbId = req.params['messageDbId'];
const msg = await prisma_1.prisma.message.findFirst({
where: { id: messageDbId, instanceId, tenantId },
select: { id: true, mediaPayload: true, type: true, chatId: true, mediaUrl: true, messageId: true },
});
if (!msg) {
res.status(404).json({ error: 'Mensagem não encontrada' });
return;
}
if (msg.mediaUrl) {
res.json({ mediaUrl: msg.mediaUrl });
return;
}
if (!msg.mediaPayload) {
res.status(400).json({ error: 'Payload de mídia não disponível para esta mensagem' });
return;
}
const sock = manager.getSocket(instanceId);
if (!sock) {
res.status(503).json({ error: 'Instância não conectada' });
return;
}
// Desserializa payload: { _b64: base64 } → Buffer/Uint8Array
const waMsg = deserializePayload(msg.mediaPayload);
const buffer = await (0, engine_1.downloadMediaMessage)(waMsg, 'buffer', {}, { logger: logger_1.logger, reuploadRequest: sock.updateMediaMessage });
if (!buffer) {
res.status(500).json({ error: 'Nenhum buffer retornado pelo WhatsApp' });
return;
}
const contentType = (0, engine_1.getContentType)(waMsg.message) ?? 'imageMessage';
const ext = EXT_MAP[contentType] ?? 'bin';
const mime = MIME_FOR_TYPE[contentType] ?? 'application/octet-stream';
const fileName = `${messageDbId}.${ext}`;
let mediaUrl = `/media/${instanceId}/${fileName}`;
if (StorageProvider_1.storageProvider.isRegistered()) {
try {
const result = await StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
usuarioSis: tenantId,
sessionJID: instanceId,
file: { originalname: fileName, buffer: buffer, mimetype: mime },
});
mediaUrl = result.path;
}
catch (uploadErr) {
logger_1.logger.warn({ uploadErr, messageDbId }, 'Upload Wasabi falhou no re-download, usando path local');
const filePath = path_1.default.resolve('./media', instanceId, fileName);
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
await promises_1.default.writeFile(filePath, buffer);
}
}
else {
const filePath = path_1.default.resolve('./media', instanceId, fileName);
await promises_1.default.mkdir(path_1.default.dirname(filePath), { recursive: true });
await promises_1.default.writeFile(filePath, buffer);
}
await prisma_1.prisma.message.update({ where: { id: messageDbId }, data: { mediaUrl } });
manager.getIO().to(`chat:${msg.chatId}`).emit('message:media_ready', {
messageId: messageDbId,
mediaUrl,
});
logger_1.logger.info({ messageDbId, contentType }, 'Mídia re-baixada com sucesso');
res.json({ mediaUrl });
}
catch (err) {
logger_1.logger.error({ err }, 'Erro ao re-baixar mídia');
res.status(500).json({ error: err.message ?? 'Erro ao re-baixar mídia' });
}
});
return router;
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
const EXT_MAP = {
imageMessage: 'jpg',
videoMessage: 'mp4',
audioMessage: 'ogg',
documentMessage: 'bin',
stickerMessage: 'webp',
};
const MIME_FOR_TYPE = {
imageMessage: 'image/jpeg',
videoMessage: 'video/mp4',
audioMessage: 'audio/ogg',
documentMessage: 'application/octet-stream',
stickerMessage: 'image/webp',
};
function deserializePayload(val) {
if (Array.isArray(val))
return val.map(deserializePayload);
if (val !== null && typeof val === 'object') {
const obj = val;
if ('_b64' in obj && typeof obj._b64 === 'string') {
return Buffer.from(obj._b64, 'base64');
}
return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserializePayload(v)]));
}
return val;
}
+169
View File
@@ -0,0 +1,169 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildWhatsAppRoutes = buildWhatsAppRoutes;
const express_1 = require("express");
const promises_1 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const InstanceRepository_1 = require("./repositories/InstanceRepository");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const prisma_1 = require("../../infra/database/prisma");
const logger_1 = require("../../config/logger");
const StorageProvider_1 = require("../../core/StorageProvider");
const ENGINE_CHANGE_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutos entre trocas
function buildWhatsAppRoutes(manager) {
const router = (0, express_1.Router)();
const repo = new InstanceRepository_1.InstanceRepository();
// Listar instâncias do tenant
router.get('/', async (req, res) => {
const tenantId = req.tenantId;
const instances = await repo.findAllByTenant(tenantId);
res.json(instances);
});
// Criar instância
router.post('/', async (req, res) => {
const tenantId = req.tenantId;
const { name } = req.body;
if (!name) {
res.status(400).json({ error: 'name é obrigatório' });
return;
}
const instance = await repo.create(tenantId, name);
res.status(201).json(instance);
});
// Conectar instância (emite QR via Socket.IO)
router.post('/:id/connect', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const instance = await repo.findById(id);
if (!instance || instance.tenantId !== tenantId) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
// Não aguarda — a conexão ocorre em background e o QR chega via Socket.IO
manager.connect(id, tenantId).catch((err) => {
console.error('[Connect] Falha ao iniciar conexão:', err);
});
res.json({ message: 'Conectando... aguarde o QR Code via WebSocket' });
});
// Desconectar instância
router.post('/:id/disconnect', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const instance = await repo.findById(id);
if (!instance || instance.tenantId !== tenantId) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
await manager.disconnect(id);
res.json({ message: 'Desconectado' });
});
// QR Code atual (fallback REST — normalmente via Socket.IO)
router.get('/:id/qr', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const instance = await repo.findById(id);
if (!instance || instance.tenantId !== tenantId) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
const qrBase64 = await dragonfly_1.dragonfly.get(`instance:${id}:qr`);
if (!qrBase64) {
res.status(404).json({ error: 'QR Code não disponível. Inicie a conexão primeiro.' });
return;
}
res.json({ qrBase64 });
});
// Deletar instância
router.delete('/:id', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const instance = await repo.findById(id);
if (!instance || instance.tenantId !== tenantId) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
// 1. Desconectar e deslogar a sessão do WhatsApp se estiver ativa
await manager.disconnect(id).catch(() => { });
// 2. Garantir a exclusão física da pasta de credenciais da sessão no servidor
await manager.deleteSessionFiles(id).catch(() => { });
// 3. Garantir a exclusão física da pasta de mídia local ./media/{instanceId} no servidor
const localMediaDir = path_1.default.resolve('./media', id);
await promises_1.default.rm(localMediaDir, { recursive: true, force: true }).catch((err) => {
console.error(`[deleteInstance] Erro ao deletar pasta local de mídia da instância ${id}:`, err);
});
// 4. Apagar do banco de dados
await repo.delete(id);
// 5. Deleta mídias associadas à instância no storage remoto (Wasabi/S3) de forma assíncrona
StorageProvider_1.storageProvider.deletePrefix(`whatsapp/${tenantId}/${id}/`).catch((err) => {
console.error(`[StorageProvider] Erro ao deletar mídias da instância ${id}:`, err);
});
res.json({ message: 'Instância removida com sucesso de todos os storages e servidores' });
});
// Estatísticas de mensagens da instância (contagens do banco)
router.get('/:id/stats', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const instance = await repo.findById(id);
if (!instance || instance.tenantId !== tenantId) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
const [received, sent, media, chats] = await Promise.all([
prisma_1.prisma.message.count({ where: { instanceId: id, fromMe: false } }),
prisma_1.prisma.message.count({ where: { instanceId: id, fromMe: true } }),
prisma_1.prisma.message.count({
where: {
instanceId: id,
type: { in: ['IMAGE', 'VIDEO', 'AUDIO', 'DOCUMENT', 'STICKER'] },
},
}),
prisma_1.prisma.chat.count({ where: { instanceId: id } }),
]);
res.json({ received, sent, media, chats });
});
// Trocar engine de uma instância (com cooldown + auditoria)
router.patch('/:id/engine', async (req, res) => {
const tenantId = req.tenantId;
const id = req.params['id'];
const { engine } = req.body;
if (engine !== 'infinite' && engine !== 'official') {
res.status(400).json({ error: 'engine deve ser "infinite" ou "official"' });
return;
}
const instance = await repo.findById(id);
if (!instance || instance.tenantId !== tenantId) {
res.status(404).json({ error: 'Instância não encontrada' });
return;
}
// Cooldown: bloqueia nova troca por 5 min após a última
const cooldownKey = `instance:${id}:engine_change_lock`;
const lockedAt = await dragonfly_1.dragonfly.get(cooldownKey);
if (lockedAt) {
const remaining = Math.ceil((ENGINE_CHANGE_COOLDOWN_MS - (Date.now() - Number(lockedAt))) / 1000);
res.status(429).json({ error: `Aguarde ${remaining}s antes de trocar a engine novamente` });
return;
}
const fromEngine = instance.engine ?? 'infinite';
if (fromEngine === engine) {
res.status(200).json({ message: 'Engine já está definida para este valor', engine });
return;
}
// Persiste a troca
await prisma_1.prisma.instance.update({ where: { id }, data: { engine } });
// Audit log estruturado
logger_1.logger.info({ tenantId, instanceId: id, fromEngine, toEngine: engine }, '[EngineChange] Troca de engine auditada');
// Aplica cooldown (5 min via TTL no Redis)
await dragonfly_1.dragonfly.psetex(cooldownKey, ENGINE_CHANGE_COOLDOWN_MS, String(Date.now()));
// Reconecta a instância com a nova engine
const isConnected = manager.getSocket(id) != null;
if (isConnected) {
await manager.disconnect(id).catch(() => { });
manager.connect(id, tenantId).catch(() => { });
}
res.json({ message: 'Engine atualizada', engine, reconnecting: isConnected });
});
return router;
}
+740
View File
@@ -0,0 +1,740 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.buildSendRoutes = buildSendRoutes;
exports.buildAvatarRoute = buildAvatarRoute;
/**
* Rotas de envio e operações WhatsApp.
*
* Endpoints:
* POST /api/instances/:instanceId/send Envia mensagem de texto (com @mentions e reply)
* GET /api/instances/:instanceId/groups/:jid/participants Lista participantes de grupo
* POST /api/instances/:instanceId/reconcile-names Reconcilia nomes via pushName (bulk SQL)
* POST /api/instances/:instanceId/broadcast Disparo em massa (async, até 500 números)
* GET /api/instances/:instanceId/broadcast Lista broadcasts da instância
* GET /api/instances/:instanceId/broadcast/:id Detalhe de um broadcast
* GET /api/inbox/avatar/:jid Proxy de foto de perfil (evita CORS)
*
* Todas as rotas exigem autenticação (tenantId no req).
* O WhatsAppConnectionManager é injetado via buildSendRoutes() para
* acessar os sockets Baileys ativos.
*
* @see WhatsAppConnectionManager provê getSocket() e getIO()
* @see ContactHandler reconciliação de nomes (mesma lógica, mas via endpoint HTTP)
*/
const express_1 = require("express");
const crypto_1 = require("crypto");
const zod_1 = require("zod");
const prisma_1 = require("../../infra/database/prisma");
const dragonfly_1 = require("../../infra/cache/dragonfly");
const logger_1 = require("../../config/logger");
const StorageProvider_1 = require("../../core/StorageProvider");
const rich_message_1 = require("./rich-message");
/**
* Constrói as rotas de envio e operações WhatsApp.
*
* Montadas em: /api/instances/:instanceId/
* Usa mergeParams para herdar :instanceId do router pai.
*/
function buildSendRoutes(manager) {
const router = (0, express_1.Router)({ mergeParams: true });
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/instances/:instanceId/send — Envio de mensagem de texto
// ═══════════════════════════════════════════════════════════════════════════
/**
* Envia uma mensagem de texto para um JID (contato ou grupo).
*
* Body:
* - jid: string (obrigatório) JID destino (ex: "5511999999999@s.whatsapp.net")
* - text: string (obrigatório) Texto da mensagem (até 4096 chars)
* - replyToMessageId?: string ID da mensagem sendo respondida (quote/reply)
* - mentions?: string[] Lista de JIDs mencionados com @ (para grupos)
*
* Fluxo:
* 1. Valida instância conectada e socket disponível
* 2. Monta contexto de reply (se houver replyToMessageId)
* 3. Envia via Baileys sock.sendMessage()
* 4. Cria/atualiza Chat no banco
* 5. Persiste a mensagem enviada
* 6. Retorna 201 com o registro da mensagem
*/
router.post('/send', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const { jid, text, footer, replyToMessageId, mentions,
// ── Tipos ricos (InfiniteAPI/Baileys) ────────────────────────────────
nativeButtons, nativeList, nativeCarousel, poll, } = req.body;
const hasRichContent = !!(nativeButtons || nativeList || nativeCarousel || poll);
if (!jid || (!text && !hasRichContent)) {
res.status(400).json({ error: 'jid e text (ou tipo rico) são obrigatórios' });
return;
}
// Valida que a instância existe e está conectada
const instance = await prisma_1.prisma.instance.findFirst({
where: { id: instanceId, tenantId },
});
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
// Busca o socket Baileys ativo
const sock = manager.getSocket(instanceId);
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' });
return;
}
// ── Resolve o JID real via onWhatsApp (corrige o dígito 9 brasileiro) ────
// Números brasileiros migraram de 8 para 9 dígitos. O usuário pode digitar
// com ou sem o 9 — o WhatsApp sabe o JID correto. Grupos (@g.us) são
// excluídos pois já têm JID definitivo.
let resolvedJid = jid;
if (!jid.endsWith('@g.us')) {
try {
const waResults = await sock.onWhatsApp(jid);
const waResult = waResults?.[0];
if (!waResult?.exists) {
res.status(422).json({ error: 'Número não encontrado no WhatsApp' });
return;
}
// Se onWhatsApp retornar @lid (WhatsApp moderno), ignoramos — o Baileys
// resolve @lid internamente ao enviar. Armazenar @lid no banco criaria
// um chat duplicado conflitando com o @s.whatsapp.net do MessageHandler.
if (!waResult.jid.endsWith('@lid')) {
resolvedJid = waResult.jid; // JID correto com ou sem o 9
}
}
catch (err) {
logger_1.logger.warn({ err, jid }, 'onWhatsApp falhou — usando JID original');
// Continua com o JID original se a consulta falhar
}
}
// Monta contexto de reply (quoted message) se necessário
// O Baileys precisa da mensagem original para exibir o quote no WhatsApp
let quotedMsg;
if (replyToMessageId) {
const reply = await prisma_1.prisma.message.findUnique({
where: { instanceId_messageId: { instanceId, messageId: replyToMessageId } },
});
if (reply?.body) {
quotedMsg = {
key: { remoteJid: resolvedJid, fromMe: reply.fromMe, id: reply.messageId },
message: { conversation: reply.body },
};
}
}
// ── Mensagens ricas: delega ao rich-message builder ──────────────────────
// O @whiskeysockets/baileys padrão não suporta nativeButtons/nativeList/
// nativeCarousel. O builder converte para os protos corretos e usa
// relayMessage() para enviar sem passar pelo generateWAMessageContent.
const hasRich = !!(nativeButtons || nativeList || nativeCarousel || poll);
let sentMessageId;
if (hasRich) {
sentMessageId = await (0, rich_message_1.sendRichMessage)(sock, resolvedJid, { text, footer, nativeButtons, nativeList, nativeCarousel, poll }, quotedMsg, instance.engine);
}
else {
// Texto simples (com suporte a @mentions)
const msgContent = { text: text ?? '' };
if (mentions?.length)
msgContent.mentions = mentions;
const sent = await sock.sendMessage(resolvedJid, msgContent, quotedMsg ? { quoted: quotedMsg } : undefined);
sentMessageId = sent?.key?.id ?? `text-${Date.now()}`;
}
const sent = { key: { id: sentMessageId } };
// ── Persiste chat e mensagem imediatamente ────────────────────────────────
// O Baileys dispara messages.upsert com type='append' para mensagens enviadas
// por nós, o que não emite eventos Socket.IO. Por isso fazemos a criação aqui
// e emitimos os eventos manualmente para o frontend atualizar em tempo real.
const isGroup = resolvedJid.endsWith('@g.us');
// Garantir que o Contato existe no banco
const contact = await prisma_1.prisma.contact.upsert({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
create: {
tenantId,
instanceId,
jid: resolvedJid,
phone: isGroup ? null : resolvedJid.split('@')[0],
},
update: {},
});
const chat = await prisma_1.prisma.chat.upsert({
where: { tenantId_instanceId_jid: { tenantId, instanceId, jid: resolvedJid } },
create: { tenantId, instanceId, jid: resolvedJid, contactId: contact.id, lastMessageAt: new Date() },
update: { contactId: contact.id, lastMessageAt: new Date() },
});
const messageId = sent?.key.id ?? `local-${Date.now()}`;
// Determina tipo, body e richPayload para persistência
let msgType = 'TEXT';
let msgBody = text ?? '';
let richPayloadData;
if (poll) {
msgType = 'POLL';
msgBody = poll.name;
richPayloadData = { poll };
}
else if (nativeButtons) {
// Detecta se há botões CTA (url/copy/call) → INTERACTIVE, senão → BUTTONS
const hasCta = nativeButtons.some((b) => ['url', 'copy', 'call'].includes(b.type));
msgType = hasCta ? 'INTERACTIVE' : 'BUTTONS';
msgBody = text ?? '';
richPayloadData = { text, footer, nativeButtons };
}
else if (nativeList) {
msgType = 'LIST';
msgBody = text ?? nativeList.buttonText;
richPayloadData = { text, footer, nativeList };
}
else if (nativeCarousel) {
msgType = 'CAROUSEL';
msgBody = text ?? nativeCarousel.cards[0]?.title ?? '[Carrossel]';
richPayloadData = { text, footer, nativeCarousel };
}
const msg = await prisma_1.prisma.message.upsert({
where: { instanceId_messageId: { instanceId, messageId } },
create: {
tenantId,
instanceId,
chatId: chat.id,
remoteJid: resolvedJid,
messageId,
fromMe: true,
type: msgType,
body: msgBody,
richPayload: richPayloadData ? JSON.parse(JSON.stringify(richPayloadData)) : undefined,
status: 'SENT',
timestamp: new Date(),
},
update: {}, // já existe (eco do Baileys chegou primeiro) — não sobrescreve
});
// ── Emite eventos Socket.IO para o frontend atualizar em tempo real ───────
const io = manager.getIO();
// Inclui replyTo quando é uma resposta — necessário para exibir a caixa
// de citação em sessões adicionais do mesmo tenant que tenham o chat aberto.
const msgToEmit = replyToMessageId
? await prisma_1.prisma.message.findUnique({
where: { id: msg.id },
include: {
replyTo: {
select: { id: true, messageId: true, body: true, fromMe: true, type: true, mediaUrl: true },
},
},
}) ?? msg
: msg;
// Atualiza o chat aberto (se o usuário já estiver nele)
io.to(`chat:${chat.id}`).emit('message:new', { ...msgToEmit, pushName: null, senderJid: null });
const displayName = isGroup
? (chat.name ?? null)
: (contact.name ?? contact.verifiedName ?? contact.notify ?? null);
// Atualiza a lista de chats do tenant (novo chat ou reordenar pelo lastMessageAt)
io.to(`tenant:${tenantId}`).emit('chat:upsert', {
id: chat.id,
tenantId: chat.tenantId,
instanceId: chat.instanceId,
jid: chat.jid,
name: chat.name ?? null,
unreadCount: chat.unreadCount,
lastMessageAt: chat.lastMessageAt,
isPinned: chat.isPinned,
isArchived: chat.isArchived,
botPaused: chat.botPaused,
contact: {
name: displayName,
phone: contact.phone,
avatarUrl: contact.avatarUrl,
scoreReputacao: contact.scoreReputacao,
flagRestricao: contact.flagRestricao,
},
lastMessage: {
body: msgBody,
fromMe: true,
status: 'SENT',
type: msgType,
timestamp: msg.timestamp,
},
});
res.status(201).json({ id: msg.id, messageId: msg.messageId });
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/instances/:instanceId/groups/:groupJid/participants
// Lista participantes de um grupo (para autocomplete de @mentions)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Retorna a lista de participantes de um grupo WhatsApp.
*
* Usado pelo InputBar do frontend para autocomplete de @mentions.
* Busca metadata via Baileys groupMetadata() e enriquece com dados do banco:
* - Nome do contato (se existir no banco)
* - Para JIDs @lid sem nome: busca pushName da última mensagem no grupo
* - Telefone formatado
*
* Response: { groupJid, subject, participants: [{ jid, admin, name, phone }] }
*/
router.get('/groups/:groupJid/participants', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const groupJid = req.params['groupJid'];
if (!groupJid?.endsWith('@g.us')) {
res.status(400).json({ error: 'JID de grupo inválido' });
return;
}
const instance = await prisma_1.prisma.instance.findFirst({
where: { id: instanceId, tenantId },
});
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
const sock = manager.getSocket(instanceId);
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' });
return;
}
try {
// Busca metadata do grupo via Baileys (inclui lista de participantes)
const metadata = await sock.groupMetadata(groupJid);
const participants = await Promise.all(metadata.participants.map(async (p) => {
// Enriquece com dados do banco (nome, telefone)
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId, instanceId, jid: p.id },
select: { name: true, notify: true, phone: true },
});
let name = contact?.name || contact?.notify || null;
let phone = contact?.phone || null;
// Para @lid JIDs sem nome: tenta buscar pushName da última mensagem no grupo
// (LID não tem telefone no JID, então o nome só pode vir de mensagens)
if (!name && p.id.endsWith('@lid')) {
const lastMsg = await prisma_1.prisma.message.findFirst({
where: {
instanceId,
remoteJid: groupJid,
senderJid: p.id,
fromMe: false,
pushName: { not: null },
},
orderBy: { timestamp: 'desc' },
select: { pushName: true },
});
if (lastMsg?.pushName)
name = lastMsg.pushName;
}
// Extrai telefone do JID (apenas para @s.whatsapp.net, não @lid)
if (!phone) {
phone = p.id.endsWith('@lid') ? null : p.id.split('@')[0].split(':')[0];
}
return {
jid: p.id,
admin: p.admin ?? null, // 'admin' | 'superadmin' | null
name,
phone,
};
}));
res.json({ groupJid, subject: metadata.subject, participants });
}
catch (err) {
logger_1.logger.error({ err, groupJid }, '[Groups] Erro ao buscar participantes');
res.status(500).json({ error: 'Erro ao buscar participantes do grupo' });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/instances/:instanceId/reconcile-names
// Reconciliação bulk de nomes via pushName (endpoint HTTP)
// ═══════════════════════════════════════════════════════════════════════════
/**
* Reconcilia nomes de contatos usando pushName das mensagens recebidas.
*
* Diferente de ContactHandler.reconcileNamesFromMessages() que roda no sync,
* este endpoint pode ser chamado pelo frontend a qualquer momento (ex: page reload).
*
* Implementação:
* - Usa raw SQL com DISTINCT ON para buscar o pushName mais recente por JID
* - Uma única query UPDATE...FROM (subquery) atualiza todos os contatos sem nome
* - Cache DragonflyDB de 30min para evitar re-execução em loop
* - Idempotente: se executou nos últimos 30min, retorna 204
*
* NOTA sobre tabelas: Prisma usa nomes como "Contact" e "Message" nos models,
* mas as tabelas PostgreSQL reais são "contacts" e "messages" (via @@map).
* No $executeRaw, SEMPRE usar os nomes reais das tabelas.
*/
// Fallback in-memory para o TTL de reconciliação quando Redis está fora
// Map<cacheKey, timestampMs do último reconcile>
const reconcileMemCache = new Map();
const RECONCILE_TTL_MS = 30 * 60 * 1000; // 30 min
router.post('/reconcile-names', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const cacheKey = `reconcile:${tenantId}:${instanceId}`;
// Camada 1: Redis (30min TTL)
const cached = await dragonfly_1.dragonfly.get(cacheKey);
if (cached) {
res.status(204).end();
return;
}
// Camada 2: in-memory (protege quando Redis está fora)
const memTs = reconcileMemCache.get(cacheKey);
if (memTs && Date.now() - memTs < RECONCILE_TTL_MS) {
res.status(204).end();
return;
}
try {
// Query bulk: encontra contatos sem nome que têm msgs recebidas com pushName.
// Usa DISTINCT ON (PostgreSQL) para pegar o pushName mais recente por JID.
// Uma única query UPDATE...FROM atualiza todos — O(1) round-trips ao banco.
const updated = await prisma_1.prisma.$executeRaw `
UPDATE contacts c
SET
"name" = sub."pushName",
"notify" = COALESCE(c."notify", sub."pushName"),
"updatedAt" = NOW()
FROM (
SELECT DISTINCT ON (m."remoteJid")
m."remoteJid",
m."pushName"
FROM messages m
WHERE m."instanceId" = ${instanceId}
AND m."fromMe" = false
AND m."pushName" IS NOT NULL
AND m."pushName" != ''
ORDER BY m."remoteJid", m."timestamp" DESC
) sub
WHERE c."instanceId" = ${instanceId}
AND c."tenantId" = ${tenantId}
AND c."jid" = sub."remoteJid"
AND c."name" IS NULL
AND c."notify" IS NULL
`;
// Seta flag no Redis (30min) + in-memory — independente do resultado
await dragonfly_1.dragonfly.set(cacheKey, String(updated), 1800);
reconcileMemCache.set(cacheKey, Date.now());
if (updated > 0) {
logger_1.logger.info({ instanceId, updated }, '[Reconcile] Nomes atualizados via pushName');
// Notifica frontend para re-buscar a lista de chats
const io = manager.getIO();
if (io) {
io.to(`tenant:${tenantId}`).emit('chats:refresh', { instanceId, reason: 'reconcile', updated });
}
}
res.json({ updated });
}
catch (err) {
logger_1.logger.error({ err, instanceId }, '[Reconcile] Erro na reconciliação bulk');
// Mesmo com erro, seta cache curto (60s) para não ficar retentando em loop
await dragonfly_1.dragonfly.set(cacheKey, '0', 60).catch(() => { });
res.status(500).json({ error: 'Erro na reconciliação' });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// POST /api/instances/:instanceId/broadcast — Disparo em massa
// ═══════════════════════════════════════════════════════════════════════════
/**
* Inicia um disparo em massa (broadcast) para até 500 números.
*
* Validação:
* - numbers: array de strings (mínimo 5 chars cada, 1-500 itens)
* - message: texto (1-4096 chars)
* - delayMs: delay entre envios (500-60000ms, default 2000ms)
*
* Fluxo:
* 1. Valida payload via Zod
* 2. Cria registro Broadcast com status 'RUNNING'
* 3. Retorna 202 com jobId imediatamente
* 4. Processa envios em background (async IIFE)
* 5. Atualiza sentCount/failedCount a cada envio
* 6. Marca como 'DONE' ao finalizar
*
* IMPORTANTE: O delay entre envios é obrigatório para evitar ban do WhatsApp.
* O default de 2s é conservador ajuste conforme volume e tolerância ao risco.
*/
router.post('/broadcast', async (req, res) => {
const schema = zod_1.z.object({
numbers: zod_1.z.array(zod_1.z.string().min(5)).min(1).max(500),
message: zod_1.z.string().min(1).max(4096),
delayMs: zod_1.z.number().int().min(500).max(60000).default(2000),
});
let body;
try {
body = schema.parse(req.body);
}
catch (err) {
res.status(400).json({ error: err.errors ?? err.message });
return;
}
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const instance = await prisma_1.prisma.instance.findFirst({ where: { id: instanceId, tenantId } });
if (!instance || instance.status !== 'CONNECTED') {
res.status(400).json({ error: 'Instância não conectada' });
return;
}
const sock = manager.getSocket(instanceId);
if (!sock) {
res.status(503).json({ error: 'Socket não disponível' });
return;
}
// Cria registro do broadcast no banco
const broadcast = await prisma_1.prisma.broadcast.create({
data: {
tenantId,
instanceId,
message: body.message,
totalCount: body.numbers.length,
sentCount: 0,
failedCount: 0,
status: 'RUNNING',
},
});
// Retorna imediatamente (processamento assíncrono)
res.status(202).json({ jobId: broadcast.id, total: body.numbers.length });
(async () => {
let sent = 0, failed = 0;
for (let i = 0; i < body.numbers.length; i++) {
const rawNumber = body.numbers[i];
// Normaliza número: remove caracteres não-numéricos
const number = rawNumber.replace(/\D/g, '');
// Se já é um JID completo (contém @), usa como está; senão, adiciona sufixo
const jid = number.includes('@') ? number : `${number}@s.whatsapp.net`;
try {
await sock.sendMessage(jid, { text: body.message });
await prisma_1.prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'SENT' },
});
sent++;
}
catch (err) {
logger_1.logger.warn({ jid, err }, '[Broadcast] Falha ao enviar');
await prisma_1.prisma.broadcastRecipient.create({
data: { broadcastId: broadcast.id, jid, status: 'FAILED' },
}).catch(() => { });
failed++;
}
// Atualiza contadores em tempo real (permite consulta de progresso)
await prisma_1.prisma.broadcast.update({
where: { id: broadcast.id },
data: { sentCount: sent, failedCount: failed },
});
// Delay entre envios para evitar ban do WhatsApp (pula no último item)
if (i < body.numbers.length - 1) {
await new Promise(r => setTimeout(r, body.delayMs));
}
}
// Marca como concluído
await prisma_1.prisma.broadcast.update({
where: { id: broadcast.id },
data: { status: 'DONE', finishedAt: new Date() },
});
})().catch(err => {
logger_1.logger.error({ err }, '[Broadcast] Erro no job');
prisma_1.prisma.broadcast.update({ where: { id: broadcast.id }, data: { status: 'FAILED' } }).catch(() => { });
});
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/instances/:instanceId/broadcast — Lista broadcasts
// ═══════════════════════════════════════════════════════════════════════════
/** Lista os últimos broadcasts da instância (paginado, max 50) */
router.get('/broadcast', async (req, res) => {
const tenantId = req.tenantId;
const instanceId = req.params['instanceId'];
const limit = Math.min(Number(req.query['limit'] ?? 20), 50);
const rows = await prisma_1.prisma.broadcast.findMany({
where: { tenantId, instanceId },
orderBy: { createdAt: 'desc' },
take: limit,
include: { _count: { select: { recipients: true } } },
});
res.json(rows);
});
// ═══════════════════════════════════════════════════════════════════════════
// GET /api/instances/:instanceId/broadcast/:id — Detalhe de broadcast
// ═══════════════════════════════════════════════════════════════════════════
/** Retorna detalhe de um broadcast específico, incluindo lista de destinatários */
router.get('/broadcast/:broadcastId', async (req, res) => {
const tenantId = req.tenantId;
const broadcastId = req.params['broadcastId'];
const row = await prisma_1.prisma.broadcast.findFirst({
where: { id: broadcastId, tenantId },
include: { recipients: { orderBy: { createdAt: 'asc' } } },
});
if (!row) {
res.status(404).json({ error: 'Não encontrado' });
return;
}
res.json(row);
});
return router;
}
// ═══════════════════════════════════════════════════════════════════════════════
// ROTA DE AVATAR — Proxy de foto de perfil (evita CORS)
// ═══════════════════════════════════════════════════════════════════════════════
/**
* Constrói a rota de proxy de avatar.
*
* Montada em: /api/inbox/avatar/:jid
*
* O frontend não pode buscar avatares diretamente do CDN do WhatsApp (CORS).
* Esta rota age como proxy:
* 1. Busca avatarUrl salvo no banco (cache DB)
* 2. Se URL da DB expirou, busca via Baileys profilePictureUrl
* 3. Faz proxy do buffer (download response) para evitar redirect/CORS
* 4. Salva URL nova no banco para próximas requisições
*
* Retorna 204 (sem conteúdo) quando avatar não disponível:
* - Foto de perfil privada
* - JID @lid (não suporta profilePictureUrl)
* - Socket não disponível
* O frontend trata 204 mostrando iniciais em vez da foto.
*
* Cache HTTP: max-age=86400 (24h) + stale-while-revalidate=604800 (7d) para
* avatares encontrados; 300s para 204. Valida revalidação via ETag.
*
* Negative cache: quando decide 204, grava `avatar:none:*` no Dragonfly
* com TTL de 1h próximos hits respondem sem tocar no Prisma nem no Baileys.
*/
function buildAvatarRoute(manager) {
const router = (0, express_1.Router)();
// Cabeçalho longo com stale-while-revalidate: o browser serve do cache
// local por 24h e, entre 24h e 7d, usa o stale enquanto revalida em
// background. Corta requisições repetidas na navegação normal.
const HIT_CACHE_CONTROL = 'public, max-age=86400, stale-while-revalidate=604800';
const MISS_CACHE_CONTROL = 'public, max-age=300';
const NO_AVATAR_TTL_SECONDS = 3600; // 1h
const noAvatarKey = (tenantId, instanceId, jid) => `avatar:none:${tenantId}:${instanceId}:${jid}`;
// ETag baseado no avatarUrl conhecido. É determinístico (hash da string),
// logo quando a URL do banco muda — sinal de que a foto mudou — o ETag
// também muda e força o browser a buscar bytes novos.
const etagFor = (urlOrMarker) => `"${(0, crypto_1.createHash)('md5').update(urlOrMarker).digest('hex')}"`;
router.get('/avatar/:jid', async (req, res) => {
const jid = req.params['jid'];
const instanceId = req.query['instance'];
const t0 = Date.now();
const tenantId = req.tenantId;
// Helper: retorna 204 No Content (não 404) para que o browser não logue
// erro no console. O <img onError> do frontend detecta a falta de imagem
// e mostra iniciais. Também grava no negative cache para curto-circuitar
// as próximas chamadas ao mesmo JID.
const noAvatar = (persist = true) => {
if (persist && instanceId) {
dragonfly_1.dragonfly
.set(noAvatarKey(tenantId, instanceId, jid), '1', NO_AVATAR_TTL_SECONDS)
.catch(() => { });
}
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
};
if (!instanceId) {
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
return;
}
// 0. Negative cache: se sabemos que não tem avatar há menos de 1h, corta
// antes de tocar Prisma/Baileys (o backend está sob pressão de memória).
try {
if (await dragonfly_1.dragonfly.exists(noAvatarKey(tenantId, instanceId, jid))) {
res.set('Cache-Control', MISS_CACHE_CONTROL).status(204).end();
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'neg_cache' }, '[Avatar] 204 cached');
return;
}
}
catch {
// Dragonfly indisponível — segue o fluxo normal sem quebrar
}
// ── Helpers para persistência no Wasabi ────────────────────────────────────
// Path determinístico: avatars/{tenantId}/{instanceId}/{jid_safe}.jpg
// Não usa timestamp — sobrescreve o arquivo quando o avatar muda.
const avatarWasabiPath = (tid, iid, j) => `avatars/${tid}/${iid}/${j.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`;
// Faz upload fire-and-forget para Wasabi e atualiza o banco com o path relativo.
// Chamado após servir os bytes ao browser para não bloquear a resposta.
const persistToWasabi = (buffer) => {
if (!StorageProvider_1.storageProvider.isRegistered())
return;
const wasabiPath = avatarWasabiPath(tenantId, instanceId, jid);
StorageProvider_1.storageProvider.uploadFile({
category: 'whatsapp',
customPath: wasabiPath,
file: { originalname: `${jid.replace(/[^a-zA-Z0-9]/g, '_')}.jpg`, buffer, mimetype: 'image/jpeg' },
}).then(() => prisma_1.prisma.contact.updateMany({ where: { tenantId, instanceId, jid }, data: { avatarUrl: wasabiPath } })).catch(() => { });
};
// 1. Tenta avatarUrl já salvo no banco (funciona para todos os tipos de JID)
const contact = await prisma_1.prisma.contact.findFirst({
where: { tenantId, instanceId, jid },
select: { avatarUrl: true },
});
if (contact?.avatarUrl) {
const storedUrl = contact.avatarUrl;
const isWasabi = !storedUrl.startsWith('http');
const etag = etagFor(storedUrl);
if (req.headers['if-none-match'] === etag) {
res.set('ETag', etag).set('Cache-Control', HIT_CACHE_CONTROL).status(304).end();
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'etag_304' }, '[Avatar] 304');
return;
}
if (isWasabi) {
// Já está no Wasabi — serve direto sem tocar no CDN do WhatsApp
try {
const buffer = await StorageProvider_1.storageProvider.getBuffer(storedUrl);
if (buffer) {
res.set('Content-Type', 'image/jpeg')
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etag)
.set('Content-Length', String(buffer.length))
.send(buffer);
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'wasabi' }, '[Avatar] served');
return;
}
}
catch { /* Wasabi indisponível — cai no fallback Baileys abaixo */ }
}
else {
// CDN URL do WhatsApp — busca bytes e inicia upload para Wasabi
try {
const upstream = await fetch(storedUrl);
if (upstream.ok) {
const buffer = Buffer.from(await upstream.arrayBuffer());
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etag)
.set('Content-Length', String(buffer.length))
.send(buffer);
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'db_cache' }, '[Avatar] served');
persistToWasabi(buffer);
return;
}
// URL expirou — cai no fallback Baileys abaixo
logger_1.logger.debug({ jid, ms: Date.now() - t0 }, '[Avatar] DB URL expirada, fallback Baileys');
}
catch { /* Erro de rede — cai no fallback Baileys abaixo */ }
}
}
// 2. JIDs @lid não suportam profilePictureUrl no Baileys (retorna 404 no WA)
if (jid.endsWith('@lid')) {
noAvatar();
return;
}
// 3. Busca URL fresca via Baileys profilePictureUrl
const sock = manager.getSocket(instanceId);
if (!sock) {
noAvatar();
return;
}
try {
const url = await sock.profilePictureUrl(jid, 'image');
if (!url) {
noAvatar();
return;
}
const upstream = await fetch(url);
if (!upstream.ok) {
noAvatar();
return;
}
const buffer = Buffer.from(await upstream.arrayBuffer());
res.set('Content-Type', upstream.headers.get('content-type') || 'image/jpeg')
.set('Cache-Control', HIT_CACHE_CONTROL)
.set('ETag', etagFor(url))
.set('Content-Length', String(buffer.length))
.send(buffer);
logger_1.logger.debug({ jid, ms: Date.now() - t0, source: 'baileys' }, '[Avatar] served');
// Persiste no Wasabi (fire-and-forget) e invalida negative cache
persistToWasabi(buffer);
dragonfly_1.dragonfly.del(noAvatarKey(tenantId, instanceId, jid)).catch(() => { });
}
catch {
noAvatar();
}
});
return router;
}
+227
View File
@@ -0,0 +1,227 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
require("dotenv/config");
const http_1 = __importDefault(require("http"));
const fs_1 = require("fs");
const express_1 = __importDefault(require("express"));
const cors_1 = __importDefault(require("cors"));
const helmet_1 = __importDefault(require("helmet"));
const path_1 = __importDefault(require("path"));
const env_1 = require("./config/env");
const logger_1 = require("./config/logger");
const prisma_1 = require("./infra/database/prisma");
// prisma re-usado inline abaixo (plan/status route)
const dragonfly_1 = require("./infra/cache/dragonfly");
const socketServer_1 = require("./infra/socket/socketServer");
const hook_bus_1 = require("./core/hook-bus");
const plugin_config_1 = require("./core/plugin-config");
const db_1 = require("./core/db");
const plugin_loader_1 = require("./core/plugin-loader");
const plugin_registry_1 = require("./core/plugin-registry");
const WhatsAppConnectionManager_1 = require("./modules/whatsapp/connection/WhatsAppConnectionManager");
const whatsapp_routes_1 = require("./modules/whatsapp/whatsapp.routes");
const chat_routes_1 = require("./modules/chats/chat.routes");
const whatsapp_send_routes_1 = require("./modules/whatsapp/whatsapp.send.routes");
const whatsapp_media_routes_1 = require("./modules/whatsapp/whatsapp.media.routes");
const chatbot_routes_1 = require("./modules/chatbot/chatbot.routes");
const contact_routes_1 = require("./modules/contacts/contact.routes");
const sector_routes_1 = require("./modules/sectors/sector.routes");
const apikey_routes_1 = require("./modules/api-keys/apikey.routes");
const auth_routes_1 = require("./modules/auth/auth.routes");
const admin_routes_1 = require("./modules/auth/admin.routes");
const pair_routes_1 = require("./modules/pair/pair.routes");
const template_routes_1 = require("./modules/templates/template.routes");
const scheduled_routes_1 = require("./modules/scheduled/scheduled.routes");
const sticker_routes_1 = require("./modules/stickers/sticker.routes");
const StorageProvider_1 = require("./core/StorageProvider");
const scheduler_service_1 = require("./modules/scheduled/scheduler.service");
const auth_middleware_1 = require("./shared/middlewares/auth.middleware");
const admin_middleware_1 = require("./shared/middlewares/admin.middleware");
const error_middleware_1 = require("./shared/middlewares/error.middleware");
async function bootstrap() {
// ── Express ──────────────────────────────────────────────────────────────
const app = (0, express_1.default)();
app.use((0, helmet_1.default)({ crossOriginResourcePolicy: { policy: 'cross-origin' } }));
app.use((0, cors_1.default)({ origin: env_1.env.FRONTEND_URL, credentials: true }));
app.use(express_1.default.json({ limit: '10mb' }));
// Servir arquivos de mídia baixados (ambos os prefixos: acesso direto e via proxy nginx)
const mediaStatic = express_1.default.static(path_1.default.resolve('./media'), { maxAge: '7d' });
app.use('/media', mediaStatic);
app.use('/api/media', mediaStatic);
// ── HTTP Server + Socket.IO ───────────────────────────────────────────────
const httpServer = http_1.default.createServer(app);
const io = (0, socketServer_1.buildSocketServer)(httpServer);
// ── WhatsApp Connection Manager ───────────────────────────────────────────
const whatsAppManager = new WhatsAppConnectionManager_1.WhatsAppConnectionManager(io);
globalThis.__whatsAppManager = whatsAppManager;
// ── Rotas ─────────────────────────────────────────────────────────────────
app.use('/api/auth', auth_routes_1.authRouter);
app.use('/api/pair', pair_routes_1.pairRouter);
// Configurações públicas de branding (sem autenticação — usadas pelo frontend antes do login)
app.get('/api/admin/settings/public', async (_req, res) => {
try {
const raw = await dragonfly_1.dragonfly.getJson('system:settings');
const defaults = { systemName: 'NewWhats', logoUrl: null, faviconUrl: null, accentColor: '#3b82f6', maintenance: { enabled: false, message: '' } };
const cfg = { ...defaults, ...(raw ?? {}) };
res.json({ systemName: cfg.systemName, logoUrl: cfg.logoUrl, faviconUrl: cfg.faviconUrl, accentColor: cfg.accentColor, maintenance: cfg.maintenance });
}
catch {
res.status(500).json({ error: 'Erro' });
}
});
app.use('/api/admin', auth_middleware_1.authMiddleware, admin_middleware_1.adminMiddleware, admin_routes_1.adminRouter);
app.use('/api/chats', auth_middleware_1.authMiddleware, (0, chat_routes_1.buildChatRoutes)(whatsAppManager));
app.use('/api/instances', auth_middleware_1.authMiddleware, (0, whatsapp_routes_1.buildWhatsAppRoutes)(whatsAppManager));
app.use('/api/instances/:instanceId', auth_middleware_1.authMiddleware, (0, whatsapp_send_routes_1.buildSendRoutes)(whatsAppManager));
app.use('/api/instances/:instanceId', auth_middleware_1.authMiddleware, (0, whatsapp_media_routes_1.buildMediaRoutes)(whatsAppManager));
app.use('/api/chatbot', auth_middleware_1.authMiddleware, (0, chatbot_routes_1.buildChatbotRoutes)());
app.use('/api/contacts', auth_middleware_1.authMiddleware, (0, contact_routes_1.buildContactRoutes)());
app.use('/api/sectors', auth_middleware_1.authMiddleware, (0, sector_routes_1.buildSectorRoutes)());
app.use('/api/api-keys', auth_middleware_1.authMiddleware, (0, apikey_routes_1.buildApiKeyRoutes)());
app.use('/api/inbox', auth_middleware_1.authMiddleware, (0, whatsapp_send_routes_1.buildAvatarRoute)(whatsAppManager));
// ── Templates e Agendamentos ──────────────────────────────────────────────
const scheduler = new scheduler_service_1.SchedulerService(whatsAppManager);
app.use('/api/templates', auth_middleware_1.authMiddleware, (0, template_routes_1.buildTemplateRoutes)());
app.use('/api/scheduled', auth_middleware_1.authMiddleware, (0, scheduled_routes_1.buildScheduledRoutes)(scheduler));
app.use('/api/stickers', auth_middleware_1.authMiddleware, (0, sticker_routes_1.buildStickerRoutes)());
// Proxy genérico para arquivos no Wasabi — sem authMiddleware porque <img>/<audio>/<video>
// do browser não enviam JWT. Paths são UUID-based (obscuros) — mesmo nível de segurança
// que a rota /media/ estática que também não requer autenticação.
app.get('/api/storage/view/*', async (req, res) => {
const filePath = req.params[0];
if (!filePath) {
res.status(400).end();
return;
}
try {
const buffer = await StorageProvider_1.storageProvider.getBuffer(filePath);
if (!buffer) {
res.status(404).end();
return;
}
const ext = filePath.split('.').pop()?.toLowerCase();
const mime = ext === 'webp' ? 'image/webp' :
ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' :
ext === 'png' ? 'image/png' :
ext === 'mp4' ? 'video/mp4' :
ext === 'ogg' ? 'audio/ogg' :
'application/octet-stream';
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.send(buffer);
}
catch {
res.status(500).end();
}
});
// Banner do frontend — dias restantes do plano
app.get('/api/plan/status', auth_middleware_1.authMiddleware, async (req, res) => {
const user = await prisma_1.prisma.user.findUnique({
where: { id: req.tenantId },
select: { trialEndsAt: true, plan: { select: { name: true } } },
});
const daysRemaining = user?.trialEndsAt
? Math.max(0, Math.ceil((new Date(user.trialEndsAt).getTime() - Date.now()) / 86_400_000))
: null;
res.json({ daysRemaining, planName: user?.plan?.name ?? 'Trial' });
});
// Presença: retorna se o próprio usuário está online (chave ainda viva no Dragonfly)
// Útil para self-check; para listar agentes online em admin, use a mesma lógica com prefixo
app.get('/api/presence/me', auth_middleware_1.authMiddleware, async (req, res) => {
const online = await dragonfly_1.dragonfly.exists(`presence:user:${req.tenantId}`);
res.json({ online });
});
// Health check (sem autenticação)
app.get('/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Retorna menu items de plugins ativos — usado pelo frontend após login
app.get('/api/plugins/menu', auth_middleware_1.authMiddleware, (_req, res) => {
const items = plugin_registry_1.pluginRegistry.getActiveMenuItems();
res.json(items);
});
app.use(error_middleware_1.errorMiddleware);
// ── Infra: conectar Dragonfly e Prisma ───────────────────────────────────
await dragonfly_1.dragonfly.connect();
await prisma_1.prisma.$connect();
logger_1.logger.info('PostgreSQL conectado via Prisma');
// ── Plugin System ─────────────────────────────────────────────────────────
await (0, db_1.ensurePluginTables)(db_1.db);
const loadedPlugins = await (0, plugin_loader_1.loadPlugins)();
const pluginNames = loadedPlugins.map((p) => p.manifest.name);
await plugin_config_1.pluginConfig.loadAll(pluginNames);
const pluginCtx = {
app,
db: db_1.db,
prisma: prisma_1.prisma,
logger: logger_1.logger,
hooks: hook_bus_1.hookBus,
config: plugin_config_1.pluginConfig,
io,
httpServer,
};
plugin_registry_1.pluginRegistry.setContext(pluginCtx);
plugin_registry_1.pluginRegistry.register(loadedPlugins);
await plugin_registry_1.pluginRegistry.activateAll();
logger_1.logger.info({ count: pluginNames.length }, '🔌 Plugins carregados');
// ── Auto-reconectar instâncias ativas após reinício ──────────────────────
// Reseta CONNECTING/QR_PENDING para DISCONNECTED antes de reconectar:
// impede que a API retorne um status "fantasma" de antes do crash.
await prisma_1.prisma.instance.updateMany({
where: { status: { in: ['CONNECTING', 'QR_PENDING'] } },
data: { status: 'DISCONNECTED' },
});
// Broadcasts travados em RUNNING (processo morreu durante o loop de envio):
// marca como FAILED para não aparecerem eternamente como "Enviando…" no frontend.
const stuckCount = await prisma_1.prisma.broadcast.updateMany({
where: { status: 'RUNNING' },
data: { status: 'FAILED', finishedAt: new Date() },
});
if (stuckCount.count > 0) {
logger_1.logger.warn({ count: stuckCount.count }, '[Broadcast] Jobs RUNNING marcados como FAILED após reinício');
}
// Reconecta toda instância não-banida que tenha credenciais persistidas
// no disco (creds.json). O filesystem é a fonte de verdade para "essa
// instância já foi pareada" — o status no DB pode estar DISCONNECTED
// temporariamente porque o watchdog matou a conexão ANTES do timer de
// reconexão rodar (ex: restart do tsx watch durante o backoff de 3s).
// Sem esta lógica, a instância fica órfã até alguém chamar connect()
// pela API, causando perda de mensagens durante o hiato.
const candidates = await prisma_1.prisma.instance.findMany({
where: { status: { notIn: ['BANNED'] } },
});
for (const inst of candidates) {
const credsPath = path_1.default.join(env_1.env.BAILEYS_SESSIONS_PATH, inst.id, 'creds.json');
if (!(0, fs_1.existsSync)(credsPath)) {
// Nunca foi pareada — pular pra não disparar QR indesejado no boot
continue;
}
logger_1.logger.info({ instanceId: inst.id, previousStatus: inst.status }, 'Reconectando instância após reinício (creds persistidas)');
whatsAppManager.connect(inst.id, inst.tenantId).catch((err) => logger_1.logger.error({ err, instanceId: inst.id }, 'Falha ao reconectar instância'));
}
// ── Scheduler de mensagens ────────────────────────────────────────────────
scheduler.start();
// ── Iniciar servidor ──────────────────────────────────────────────────────
const port = parseInt(env_1.env.PORT, 10);
httpServer.listen(port, () => {
logger_1.logger.info(`🚀 NewWhats backend rodando em http://localhost:${port}`);
logger_1.logger.info(` Socket.IO pronto — frontend: ${env_1.env.FRONTEND_URL}`);
});
// ── Graceful shutdown ─────────────────────────────────────────────────────
const shutdown = async (signal) => {
logger_1.logger.info({ signal }, 'Encerrando servidor...');
scheduler.stop();
httpServer.close();
await prisma_1.prisma.$disconnect();
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
bootstrap().catch((err) => {
console.error('Falha fatal ao iniciar servidor:', err);
process.exit(1);
});
+14
View File
@@ -0,0 +1,14 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.adminMiddleware = adminMiddleware;
/**
* Garante que apenas usuários com role ADMIN acessem a rota.
* Deve ser usado APÓS o authMiddleware.
*/
function adminMiddleware(req, res, next) {
if (req.userRole !== 'ADMIN') {
res.status(403).json({ error: 'Acesso restrito a administradores' });
return;
}
next();
}
+27
View File
@@ -0,0 +1,27 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.authMiddleware = authMiddleware;
const jsonwebtoken_1 = __importDefault(require("jsonwebtoken"));
const env_1 = require("../../config/env");
function authMiddleware(req, res, next) {
const header = req.headers.authorization;
// Aceita token via query string para uso em <img src="...?token=...">
const queryToken = typeof req.query['token'] === 'string' ? req.query['token'] : null;
if (!header?.startsWith('Bearer ') && !queryToken) {
res.status(401).json({ error: 'Token não fornecido' });
return;
}
const token = header?.startsWith('Bearer ') ? header.slice(7) : queryToken;
try {
const payload = jsonwebtoken_1.default.verify(token, env_1.env.JWT_SECRET);
req.tenantId = payload.sub;
req.userRole = payload.role;
next();
}
catch {
res.status(401).json({ error: 'Token inválido ou expirado' });
}
}
+8
View File
@@ -0,0 +1,8 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.errorMiddleware = errorMiddleware;
const logger_1 = require("../../config/logger");
function errorMiddleware(err, _req, res, _next) {
logger_1.logger.error(err, 'Unhandled error');
res.status(500).json({ error: 'Erro interno do servidor' });
}
+68
View File
@@ -0,0 +1,68 @@
"use strict";
/**
* Utilitários para manipulação de JIDs (Jabber IDs) do WhatsApp.
*
* O WhatsApp usa JIDs como identificadores únicos para contatos, grupos e canais:
* - Contatos individuais: "5511999999999@s.whatsapp.net"
* - Grupos: "120363012345678@g.us"
* - Canais/newsletters: "120363012345678@newsletter"
* - LID (Linked ID): "123456789012345@lid" (identificador interno moderno)
* - Broadcast: "status@broadcast"
*
* JIDs de dispositivo incluem sufixo ":N" (ex: "5511999999999:1@s.whatsapp.net")
* que identifica o dispositivo específico (multi-device). Para armazenamento
* e comparação, normalizamos removendo esse sufixo.
*
* @see MessageHandler usa normalizeJid para persistir mensagens
* @see ContactHandler usa para vincular contatos a chats
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.normalizeJid = normalizeJid;
exports.extractPhone = extractPhone;
/**
* Normaliza um JID do WhatsApp removendo sufixos de dispositivo (:1, :2, etc).
*
* Exemplos:
* "556199999999:1@s.whatsapp.net" "556199999999@s.whatsapp.net"
* "556199999999@s.whatsapp.net" "556199999999@s.whatsapp.net" (sem mudança)
* "120363012345@g.us" "120363012345@g.us" (grupos não têm sufixo)
* null / undefined "" (string vazia como fallback seguro)
*
* IMPORTANTE: Não confundir com resolução LID. Este método apenas remove
* o sufixo de dispositivo NÃO converte @lid para @s.whatsapp.net.
* Para resolução LID, veja MessageHandler.resolveLidToPhoneJid().
*
* @param jid - JID bruto do Baileys (pode conter sufixo de dispositivo)
* @returns JID normalizado sem sufixo de dispositivo
*/
function normalizeJid(jid) {
if (!jid)
return '';
const [userPart, domain] = jid.split('@');
if (!domain)
return jid; // Não é um JID válido (sem @), retorna como está
// Remove sufixo de dispositivo: "556199999999:1" → "556199999999"
const [cleanUser] = userPart.split(':');
return `${cleanUser}@${domain}`;
}
/**
* Extrai o número de telefone puro de um JID.
*
* Exemplos:
* "5511999999999@s.whatsapp.net" "5511999999999"
* "5511999999999:1@s.whatsapp.net" "5511999999999"
* "120363012345@g.us" null (grupos não têm telefone único)
* null / undefined null
*
* @param jid - JID do WhatsApp
* @returns Número de telefone (apenas dígitos + código do país) ou null
*/
function extractPhone(jid) {
if (!jid)
return null;
if (jid.endsWith('@g.us'))
return null; // Grupos não têm "telefone" único no JID
const [userPart] = jid.split('@');
const [cleanUser] = userPart.split(':');
return cleanUser;
}
+9610
View File
File diff suppressed because it is too large Load Diff
+68
View File
@@ -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,21 @@
-- CreateTable
CREATE TABLE "stickers" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"sha256" TEXT NOT NULL,
"wasabiPath" TEXT NOT NULL,
"isAnimated" BOOLEAN NOT NULL DEFAULT false,
"isFavorite" BOOLEAN NOT NULL DEFAULT false,
"useCount" INTEGER NOT NULL DEFAULT 1,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "stickers_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "stickers_tenantId_sha256_key" ON "stickers"("tenantId", "sha256");
-- CreateIndex
CREATE INDEX "stickers_tenantId_instanceId_isFavorite_lastSeenAt_idx" ON "stickers"("tenantId", "instanceId", "isFavorite", "lastSeenAt" DESC);
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+560
View File
@@ -0,0 +1,560 @@
// ─────────────────────────────────────────────────────────────────────────────
// Prisma Schema — NewWhats SaaS Omnichannel
// ─────────────────────────────────────────────────────────────────────────────
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── PLANOS ──────────────────────────────────────────────────────────────────
model Plan {
id String @id @default(uuid())
name String
maxInstances Int @default(1)
maxAgents Int @default(5)
priceMonthly Decimal @default(0) @db.Decimal(10, 2)
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users User[]
}
// ─── USUÁRIOS (ADMIN + TENANTS) ───────────────────────────────────────────────
model User {
id String @id @default(uuid())
name String
email String @unique
passwordHash String
role UserRole @default(USER)
isActive Boolean @default(true)
planId String?
trialEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
plan Plan? @relation(fields: [planId], references: [id])
instances Instance[]
sectors Sector[]
teams Team[]
apiKeys ApiKey[]
aiCredentials AICredential[]
aiBots AIBot[]
broadcasts Broadcast[]
pluginPairs PluginPair[]
extWebhooks ExtWebhook[]
messageTemplates MessageTemplate[]
scheduledMessages ScheduledMessage[]
@@map("users")
}
enum UserRole {
ADMIN
USER
}
// ─── API KEYS ─────────────────────────────────────────────────────────────────
model ApiKey {
id String @id @default(uuid())
tenantId String
key String @unique @default(uuid())
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
expiresAt DateTime?
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("api_keys")
}
// ─── INSTÂNCIAS WHATSAPP ──────────────────────────────────────────────────────
model Instance {
id String @id @default(uuid())
tenantId String
name String
phone String?
avatar String?
status InstanceStatus @default(DISCONNECTED)
engine String @default("infinite")
sessionPath String?
webhookUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
contacts Contact[]
chats Chat[]
messages Message[]
broadcasts Broadcast[]
@@unique([tenantId, name])
@@map("instances")
}
enum InstanceStatus {
DISCONNECTED
CONNECTING
QR_PENDING
CONNECTED
BANNED
}
// ─── SETORES ─────────────────────────────────────────────────────────────────
model Sector {
id String @id @default(uuid())
tenantId String
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
teams Team[]
protocols Protocol[]
@@unique([tenantId, name])
@@map("sectors")
}
// ─── EQUIPES ─────────────────────────────────────────────────────────────────
model Team {
id String @id @default(uuid())
tenantId String
sectorId String
name String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
sector Sector @relation(fields: [sectorId], references: [id], onDelete: Cascade)
protocols Protocol[]
@@unique([tenantId, sectorId, name])
@@map("teams")
}
// ─── CONTATOS ─────────────────────────────────────────────────────────────────
model Contact {
id String @id @default(uuid())
tenantId String
instanceId String
jid String
lidJid String?
// Prioridade: name (agenda) > verifiedName > notify (pushname)
name String?
verifiedName String?
notify String?
phone String?
avatarUrl String?
scoreReputacao Int @default(100)
flagRestricao Boolean @default(false)
isBlocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
chats Chat[]
protocols Protocol[]
@@unique([tenantId, instanceId, jid])
@@index([tenantId, instanceId, lidJid])
@@map("contacts")
}
// ─── CHATS ────────────────────────────────────────────────────────────────────
model Chat {
id String @id @default(uuid())
tenantId String
instanceId String
contactId String?
jid String
name String? // Subject para grupos (@g.us); null para individuais
unreadCount Int @default(0)
lastMessageAt DateTime?
isPinned Boolean @default(false)
isArchived Boolean @default(false)
// IA Chatbot
botPaused Boolean @default(false) // human takeover — pausa o bot neste chat
botSummary String? // Cérebro: resumo de 2 frases da conversa
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
contact Contact? @relation(fields: [contactId], references: [id])
messages Message[]
protocols Protocol[]
@@unique([tenantId, instanceId, jid])
// Índice composto para a query principal da inbox:
// WHERE tenantId + instanceId + isArchived (filtro)
// ORDER BY isPinned DESC, lastMessageAt DESC (ordenação)
@@index([tenantId, instanceId, isArchived, isPinned(sort: Desc), lastMessageAt(sort: Desc)])
@@map("chats")
}
// ─── MENSAGENS ────────────────────────────────────────────────────────────────
model Message {
id String @id @default(uuid())
tenantId String
instanceId String
chatId String
remoteJid String
messageId String
fromMe Boolean @default(false)
type MessageType @default(TEXT)
body String?
caption String?
mediaUrl String?
mediaPath String?
mimeType String?
fileName String?
duration Int?
pushName String? // Nome do remetente no momento do envio (grupos: quem enviou)
senderJid String? // JID do remetente real (grupos: key.participant)
replyToId String? // ID da mensagem citada (reply)
richPayload Json? // Payload rico original (buttons/list/carousel/poll)
mediaPayload Json? // Inner WAMessage payload para re-download de mídia
status MessageStatus @default(PENDING)
timestamp DateTime
createdAt DateTime @default(now())
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
replyTo Message? @relation("MessageReply", fields: [replyToId], references: [id])
replies Message[] @relation("MessageReply")
@@unique([instanceId, messageId])
// Índice para "última mensagem por chat" — usado no include da inbox
// ORDER BY timestamp DESC LIMIT 1 (por chatId)
@@index([chatId, timestamp(sort: Desc)])
// Índice para reconcile-names: DISTINCT ON remoteJid ORDER BY timestamp DESC
// WHERE instanceId = ? AND fromMe = false AND pushName IS NOT NULL
@@index([instanceId, fromMe, remoteJid, timestamp(sort: Desc)])
// Índice para obter a última mensagem da instância — usado na verificação de gap de sincronização
@@index([instanceId, timestamp(sort: Desc)])
@@map("messages")
}
enum MessageType {
TEXT
IMAGE
VIDEO
AUDIO
DOCUMENT
STICKER
LOCATION
CONTACT
REACTION
POLL
BUTTONS
LIST
CAROUSEL
INTERACTIVE
UNSUPPORTED
}
enum MessageStatus {
PENDING
SENT
DELIVERED
READ
FAILED
}
// ─── PROTOCOLOS DE ATENDIMENTO ────────────────────────────────────────────────
model Protocol {
id String @id @default(uuid())
tenantId String
number String @unique // Número sequencial rastreável: #0001-2024
chatId String
contactId String
sectorId String?
teamId String?
agentId String?
status ProtocolStatus @default(OPEN)
summary String? // Resumo de 2 frases gerado pelo Temporal
deadline DateTime?
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
chat Chat @relation(fields: [chatId], references: [id])
contact Contact @relation(fields: [contactId], references: [id])
sector Sector? @relation(fields: [sectorId], references: [id])
team Team? @relation(fields: [teamId], references: [id])
// Índice para filtro de protocolos abertos por chat (usado no include da inbox)
// WHERE chatId = ? AND status IN ('OPEN', 'IN_PROGRESS', ...) ORDER BY createdAt DESC
@@index([chatId, status, createdAt(sort: Desc)])
@@map("protocols")
}
enum ProtocolStatus {
OPEN
IN_PROGRESS
WAITING_CLIENT
WAITING_AGENT
RESOLVED
CANCELLED
}
// ─── IA MULTI-AGENTE ──────────────────────────────────────────────────────────
model AICredential {
id String @id @default(uuid())
tenantId String
instanceId String
name String
provider LLMProvider @default(GEMINI)
apiKey String
createdAt DateTime @default(now())
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
bots AIBot[]
@@unique([tenantId, instanceId, name])
@@map("ai_credentials")
}
model AIBot {
id String @id @default(uuid())
tenantId String
instanceId String
credentialId String
name String
systemPrompt String @db.Text
model String @default("gemini-1.5-flash")
enabled Boolean @default(false)
triggerMode TriggerMode @default(ALL)
keywords String[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
credential AICredential @relation(fields: [credentialId], references: [id])
@@unique([tenantId, instanceId])
@@map("ai_bots")
}
enum LLMProvider {
GEMINI
OPENAI
}
enum TriggerMode {
ALL
KEYWORD
}
// ─── BROADCAST (DISPARO EM MASSA) ─────────────────────────────────────────────
model Broadcast {
id String @id @default(uuid())
tenantId String
instanceId String
message String @db.Text
totalCount Int @default(0)
sentCount Int @default(0)
failedCount Int @default(0)
status String @default("RUNNING") // RUNNING | DONE | FAILED
createdAt DateTime @default(now())
finishedAt DateTime?
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
recipients BroadcastRecipient[]
@@map("broadcasts")
}
// ─── PLUGIN PAIRS ─────────────────────────────────────────────────────────────
// Registra sistemas externos pareados com uma conta NewWhats.
// Cada par tem uma integration_key exclusiva que autentica chamadas /nw/*.
model PluginPair {
id String @id @default(uuid())
userId String
clientSystem String // identificador técnico ex: "alemao-conveniencias"
clientName String // nome amigável ex: "Alemão Conveniências"
clientUrl String? // URL base do sistema cliente
integrationKey String @unique
lastSeenAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("plugin_pairs")
}
// ─── EXT WEBHOOKS ─────────────────────────────────────────────────────────────
// Endpoints externos registrados por tenant para receber push de eventos.
model ExtWebhook {
id String @id @default(uuid())
tenantId String
url String
events String[]
secret String
active Boolean @default(true)
createdAt DateTime @default(now())
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
@@map("ext_webhooks")
}
model BroadcastRecipient {
id String @id @default(uuid())
broadcastId String
jid String
status String @default("PENDING") // PENDING | SENT | FAILED
createdAt DateTime @default(now())
broadcast Broadcast @relation(fields: [broadcastId], references: [id], onDelete: Cascade)
@@map("broadcast_recipients")
}
// ─── TEMPLATES DE MENSAGEM ────────────────────────────────────────────────────
model MessageTemplate {
id String @id @default(uuid())
tenantId String
name String
description String?
type TemplateType @default(TEXT)
payload Json
tags String[]
usageCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
schedules ScheduledMessage[]
@@map("message_templates")
}
enum TemplateType {
TEXT
BUTTONS
INTERACTIVE
LIST
POLL
CAROUSEL
}
// ─── AGENDAMENTOS DE MENSAGEM ─────────────────────────────────────────────────
model ScheduledMessage {
id String @id @default(uuid())
tenantId String
instanceId String
name String
templateId String?
payload Json
recipientMode RecipientMode @default(MANUAL)
recipients Json
scheduleType ScheduleType @default(ONCE)
cronExpr String?
scheduledAt DateTime?
eventType EventType?
timezone String @default("America/Sao_Paulo")
status ScheduleStatus @default(ACTIVE)
lastRunAt DateTime?
nextRunAt DateTime?
runCount Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant User @relation(fields: [tenantId], references: [id], onDelete: Cascade)
template MessageTemplate? @relation(fields: [templateId], references: [id])
logs ScheduledMessageLog[]
@@map("scheduled_messages")
}
model ScheduledMessageLog {
id String @id @default(uuid())
scheduleId String
status String
sentCount Int @default(0)
failedCount Int @default(0)
error String?
runAt DateTime @default(now())
schedule ScheduledMessage @relation(fields: [scheduleId], references: [id], onDelete: Cascade)
@@map("scheduled_message_logs")
}
enum RecipientMode {
MANUAL
ALL_CONTACTS
TAG_FILTER
}
enum ScheduleType {
ONCE
RECURRING
EVENT
}
enum EventType {
BIRTHDAY
ANNIVERSARY
SIGNUP_DAYS
}
enum ScheduleStatus {
ACTIVE
PAUSED
DONE
CANCELLED
}
// ─── FIGURINHAS (STICKERS) ────────────────────────────────────────────────────
// Armazenamento persistente de stickers usando fileSha256 do protocolo Baileys
// como chave de conteúdo. Garante deduplicação mesmo após reinstalação do WA.
model Sticker {
id String @id @default(uuid())
tenantId String
instanceId String
sha256 String // fileSha256 hex — identidade permanente do conteúdo
wasabiPath String // stickers/{tenantId}/{sha256}.webp
isAnimated Boolean @default(false)
isFavorite Boolean @default(false)
useCount Int @default(1)
lastSeenAt DateTime @default(now())
createdAt DateTime @default(now())
@@unique([tenantId, sha256])
@@index([tenantId, instanceId, isFavorite, lastSeenAt(sort: Desc)])
@@map("stickers")
}
// Webhook allowed host fix
// Webhook allowed host fix
// Webhook query string fix
+45
View File
@@ -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())
+81
View File
@@ -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)
})
+9
View File
@@ -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
+1
View File
@@ -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"}
+3
View File
@@ -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'
+27
View File
@@ -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
+1
View File
@@ -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"}
+27
View File
@@ -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
+15
View File
@@ -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
+1
View File
@@ -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"}
+10
View File
@@ -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,
})
+91
View File
@@ -0,0 +1,91 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.storageProvider = void 0;
const logger_1 = require("../config/logger");
/**
* Singleton registry para o storage provider ativo.
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
*/
class StorageProviderRegistry {
constructor() {
this.provider = null;
this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
};
}
register(provider) {
this.provider = provider;
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' };
logger_1.logger.info('[StorageProvider] Provider registrado');
}
isRegistered() {
return this.provider !== null;
}
isHealthy() {
return this.healthy;
}
getLastHealthStatus() {
return this.lastHealthStatus;
}
setHealthStatus(status) {
this.lastHealthStatus = status;
this.healthy = status.healthy;
}
async uploadFile(payload) {
if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.');
}
return this.provider.upload(payload);
}
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
async updateHealth() {
if (!this.provider) {
this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
};
return;
}
try {
const status = await this.provider.checkDetailedHealth();
this.healthy = status.healthy;
this.lastHealthStatus = status;
}
catch {
this.healthy = false;
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
};
}
}
async getBuffer(path) {
if (!this.provider)
return null;
return this.provider.getBuffer(path);
}
async deletePrefix(prefix) {
if (!this.provider) {
logger_1.logger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado');
return { deleted: 0 };
}
return this.provider.deletePrefix(prefix);
}
unregister() {
this.provider = null;
this.healthy = false;
logger_1.logger.info('[StorageProvider] Provider removido');
}
}
exports.storageProvider = new StorageProviderRegistry();
//# sourceMappingURL=StorageProvider.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"StorageProvider.js","sourceRoot":"","sources":["StorageProvider.ts"],"names":[],"mappings":";;;AAAA,6CAAuD;AAoBvD;;;;GAIG;AACH,MAAM,uBAAuB;IAA7B;QACU,aAAQ,GAAoC,IAAI,CAAA;QAChD,YAAO,GAAG,KAAK,CAAA;QACf,qBAAgB,GAAwB;YAC9C,OAAO,EAAE,KAAK;YACd,UAAU,EAAE,KAAK;YACjB,MAAM,EAAE,gBAAgB;YACxB,OAAO,EAAE,2BAA2B;SACrC,CAAA;IA6EH,CAAC;IA3EC,QAAQ,CAAC,QAAkC;QACzC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAA;QACxB,IAAI,CAAC,gBAAgB,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAA;QAC1G,eAAU,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAA;IAC1D,CAAC;IAED,YAAY;QACV,OAAO,IAAI,CAAC,QAAQ,KAAK,IAAI,CAAA;IAC/B,CAAC;IAED,SAAS;QACP,OAAO,IAAI,CAAC,OAAO,CAAA;IACrB,CAAC;IAED,mBAAmB;QACjB,OAAO,IAAI,CAAC,gBAAgB,CAAA;IAC9B,CAAC;IAED,eAAe,CAAC,MAA2B;QACzC,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAC9B,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;IAC/B,CAAC;IAED,KAAK,CAAC,UAAU,CAAC,OAA6B;QAC5C,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,MAAM,IAAI,KAAK,CAAC,4EAA4E,CAAC,CAAA;QAC/F,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;IACtC,CAAC;IAED,sFAAsF;IACtF,KAAK,CAAC,YAAY;QAChB,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,KAAK;gBACjB,MAAM,EAAE,gBAAgB;gBACxB,OAAO,EAAE,2EAA2E;aACrF,CAAA;YACD,OAAM;QACR,CAAC;QACD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,QAAQ,CAAC,mBAAmB,EAAE,CAAA;YACxD,IAAI,CAAC,OAAO,GAAG,MAAM,CAAC,OAAO,CAAA;YAC7B,IAAI,CAAC,gBAAgB,GAAG,MAAM,CAAA;QAChC,CAAC;QAAC,MAAM,CAAC;YACP,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;YACpB,IAAI,CAAC,gBAAgB,GAAG;gBACtB,OAAO,EAAE,KAAK;gBACd,UAAU,EAAE,IAAI;gBAChB,MAAM,EAAE,SAAS;gBACjB,OAAO,EAAE,2CAA2C;aACrD,CAAA;QACH,CAAC;IACH,CAAC;IAED,KAAK,CAAC,SAAS,CAAC,IAAY;QAC1B,IAAI,CAAC,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAA;QAC/B,OAAO,IAAI,CAAC,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,CAAA;IACtC,CAAC;IAED,KAAK,CAAC,YAAY,CAAC,MAAc;QAC/B,IAAI,CAAC,IAAI,CAAC,QAAQ,EAAE,CAAC;YACnB,eAAU,CAAC,IAAI,CAAC,4EAA4E,CAAC,CAAA;YAC7F,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,CAAA;QACvB,CAAC;QACD,OAAO,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,MAAM,CAAC,CAAA;IAC3C,CAAC;IAED,UAAU;QACR,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAA;QACpB,IAAI,CAAC,OAAO,GAAG,KAAK,CAAA;QACpB,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;CACF;AAEY,QAAA,eAAe,GAAG,IAAI,uBAAuB,EAAE,CAAA"}
+114
View File
@@ -0,0 +1,114 @@
import { logger as rootLogger } from '../config/logger'
import type { StorageProviderInterface, StorageUploadPayload } from './types'
export type StorageHealthReason =
| 'ok'
| 'not_configured'
| 'needs_restart'
| 'auth_error'
| 'billing_error'
| 'bucket_not_found'
| 'network_error'
| 'unknown'
export interface StorageHealthStatus {
healthy: boolean
configured: boolean
reason: StorageHealthReason
message: string
}
/**
* Singleton registry para o storage provider ativo.
* O plugin UnifiedStorageProvider se registra aqui durante o activate().
* O plugin uploads chama uploadFile() para enviar ao storage registrado.
*/
class StorageProviderRegistry {
private provider: StorageProviderInterface | null = null
private healthy = false
private lastHealthStatus: StorageHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não inicializado.',
}
register(provider: StorageProviderInterface): void {
this.provider = provider
this.lastHealthStatus = { healthy: true, configured: true, reason: 'ok', message: 'Wasabi inicializado.' }
rootLogger.info('[StorageProvider] Provider registrado')
}
isRegistered(): boolean {
return this.provider !== null
}
isHealthy(): boolean {
return this.healthy
}
getLastHealthStatus(): StorageHealthStatus {
return this.lastHealthStatus
}
setHealthStatus(status: StorageHealthStatus): void {
this.lastHealthStatus = status
this.healthy = status.healthy
}
async uploadFile(payload: StorageUploadPayload): Promise<{ provider: string; path: string }> {
if (!this.provider) {
throw new Error('Nenhum storage provider registrado. Ative o plugin UnifiedStorageProvider.')
}
return this.provider.upload(payload)
}
/** Atualiza o status de saúde — chamado periodicamente pelo UnifiedStorageProvider */
async updateHealth(): Promise<void> {
if (!this.provider) {
this.healthy = false
this.lastHealthStatus = {
healthy: false,
configured: false,
reason: 'not_configured',
message: 'Storage não configurado. Acesse Admin > Plugins > UnifiedStorageProvider.',
}
return
}
try {
const status = await this.provider.checkDetailedHealth()
this.healthy = status.healthy
this.lastHealthStatus = status
} catch {
this.healthy = false
this.lastHealthStatus = {
healthy: false,
configured: true,
reason: 'unknown',
message: 'Erro desconhecido ao verificar o storage.',
}
}
}
async getBuffer(path: string): Promise<Buffer | null> {
if (!this.provider) return null
return this.provider.getBuffer(path)
}
async deletePrefix(prefix: string): Promise<{ deleted: number }> {
if (!this.provider) {
rootLogger.warn('[StorageProvider] deletePrefix chamado mas nenhum provider está registrado')
return { deleted: 0 }
}
return this.provider.deletePrefix(prefix)
}
unregister(): void {
this.provider = null
this.healthy = false
rootLogger.info('[StorageProvider] Provider removido')
}
}
export const storageProvider = new StorageProviderRegistry()
export type { StorageUploadPayload, StorageProviderInterface }
+56
View File
@@ -0,0 +1,56 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.db = void 0;
exports.ensurePluginTables = ensurePluginTables;
const knex_1 = __importDefault(require("knex"));
const env_1 = require("../config/env");
const logger_1 = require("../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() {
// DATABASE_URL formato: postgresql://user:pass@host:port/db
const connection = env_1.env.DATABASE_URL;
const instance = (0, knex_1.default)({
client: 'pg',
connection,
pool: { min: 1, max: 5 },
acquireConnectionTimeout: 10000,
});
instance.on('query-error', (err, query) => {
logger_1.logger.error({ err, sql: query.sql }, '[Knex] Query error');
});
return instance;
}
exports.db = createKnex();
/** Garante que a tabela de log do janitor existe (usada por UnifiedStorageProvider) */
async function ensurePluginTables(knexInstance) {
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);
});
logger_1.logger.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);
});
logger_1.logger.info('[Knex] Tabela nanobana_usage criada');
}
}
//# sourceMappingURL=db.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"db.js","sourceRoot":"","sources":["db.ts"],"names":[],"mappings":";;;;;;AA8BA,gDAwBC;AAtDD,gDAAiC;AACjC,uCAAmC;AACnC,6CAAuD;AAEvD;;;;GAIG;AACH,SAAS,UAAU;IACjB,4DAA4D;IAC5D,MAAM,UAAU,GAAG,SAAG,CAAC,YAAY,CAAA;IAEnC,MAAM,QAAQ,GAAG,IAAA,cAAI,EAAC;QACpB,MAAM,EAAE,IAAI;QACZ,UAAU;QACV,IAAI,EAAE,EAAE,GAAG,EAAE,CAAC,EAAE,GAAG,EAAE,CAAC,EAAE;QACxB,wBAAwB,EAAE,KAAK;KAChC,CAAC,CAAA;IAEF,QAAQ,CAAC,EAAE,CAAC,aAAa,EAAE,CAAC,GAAU,EAAE,KAAsB,EAAE,EAAE;QAChE,eAAU,CAAC,KAAK,CAAC,EAAE,GAAG,EAAE,GAAG,EAAE,KAAK,CAAC,GAAG,EAAE,EAAE,oBAAoB,CAAC,CAAA;IACjE,CAAC,CAAC,CAAA;IAEF,OAAO,QAAQ,CAAA;AACjB,CAAC;AAEY,QAAA,EAAE,GAAG,UAAU,EAAE,CAAA;AAE9B,uFAAuF;AAChF,KAAK,UAAU,kBAAkB,CAAC,YAAkB;IACzD,MAAM,cAAc,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,cAAc,CAAC,CAAA;IACzE,IAAI,CAAC,cAAc,EAAE,CAAC;QACpB,MAAM,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,cAAc,EAAE,CAAC,CAAC,EAAE,EAAE;YAC1D,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAA;YAC5B,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAA;YAClC,CAAC,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,WAAW,EAAE,CAAA;YAClC,CAAC,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC,WAAW,EAAE,CAAA;YAChC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,QAAQ,EAAE,CAAA;YAC5B,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QACF,eAAU,CAAC,IAAI,CAAC,mCAAmC,CAAC,CAAA;IACtD,CAAC;IAED,MAAM,gBAAgB,GAAG,MAAM,YAAY,CAAC,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAC,CAAA;IAC7E,IAAI,CAAC,gBAAgB,EAAE,CAAC;QACtB,MAAM,YAAY,CAAC,MAAM,CAAC,WAAW,CAAC,gBAAgB,EAAE,CAAC,CAAC,EAAE,EAAE;YAC5D,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,OAAO,EAAE,CAAA;YAC5B,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC,WAAW,EAAE,CAAA;YACpC,CAAC,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,WAAW,EAAE,CAAA;YAC9B,CAAC,CAAC,UAAU,CAAC,IAAI,EAAE,IAAI,CAAC,CAAA;QAC1B,CAAC,CAAC,CAAA;QACF,eAAU,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAA;IACxD,CAAC;AACH,CAAC"}
+55
View File
@@ -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')
}
}
+50
View File
@@ -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
+1
View File
@@ -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"}
+53
View File
@@ -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()
+52
View File
@@ -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
+1
View File
@@ -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"}
+54
View File
@@ -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()
+133
View File
@@ -0,0 +1,133 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.loadPlugins = loadPlugins;
const path_1 = __importDefault(require("path"));
const fs_1 = __importDefault(require("fs"));
const logger_1 = require("../config/logger");
const PLUGINS_DIR = process.env.NODE_ENV === 'production'
? '/app/plugins'
: path_1.default.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) {
const byName = new Map(plugins.map((p) => [p.manifest.name, p]));
const inDegree = new Map();
const dependents = new Map();
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)) {
logger_1.logger.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 = [];
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) {
logger_1.logger.error('[PluginLoader] Dependência circular detectada — carregando na ordem original');
return plugins;
}
return sorted;
}
/**
* Escaneia o diretório /plugins, cada manifest.json e importa index.ts/js.
* Retorna plugins ordenados por dependência.
*/
async function loadPlugins() {
if (!fs_1.default.existsSync(PLUGINS_DIR)) {
logger_1.logger.warn({ dir: PLUGINS_DIR }, '[PluginLoader] Diretório plugins/ não encontrado');
return [];
}
const entries = fs_1.default.readdirSync(PLUGINS_DIR, { withFileTypes: true });
const pluginDirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
const loaded = [];
for (const dirName of pluginDirs) {
const pluginDir = path_1.default.join(PLUGINS_DIR, dirName);
const manifestPath = path_1.default.join(pluginDir, 'manifest.json');
const indexTs = path_1.default.join(pluginDir, 'index.ts');
const indexJs = path_1.default.join(pluginDir, 'index.js');
if (!fs_1.default.existsSync(manifestPath)) {
logger_1.logger.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_1.default.existsSync(indexJs) ? indexJs : fs_1.default.existsSync(indexTs) ? indexTs : null;
if (!entryPoint) {
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] index.ts/js não encontrado — pulando');
continue;
}
try {
const manifest = JSON.parse(fs_1.default.readFileSync(manifestPath, 'utf-8'));
const mod = await Promise.resolve(`${entryPoint}`).then(s => __importStar(require(s)));
const instance = mod.default ?? mod;
if (typeof instance.activate !== 'function') {
logger_1.logger.warn({ dir: dirName }, '[PluginLoader] Plugin sem método activate() — pulando');
continue;
}
loaded.push({ instance, manifest, dir: pluginDir });
logger_1.logger.info({ name: manifest.name, version: manifest.version }, '[PluginLoader] Plugin carregado');
}
catch (err) {
logger_1.logger.error({ err, dir: dirName }, '[PluginLoader] Falha ao carregar plugin');
}
}
return topoSort(loaded);
}
//# sourceMappingURL=plugin-loader.js.map

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